Skip to main content

Retained email

Read this when a business operation sends email, when inspecting retained email, or when configuring delivery. The feature is independent of account workflows. Its writer, reader and delivery variants live in services/tadoku-api/features/email/; the templates are plain text.

Write inside the business transaction​

Pass a domain/mail.Message to email.Writer.Write inside the caller's existing PostgreSQL transaction. Persist every returned WriteResult.Jobs through jobqueue.Service.Enqueue before committing that same transaction. Do not call the writer outside a transaction or enqueue after commit. Failure rolls back the business write, email and queued work together.

TemplateRequired string parameters
account.recoveryrecovery_url
account.email_changednew_email, changed_at (RFC3339)
account.deleteddeleted_at (RFC3339)

The writer rejects unknown templates, missing, empty or unexpected parameters, parameters over 4096 bytes, invalid addresses and recovery links outside the Tadoku app host allowlist. Addresses are bare mailboxes, without display names or header line breaks, that net/mail reads back unchanged and that use at most 320 bytes. domain/mail.ValidAddress holds this rule. Recovery links require HTTPS and exclude credentials and explicit ports. The allowlist covers the main, account, admin and authv2 hosts, preview hosts and development branch equivalents.

A successful write inserts a pending email with its rendered subject/body and parameter map. The job is email.send.v1 with only {"email_id":"<uuid>"}. Its tenant is the queue row's tenant; payloads never carry tenant, recipient, content or credentials. See Jobs and worker.

Construct the service with NewService(repository, sender). A nil sender disables SMTP and holds every pending email.

Delivery and failures​

The worker registers email delivery with concurrency 2, a 20-second handler bound and five attempts. It holds a pending row unless both the deployment has enabled sending and the persisted tenant is tadoku/prod. A missing ID in the job's tenant is harmless; a guessed ID cannot reach another tenant's row. Sent and held rows are not sent again; a failed row is. Replaying its failed send job resends it; a sent outcome or the replay's final failure replaces the recorded failure. If the failed email had in fact been delivered, for example after a crash that followed SMTP acceptance, the replay delivers it again, so replaying is the operator's call. A replay in a tenant that holds its mail completes without sending and leaves the email failed; a replay of an email that retention deleted also completes without sending.

Enabled production delivery uses authenticated SMTP over STARTTLS, verifies the server certificate and records sent, the sending time and the provider's message ID returned by SMTP. The sender supplies an RFC Message-ID derived from the email ID, but SES replaces Message-ID and Date headers. Use the recorded provider message ID to investigate a delivered message. A failed SMTP attempt leaves the email unchanged until the fifth attempt, which records failed and its error. Worker maintenance also reconciles pending rows whose delivery jobs have all failed, including abandoned final leases. For each tenant, email.Service.ReconcileFailures applies the delivery hold rule that SendAttempt uses and settles those emails in one statement, matching email.send.v1 jobs by their payload's email_id. An email with a pending, running or completed send job stays pending. A settled email becomes held, or failed with the latest failed job's last_error, or the job queue's attempts_exhausted when that job recorded none. Reconciliation runs at startup and with the worker's 15-second maintenance pass.

SMTP cannot make the external send and PostgreSQL update atomic. A crash after SMTP accepts a message and before sent is persisted can deliver twice, as can an SMTP error after the provider accepted the message, a failed sent update, or a handler that keeps sending after its lease lapsed while a new claim sends again. The submitted Message-ID does not provide a deduplication guarantee. A send that finishes after its email was settled failed, from a handler whose lease lapsed or a replay inserted after reconciliation's statement snapshot, still records sent: provider acceptance replaces failed, as a newer failure does. Held and sent emails never change. See Execution guarantees.

Read retained messages​

GET /emails and GET /emails/{id} require an administrator of the verified request tenant. The gateway must expose these API routes; an environment without its email rule returns 404 before reaching the API. Administrator cookie requests require the gateway's cookie_session authentication and id_token mutation. The list accepts exact recipient, template and status filters, since (inclusive), limit (1–100; default 100) and cursor. Results are newest first, with ID as a deterministic tie-breaker. Continue with the opaque next_cursor; cursors belong to one tenant and never change request scope. Both endpoints return rendered content and params, so treat the response as sensitive even in development.

Every returned tadoku/prod row writes one read_email moderation audit entry, containing the actor and email ID, in the read transaction. A failed audit write fails the response without returning email content. Empty lists and denied or missing reads disclose no email and create no read entry. Other tenants do not write email-read audits.

Non-production email:read access uses the dedicated token route and a tenant-bound verifier. An absent verifier fails closed. Canonical development token reads record the token UUID as actor with access_token_id metadata in the same fail-closed audit transaction; production rejects those tokens. Gateway bearer pass-through preserves that verifier boundary alongside administrator cookie authentication; anonymous access never substitutes for token verification. See access-token commands.

Retention and operations​

email.Service.Retain owns the retention periods. It deletes the context tenant's rows strictly older than three calendar days in production and fourteen in other tenants, counted in the database session's time zone. Exact-boundary rows remain. It runs retention at startup and daily. A branch worker maintains only its own tenant; the base enumerates registered tenants and runs each statement within that tenant's ordinary row-level security scope. Deleting a tenant cascades to its email rows. Email retention and successful-job retention are independent.

See Worker configuration for settings. Local E2Es use disposable databases, real worker execution, real authorization providers and a loopback SMTP server with TLS and synthetic credentials. They do not contact SES. Run them with the disposable provider setup in Testing to repeat the writer → worker → administrator-read proof without shared provider writes. Live branch verification uses an already approved session or branch-owned account fixture and confirms the selected API backend. Creating a shared-provider session requires explicit maintainer authorization. If an account flow needs a temporary administrator-only test entrypoint, restrict it to the branch, remove it afterward and retain its exact patch and sanitized responses outside source control. Never add a permanent public send endpoint or change shared provider identities to manufacture verification.