Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
56f5cc7
feat: migrate contact export to edge function (#2812)
malek10xdev Jun 19, 2026
931316b
fix: use upsert and private schema prefix in export-contacts RPC calls
malek10xdev Jun 26, 2026
1acf0cd
fix: use .schema('private').rpc() instead of dot-notation in contacts…
malek10xdev Jun 26, 2026
481bf84
fix(enrich): add missing user_id field to enriched contact data
malek10xdev Jun 26, 2026
a441cef
feat: add IMAP boxes endpoint to emails-fetcher microservice
malek10xdev Jun 25, 2026
591aa1e
feat: migrate frontend IMAP boxes to use edge function directly
malek10xdev Jun 25, 2026
eca4599
feat: update IMAP edge function with Hono and /boxes proxy endpoint
malek10xdev Jun 25, 2026
4a59e59
chore: remove deprecated backend IMAP boxes routes and code
malek10xdev Jun 25, 2026
e7eddc8
feat(config): register export-contacts edge function in Docker runtime
malek10xdev Jun 26, 2026
9eb303f
fix: add hono import map, EMAILS_FETCHER_URL env, header type fix
malek10xdev Jun 26, 2026
1de1c7d
refactor(enrich): port Engine interface from backend
malek10xdev Jun 26, 2026
4d86d23
refactor(enrich): port Enricher orchestrator from backend
malek10xdev Jun 26, 2026
c9dcdd7
refactor(enrich): port engine classes (EnrichLayer, TheDig, Voilanorb…
malek10xdev Jun 26, 2026
7e35076
feat(frontend): migrate enrichment to call edge function directly
malek10xdev Jun 26, 2026
f7315a0
feat(config): register imap edge function in Docker runtime
malek10xdev Jun 26, 2026
4c5a9ac
chore: remove deprecated backend enrichment routes and services
malek10xdev Jun 26, 2026
a32956b
merge: resolve conflicts with main — keep enrich edge function refact…
malek10xdev Jun 26, 2026
a6081fe
fix: remove orphaned googleContacts test referencing deleted export p…
malek10xdev Jun 26, 2026
e92ab26
fix: replace unsafe as string env casts with getRequiredEnv in edge f…
malek10xdev Jun 26, 2026
93ba93c
fix: add service role auth to passive-mining endpoint and use structu…
malek10xdev Jun 26, 2026
436cfde
fix: remove dead getExportedContacts/getNonExportedContacts/registerE…
malek10xdev Jun 26, 2026
a56ce0d
fix: add Zod validation to enrich /person, /person/bulk, and /webhook…
malek10xdev Jun 26, 2026
0a7082d
fix: add Zod validation to export-contacts /:type endpoint
malek10xdev Jun 26, 2026
685c67c
fix: consolidate auth middleware to _shared/middlewares.ts mixedAuth
malek10xdev Jun 26, 2026
e227d85
docs: defer rate limiting to follow-up — requires Redis setup
malek10xdev Jun 26, 2026
a4b0cae
fix: remove orphaned csvHelpers test referencing deleted export paths
malek10xdev Jun 26, 2026
4d022ed
fix: add deno.json import maps and minimal i18n.ts for enrich, export…
malek10xdev Jun 26, 2026
f26bdde
fix: remove unused FetchError import and unused data parameter in Exp…
malek10xdev Jun 29, 2026
b78b97b
fix: address oracle review findings
malek10xdev Jun 29, 2026
205f793
fix: remove unused miningSources param and run prettier (CI fixes)
malek10xdev Jun 29, 2026
fbdd88f
fix: resolve type checking errors in edge functions
malek10xdev Jun 29, 2026
44d6ded
style: prettier formatting on contacts.routes.ts after miningSources …
malek10xdev Jun 29, 2026
feccb55
fix(shared/i18n): remove unused Resource type, replace as any with pr…
malek10xdev Jun 30, 2026
7d06aca
fix(shared/rate-limiter): replace all any types with proper Redis typing
malek10xdev Jun 30, 2026
b31a9dd
fix(export-contacts): remove unused imports and unused parameter
malek10xdev Jun 30, 2026
5fcb068
fix(enrich/i18n): remove unused i18n and Resources variables
malek10xdev Jun 30, 2026
3bc85fe
fix(deepsource): remove unused vars/parameters
malek10xdev Jun 30, 2026
9ef6463
fix(deepsource): make methods static where this unused, add skipcq fo…
malek10xdev Jun 30, 2026
e342cc1
fix(deepsource): remove async without await
malek10xdev Jun 30, 2026
8baff07
fix(deepsource): replace template strings, remove returns, suppress t…
malek10xdev Jun 30, 2026
c50198c
fix(deepsource): suppress non-null assertions in imapTreeHelpers
malek10xdev Jun 30, 2026
292b527
fix(deepsource): replace ExportType type cast with any in test
malek10xdev Jun 30, 2026
f60f335
fix(deepsource): add missing skipcq for enrichAsync this-not-used
malek10xdev Jun 30, 2026
748d0c2
style: replace as any with as unknown as ExportType per AGENTS.md
malek10xdev Jun 30, 2026
0730d35
fix(deepsource): correct skipcq codes JS-0356→JS-0105 for this-not-us…
malek10xdev Jun 30, 2026
7cfcb2a
refactor(deepsource): make Enricher.sync/asyncRun static, update call…
malek10xdev Jun 30, 2026
6f99735
refactor(enrich): extract 4 helpers from POST /person/bulk handler to…
malek10xdev Jun 30, 2026
473dc27
fix(deepsource): replace non-null assertions in imapTreeHelpers with …
malek10xdev Jun 30, 2026
50b71a6
refactor(export-contacts): convert export methods to static class mem…
malek10xdev Jun 30, 2026
2e74c75
refactor(enrich): promote stateless engine checks to static members
malek10xdev Jun 30, 2026
83c3c38
fix(types): narrow parent/children in PopulatedTree via type guard
malek10xdev Jun 30, 2026
0e144dc
fix(deepsource): replace as any with as unknown as ExportType in fact…
malek10xdev Jun 30, 2026
76c6caf
refactor(export-contacts): convert CsvExport from static-only class t…
malek10xdev Jun 30, 2026
0798e8f
refactor(export-contacts): convert VCardExport from static-only class…
malek10xdev Jun 30, 2026
cd3d477
refactor(export-contacts): convert GoogleContactsExport from static-o…
malek10xdev Jun 30, 2026
aad3f1d
refactor(shared): replace custom rate-limiter with rate-limiter-flexi…
malek10xdev Jul 1, 2026
33ca938
refactor(enrich): port backend enrichment engines and DB clients to edge
malek10xdev Jul 1, 2026
2fc8ffb
fix(deepsource): remove unused task var and make buildContactsDbPaylo…
malek10xdev Jul 1, 2026
43b06e2
fix(enrich): move secrets to .env.dev and fix URL string concat in En…
malek10xdev Jul 2, 2026
1b0b8d0
fix(sms-campaigns): use + prefix normalization in libphonenumber-js, …
malek10xdev Jul 2, 2026
bd50b33
feat(mocks): add TheDig mock endpoint for local enrichment testing
malek10xdev Jul 2, 2026
4ef0490
fix(enrich): set verify_jwt=false, rely on per-route authMiddleware f…
malek10xdev Jul 3, 2026
3eea039
fix: address CI Prettier + DeepSource MINOR + false-positive CRITICAL
malek10xdev Jul 3, 2026
6a791e4
feat(edge): graceful rate-limiter fallback for outbound API calls
malek10xdev Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .deepsource.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ exclude_patterns = [
"frontend/public",
"backend/logs",
"backend/coverage",
"backend/.nyc_output"
"backend/.nyc_output",
"supabase/functions/.env.dev",
"supabase/functions/.env.example"
]

[[analyzers]]
Expand Down
25 changes: 25 additions & 0 deletions .rate-limiting-deferred.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Rate Limiting — Deferred to Follow-up

Rate limiting on enrich + export-contacts endpoints was deferred because:

1. **Redis dependency**: `_shared/rate-limiter.ts` requires `REDIS_URL` env var. If Redis isn't configured, every request would throw on `getRedisClient()`.
2. **No new code constraint**: The "no new code, no new logic" directive from the user means we can't add new middleware configuration patterns or fallbacks.
3. **Existing rate limiter exists but is unused**: `_shared/rate-limiter.ts` already provides `withRateLimit()` — it just isn't wired in.

## Follow-up Tasks

1. Verify REDIS_URL is configured in Supabase Edge Function settings (dashboard or CLI)
2. Wire `withRateLimit()` into enrich `/person` (weight 1 per request)
3. Wire `withRateLimit()` into enrich `/person/bulk` (weight = contact count)
4. Wire `withRateLimit()` into export-contacts `/:type` (weight 1 per request)
5. Add graceful fallback when Redis is unavailable
6. Test rate limit triggers at quota thresholds

## Risk

Without rate limiting, an authenticated user could:
- Trigger unlimited enrichment API calls (credit-burning)
- Generate unlimited export jobs (Google API quota)
- Exhaust edge function concurrency

This is a known regression from the backend (which had per-endpoint `rateLimit({windowMs: 15*60*1000, max: 100})` middleware).
4 changes: 0 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import notFound from './middleware/notFound';
import initializeSentry from './middleware/sentry';
import initializeAuthRoutes from './routes/auth.routes';
import initializeContactsRoutes from './routes/contacts.routes';
import initializeEnrichmentRoutes from './routes/enrichment.routes';
import initializeImapRoutes from './routes/imap.routes';
import initializeMiningRoutes from './routes/mining.routes';
import initializeSmtpSendersRoutes from './routes/smtp-senders.routes';
import initializeStreamRouter from './routes/stream.routes';
Expand Down Expand Up @@ -65,7 +63,6 @@ export default function initializeApp(
);

app.use('/api/auth', initializeAuthRoutes());
app.use('/api/imap', initializeImapRoutes(authResolver, miningSourceService));
app.use('/api/imap', initializeStreamRouter(miningEngine, authResolver));
app.use(
'/api/imap',
Expand All @@ -81,7 +78,6 @@ export default function initializeApp(
'/api',
initializeContactsRoutes(contacts, authResolver, miningSourceService)
);
app.use('/api/enrich', initializeEnrichmentRoutes(authResolver));
app.use(
'/api',
initializeSmtpSendersRoutes(smtpSenders, authResolver, miningSources)
Expand Down
276 changes: 2 additions & 274 deletions backend/src/controllers/contacts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,275 +1,3 @@
import { User } from '@supabase/supabase-js';
import { NextFunction, Request, Response } from 'express';
import { Contacts } from '../db/interfaces/Contacts';
import { Contact, ExportService } from '../db/types';
import Billing from '../utils/billing-plugin';
import { ExportOptions, ExportType } from '../services/export/types';
import ExportFactory from '../services/export';
import {
MiningSources,
OAuthMiningSourceCredentials
} from '../db/interfaces/MiningSources';

async function validateRequest(
req: Request,
res: Response,
miningSourceService: MiningSources
) {
const user = res.locals.user as User;
const partialExport = req.body.partialExport ?? false;
const updateEmptyFieldsOnly = req.body.updateEmptyFieldsOnly ?? false;
const exportType = (req.params.exportType ?? ExportType.CSV) as ExportType;

const localeFromHeader = req.headers['accept-language'];
const delimiterOption = req.query.delimiter?.toString();

const validExportType = Object.values(ExportType).includes(
exportType as ExportType
);

if (!validExportType) {
throw new Error(`Invalid export type: ${exportType}`);
}

let googleContactsOptions: ExportOptions['googleContactsOptions'] = {
userId: user.id,
accessToken: undefined,
refreshToken: undefined,
updateEmptyFieldsOnly
};

if (exportType === ExportType.GOOGLE_CONTACTS) {
const targetEmail = req.body.targetEmail || user.email;
const sources = await miningSourceService.getSourcesForUser(
user.id,
targetEmail as string
);
const oauthCredentials = sources?.find((e) => e.email === targetEmail)
?.credentials as OAuthMiningSourceCredentials;

googleContactsOptions = {
...googleContactsOptions,
accessToken: oauthCredentials?.accessToken,
refreshToken: oauthCredentials?.refreshToken
};
}

const {
ids,
exportAllContacts
}: { ids?: string[]; exportAllContacts: boolean } = req.body;

if (!exportAllContacts && (!Array.isArray(ids) || !ids.length)) {
return {
userId: user.id,
exportType,
contactsToExport: null,
partialExport,
exportOptions: {
locale: localeFromHeader,
delimiter: undefined,
googleContactsOptions
}
};
}

const contactsToExport = exportAllContacts ? undefined : ids;

return {
userId: user.id,
exportType,
contactsToExport,
partialExport,
exportOptions: {
locale: localeFromHeader,
delimiter: delimiterOption,
googleContactsOptions
}
};
}

async function respondWithContacts(
res: Response,
userId: string,
contacts: Contacts,
exportType: ExportType,
contactsToExport?: string[],
exportOption?: ExportOptions
) {
const selectedContacts = await contacts.getContacts(userId, contactsToExport);

if (!selectedContacts.length) {
return res.sendStatus(204); // 204 No Content
}

try {
const { content, contentType } = await ExportFactory.get(exportType).export(
selectedContacts,
exportOption
);

return res.header('Content-Type', contentType).status(200).send(content);
} catch (err) {
if ((err as Error).message === 'Invalid credentials.') {
return res.sendStatus(401);
}
throw err;
}
}

async function verifyCredits(
userId: string,
contacts: Contacts,
contactsToExport?: string[]
) {
const newContacts = await contacts.getNonExportedContacts(
userId,
contactsToExport
);
const previousExportedContacts = await contacts.getExportedContacts(
userId,
contactsToExport
);
const creditsInfo = await Billing!.validateCustomerCredits(
userId,
newContacts.length
);
const response = {
total: newContacts.length + previousExportedContacts.length,
available: Math.floor(creditsInfo.availableUnits),
availableAlready: previousExportedContacts.length
};
return {
newContacts,
previousExportedContacts,
creditsInfo,
response
};
}

async function registerAndDeductCredits(
userId: string,
exportType: ExportType,
availableUnits: number,
contacts: Contacts,
availableContacts: Contact[]
) {
if (availableContacts.length) {
await contacts.registerExportedContacts(
availableContacts.map(({ id }) => id),
exportType as unknown as ExportService,
userId
);
await Billing!.deductCustomerCredits(userId, availableUnits);
}
}

async function respondWithConfirmedContacts(
res: Response,
userId: string,
exportType: ExportType,
contacts: Contacts,
newContacts: Contact[],
previousExportedContacts: Contact[],
availableUnits: number,
statusCode: number,
exportOptions?: ExportOptions
) {
const availableContacts = newContacts.slice(0, availableUnits);
const selectedContacts = [...previousExportedContacts, ...availableContacts];

try {
const { content, contentType } = await ExportFactory.get(exportType).export(
selectedContacts,
exportOptions
);

await registerAndDeductCredits(
userId,
exportType,
availableUnits,
contacts,
selectedContacts
);

return res
.header('Content-Type', contentType)
.status(statusCode)
.send(content);
} catch (err) {
if ((err as Error).message === 'Invalid credentials.') {
return res.sendStatus(401);
}
throw err;
}
}

export default function initializeContactsController(
contacts: Contacts,
miningSources: MiningSources
) {
return {
async exportContactsCSV(req: Request, res: Response, next: NextFunction) {
const {
userId,
contactsToExport,
partialExport,
exportType,
exportOptions
} = await validateRequest(req, res, miningSources);
if (contactsToExport === null) {
return res.status(400).json({
message: 'Parameter "ids" must be a non-empty list of person ids'
});
}

let statusCode = 200;
try {
if (!Billing) {
// No need to Verify Credits, Export.
return await respondWithContacts(
res,
userId,
contacts,
exportType,
contactsToExport,
exportOptions
);
}

const { newContacts, previousExportedContacts, creditsInfo, response } =
await verifyCredits(userId, contacts, contactsToExport);

if (
creditsInfo.hasDeficientCredits &&
!previousExportedContacts.length
) {
return res.status(402).json(response);
}

if (creditsInfo.hasInsufficientCredits && newContacts.length) {
if (!partialExport) {
res.statusMessage = 'Confirm Partial Content';
return res.status(266).json(response); // 266 Confirm Partial Content
}
statusCode = 206; // 206 Partial Content
}

// Export confirmed contacts.
return await respondWithConfirmedContacts(
res,
userId,
exportType,
contacts,
newContacts,
previousExportedContacts,
creditsInfo.availableUnits,
statusCode,
exportOptions
);
} catch (error) {
return next(error);
}
}
};
export default function initializeContactsController() {
return {};
}
Loading
Loading