Account mail (since 12.1)
Version note: this contract was introduced in 12.1 and continues in v13. The v13 line removed the Azure Communication Services transport (its section below is kept for 12.x readers), together with the SendGrid, Postmark, Mailjet and Mailtrap transports; they remain in the 12.x line, which reached end of life on 16 October 2026. Account mail in v13 is delivered through Resend, AWS SES, SendPulse, SMTP or an application-owned
MailDriver; see the migration guide row “Mail providers removed” and Writing your own mail transport.
This is an opt-in contract. Updating a dependency does not migrate an application’s account table, replace its session middleware, configure a sender or deploy a worker. The 12.x release record lists its first publication.
Boundaries
rullst-auth::recovery owns the authoritative account registry, password reset,
revocable opaque sessions and encrypted transactional outbox. rullst-mail owns
message construction, mandatory preparation and transport. The small
rullst::account_mail bridge composes them without coupling Auth to a provider
or introducing another publishable crate.
Enable account-mail-postgres or account-mail-sqlite on rullst, or the
corresponding recovery-* feature on Auth when supplying a different worker.
These features do not enable the general ORM. Features remain additive. In v13
rullst has no default features; on 12.x, disable the umbrella defaults when a
backend-exclusive dependency graph is required.
Adoption sequence
- Obtain two independent persistent random 32-byte application secrets from a
secret manager.
RecoverySecrets::newseparates keyed token/email digests from AES-256-GCM outbox/address encryption. Never use the fixture keys from tests. There is no automatic key rotation: plan a reviewed migration and backup restoration with the same secrets. - Connect
SqlRecoveryStore, then explicitly runmigrate()during deployment. PostgreSQL and SQLite share the fixed authoritative schema. Namespace stores by application/tenant with separate databases and secrets; a public request cannot select a tenant or supply a database URL. SQLite filesystem paths must be URL-encoded; usesqlite:PATH?mode=rwcwithout an authority for Windows drive letters. A remote PostgreSQL host always usessslmode=verify-full(certificate and hostname verification), whatever the URL requests; supplysslrootcertfor a private CA. Loopback hosts and Unix sockets keep the configured mode. Query options other thansslmode,sslrootcert,sslcert,sslkey,host,hostaddr,port,dbname,userandpasswordfail withConfigurationbefore connecting. During shutdown, stop the request/worker tasks and awaitclose()before moving or deleting a database; it closes the connection pool shared by every store clone. - Register new accounts with
register_account_with_locale. Account creation and the encrypted welcome notice commit together. The supported recorded locales are English, Brazilian Portuguese and Spanish; the worker supplies a deterministic fallback. An email or subject that is already registered returnsInvalidAction(without naming which), not theStorageerror of a database failure; present it without confirming that the email exists. Existing accounts require an application migration; there is no automatic import of arbitrary password tables. - Use
authenticate,create_sessionandverify_sessionfor this registry. Passwords contain at least 12 characters and at most 72 bytes, the Argon2 input limit. Registration, reset andauthenticatereject longer input withInvalidInputbefore any account lookup, so the outcome does not reveal whether an email is registered; unknown accounts still perform Argon2 work. Verify the opaque session on every authenticated request. Set it in a Secure, HttpOnly, appropriately SameSite cookie. Userevoke_sessionfor logout. Legacy encrypted-only session cookies do not gain revocation implicitly. - A protected, independently rate-limited request endpoint calls
request_password_reset(email, trusted_now). Return the same generic acknowledgement for unknown, throttled and accepted requests. No mail provider is contacted inline. Database failures require a generic service response. - A separate bounded worker calls
deliver_next_account_mailwith anAccountMailConfigcontaining the server-owned HTTPS origin, reset endpoint, sender, application name and fallback locale. The endpoint cannot contain a query or fragment. The provider must have a verified sending domain. - Submit the new password and opaque code to a CSRF-protected consume endpoint
using
complete_password_reset. Never include the password in a URL. On success, direct the member to login; no automatic login is performed.
The reset page must set Cache-Control: no-store and Referrer-Policy: no-referrer, remove the action query from browser history, avoid third-party
page resources, and ensure proxy/access logs omit the query string. The SDK
cannot configure a deployed proxy. Apply separate ingress limits to login,
request and consume routes, and test response timing in the deployed system.
The store enforces a 250 ms minimum request duration, but this alone cannot hide
arbitrary database delays or establish indistinguishability under outages.
Reset and delivery guarantees
- Reset codes use 256 bits from the operating system CSPRNG and expire after 20 minutes. The credential table retains only a purpose-separated keyed digest. A replacement request invalidates previous unused codes.
- Password replacement, consumption, sibling invalidation, session revocation and creation of a password-changed notice share one database transaction. Failure to persist the outbox rolls the password change back.
- Recovery requests have a three-per-account/15-minute limit and a global
120-per-minute ceiling. Consumption looks the code up before any Argon2 work,
so unknown, expired or consumed codes cost one indexed read. Each code has its
own budget of 10 attempts per minute, counting attempts in flight; only a
failed lookup keeps its unit. An exhausted code receives
RecoveryError::Throttled(map it to a generic “try again later”), while other members’ codes are never blocked. The budget is process-local and bounded to 10,000 recent codes. These are bounded defaults, not a substitute for per-client/distributed ingress controls. - The outbox encrypts recipient and reset code, with per-record authenticated encryption. The recovery credential remains digest-only; the delivery copy is decryptable using the application encryption key until expiry/terminal cleanup. Retention is bounded at 10,000 records, with six attempts, 60-second leases and exponential retry delay. When the outbox is full, the oldest delivered or failed (ciphertext-free) records are evicted first, so only pending and leased notices can block registration or silently drop a reset request. Old workers cannot acknowledge a newer lease.
- Reset emails render an absolute UTC expiry, keeping the body identical across retries. Stable delivery IDs reach Resend through observation, inspection, suppression, resolver and failover wrappers. Other transports remain at-least-once. Switching providers or crashing after acceptance can duplicate mail. Resend’s idempotency contract has its own retention window; this is not an exactly-once inbox guarantee.
Message content and feedback
ActionLink validates the application’s exact configured origin. AccountMail
provides deterministic localized welcome, reset, password-changed, verification,
new-device, security-alert, address-change, closure and export notices. These
additional templates do not implement the corresponding identity transactions.
MailPurpose identifies their purpose; a template is not a marketing consent
record or a legal-compliance certificate.
MAIL-001/RULLST-005 is fixed in the mandatory pipeline: bounded opaque token=
query values inside safe body URLs survive preparation, including HTML-escaped
query separators and plain-text links wrapped in <…>, (…) or Markdown
[label](…) or followed by sentence punctuation. Other credentials and non-URL
token text remain redacted.
Message, action links and recovery notices omit sensitive content from Debug;
LogDriver emits delivery metadata only. Do not log values explicitly exposed
for delivery by expose_url, expose or into_message.
ResendFeedbackVerifier authenticates the exact raw body with Svix v1 HMAC and
five-minute freshness. Reject duplicate signature headers at the HTTP boundary.
It classifies email.delivered, email.delivery_delayed, email.bounced,
email.complained and email.failed; subscribe the webhook to those. Another
authentic event type (such as email.sent or email.opened) returns the v13
MailFeedbackError::UnsupportedEvent, which an adapter can acknowledge without
acting on it, while malformed or unclassifiable bounce payloads stay
InvalidPayload.
Use its suppression_event() with MutableSuppressionStore::record, then place
SuppressionGuard around the worker transport. Permanent bounces and complaints
suppress delivery; transient failures do not. A durable suppression store is
required across process restarts. The SDK provides a SQLite suppression adapter;
other production deployments supply their own SuppressionStore. Verified
feedback does not automatically mutate an unrelated account registry.
Security templates add no tracking or marketing unsubscribe dependency. Disable tracking at the provider/domain/account level as well; for example, the 12.x Postmark transport’s server settings can override the message flag. Marketing consent, legal basis, preference withdrawal and retention are application responsibilities; enabling Mail cannot establish GDPR/LGPD compliance.
Azure Communication Services (12.x only)
This transport was removed in v13. On 12.x, AzureCommunicationDriver supports the ACS Email 2023-03-31 REST
contract. Set MAIL_DRIVER=azure-acs and
AZURE_COMMUNICATION_EMAIL_ENDPOINT=https://RESOURCE.communication.azure.com.
In Azure Container Apps, AzureManagedIdentity uses the platform-injected
loopback IDENTITY_ENDPOINT/IDENTITY_HEADER and optional user-assigned
AZURE_CLIENT_ID for the Communication Services resource. The endpoint must be
plain HTTP on a literal loopback IP or the exact name localhost, which the
identity client pins to 127.0.0.1/::1 instead of resolving. The host must
grant the identity email-sending permission and verify its domain/sender. A
credential reuses its token until five minutes before expires_on; identity
endpoint throttling (HTTP 429) and 5xx outages are RateLimited/Transient,
while other refusals remain a permanent ConfigError. The Mail facade builds
a new credential per send, so reuse applies to a long-lived driver instance.
The driver disables engagement tracking, bounds payload/response sizes,
validates the operation-polling origin, and accepts only a terminal successful
operation as success. A pending operation after the bounded wait needs
reconciliation; ACS retries have at-least-once semantics. An empty or mock_*
endpoint/credential is deterministic offline behavior, not Azure acceptance.
Live Managed Identity/ACS sending still requires deployment acceptance.
See the official REST contract
and Container Apps identity contract.
Remaining proposal work
The examples proposal is broader than welcome/password recovery. A Redis
identity registry, durable email-verification/address-change transactions,
marketing consent policy, and an authorized/audited reset-resend administration
workflow are separate work. Existing Mail observers and outbox_snapshot()
provide minimized application diagnostics; this change does not expose a global
mailbox browser, reset codes or administrative resend endpoint.
Local contracts cover mandatory preparation, localized mock delivery, SQLite restart/encryption/fencing, concurrent single use, rollback, session revocation, signed feedback and PostgreSQL recovery. CI runs the PostgreSQL contract against a pinned disposable image. Provider fixtures do not establish actual delivery, reputation, merchant readiness or regulatory compliance.