/**
* Run one provider's leg: a single send and a bulk send through the
* production delivery code, against the provider's real API. The probes
* carry the shapes that differ per provider — an attachment, a reply-to
* address, and the bulk unsubscribe substitution — so drift in any mapping
* surfaces as a refused request.
*/
import { toBase64 } from "#crypto/utils.ts";
import { randomId } from "#e2e/config.ts";
import {
BULK_UNSUBSCRIBE_PLACEHOLDER,
type BulkEmailPayload,
sendBulkEmails,
} from "#shared/email/bulk.ts";
import {
type EmailConfig,
type EmailMessage,
type EmailProvider,
sendEmail,
} from "#shared/email.ts";
import { errorMessage } from "#shared/error-message.ts";
import type { ValidEmail } from "#shared/validation/email.ts";
import { resolveEmailLeg } from "./legs.ts";
/** What one leg reports back, with a one-line fact for the summary table. */
export interface EmailLegOutcome {
detail: string;
provider: EmailProvider;
state: "sent" | "skipped" | "failed";
}
/** A tiny valid SVG, so every provider's attachment mapping is exercised. */
const PROBE_SVG =
'';
/** The address the bulk unsubscribe substitution writes into the message. */
const PROBE_UNSUBSCRIBE_URL = "https://example.com/unsubscribe";
/** One leg's allowance for its two live requests. A provider that accepts
* the connection and then stalls becomes a failed leg, not a killed job. */
const LEG_TIMEOUT_MS = 120_000;
const probeMessage = (
config: EmailConfig,
to: ValidEmail,
runId: string,
): EmailMessage => ({
attachments: [
{
content: toBase64(new TextEncoder().encode(PROBE_SVG)),
contentType: "image/svg+xml",
filename: "probe.svg",
},
],
html: `
Single-send probe ${runId} for ${config.provider}.
`,
recipients: [{ to, unsubscribeUrl: PROBE_UNSUBSCRIBE_URL }],
subject: `Email sandbox e2e ${runId} (${provider}, bulk)`,
text: `Bulk probe ${runId} for ${provider}. Unsubscribe: ${BULK_UNSUBSCRIBE_PLACEHOLDER}`,
});
/** A reply flattened to one short Markdown-table-safe line, with every
* email-shaped token blanked — a provider can echo the configured sender
* or recipient, and an address must not reach a CI log. */
const oneLine = (text: string): string =>
text
.replaceAll(/\S+@\S+/g, "[redacted]")
.replaceAll(/[|\s]+/g, " ")
.trim()
.slice(0, 160);
const runReadyLeg = async (
config: EmailConfig,
to: ValidEmail,
signal: AbortSignal,
): Promise => {
const runId = randomId();
const single = await sendEmail(
config,
probeMessage(config, to, runId),
signal,
);
const bulk = await sendBulkEmails(
config,
bulkProbePayload(config.provider, to, runId),
signal,
);
const sent = single.delivered && bulk.failed === 0 && bulk.unconfirmed === 0;
const refusals = [
// sendEmail already parses, redacts, and caps the single reply's reason.
...(single.delivered ? [] : [oneLine(single.reason)]),
...bulk.responses
.filter((response) => !response.ok)
.map((response) => oneLine(response.body)),
// sendBulkEmails reads each accepted reply for messages refused inside
// it. Its reasons are redacted but not table-safe, so they still pass
// through oneLine before reaching the report's Markdown table.
...bulk.responses.flatMap((response) =>
response.outcome.reasons.map(oneLine),
),
...(bulk.unconfirmed === 0
? []
: [`${bulk.unconfirmed} message(s) left unconfirmed`]),
// A refusal can come with an empty body; the status already says it.
].filter((text) => text !== "");
// A thrown single send has no status; sendEmail reports it as undefined.
const statuses = `single ${single.status ?? "no response"}, bulk ${bulk.responses
.map((response) => response.status)
.join(", ")}`;
return {
detail: [statuses, ...refusals].join(" — "),
provider: config.provider,
state: sent ? "sent" : "failed",
};
};
const legTimeout = (): {
cancel: () => void;
expired: Promise;
signal: AbortSignal;
} => {
const controller = new AbortController();
const timer = { id: 0 };
const expired = new Promise((_, reject) => {
timer.id = setTimeout(() => {
// Cancel the request still in flight, so a stalled provider leaves
// nothing running once its leg has reported.
controller.abort();
reject(new Error(`leg timed out after ${LEG_TIMEOUT_MS / 1000}s`));
}, LEG_TIMEOUT_MS);
});
return {
cancel: () => clearTimeout(timer.id),
expired,
signal: controller.signal,
};
};
/** Resolve and run one provider's leg, and say what happened. */
export const runEmailLeg = async (
provider: EmailProvider,
): Promise => {
const plan = resolveEmailLeg(provider);
if (plan.state === "skipped") {
return { detail: plan.reason, provider, state: "skipped" };
}
if (plan.state === "broken") {
return { detail: plan.reason, provider, state: "failed" };
}
const timeout = legTimeout();
try {
// finally() cancels the timer on both settlements, so a finished leg
// leaves no pending timeout behind.
return await Promise.race([
runReadyLeg(plan.config, plan.to, timeout.signal),
timeout.expired,
]).finally(timeout.cancel);
} catch (error) {
// One leg's crash must not stop the later legs from running and
// reporting. A parse error can quote the reply, so redact it too.
return { detail: oneLine(errorMessage(error)), provider, state: "failed" };
}
};