Skip to content

Commit 17da3e9

Browse files
authored
Merge pull request #2664 from ankaboot-source/feat/add-fetch-mining-source-edge-function
feat: add central fetch-mining-source edge function
2 parents ed9e4a0 + 9003524 commit 17da3e9

File tree

11 files changed

+401
-63
lines changed

11 files changed

+401
-63
lines changed

AGENTS.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,34 @@ npm run build # Compile TypeScript
8484
npm run dev:supabase-functions # Serve functions locally
8585
```
8686

87+
#### Creating New Edge Functions
88+
89+
**Always use the Supabase CLI to create new edge functions:**
90+
91+
```bash
92+
# Create new edge function (auto-generates proper structure)
93+
npx supabase functions new <function-name>
94+
```
95+
96+
**Why?** The CLI:
97+
98+
- Creates proper directory structure
99+
- Sets up deno.json with correct permissions
100+
- Ensures function is registered with Supabase
101+
102+
**Example workflow:**
103+
104+
```bash
105+
# 1. Create the function scaffold
106+
npx supabase functions new my-new-function
107+
108+
# 2. Implement your code in the generated index.ts
109+
# (copy your implementation into the created file)
110+
111+
# 3. Test locally
112+
npm run dev:supabase-functions
113+
```
114+
87115
## Code Style Guidelines
88116

89117
### TypeScript Configuration

backend/.env.dev

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ LEADMINER_API_LOG_LEVEL = debug # Logging level (debug, info, notice, warning.
2222
# ==============| API - SUPABASE |============= #
2323
SUPABASE_PROJECT_URL = http://127.0.0.1:54321 # ( REQUIRED ) Supabase project URL (e.g., https://db.yourdomain.com for self-hosted/prod)
2424
SUPABASE_SECRET_PROJECT_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU # ( REQUIRED ) Supabase project token
25+
SUPABASE_SERVICE_ROLE_KEY = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU # ( REQUIRED ) Supabase service role key
2526
PG_CONNECTION_STRING = postgresql://postgres:postgres@127.0.0.1:54322/postgres # ( REQUIRED ) Postgres connection string
2627

2728

backend/src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const schema = z.object({
4545
/* SUPABASE + POSTGRES */
4646
SUPABASE_PROJECT_URL: z.string().url(),
4747
SUPABASE_SECRET_PROJECT_TOKEN: z.string().min(1),
48+
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
4849
PG_CONNECTION_STRING: z.string().url(),
4950

5051
/* SENTRY */

backend/src/controllers/imap.controller.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import azureOAuth2Client from '../services/OAuth2/azure';
99
import googleOAuth2Client from '../services/OAuth2/google';
1010
import ImapBoxesFetcher from '../services/imap/ImapBoxesFetcher';
1111
import ImapConnectionProvider from '../services/imap/ImapConnectionProvider';
12+
import { miningSourceService } from '../services/MiningSourceService';
1213
import { ImapAuthError } from '../utils/errors';
1314
import hashEmail from '../utils/helpers/hashHelpers';
1415
import validateType from '../utils/helpers/validation';
@@ -93,20 +94,34 @@ export default function initializeImapController(miningSources: MiningSources) {
9394
});
9495

9596
if (token.expired(1000)) {
96-
const newToken = (await token.refresh()).token as NewToken;
97-
98-
await upsertMiningSource(
99-
miningSources,
97+
const { sources } = await miningSourceService.getSourcesForUser(
10098
userId,
101-
{
102-
...newToken,
103-
refresh_token: newToken.refresh_token ?? refreshToken
104-
},
105-
provider,
10699
data.email
107100
);
108101

109-
data.accessToken = newToken.access_token;
102+
const refreshedSource = sources.find((s) => s.email === data.email);
103+
if (
104+
refreshedSource &&
105+
'accessToken' in refreshedSource.credentials
106+
) {
107+
data.accessToken = refreshedSource.credentials
108+
.accessToken as string;
109+
} else {
110+
const newToken = (await token.refresh()).token as NewToken;
111+
112+
await upsertMiningSource(
113+
miningSources,
114+
userId,
115+
{
116+
...newToken,
117+
refresh_token: newToken.refresh_token ?? refreshToken
118+
},
119+
provider,
120+
data.email
121+
);
122+
123+
data.accessToken = newToken.access_token;
124+
}
110125
}
111126
}
112127

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import ENV from '../config';
2+
3+
export interface MiningSourceWithCredentials {
4+
email: string;
5+
type: string;
6+
credentials: Record<string, unknown>;
7+
}
8+
9+
export class MiningSourceService {
10+
private supabaseUrl: string;
11+
12+
constructor() {
13+
this.supabaseUrl = ENV.SUPABASE_PROJECT_URL;
14+
}
15+
16+
async getSourcesForUser(
17+
userId: string,
18+
email?: string
19+
): Promise<{
20+
sources: MiningSourceWithCredentials[];
21+
refreshed: string[];
22+
}> {
23+
const response = await fetch(
24+
`${this.supabaseUrl}/functions/v1/fetch-mining-source`,
25+
{
26+
method: 'POST',
27+
headers: {
28+
'Content-Type': 'application/json',
29+
'x-service-key': ENV.SUPABASE_SERVICE_ROLE_KEY
30+
},
31+
body: JSON.stringify({
32+
email: email || 'all',
33+
mode: 'service',
34+
user_id: userId
35+
})
36+
}
37+
);
38+
39+
if (!response.ok) {
40+
const error = await response.text();
41+
throw new Error(`Failed to fetch mining sources: ${error}`);
42+
}
43+
44+
return response.json();
45+
}
46+
}
47+
48+
export const miningSourceService = new MiningSourceService();

supabase/config.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,14 @@ entrypoint = "./functions/delete-mining-source/index.ts"
337337
# Specifies static files to be bundled with the function. Supports glob patterns.
338338
# For example, if you want to serve static HTML pages in your function:
339339
# static_files = [ "./functions/delete-mining-source/*.html" ]
340+
341+
[functions.fetch-mining-source]
342+
enabled = true
343+
verify_jwt = true
344+
import_map = "./functions/fetch-mining-source/deno.json"
345+
# Uncomment to specify a custom file path to the entrypoint.
346+
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
347+
entrypoint = "./functions/fetch-mining-source/index.ts"
348+
# Specifies static files to be bundled with the function. Supports glob patterns.
349+
# For example, if you want to serve static HTML pages in your function:
350+
# static_files = [ "./functions/fetch-mining-source/*.html" ]

supabase/functions/email-campaigns/index.ts

Lines changed: 23 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -526,22 +526,30 @@ async function getAuthenticatedUser(c: Context) {
526526
}
527527

528528
async function getUserMiningSources(authorization: string) {
529-
const supabase = createSupabaseClient(authorization);
530-
const { data, error } = await supabase
531-
.schema("private")
532-
.rpc("get_user_mining_source_credentials", {
533-
_encryption_key: LEADMINER_API_HASH_SECRET,
534-
});
529+
const response = await fetch(`${SUPABASE_URL}/functions/v1/fetch-mining-source`, {
530+
method: "POST",
531+
headers: {
532+
"Content-Type": "application/json",
533+
"Authorization": authorization,
534+
},
535+
body: JSON.stringify({ email: "all" }),
536+
});
535537

536-
if (error) {
537-
throw new Error(`Unable to fetch mining credentials: ${error.message}`);
538+
if (!response.ok) {
539+
const errorText = await response.text();
540+
throw new Error(`Failed to fetch mining sources: ${response.status} ${errorText}`);
538541
}
539542

540-
return (data ?? []) as {
541-
email: string;
542-
type: string;
543-
credentials: Record<string, unknown>;
544-
}[];
543+
const result = await response.json() as {
544+
sources: { email: string; type: string; credentials: Record<string, unknown> }[];
545+
refreshed: string[];
546+
};
547+
548+
if (result.refreshed.length > 0) {
549+
logger.info("Tokens refreshed via central function", { emails: result.refreshed });
550+
}
551+
552+
return result.sources;
545553
}
546554

547555
async function guessCustomSmtpHost(email: string) {
@@ -635,43 +643,11 @@ async function resolveSenderOptions(authorization: string, userEmail: string) {
635643
const transportBySender: Record<string, Transport | null> = {
636644
[fallbackSenderEmail]: null,
637645
};
638-
const supabaseAdmin = createSupabaseAdmin();
639646

640647
const sources = listUniqueSenderSources(
641648
await getUserMiningSources(authorization),
642649
);
643650

644-
// Refresh expired OAuth tokens
645-
for (let i = 0; i < sources.length; i++) {
646-
const source = sources[i];
647-
const credentialIssue = getSenderCredentialIssue(source);
648-
649-
if (credentialIssue?.includes("expired")) {
650-
try {
651-
const refreshed = await refreshOAuthToken(source);
652-
if (refreshed) {
653-
const updated = await updateMiningSourceCredentials(
654-
supabaseAdmin,
655-
source.email,
656-
refreshed.credentials,
657-
);
658-
if (updated) {
659-
sources[i] = refreshed;
660-
}
661-
} else {
662-
logger.warn("Could not refresh token, source will remain unavailable", {
663-
email: source.email,
664-
});
665-
}
666-
} catch (error) {
667-
logger.error("Failed to refresh token for source", {
668-
email: source.email,
669-
error: error instanceof Error ? error.message : String(error),
670-
});
671-
}
672-
}
673-
}
674-
675651
for (const source of sources) {
676652
const nowMs = Date.now();
677653
const expired = isTokenExpired(source.credentials, nowMs);
@@ -2068,9 +2044,9 @@ app.get("/unsubscribe/:token", async (c: Context) => {
20682044
status: 302,
20692045
headers: {
20702046
...corsHeaders,
2071-
Location: success });
2072-
});Url,
2047+
Location: successUrl,
20732048
},
2049+
});
20742050

20752051

20762052
app.get("/track/open/:token", async (c: Context) => {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Configuration for private npm package dependencies
2+
# For more information on using private registries with Edge Functions, see:
3+
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"imports": {}
3+
}

0 commit comments

Comments
 (0)