From ba9f89737bf3561ecc74ebd7a208fbe575a72449 Mon Sep 17 00:00:00 2001 From: Sauer Date: Thu, 12 Nov 2026 22:44:59 +0100 Subject: [PATCH] feat(web): send our own mail straight to Amazon SES over SMTP The web app and GoTrue move off the mailcow relay onto SES's SMTP endpoint (the SES side, IAM user and env changes live outside this repository). - Every message names the SES configuration set from SES_CONFIGURATION_SET and carries its kind as an X-SES-MESSAGE-TAGS tag, so bounce and complaint events say which mail they were about. EmailMessage.kind is required. - The send logs SES's message id (from its 250 reply) with the kind. - The unused Resend fallback and the resend dependency are gone; production requires SMTP_HOST. - Docs: the SES setup and why GoTrue gets its configuration set from the noreply@webhooks.cc address identity. Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 +- AGENTS.md | 1 + apps/web/changelog/web/direct-ses-mail.json | 6 ++ apps/web/lib/agent/agent-auth.ts | 1 + apps/web/lib/agent/notify.ts | 1 + apps/web/lib/alerts/notify.ts | 1 + apps/web/lib/email/dev-transport.ts | 4 +- apps/web/lib/email/mailer.ts | 67 +++++---------- apps/web/lib/email/quota-exhausted-email.ts | 1 + apps/web/lib/email/resend-transport.ts | 26 ------ apps/web/lib/email/smtp-transport.test.ts | 36 ++++++++ apps/web/lib/email/smtp-transport.ts | 85 +++++++++++-------- apps/web/lib/email/team-invite-email.ts | 1 + apps/web/lib/email/welcome-email.ts | 1 + apps/web/lib/env.ts | 23 +++-- apps/web/lib/mfa-recovery.ts | 1 + apps/web/lib/support/emails.ts | 4 + apps/web/lib/tunnels/reports.ts | 4 + apps/web/lib/tunnels/usage.ts | 1 + apps/web/package.json | 1 - apps/web/tests/integration/agent-auth.test.ts | 5 +- .../web/tests/integration/agent-claim.test.ts | 1 - infra/supabase/gotrue-email-auth.md | 28 +++++- pnpm-lock.yaml | 22 ----- 24 files changed, 176 insertions(+), 154 deletions(-) create mode 100644 apps/web/changelog/web/direct-ses-mail.json delete mode 100644 apps/web/lib/email/resend-transport.ts create mode 100644 apps/web/lib/email/smtp-transport.test.ts diff --git a/.env.example b/.env.example index 0c833c652..1c0db058e 100644 --- a/.env.example +++ b/.env.example @@ -169,15 +169,16 @@ BLOG_API_SECRET= # AGENT_SANDBOX_MAX_ENDPOINTS=200 # EPHEMERAL_TTL_HOURS=12 -# Agent auth (auth.md) - optional -# RESEND_API_KEY= -# SMTP transport for agent OTP email (production only; SMTP -> Resend -> dev fallback) +# Outbound email over SMTP (required in production, where it is Amazon SES's +# SMTP endpoint; outside production mail is only logged, never sent) # STARTTLS: SMTP_PORT=587 + SMTP_SECURE=false; implicit TLS: SMTP_PORT=465 + SMTP_SECURE=true -# SMTP_HOST= +# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com # SMTP_PORT=587 # SMTP_SECURE=false # SMTP_USER= # SMTP_PASS= +# SES configuration set named on every message, with its kind as a message tag +# SES_CONFIGURATION_SET=webhooks-cc-app # Shared from-address for all outbound email (falls back to AGENT_EMAIL_FROM) # EMAIL_FROM=webhooks.cc # AGENT_EMAIL_FROM=webhooks.cc diff --git a/AGENTS.md b/AGENTS.md index 8928d81a1..c753c614a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ Decisions worth knowing, with the reasons: - **Analytics: consent in the browser, accounts on the server.** posthog-js runs in cookieless mode until the visitor accepts in the cookie banner (`cookieless_mode: "on_reject"`, opted out by default, Do Not Track and GPC count as a decline), so nothing is stored in the browser without consent and a signed-in user is identified only after it; each site keeps its own choice, and the site switcher carries it over sealed in its grant and ticket (an accept only answers an unanswered banner, a decline always applies); a signed-in browser also saves it on the account (`users.analytics_consent`, migration 00082, `lib/analytics-account-consent.ts`) and is answered from it by the same rules, the banner waiting for that answer, an accept made before signing in is saved only when made in the same tab, since a remembered one may be someone else's on a shared computer, a decline that only Do Not Track or GPC made is never saved, and a choice is kept in the browser from the click until the account has it, then sent again on a later load unless the account was told something since (`analytics_consent_at` moves on every save, compared on the server's clock); the PostHog project's cookieless tracking setting must stay on or those events are dropped. `before_send` (`lib/analytics-redact.ts`) strips share tokens, fragments and every query parameter not on its list from every URL, because slugs, Polar session tokens and auth codes ride in them. Account analytics come from the server (`lib/analytics-server.ts`, on in production builds): every audit event is mirrored, so the API, CLI, SDKs, MCP and agents are counted without call sites of their own; applied Polar webhooks become `order_paid` (`revenue` in cents, `subscription_id`) and subscription events; and `lib/analytics-reporter.ts` sends hourly signups and a daily usage snapshot per account from `endpoint_daily_stats` (migration 00080). Never send an event per capture, and never put contents, URLs, email addresses or secrets in properties; accounts are named by id only. Dashboard components that show captured content or endpoint names carry `ph-no-capture`, so add it to new ones. /privacy promises the data stays with us and is never sold or shared; keep it true. - **Outbound deliveries hide the origin IP** by going through the Cloudflare Worker when `NOTIFY_PROXY_URL` is set; otherwise the web app delivers directly with SSRF-safe DNS pinning. - **Email/password auth is GoTrue configuration, not app code.** SMTP, minimum password length 8, and the template URLs live in the Supabase instance's `docker-compose.override.yml`; see `infra/supabase/gotrue-email-auth.md`. Email links carry a `token_hash` to `/auth/confirm`, which verifies server-side. If GoTrue could not fetch the templates at startup it keeps serving its defaults until the auth container restarts. +- **Our own mail goes straight to Amazon SES over SMTP.** The web app (`sendEmail()` in `lib/email/mailer.ts`, the only way it sends) and GoTrue both send from `noreply@webhooks.cc` through SES's SMTP endpoint with one IAM user, not through the API: GoTrue can only speak SMTP, and at our volume the API's extras are all reachable over SMTP, so one credential and one path serve both. Every message lands in the `webhooks-cc-app` configuration set, the app's by header and GoTrue's through the address identity's default (`infra/supabase/gotrue-email-auth.md`), and each app message carries its `kind` as a message tag. There is no queue and no fallback transport: a failed send throws, and the caller retries (the pollers), shows the error, or reports it; the transport never retries, because the welcome email must not go out twice. The SES side and its event destinations are not in this repository. - **Agent registration follows auth.md v0.6.** An agent registers, gets a signed identity assertion, and exchanges it for short-lived access tokens that are ordinary `whcc_` rows in `api_keys`, so the bearer path and every route accept them unchanged; each exchange reads the registration again, so revoking it ends everything at once. Pre-claim tokens have no user: every consumer of an API key must tolerate a null user, and the routes that need one refuse them, which confines them to the sandbox. Sandbox endpoints belong to the registration, never to a user, so guest reads and the guest claim must keep skipping them; they have their own pool, budget and billing key in `capture_webhook()`. A claim (the human signs in and types the agent's code; the email the agent named decides who may) adopts them into the human's account in one transaction, deletes the pre-claim tokens, and lets the agent collect account tokens once with its claim token; claimed agent tokens are capped as connected agents, not counted as API keys, and never reach session-only routes. The proof of work on anonymous registration only raises the cost of scripted abuse; the rate limits, the live cap and the pool are the real bounds. `AGENT_IDJAG_PROVIDERS` stays empty in production until a real ID-JAG issuer exists. An ID-JAG identity `(iss, sub)` never binds silently to an existing account: without a delegation on file, an account with its verified email gets `interaction_required` and the same claim ceremony (`link_agent_idjag_identity()`, migration 00057), only an account created for the email in that request links at once, `auth_time` must be recent on every presentation, and a provider's Security Event Token at `/api/agent/event/notify` revokes the delegation with its tokens. The protocol itself is documented in `/auth.md` (`lib/agent/metadata.ts`). - **The pytest fixtures have one home, the Python SDK.** `webhooks_cc` and `webhooks_cc_endpoint` are defined in `packages/sdk-python` (`webhooks_cc/pytest_plugin.py`) and registered by the SDK's own `pytest11` entry point, named `webhooks_cc`, because projects that install only `webhooks-cc` rely on them. `pytest-webhooks-cc` (`packages/pytest-webhooks-cc`) exists so the plugin is found where pytest plugins are looked for (the plugin list on docs.pytest.org is generated from PyPI projects named `pytest-*`, and reads the summary, the status classifier and the `pytest` requirement): it depends on the SDK, defines no fixtures, and has an entry point of its own under another name (`pytest_webhooks_cc`). Never point a second entry point at the SDK's module or list it in `pytest_plugins`: pluggy refuses one module under two names, and every run would fail. The plugin registers the SDK's module itself only when pytest did not (`PYTEST_DISABLE_PLUGIN_AUTOLOAD`), under the SDK's name, so `-p no:webhooks_cc` still turns the fixtures off. Add fixtures to the SDK, and to the plugin only what is pytest's alone (report lines, options). - **Agents keep their registrations in one shared file.** The MCP server, the TypeScript, Python and Go SDKs and the CLI read and write `~/.config/whk/agent.json` (XDG, macOS and Windows rules, `WHK_AGENT_FILE` override; the CLI's `token.json` lives beside it), so a connection a human approved once survives restarts and works from every tool. The format and the rules (live slots, the cached access token, atomic 0600 writes, what `invalid_grant` removes) are in `packages/sdk/conformance/agent-credentials.md`, and every implementation runs `agent-credentials.json`; change them together. The claim token is never written, as `/auth.md` asks, so only the process that registered a sandbox can move its endpoints into an account; a later one connects the human through a service_auth registration. An explicit API key always wins over the file. diff --git a/apps/web/changelog/web/direct-ses-mail.json b/apps/web/changelog/web/direct-ses-mail.json new file mode 100644 index 000000000..a0a9853f4 --- /dev/null +++ b/apps/web/changelog/web/direct-ses-mail.json @@ -0,0 +1,6 @@ +{ + "title": "Account email sent directly through Amazon SES", + "items": [ + "Sign-up, password, verification code and notification emails leave through Amazon SES directly instead of a relay, with bounces and complaints reported back" + ] +} diff --git a/apps/web/lib/agent/agent-auth.ts b/apps/web/lib/agent/agent-auth.ts index d944b8414..d77970a92 100644 --- a/apps/web/lib/agent/agent-auth.ts +++ b/apps/web/lib/agent/agent-auth.ts @@ -171,6 +171,7 @@ export async function issueVerifiedEmailClaim(args: { try { await sendEmail({ to: email, + kind: "agent_code", subject: "Your webhooks.cc verification code", text: `Your webhooks.cc verification code is ${otp}. It expires in 10 minutes.`, }); diff --git a/apps/web/lib/agent/notify.ts b/apps/web/lib/agent/notify.ts index 84837b8e9..cbba7ff30 100644 --- a/apps/web/lib/agent/notify.ts +++ b/apps/web/lib/agent/notify.ts @@ -29,6 +29,7 @@ export async function notifyAgentConnected(input: { try { await sendEmail({ to: input.email, + kind: "agent_connected", subject: "An agent was connected to your webhooks.cc account", text: lines.join("\n"), }); diff --git a/apps/web/lib/alerts/notify.ts b/apps/web/lib/alerts/notify.ts index fe98fd8e8..5fbccb75b 100644 --- a/apps/web/lib/alerts/notify.ts +++ b/apps/web/lib/alerts/notify.ts @@ -234,6 +234,7 @@ async function notifyOne(event: AlertEventRow): Promise { try { await sendEmail({ to: context.ownerEmail, + kind: "alert", subject: `${event.resolved_at ? "Resolved" : "Alert"}: ${summary}`, text: `${text}\n\nYou get this because alerts are on for this endpoint. Change them in its settings.\n`, }); diff --git a/apps/web/lib/email/dev-transport.ts b/apps/web/lib/email/dev-transport.ts index ef3cb10a6..2d28465bb 100644 --- a/apps/web/lib/email/dev-transport.ts +++ b/apps/web/lib/email/dev-transport.ts @@ -5,8 +5,8 @@ import type { EmailMessage } from "./mailer"; * * It logs the message (including any OTP) for local development and records the * last message per recipient so the integration test can read the OTP via - * `getLastOtpForEmail` without DNS/SPF/Resend. Production never imports the - * test hooks (it uses the Resend transport instead). + * `getLastOtpForEmail` without sending anything. Production never imports the + * test hooks (it sends over SMTP instead). */ /** Last delivered message keyed by lower-cased recipient email. */ diff --git a/apps/web/lib/email/mailer.ts b/apps/web/lib/email/mailer.ts index 223ef9a20..57b3f3b03 100644 --- a/apps/web/lib/email/mailer.ts +++ b/apps/web/lib/email/mailer.ts @@ -1,25 +1,18 @@ import { serverEnv } from "@/lib/env"; /** - * Minimal email abstraction for the verified_email OTP flow. + * The one way the web app sends email. * - * Transport is selected at call time, and real delivery only happens in - * production. Production fails closed — it never falls back to the dev/test - * transport. The selection order in production is: - * - SMTP (real delivery) when `SMTP_HOST` is set. The `nodemailer` dependency - * is dynamic-imported so it only loads when used. If an SMTP send fails and - * `RESEND_API_KEY` is set, it falls back to Resend; otherwise it throws. - * - Resend (real delivery) when `RESEND_API_KEY` is set (and SMTP_HOST is - * unset). The `resend` dependency is dynamic-imported so it only loads when - * used. - * - When neither is configured (or both fail), it throws. + * In production every message goes over SMTP (`SMTP_HOST`, in production + * Amazon SES's SMTP endpoint) and a failure throws: there is no fallback and + * no queue, so each caller decides whether to retry (see the pollers), show + * the error, or report it. Production never falls back to the dev transport. + * The `nodemailer` dependency is dynamic-imported so it only loads there. * - * In non-production (NODE_ENV !== "production", i.e. tests and local dev) the - * dev/test transport is ALWAYS used even when SMTP_HOST / RESEND_API_KEY are - * set in .env.local: it logs the message (incl. OTP) and records it for the - * integration test to read via `getLastOtpForEmail` — never sends. Use the - * directly-importable `sendViaSmtp` / `verifySmtp` for real SMTP delivery in - * any environment (e.g. the deliverability test). + * Everywhere else (NODE_ENV !== "production", i.e. tests and local dev) the + * dev transport is used even when SMTP_HOST is set in .env.local: it logs the + * message (incl. any code) and records it for the integration tests to read + * via `getLastOtpForEmail`, and never sends. */ export interface EmailMessage { @@ -29,38 +22,24 @@ export interface EmailMessage { html?: string; /** Extra headers, such as Auto-Submitted on automatic notices. */ headers?: Record; + /** + * Which mail this is, as a short tag (letters, digits, `_`, `-`). It goes + * to SES as a message tag, so a bounce or complaint event names the kind of + * mail it was about, and into the log line of the send. Never a value that + * identifies a person. + */ + kind: string; } export async function sendEmail(message: EmailMessage): Promise { - const { SMTP_HOST, RESEND_API_KEY } = serverEnv(); - if (process.env.NODE_ENV === "production") { - // Production never falls back to the dev transport: it fails closed. - if (SMTP_HOST) { - const { sendViaSmtp } = await import("./smtp-transport"); - try { - await sendViaSmtp(message); - return; - } catch (error) { - // SMTP is primary; fall back to Resend if available, otherwise rethrow. - if (!RESEND_API_KEY) { - throw error; - } - const { sendViaResend } = await import("./resend-transport"); - await sendViaResend(message, RESEND_API_KEY); - return; - } - } - - if (RESEND_API_KEY) { - const { sendViaResend } = await import("./resend-transport"); - await sendViaResend(message, RESEND_API_KEY); - return; + if (!serverEnv().SMTP_HOST) { + throw new Error("Email delivery is not configured: set SMTP_HOST in production"); } - - throw new Error( - "Email delivery is not configured: set SMTP_HOST or RESEND_API_KEY in production" - ); + const { sendViaSmtp } = await import("./smtp-transport"); + const { messageId } = await sendViaSmtp(message); + console.info(`[email] sent kind=${message.kind} id=${messageId}`); + return; } const { sendViaDevTransport } = await import("./dev-transport"); diff --git a/apps/web/lib/email/quota-exhausted-email.ts b/apps/web/lib/email/quota-exhausted-email.ts index f3fcb5b86..8aaaeee21 100644 --- a/apps/web/lib/email/quota-exhausted-email.ts +++ b/apps/web/lib/email/quota-exhausted-email.ts @@ -203,6 +203,7 @@ export function buildQuotaExhaustedEmail(params: { return { to, + kind: "quota_exhausted", subject: "Your webhooks.cc endpoints are rejecting webhooks", text: [ ...intro, diff --git a/apps/web/lib/email/resend-transport.ts b/apps/web/lib/email/resend-transport.ts deleted file mode 100644 index c7fff50c5..000000000 --- a/apps/web/lib/email/resend-transport.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { serverEnv } from "@/lib/env"; -import type { EmailMessage } from "./mailer"; - -/** - * Resend email transport. The `resend` package is dynamic-imported so the - * dependency is only loaded when real delivery is configured (production with - * RESEND_API_KEY set). On failure it throws so the route maps to a 500 — the - * OTP row is already created, so the user can re-trigger. - */ -export async function sendViaResend(message: EmailMessage, apiKey: string): Promise { - const { Resend } = await import("resend"); - const resend = new Resend(apiKey); - - const { error } = await resend.emails.send({ - from: serverEnv().EMAIL_FROM ?? serverEnv().AGENT_EMAIL_FROM, - to: message.to, - subject: message.subject, - text: message.text, - ...(message.html ? { html: message.html } : {}), - ...(message.headers ? { headers: message.headers } : {}), - }); - - if (error) { - throw new Error(`Resend delivery failed: ${error.message}`); - } -} diff --git a/apps/web/lib/email/smtp-transport.test.ts b/apps/web/lib/email/smtp-transport.test.ts new file mode 100644 index 000000000..c2171a7b1 --- /dev/null +++ b/apps/web/lib/email/smtp-transport.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { sesHeaders, sesMessageId } from "./smtp-transport"; + +describe("sesHeaders", () => { + it("sends nothing without a configuration set", () => { + expect(sesHeaders({ kind: "welcome" }, undefined)).toEqual({}); + }); + + it("names the configuration set and tags the kind", () => { + expect(sesHeaders({ kind: "agent_code" }, "webhooks-cc-app")).toEqual({ + "X-SES-CONFIGURATION-SET": "webhooks-cc-app", + "X-SES-MESSAGE-TAGS": "kind=agent_code", + }); + }); + + it("drops characters SES refuses in a tag value", () => { + expect(sesHeaders({ kind: "a b,c=d;e" }, "set")["X-SES-MESSAGE-TAGS"]).toBe("kind=abcde"); + expect(sesHeaders({ kind: " " }, "set")["X-SES-MESSAGE-TAGS"]).toBe("kind=unknown"); + }); +}); + +describe("sesMessageId", () => { + it("reads the id from SES's reply to the message", () => { + expect( + sesMessageId( + "250 Ok 0107019a6d1f2b3c-4d5e6f70-1234-4abc-9def-0123456789ab-000000", + "" + ) + ).toBe("0107019a6d1f2b3c-4d5e6f70-1234-4abc-9def-0123456789ab-000000"); + }); + + it("falls back to the generated Message-ID for other servers", () => { + expect(sesMessageId("250 2.0.0 Ok: queued as 4ABC", "")).toBe(""); + expect(sesMessageId(undefined, "")).toBe(""); + }); +}); diff --git a/apps/web/lib/email/smtp-transport.ts b/apps/web/lib/email/smtp-transport.ts index caa2576f2..407de5ccb 100644 --- a/apps/web/lib/email/smtp-transport.ts +++ b/apps/web/lib/email/smtp-transport.ts @@ -2,23 +2,27 @@ import { serverEnv } from "@/lib/env"; import type { EmailMessage } from "./mailer"; /** - * SMTP email transport (nodemailer). The `nodemailer` package is - * dynamic-imported so the dependency is only loaded when SMTP delivery is - * actually used. On failure it throws so the route maps to a 500 — the OTP row - * is already created, so the user can re-trigger. + * SMTP email transport (nodemailer), in production Amazon SES's SMTP endpoint + * (see infra/supabase/gotrue-email-auth.md for the SES side). The `nodemailer` + * package is dynamic-imported so it only loads when mail is sent. * * Connection modes (per RFC): * - STARTTLS: port 587, SMTP_SECURE=false (plaintext upgraded to TLS) * - Implicit TLS: port 465, SMTP_SECURE=true (TLS from the first byte) * - * Auth user is SMTP_USER (e.g. postmaster@webhooks.cc); the visible From header - * is EMAIL_FROM, falling back to the legacy AGENT_EMAIL_FROM (e.g. - * webhooks.cc ). Secrets are read only from the validated - * server env / process.env and are never logged. + * The visible From header is EMAIL_FROM, falling back to the legacy + * AGENT_EMAIL_FROM (webhooks.cc ). Secrets are read only + * from the validated server env and are never logged. * - * `sendViaSmtp` / `verifySmtp` send/connect via SMTP regardless of NODE_ENV so - * the deliverability test can exercise real delivery directly. Routine OTP - * delivery is gated by the mailer (production + SMTP_HOST set) — see mailer.ts. + * With SES_CONFIGURATION_SET, each message names that configuration set and + * carries its kind as a message tag (`X-SES-CONFIGURATION-SET`, + * `X-SES-MESSAGE-TAGS`; SES removes both before delivery). The set's event + * destinations then report bounces and complaints with the kind they were + * about. + * + * A failed send is not retried here. SMTP cannot tell whether a message the + * connection broke on was taken, and some mail (the welcome email) must never + * go out twice, so the callers decide. */ interface SmtpConfig { @@ -49,6 +53,34 @@ function resolveSmtpConfig(): SmtpConfig { }; } +/** SES tag values allow letters, digits, `_`, `-`, `.` and `@`; anything else is dropped. */ +function sesTagValue(value: string): string { + return value.replace(/[^A-Za-z0-9_.@-]/g, "").slice(0, 256) || "unknown"; +} + +/** The SES headers for a message, empty without a configuration set. */ +export function sesHeaders( + message: Pick, + configurationSet: string | undefined +): Record { + if (!configurationSet) return {}; + return { + "X-SES-CONFIGURATION-SET": configurationSet, + "X-SES-MESSAGE-TAGS": `kind=${sesTagValue(message.kind)}`, + }; +} + +/** + * The id SES gave the message, from its reply to the message ("250 Ok "). + * SES also rewrites the Message-ID header to `@.amazonses.com`, and + * its bounce and complaint events name the same id. Other servers answer + * differently, so this falls back to the Message-ID nodemailer generated. + */ +export function sesMessageId(response: string | undefined, fallback: string): string { + const match = /^250 Ok (\S+)/.exec(response ?? ""); + return match ? match[1] : fallback; +} + async function createTransport() { const { host, port, secure, user, pass } = resolveSmtpConfig(); const nodemailer = await import("nodemailer"); @@ -64,29 +96,27 @@ async function createTransport() { } /** - * Send a message via SMTP. Sends regardless of NODE_ENV — callers that should + * Send a message via SMTP. Sends regardless of NODE_ENV; callers that should * only send in production must gate the call themselves (see mailer.ts). - * - * Returns the provider message id and the array of accepted recipients so - * operational checks / the deliverability test can report per-send acceptance. */ export async function sendViaSmtp( message: EmailMessage ): Promise<{ messageId: string; accepted: string[] }> { + const env = serverEnv(); const transport = await createTransport(); try { const info = await transport.sendMail({ - from: serverEnv().EMAIL_FROM ?? serverEnv().AGENT_EMAIL_FROM, + from: env.EMAIL_FROM ?? env.AGENT_EMAIL_FROM, to: message.to, subject: message.subject, text: message.text, ...(message.html ? { html: message.html } : {}), - ...(message.headers ? { headers: message.headers } : {}), + headers: { ...message.headers, ...sesHeaders(message, env.SES_CONFIGURATION_SET) }, }); return { - messageId: info.messageId, - accepted: info.accepted ?? [], + messageId: sesMessageId(info.response, info.messageId), + accepted: (info.accepted ?? []).map(String), }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -95,20 +125,3 @@ export async function sendViaSmtp( transport.close(); } } - -/** - * Verify SMTP connectivity and authentication without sending a message. - * Throws on failure. Used by the deliverability test and operational checks. - */ -export async function verifySmtp(): Promise { - const transport = await createTransport(); - - try { - await transport.verify(); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`SMTP verification failed: ${reason}`); - } finally { - transport.close(); - } -} diff --git a/apps/web/lib/email/team-invite-email.ts b/apps/web/lib/email/team-invite-email.ts index 0a106dd89..e744fbcf8 100644 --- a/apps/web/lib/email/team-invite-email.ts +++ b/apps/web/lib/email/team-invite-email.ts @@ -26,6 +26,7 @@ export function buildTeamInviteEmail(params: { return { to: invitedEmail, + kind: "team_invite", subject: `${inviterEmail} invited you to ${teamName} on webhooks.cc`, text: [ `${inviterEmail} invited you to join the team "${teamName}" on webhooks.cc,`, diff --git a/apps/web/lib/email/welcome-email.ts b/apps/web/lib/email/welcome-email.ts index f631d5c27..d93a77fe6 100644 --- a/apps/web/lib/email/welcome-email.ts +++ b/apps/web/lib/email/welcome-email.ts @@ -106,6 +106,7 @@ export function buildWelcomeEmail(params: { return { to, + kind: "welcome", subject: `Getting started with ${name}`, headers: AUTOMATIC, text: [ diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 048ca7b29..982b48f5a 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -204,11 +204,9 @@ const serverEnvSchema = z // Without both, Sheets is not offered. GOOGLE_OAUTH_CLIENT_ID: blankToUndefined(z.string().optional()), GOOGLE_OAUTH_CLIENT_SECRET: blankToUndefined(z.string().optional()), - // Agent auth (auth.md) — all optional with defaults so existing deploys are unaffected. - RESEND_API_KEY: z.string().optional(), - // SMTP transport for agent OTP email (used in production when SMTP_HOST is set; - // selection order: SMTP -> Resend -> dev-capture). Non-production always uses the - // dev-capture transport regardless of these values. + // Outbound email (lib/email/mailer.ts): required in production, where it is + // Amazon SES's SMTP endpoint. Non-production always uses the dev transport + // regardless of these values. SMTP_HOST: z.string().optional(), SMTP_PORT: z.coerce.number().int().min(1).max(65_535).default(587), // STARTTLS on 587 -> false; implicit TLS on 465 -> true. @@ -218,6 +216,9 @@ const serverEnvSchema = z .default(false), SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), + // The SES configuration set each message names, with its kind as a message + // tag (lib/email/smtp-transport.ts). Unset sends neither header. + SES_CONFIGURATION_SET: blankToUndefined(z.string().optional()), // Shared from-address for all outbound email; AGENT_EMAIL_FROM is the // legacy name and remains the fallback so existing deployments keep working. // Blank values normalize to undefined: the transports fall back with ??, @@ -289,14 +290,12 @@ const serverEnvSchema = z AGENT_MAX_PENDING_OTP_PER_EMAIL: z.coerce.number().int().min(1).default(3), }) .superRefine((env, ctx) => { - // Fail closed in production: a real email transport MUST be configured. - // Otherwise sendEmail() silently falls back to the dev-capture transport - // (which only logs the OTP and never delivers), breaking verified_email. - if (process.env.NODE_ENV === "production" && !env.SMTP_HOST && !env.RESEND_API_KEY) { + // Fail closed in production: a real email transport MUST be configured, + // or every email (sign-in codes included) would fail to send. + if (process.env.NODE_ENV === "production" && !env.SMTP_HOST) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "Production requires an email transport: set SMTP_HOST (+ SMTP_USER/SMTP_PASS) or RESEND_API_KEY.", + message: "Production requires an email transport: set SMTP_HOST (+ SMTP_USER/SMTP_PASS).", path: ["SMTP_HOST"], }); } @@ -381,12 +380,12 @@ export function serverEnv() { OBJECT_STORAGE_SECRET_ACCESS_KEY: process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY, GOOGLE_OAUTH_CLIENT_ID: process.env.GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET: process.env.GOOGLE_OAUTH_CLIENT_SECRET, - RESEND_API_KEY: process.env.RESEND_API_KEY, SMTP_HOST: process.env.SMTP_HOST, SMTP_PORT: process.env.SMTP_PORT, SMTP_SECURE: process.env.SMTP_SECURE, SMTP_USER: process.env.SMTP_USER, SMTP_PASS: process.env.SMTP_PASS, + SES_CONFIGURATION_SET: process.env.SES_CONFIGURATION_SET, EMAIL_FROM: process.env.EMAIL_FROM, AGENT_EMAIL_FROM: process.env.AGENT_EMAIL_FROM, SUPPORT_ALERT_EMAIL: process.env.SUPPORT_ALERT_EMAIL || undefined, diff --git a/apps/web/lib/mfa-recovery.ts b/apps/web/lib/mfa-recovery.ts index 65658467e..1388e8f53 100644 --- a/apps/web/lib/mfa-recovery.ts +++ b/apps/web/lib/mfa-recovery.ts @@ -118,6 +118,7 @@ export function buildTwoFactorOffEmail(params: { const notYou = "If this was not you, reset your password now and email support@webhooks.cc."; return { to: params.to, + kind: "two_factor_off", subject: "Two-factor sign-in was turned off", text: `${what}\n\nSet it up again: ${link}\n\n${notYou}`, html: diff --git a/apps/web/lib/support/emails.ts b/apps/web/lib/support/emails.ts index 2427e017c..88a502ff4 100644 --- a/apps/web/lib/support/emails.ts +++ b/apps/web/lib/support/emails.ts @@ -92,6 +92,7 @@ export function buildReplyEmail(params: { const end = footer(link); return { to: params.to, + kind: "support_reply", subject: `[#${ticket.number}] Support replied to your ticket`, text: `Support answered your ticket #${ticket.number}.\n\n${repliesText(replies)}\n\n${end.text}`, html: layout( @@ -126,6 +127,7 @@ export function buildClosedEmail(params: { const end = footer(link); return { to: params.to, + kind: "support_closed", subject: `[#${ticket.number}] Your ticket is closed`, text: `${why}\n\n` + @@ -157,6 +159,7 @@ export function buildReminderEmail(params: { const end = footer(link); return { to: params.to, + kind: "support_reminder", subject: `[#${ticket.number}] Support is waiting for your answer`, text: `${line}\n\n${end.text}`, html: layout(`

${escapeHtml(line)}

${end.html}`), @@ -181,6 +184,7 @@ export function buildStaffAlert(params: { const line = `${what} #${params.number}: ${topic}, ${plan}${urgent}.`; return { to: params.to, + kind: "support_staff_alert", subject: `${what} #${params.number} (${topic}${urgent})`, text: `${line}\n\n${params.link}`, html: layout( diff --git a/apps/web/lib/tunnels/reports.ts b/apps/web/lib/tunnels/reports.ts index c6fc4fa40..fc3065966 100644 --- a/apps/web/lib/tunnels/reports.ts +++ b/apps/web/lib/tunnels/reports.ts @@ -189,6 +189,7 @@ export async function tellStaffOfReport( const { SUPPORT_ALERT_EMAIL } = serverEnv(); await sendEmail({ to: SUPPORT_ALERT_EMAIL, + kind: "report_staff_alert", subject: urgent ? "URGENT: a preview was reported" : ALERT_SUBJECTS[source], text: `Report ${id} is waiting in the operations center:\n` + @@ -227,6 +228,7 @@ export async function confirmReceived(report: ReportRow): Promise { try { await sendEmail({ to: report.reporter_email, + kind: "report_received", subject: "We got your report", text: `Thank you for reporting ${reported(report)}. Someone at webhooks.cc will look at it, ` + @@ -257,6 +259,7 @@ export async function tellDecision(report: ReportRow): Promise { try { await sendEmail({ to: report.reporter_email, + kind: "report_decision", subject: "About your report", text: (outcome === "removed" @@ -299,6 +302,7 @@ export async function sendStatementOfReasons( try { await sendEmail({ to: owner.email, + kind: "preview_removed", subject: "We took your preview down", text: `Your preview ${reported(report)} broke the webhooks.cc terms: ` + diff --git a/apps/web/lib/tunnels/usage.ts b/apps/web/lib/tunnels/usage.ts index 829997dff..6a1b6dd04 100644 --- a/apps/web/lib/tunnels/usage.ts +++ b/apps/web/lib/tunnels/usage.ts @@ -244,6 +244,7 @@ export async function tellPaused(userIds: string[], now = Date.now()): Promise # e.g. https://webhooks.cc (used as {{ .SiteURL }} in templates) SMTP_ADMIN_EMAIL=noreply@webhooks.cc -SMTP_HOST= # prod: the same SMTP host the web app uses (SMTP_HOST in .env.local) +SMTP_HOST= # prod: email-smtp.eu-central-1.amazonaws.com, as for the web app SMTP_PORT= # prod: 587 (STARTTLS) SMTP_USER= # prod: same as the web app's SMTP_USER; dev (Inbucket): empty SMTP_PASS= # prod: same as the web app's SMTP_PASS; dev (Inbucket): empty SMTP_SENDER_NAME=webhooks.cc ``` -Deliverability on prod is already proven for this host/user pair: team invite -emails and agent OTP emails send through it from the web app. +### Amazon SES in production + +GoTrue and the web app send through SES's SMTP endpoint with one IAM user, +`ses-webhooks-cc-app` (eu-central-1). Its only permission is +`ses:SendRawEmail` on the identities `webhooks.cc` and `noreply@webhooks.cc` +and the configuration set `webhooks-cc-app`, from `noreply@webhooks.cc` +(`ses:FromAddress`) and the production host's addresses (`aws:SourceIp`), so +the credentials send nothing from anywhere else. `SMTP_PASS` is the SES SMTP +password derived from the user's secret access key (SigV4, version byte 4); +rotating the key means deriving it again and changing both env files. + +GoTrue cannot add headers, so its mail picks the configuration set another +way: `noreply@webhooks.cc` is an SES email address identity of its own whose +default configuration set is `webhooks-cc-app`, and an address identity's +default overrides the domain's (`webhooks.cc` keeps `mailcow-relay` for mail +people write from other addresses). The web app names the set itself +(`SES_CONFIGURATION_SET`) and adds a `kind` message tag. The set sends +BOUNCE, COMPLAINT, REJECT and DELIVERY_DELAY events to the SNS topic +`webhooks-cc-ses-events`; bounces and complaints also land on the account's +suppression list, after which SES drops sends to that address and reports +them as bounces. + +There is no queue in front of SES: a send that fails fails the request (sign +up, password reset) and the person tries again. ## 2. `docker-compose.override.yml` (next to `docker-compose.yml`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9fb39e17..5efc2d933 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,9 +203,6 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 - resend: - specifier: ^6.37.0 - version: 6.37.0 shiki: specifier: ^4.6.0 version: 4.6.0 @@ -3942,9 +3939,6 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postal-mime@2.7.6: - resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} - postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -4175,15 +4169,6 @@ packages: reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} - resend@6.37.0: - resolution: {integrity: sha512-iYNhmhfXVomZr+TMIxf6oqj7kTeGqPDE+wGcYw1H4g8WMBR59NsbShnUz43dSYpStW16iEA6dZs+egqQmdm0zA==} - engines: {node: '>=20'} - peerDependencies: - '@react-email/render': '*' - peerDependenciesMeta: - '@react-email/render': - optional: true - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -9287,8 +9272,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postal-mime@2.7.6: {} - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.32)(tsx@4.23.15)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 @@ -9600,11 +9583,6 @@ snapshots: reselect@5.3.0: {} - resend@6.37.0: - dependencies: - postal-mime: 2.7.6 - standardwebhooks: 1.1.1 - resolve-from@4.0.0: {} resolve-from@5.0.0: {}