Skip to content

Commit e9f5aec

Browse files
authored
feat(campaigns): refresh OAuth tokens automatically before sending campaigns (#2635)
* chore: ignore local worktree directory * fix: pass SUPABASE_PROJECT_URL to leadminer.io for edge functions deployment - Add SUPABASE_PROJECT_URL to repository_dispatch client_payload for QA and prod triggers - Add validation to fail if secret is missing before dispatching - This enables edge functions to use the public URL instead of internal kong URL for tracking links * docs: clarify SUPABASE_PROJECT_URL usage for self-hosted/prod Add clear comments explaining that SUPABASE_PROJECT_URL needs to be set to the public Supabase URL (e.g., https://db.yourdomain.com) for self-hosted and production deployments. * fix: refresh sources after mining completes/stops, update wording * fix: show GDPR toast when starting mining * feat(campaigns): add OAuth token refresh function * fix(campaigns): add error logging in OAuth token refresh * feat(campaigns): add database update function for credentials * fix(campaigns): add error logging and input validation to updateMiningSourceCredentials * fix(campaigns): add logging for silent failures in listUniqueSenderSources and refreshOAuthToken * feat(campaigns): refresh OAuth tokens before verifying sender availability * fix(campaigns): add error handling for token refresh in resolveSenderOptions * fix(campaigns): add warning when token refresh fails * style: fix DeepSource issues - optional chaining and property shorthand
1 parent 8a0f399 commit e9f5aec

File tree

5 files changed

+175
-6
lines changed

5 files changed

+175
-6
lines changed

frontend/src/i18n/messages.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@
136136
"toast_canceled_title": "Mining canceled",
137137
"toast_canceled_by_connection_detail": "Your mining was interrupted due to an internet connection issue.",
138138
"extract_signatures_option": "Extract contact details from signatures",
139-
"extract_signatures_sub": "( this may take more time )"
139+
"extract_signatures_sub": "( this may take more time )",
140+
"contacts_got_rights_title": "Your contacts have rights",
141+
"contacts_got_rights_detail": "You need consent or legitimate interest to contact them.",
142+
"learn_more_about_rights": "Learn more about your contacts' rights"
140143
},
141144
"error": {
142145
"default": {
@@ -287,7 +290,10 @@
287290
"toast_canceled_title": "Extraction annulée",
288291
"toast_canceled_by_connection_detail": "Connexion perdue. Extraction annulée. Veuillez vous reconnecter pour commencer une nouvelle extraction.",
289292
"extract_signatures_option": "Extraire les coordonnées depuis les signatures",
290-
"extract_signatures_sub": "(l'extraction prendra plus de temps)"
293+
"extract_signatures_sub": "(l'extraction prendra plus de temps)",
294+
"contacts_got_rights_title": "Vos contacts ont des droits",
295+
"contacts_got_rights_detail": "Vous avez besoin du consentement ou d'un intérêt légitime pour les contacter.",
296+
"learn_more_about_rights": "En savoir plus sur les droits de vos contacts"
291297
},
292298
"error": {
293299
"default": {

frontend/src/stores/leadminer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,9 @@ export const useLeadminerStore = defineStore('leadminer', () => {
274274
onMiningCompleted: () => {
275275
console.info('Mining marked as completed.');
276276
miningCompleted.value = true;
277-
setTimeout(() => {
277+
setTimeout(async () => {
278278
miningTask.value = undefined;
279+
await fetchMiningSources();
279280
}, 100);
280281
},
281282
});
@@ -448,11 +449,13 @@ export const useLeadminerStore = defineStore('leadminer', () => {
448449
fetchingFinished.value = true;
449450
extractionFinished.value = true;
450451
isLoadingStopMining.value = false;
452+
await fetchMiningSources();
451453
} catch (err) {
452454
fetchingFinished.value = true;
453455
extractionFinished.value = true;
454456
cleaningFinished.value = true;
455457
isLoadingStopMining.value = false;
458+
await fetchMiningSources();
456459
throw err;
457460
}
458461
}

frontend/src/utils/extras.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,26 @@ import type { NormalizedLocation } from '~/types/contact';
33
export const PrivacyPolicyButton = null;
44
export const AcceptNewsLetter = null;
55
export const CampaignButton = null;
6-
// skipcq: JS-0321
7-
export function startMiningNotification() {}
6+
7+
export function startMiningNotification() {
8+
const { t } = useI18n({ useScope: 'global' });
9+
const $toast = useToast();
10+
11+
$toast.add({
12+
severity: 'info',
13+
summary: t('mining.contacts_got_rights_title'),
14+
detail: {
15+
message: t('mining.contacts_got_rights_detail'),
16+
link: {
17+
text: t('mining.learn_more_about_rights'),
18+
url: 'https://leadminer.io/donnees-personnelles',
19+
},
20+
},
21+
life: 8000,
22+
group: 'has-links',
23+
});
24+
}
25+
826
export function getLocationUrl(location: NormalizedLocation) {
927
return `https://www.openstreetmap.org/${location.osm_type}/${location.osm_id}`;
1028
}

supabase/functions/email-campaigns/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { sendEmail, verifyTransport } from "./email.ts";
1212
import {
1313
getSenderCredentialIssue,
1414
listUniqueSenderSources,
15+
refreshOAuthToken,
16+
updateMiningSourceCredentials,
1517
} from "./sender-options.ts";
1618

1719
const functionName = "email-campaigns";
@@ -569,10 +571,43 @@ async function resolveSenderOptions(authorization: string, userEmail: string) {
569571
const transportBySender: Record<string, Transport | null> = {
570572
[fallbackSenderEmail]: null,
571573
};
574+
const supabaseAdmin = createSupabaseAdmin();
575+
572576
const sources = listUniqueSenderSources(
573577
await getUserMiningSources(authorization),
574578
);
575579

580+
for (let i = 0; i < sources.length; i++) {
581+
const source = sources[i];
582+
const credentialIssue = getSenderCredentialIssue(source);
583+
584+
if (credentialIssue?.includes("expired")) {
585+
try {
586+
const refreshed = await refreshOAuthToken(source);
587+
if (refreshed) {
588+
const updated = await updateMiningSourceCredentials(
589+
supabaseAdmin,
590+
source.email,
591+
refreshed.credentials,
592+
);
593+
if (updated) {
594+
sources[i] = refreshed;
595+
}
596+
} else {
597+
console.warn(
598+
`Could not refresh token for ${source.email}, source will remain unavailable`,
599+
);
600+
}
601+
} catch (error) {
602+
console.error(
603+
"Failed to refresh token for source:",
604+
source.email,
605+
error,
606+
);
607+
}
608+
}
609+
}
610+
576611
for (const source of sources) {
577612
const credentialIssue = getSenderCredentialIssue(source);
578613
if (credentialIssue) {

supabase/functions/email-campaigns/sender-options.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,104 @@
11
import { normalizeEmail } from "../_shared/email.ts";
2+
import { createSupabaseAdmin } from "../_shared/supabase.ts";
23

34
export type MiningSourceCredential = {
45
email: string;
56
type: string;
67
credentials: Record<string, unknown>;
78
};
89

10+
export async function refreshOAuthToken(
11+
source: MiningSourceCredential,
12+
): Promise<MiningSourceCredential | null> {
13+
const kind = source.type.trim().toLowerCase();
14+
if (kind !== "google" && kind !== "azure") {
15+
return null;
16+
}
17+
18+
const refreshToken = String(source.credentials.refreshToken || "");
19+
if (!refreshToken) {
20+
return null;
21+
}
22+
23+
let tokenUrl: string;
24+
let clientId: string;
25+
let clientSecret: string;
26+
27+
if (kind === "google") {
28+
tokenUrl = "https://oauth2.googleapis.com/token";
29+
clientId = Deno.env.get("GOOGLE_OAUTH_CLIENT_ID") || "";
30+
clientSecret = Deno.env.get("GOOGLE_OAUTH_CLIENT_SECRET") || "";
31+
} else {
32+
tokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
33+
clientId = Deno.env.get("AZURE_OAUTH_CLIENT_ID") || "";
34+
clientSecret = Deno.env.get("AZURE_OAUTH_CLIENT_SECRET") || "";
35+
}
36+
37+
if (!clientId || !clientSecret) {
38+
return null;
39+
}
40+
41+
const params = new URLSearchParams({
42+
client_id: clientId,
43+
client_secret: clientSecret,
44+
refresh_token: refreshToken,
45+
grant_type: "refresh_token",
46+
});
47+
48+
const expiresAtInput = Number(source.credentials.expiresAt);
49+
if (!Number.isFinite(expiresAtInput) || expiresAtInput <= 0) {
50+
console.warn("Missing or invalid expiresAt in source credentials");
51+
}
52+
53+
try {
54+
const response = await fetch(tokenUrl, {
55+
method: "POST",
56+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
57+
body: params.toString(),
58+
});
59+
60+
if (!response.ok) {
61+
const status = response.status;
62+
const body = await response.text();
63+
console.error(`OAuth token refresh failed: ${status} - ${body}`);
64+
return null;
65+
}
66+
67+
const tokenData = (await response.json()) as {
68+
access_token: string;
69+
refresh_token?: string;
70+
expires_in: number;
71+
};
72+
73+
const nowMs = Date.now();
74+
const expiresAt = nowMs + tokenData.expires_in * 1000;
75+
76+
return {
77+
...source,
78+
credentials: {
79+
...source.credentials,
80+
accessToken: tokenData.access_token,
81+
refreshToken: tokenData.refresh_token || refreshToken,
82+
expiresAt,
83+
},
84+
};
85+
} catch (error) {
86+
console.error("Failed to refresh OAuth token:", error);
87+
return null;
88+
}
89+
}
90+
991
export function listUniqueSenderSources(
1092
sources: MiningSourceCredential[],
1193
): MiningSourceCredential[] {
1294
const byEmail = new Map<string, MiningSourceCredential>();
1395
for (const source of sources) {
1496
const key = normalizeEmail(source.email);
15-
if (!key || byEmail.has(key)) continue;
97+
if (!key) {
98+
console.warn("Skipping source with invalid email:", source.email);
99+
continue;
100+
}
101+
if (byEmail.has(key)) continue;
16102
byEmail.set(key, source);
17103
}
18104
return [...byEmail.values()];
@@ -34,3 +120,24 @@ export function getSenderCredentialIssue(
34120

35121
return null;
36122
}
123+
124+
export async function updateMiningSourceCredentials(
125+
supabaseAdmin: ReturnType<typeof createSupabaseAdmin>,
126+
email: string,
127+
credentials: Record<string, unknown>,
128+
): Promise<boolean> {
129+
if (!email || typeof email !== "string") {
130+
console.error("Invalid email provided to updateMiningSourceCredentials");
131+
return false;
132+
}
133+
134+
const { error } = await supabaseAdmin
135+
.from("mining_sources")
136+
.update({ credentials })
137+
.eq("email", email);
138+
139+
if (error) {
140+
console.error("Failed to update mining source credentials:", error);
141+
}
142+
return !error;
143+
}

0 commit comments

Comments
 (0)