diff --git a/apps/fumadocs/src/components/provider-catalog.tsx b/apps/fumadocs/src/components/provider-catalog.tsx index 0c50ac4..88f0e04 100644 --- a/apps/fumadocs/src/components/provider-catalog.tsx +++ b/apps/fumadocs/src/components/provider-catalog.tsx @@ -15,11 +15,12 @@ export function ProviderGrid() { >
-
+
{provider.name}
{provider.category}
+ {provider.importPath} ))} @@ -35,27 +36,63 @@ export function ProviderBadge({ adapter }: { adapter: string }) { } return ( -
- -
-
{provider.name}
- {provider.importPath} +
+
+ +
+
{provider.name}
+ {provider.importPath} +
+ +
+
+ {provider.verification.note} If live delivery fails or provider behavior has changed,{" "} + + open an issue + + .
); } +function VerificationPill({ + className, + status, +}: { + className?: string; + status: "verified" | "untested"; +}) { + if (status === "verified") { + return ( + + Live verified + + ); + } + + return ( + + Untested live + + ); +} + function ProviderMark({ logo, name }: { logo: string; name: string }) { if (!logo) { return ( - + SMTP ); } return ( - + {`${name}; + +type SmokeProvider = { + name: string; + env: string[]; + optionalEnv?: string[]; + create: () => EmailProvider; +}; + +const flags = parseFlags(Bun.argv.slice(2)); +const sendLive = truthyFlag("send"); +const selectedAdapters = selectedAdapterNames(); +const to = stringFlag("to") ?? process.env.LIVE_EMAIL_TO ?? "leodoesdev@gmail.com"; +const subjectPrefix = + stringFlag("subject-prefix") ?? process.env.LIVE_EMAIL_SUBJECT_PREFIX ?? "Email SDK live smoke"; + +const providers: SmokeProvider[] = [ + { + name: "resend", + env: ["RESEND_API_KEY"], + create: () => resend({ apiKey: env("RESEND_API_KEY") }), + }, + { + name: "postmark", + env: ["POSTMARK_SERVER_TOKEN"], + optionalEnv: ["POSTMARK_MESSAGE_STREAM"], + create: () => + postmark({ + serverToken: env("POSTMARK_SERVER_TOKEN"), + messageStream: process.env.POSTMARK_MESSAGE_STREAM, + }), + }, + { + name: "sendgrid", + env: ["SENDGRID_API_KEY"], + create: () => sendgrid({ apiKey: env("SENDGRID_API_KEY") }), + }, + { + name: "mailgun", + env: ["MAILGUN_API_KEY", "MAILGUN_DOMAIN"], + optionalEnv: ["MAILGUN_BASE_URL"], + create: () => + mailgun({ + apiKey: env("MAILGUN_API_KEY"), + domain: env("MAILGUN_DOMAIN"), + baseUrl: process.env.MAILGUN_BASE_URL, + }), + }, + { + name: "mailersend", + env: ["MAILERSEND_API_KEY"], + create: () => mailersend({ apiKey: env("MAILERSEND_API_KEY") }), + }, + { + name: "brevo", + env: ["BREVO_API_KEY"], + create: () => brevo({ apiKey: env("BREVO_API_KEY") }), + }, + { + name: "mailchimp", + env: ["MAILCHIMP_API_KEY"], + create: () => mailchimp({ apiKey: env("MAILCHIMP_API_KEY") }), + }, + { + name: "sparkpost", + env: ["SPARKPOST_API_KEY"], + create: () => sparkpost({ apiKey: env("SPARKPOST_API_KEY") }), + }, + { + name: "loops", + env: ["LOOPS_API_KEY", "LOOPS_TRANSACTIONAL_ID"], + create: () => + loops({ + apiKey: env("LOOPS_API_KEY"), + transactionalId: env("LOOPS_TRANSACTIONAL_ID"), + }), + }, + { + name: "plunk", + env: ["PLUNK_API_KEY"], + create: () => plunk({ apiKey: env("PLUNK_API_KEY") }), + }, + { + name: "mailtrap", + env: ["MAILTRAP_API_KEY"], + create: () => mailtrap({ apiKey: env("MAILTRAP_API_KEY") }), + }, + { + name: "scaleway", + env: ["SCALEWAY_SECRET_KEY", "SCALEWAY_PROJECT_ID"], + optionalEnv: ["SCALEWAY_REGION"], + create: () => + scaleway({ + secretKey: env("SCALEWAY_SECRET_KEY"), + projectId: env("SCALEWAY_PROJECT_ID"), + region: process.env.SCALEWAY_REGION, + }), + }, + { + name: "zeptomail", + env: ["ZEPTOMAIL_TOKEN"], + create: () => zeptomail({ token: env("ZEPTOMAIL_TOKEN") }), + }, + { + name: "mailpace", + env: ["MAILPACE_API_KEY"], + create: () => mailpace({ apiKey: env("MAILPACE_API_KEY") }), + }, +]; + +if (truthyFlag("help")) { + printHelp(); + process.exit(0); +} + +const candidates = providers.filter((provider) => { + return selectedAdapters.length === 0 || selectedAdapters.includes(provider.name); +}); + +if (candidates.length === 0) { + console.error( + `No matching adapters. Known adapters: ${providers.map((item) => item.name).join(", ")}`, + ); + process.exit(1); +} + +const results: Array< + | { ok: true; adapter: string; sent: boolean; response?: EmailProviderResponse } + | { ok: false; adapter: string; skipped?: boolean; missing?: string[]; error?: string } +> = []; + +for (const provider of candidates) { + const missing = provider.env.filter((name) => !process.env[name]); + const liveFrom = senderFor(provider.name); + + if (!liveFrom) { + missing.push(`LIVE_EMAIL_FROM_${envSuffix(provider.name)} or LIVE_EMAIL_FROM or --from`); + } + + if (missing.length > 0) { + results.push({ ok: false, adapter: provider.name, skipped: true, missing }); + continue; + } + + const message = buildMessage(provider.name, liveFrom as string); + + if (!sendLive) { + results.push({ ok: true, adapter: provider.name, sent: false }); + continue; + } + + try { + const client = createEmailClient({ adapters: [provider.create()] }); + const response = await client.send(message, { + idempotencyKey: `email-sdk-live-smoke-${provider.name}-${Date.now()}`, + metadata: { test: "live-smoke", adapter: provider.name }, + }); + + results.push({ + ok: true, + adapter: provider.name, + sent: true, + response: redactResponse(response), + }); + } catch (error) { + results.push({ + ok: false, + adapter: provider.name, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +const summary = { + mode: sendLive ? "send" : "dry-run", + to, + results, +}; + +console.log(JSON.stringify(summary, null, 2)); + +if (results.some((result) => !result.ok && !result.skipped)) { + process.exit(1); +} + +function buildMessage(adapter: string, from: string): EmailMessage { + const subject = `${subjectPrefix} [${adapter}]`; + + return { + from, + to, + subject, + text: [ + "Email SDK live smoke test.", + `Adapter: ${adapter}`, + `Timestamp: ${new Date().toISOString()}`, + ].join("\n"), + html: [ + "

Email SDK live smoke test.

", + `

Adapter: ${escapeHtml(adapter)}

`, + `

Timestamp: ${escapeHtml(new Date().toISOString())}

`, + ].join(""), + }; +} + +function redactResponse(response: EmailProviderResponse) { + return { + provider: response.provider, + id: response.id, + messageId: response.messageId, + accepted: response.accepted, + rejected: response.rejected, + }; +} + +function selectedAdapterNames() { + const adapterFlag = + stringFlag("adapter") ?? stringFlag("provider") ?? process.env.LIVE_EMAIL_ADAPTERS; + + return adapterFlag + ? adapterFlag + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; +} + +function senderFor(adapter: string) { + return ( + stringFlag("from") ?? + process.env[`LIVE_EMAIL_FROM_${envSuffix(adapter)}`] ?? + process.env.LIVE_EMAIL_FROM + ); +} + +function envSuffix(adapter: string) { + return adapter.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +} + +function parseFlags(args: string[]): SmokeFlags { + const parsed: SmokeFlags = {}; + + for (let index = 0; index < args.length; index += 1) { + const current = args[index]; + + if (!current?.startsWith("--")) { + continue; + } + + const [key, inlineValue] = current.slice(2).split(/=(.*)/s); + + if (!key) { + continue; + } + + if (inlineValue !== undefined) { + parsed[key] = inlineValue; + continue; + } + + const next = args[index + 1]; + + if (!next || next.startsWith("--")) { + parsed[key] = true; + continue; + } + + parsed[key] = next; + index += 1; + } + + return parsed; +} + +function stringFlag(name: string) { + const value = flags[name]; + return typeof value === "string" ? value : undefined; +} + +function truthyFlag(name: string) { + const value = flags[name]; + return value === true || value === "true" || value === "1"; +} + +function env(name: string) { + const value = process.env[name]; + + if (!value) { + throw new Error(`Missing ${name}`); + } + + return value; +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return character; + } + }); +} + +function printHelp() { + console.log(`Email SDK live smoke + +Usage: + bun run live:smoke -- --from "Acme " + bun run live:smoke -- --send --adapter resend,postmark --from "Acme " + +Options: + --send Actually send emails. Omit for a non-sending env check. + --adapter Comma-separated adapter names. Defaults to all known adapters. + --provider Alias for --adapter. + --from
Verified sender address. Defaults to LIVE_EMAIL_FROM. + Can be overridden per adapter with LIVE_EMAIL_FROM_RESEND, etc. + --to
Recipient. Defaults to LIVE_EMAIL_TO or leodoesdev@gmail.com. + --subject-prefix Subject prefix. Defaults to LIVE_EMAIL_SUBJECT_PREFIX. + +Environment: + LIVE_EMAIL_FROM Verified sender used for all adapters. + LIVE_EMAIL_TO Smoke recipient. + LIVE_EMAIL_ADAPTERS Optional comma-separated adapter filter. +`); +}