Skip to content

Commit c12ad3e

Browse files
authored
fix: keep sender options visible and normalize source emails (#2626)
1 parent 5456e72 commit c12ad3e

File tree

6 files changed

+201
-29
lines changed

6 files changed

+201
-29
lines changed

frontend/src/components/campaigns/CampaignComposerDialog.vue

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,21 @@
6666
</label>
6767
<Select
6868
v-model="form.senderEmail"
69-
:options="availableSenderEmails"
69+
:options="senderOptions"
70+
option-label="label"
71+
option-value="email"
72+
option-disabled="disabled"
7073
:loading="isLoadingSenderOptions"
7174
@update:model-value="markTouched('senderEmail')"
7275
/>
7376
<small v-if="showFieldError('senderEmail')" class="text-red-500">
7477
{{ validationErrors.senderEmail }}
7578
</small>
79+
<small v-else-if="unavailableSenderCount" class="text-amber-600">
80+
{{
81+
t('some_senders_unavailable', { count: unavailableSenderCount })
82+
}}
83+
</small>
7684
</div>
7785

7886
<div class="flex flex-col gap-1 md:col-span-2">
@@ -363,7 +371,15 @@ const dialogHeader = computed(() =>
363371
t('send_email_campaign_with_count', { count: selectedEmails.value.length }),
364372
);
365373
366-
const availableSenderEmails = ref<string[]>([]);
374+
type SenderOptionItem = {
375+
email: string;
376+
available: boolean;
377+
reason?: string;
378+
label: string;
379+
disabled: boolean;
380+
};
381+
382+
const senderOptions = ref<SenderOptionItem[]>([]);
367383
const fallbackSenderEmail = ref('');
368384
const runtimeConfig = useRuntimeConfig();
369385
@@ -448,6 +464,10 @@ type EdgeResponseError = {
448464
};
449465
};
450466
467+
const unavailableSenderCount = computed(
468+
() => senderOptions.value.filter((option) => option.disabled).length,
469+
);
470+
451471
type FormField = 'senderName' | 'senderEmail' | 'replyTo' | 'subject' | 'body';
452472
453473
const touched = reactive<Record<FormField, boolean>>({
@@ -882,22 +902,57 @@ async function loadSenderOptions() {
882902
883903
fallbackSenderEmail.value = data.fallbackSenderEmail || '';
884904
form.senderDailyLimit = Number(data.defaultDailyLimit || 1000);
885-
availableSenderEmails.value = (data.options || [])
886-
.filter((option: { available: boolean }) => option.available)
887-
.map((option: { email: string }) => option.email);
905+
senderOptions.value = (data.options || []).map(
906+
(option: { email: string; available: boolean; reason?: string }) => {
907+
const reason = String(option.reason || '').trim();
908+
const unavailableLabel = reason
909+
? t('sender_unavailable_reason', {
910+
email: option.email,
911+
reason,
912+
})
913+
: t('sender_unavailable', {
914+
email: option.email,
915+
});
916+
917+
return {
918+
email: option.email,
919+
available: option.available,
920+
reason,
921+
label: option.available ? option.email : unavailableLabel,
922+
disabled: !option.available,
923+
};
924+
},
925+
);
888926
889-
if (!availableSenderEmails.value.length) {
890-
if (fallbackSenderEmail.value) {
891-
availableSenderEmails.value = [fallbackSenderEmail.value];
892-
}
927+
if (!senderOptions.value.length && fallbackSenderEmail.value) {
928+
senderOptions.value = [
929+
{
930+
email: fallbackSenderEmail.value,
931+
available: true,
932+
label: fallbackSenderEmail.value,
933+
disabled: false,
934+
},
935+
];
893936
}
894937
895-
if (!availableSenderEmails.value.includes(form.senderEmail)) {
896-
form.senderEmail = availableSenderEmails.value[0] || '';
938+
const firstAvailable =
939+
senderOptions.value.find((option) => !option.disabled)?.email || '';
940+
const selected = senderOptions.value.find(
941+
(option) => option.email === form.senderEmail,
942+
);
943+
if (!selected || selected.disabled) {
944+
form.senderEmail = firstAvailable;
897945
}
898946
} catch (error: unknown) {
899947
if (fallbackSenderEmail.value) {
900-
availableSenderEmails.value = [fallbackSenderEmail.value];
948+
senderOptions.value = [
949+
{
950+
email: fallbackSenderEmail.value,
951+
available: true,
952+
label: fallbackSenderEmail.value,
953+
disabled: false,
954+
},
955+
];
901956
form.senderEmail = fallbackSenderEmail.value;
902957
}
903958
$toast.add({
@@ -1105,6 +1160,9 @@ watch(
11051160
"sender_name_help": "The sender name displayed in your recipient inbox.",
11061161
"sender_email": "Sender email",
11071162
"sender_email_help": "The email address used to send this campaign.",
1163+
"sender_unavailable": "{email} (unavailable)",
1164+
"sender_unavailable_reason": "{email} (unavailable: {reason})",
1165+
"some_senders_unavailable": "{count} sender option(s) are currently unavailable.",
11081166
"reply_to": "Reply-to",
11091167
"reply_to_help": "Replies from recipients will be sent to this email address.",
11101168
"subject": "Subject",
@@ -1195,6 +1253,9 @@ watch(
11951253
"sender_name_help": "Nom affiché dans la boîte de réception de vos destinataires.",
11961254
"sender_email": "Adresse d'expédition",
11971255
"sender_email_help": "Adresse email utilisée pour envoyer cette campagne.",
1256+
"sender_unavailable": "{email} (indisponible)",
1257+
"sender_unavailable_reason": "{email} (indisponible : {reason})",
1258+
"some_senders_unavailable": "{count} option(s) d'expéditeur sont actuellement indisponibles.",
11981259
"reply_to": "Répondre à",
11991260
"reply_to_help": "Les réponses de vos destinataires seront envoyées à cette adresse.",
12001261
"subject": "Sujet",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
2+
import { normalizeEmail } from "./email.ts";
3+
4+
Deno.test("normalizeEmail trims and lowercases", () => {
5+
assertEquals(
6+
normalizeEmail(" Bader.Lejmi@GMAIL.com "),
7+
"bader.lejmi@gmail.com",
8+
);
9+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function normalizeEmail(email: string): string {
2+
return email.trim().toLowerCase();
3+
}

supabase/functions/email-campaigns/index.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ import {
66
createSupabaseAdmin,
77
createSupabaseClient,
88
} from "../_shared/supabase.ts";
9+
import { normalizeEmail } from "../_shared/email.ts";
910
import { resolveCampaignBaseUrlFromEnv } from "../_shared/url.ts";
1011
import { sendEmail, verifyTransport } from "./email.ts";
12+
import {
13+
getSenderCredentialIssue,
14+
listUniqueSenderSources,
15+
} from "./sender-options.ts";
1116

1217
const functionName = "email-campaigns";
1318
const app = new Hono().basePath(`/${functionName}`);
@@ -25,7 +30,7 @@ const CAMPAIGN_COMPLIANCE_FOOTER = (
2530
const PUBLIC_CAMPAIGN_BASE_URL = resolveCampaignBaseUrlFromEnv((key) =>
2631
Deno.env.get(key),
2732
);
28-
const SMTP_USER = (Deno.env.get("SMTP_USER") || "").trim().toLowerCase();
33+
const SMTP_USER = normalizeEmail(Deno.env.get("SMTP_USER") || "");
2934
const DEFAULT_SENDER_DAILY_LIMIT = 1000;
3035
const MAX_SENDER_DAILY_LIMIT = 2000;
3136
const PROCESSING_BATCH_SIZE = 300;
@@ -564,34 +569,58 @@ async function resolveSenderOptions(authorization: string, userEmail: string) {
564569
const transportBySender: Record<string, Transport | null> = {
565570
[fallbackSenderEmail]: null,
566571
};
567-
const sources = await getUserMiningSources(authorization);
568-
const matchingSource = sources.find((source) => source.email === userEmail);
572+
const sources = listUniqueSenderSources(
573+
await getUserMiningSources(authorization),
574+
);
575+
576+
for (const source of sources) {
577+
const credentialIssue = getSenderCredentialIssue(source);
578+
if (credentialIssue) {
579+
options.push({
580+
email: source.email,
581+
available: false,
582+
reason: credentialIssue,
583+
});
584+
continue;
585+
}
569586

570-
if (!matchingSource) {
571-
options.push({
572-
email: userEmail,
573-
available: false,
574-
reason: "No matching mining source credentials",
575-
});
576-
} else {
577587
try {
578588
const transport = await buildUserTransport(
579-
userEmail,
580-
matchingSource.credentials,
589+
source.email,
590+
source.credentials,
581591
);
582592
await verifyTransport(transport);
583-
options.push({ email: userEmail, available: true });
584-
transportBySender[userEmail] = transport;
593+
options.push({ email: source.email, available: true });
594+
transportBySender[source.email] = transport;
585595
} catch (error) {
586596
options.push({
587-
email: userEmail,
597+
email: source.email,
588598
available: false,
589599
reason: extractErrorMessage(error),
590600
});
591601
}
592602
}
593603

594-
if (!options.some((option) => option.email === fallbackSenderEmail)) {
604+
const normalizedUserEmail = normalizeEmail(userEmail);
605+
if (
606+
!options.some(
607+
(option) => normalizeEmail(option.email) === normalizedUserEmail,
608+
)
609+
) {
610+
options.push({
611+
email: userEmail,
612+
available: false,
613+
reason: "No matching mining source credentials",
614+
});
615+
}
616+
617+
const fallbackOption = options.find(
618+
(option) => option.email === fallbackSenderEmail,
619+
);
620+
if (fallbackOption) {
621+
fallbackOption.available = true;
622+
delete fallbackOption.reason;
623+
} else {
595624
options.push({ email: fallbackSenderEmail, available: true });
596625
}
597626

@@ -603,8 +632,11 @@ async function resolveSenderOptions(authorization: string, userEmail: string) {
603632
}
604633

605634
function ensureAllowedSender(senderEmail: string, options: SenderOption[]) {
635+
const normalizedSenderEmail = normalizeEmail(senderEmail);
606636
return options.some(
607-
(option) => option.email === senderEmail && option.available,
637+
(option) =>
638+
normalizeEmail(option.email) === normalizedSenderEmail &&
639+
option.available,
608640
);
609641
}
610642

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
2+
import {
3+
getSenderCredentialIssue,
4+
listUniqueSenderSources,
5+
} from "./sender-options.ts";
6+
7+
Deno.test("listUniqueSenderSources keeps distinct sender emails", () => {
8+
const sources = [
9+
{ email: "bader.lejmi@gmail.com", type: "google", credentials: {} },
10+
{ email: "sales@acme.io", type: "imap", credentials: {} },
11+
{ email: "BADER.LEJMI@gmail.com", type: "google", credentials: {} },
12+
];
13+
14+
const result = listUniqueSenderSources(sources);
15+
assertEquals(result.length, 2);
16+
assertEquals(result[0].email, "bader.lejmi@gmail.com");
17+
assertEquals(result[1].email, "sales@acme.io");
18+
});
19+
20+
Deno.test("getSenderCredentialIssue flags expired OAuth token", () => {
21+
const issue = getSenderCredentialIssue(
22+
{
23+
email: "bader.lejmi@gmail.com",
24+
type: "google",
25+
credentials: { expiresAt: 1000 },
26+
},
27+
2000,
28+
);
29+
30+
assertEquals(issue, "OAuth token expired. Reconnect this source.");
31+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { normalizeEmail } from "../_shared/email.ts";
2+
3+
export type MiningSourceCredential = {
4+
email: string;
5+
type: string;
6+
credentials: Record<string, unknown>;
7+
};
8+
9+
export function listUniqueSenderSources(
10+
sources: MiningSourceCredential[],
11+
): MiningSourceCredential[] {
12+
const byEmail = new Map<string, MiningSourceCredential>();
13+
for (const source of sources) {
14+
const key = normalizeEmail(source.email);
15+
if (!key || byEmail.has(key)) continue;
16+
byEmail.set(key, source);
17+
}
18+
return [...byEmail.values()];
19+
}
20+
21+
export function getSenderCredentialIssue(
22+
source: MiningSourceCredential,
23+
nowMs = Date.now(),
24+
): string | null {
25+
const kind = source.type.trim().toLowerCase();
26+
if (kind !== "google" && kind !== "azure") {
27+
return null;
28+
}
29+
30+
const expiresAt = Number(source.credentials.expiresAt);
31+
if (Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= nowMs) {
32+
return "OAuth token expired. Reconnect this source.";
33+
}
34+
35+
return null;
36+
}

0 commit comments

Comments
 (0)