Send email
Configure how a self-hosted TulipFarm sends sign-in links and invitations through SMTP or Azure Communication Services, and how delivery retries.
packages/email renders React Email templates and sends
them through Azure Communication Services (ACS) or SMTP.
The API queues encrypted invitation, magic-link, and password reset email in app.email_deliveries.
The worker polls that outbox and starts deliverEmailWorkflow with only a delivery ID.
Its Activity decrypts and renders the email. Neither tokens nor rendered email enter Temporal history.
Configuration
Section titled “Configuration”Choose a transport
Section titled “Choose a transport”The provider is only the transport. Templates, the encrypted
outbox, claims, retries, expiry, and delivery status are the same for both.
emailSenderConfigFromEnv in packages/email/src/sender.ts selects it:
EMAIL_PROVIDER=acsorEMAIL_PROVIDER=smtpselects explicitly.- When
EMAIL_PROVIDERis unset, ACS is used ifACS_CONNECTION_STRINGis set, otherwise SMTP ifSMTP_HOSTis set. - Any of
EMAIL_PROVIDER,ACS_CONNECTION_STRING, orSMTP_HOSTcounts as real email, which disables the local development sign-in link described in Sign-in.
Without any transport, people can still sign in with Google, or with a password when password sign-in is on. An administrator then prints password reset links instead of emailing them.
| SMTP key | Purpose |
|---|---|
SMTP_HOST |
Server host name. Required. |
SMTP_PORT |
Defaults to 465 when SMTP_SECURE=true, otherwise 587. |
SMTP_SECURE |
true for implicit TLS. Defaults to true only when the port is 465. |
SMTP_REQUIRE_TLS |
Defaults to true: without implicit TLS, sending fails unless STARTTLS succeeds. Set false only for a trusted local relay such as Mailpit. |
SMTP_USER, SMTP_PASSWORD |
Optional. Set both or neither. |
SMTP_FROM_ADDRESS |
Sender address, for example [email protected]. Required. |
The SMTP adapter uses Nodemailer with 10-second connection and greeting timeouts
and a 30-second socket timeout, within the one-minute delivery Activity. A
rejected recipient fails the attempt, which then follows the outbox retry schedule.
The sender returns the generated Message-ID header.
ACS and delivery keys
Section titled “ACS and delivery keys”| Key | Purpose |
|---|---|
ACS_CONNECTION_STRING |
Azure Communication Services connection string. |
ACS_SENDER_ADDRESS |
Verified sender address for email. |
EMAIL_DELIVERY_KEY_ID, EMAIL_DELIVERY_KEY |
Current outbox encryption key ID and key. |
EMAIL_DELIVERY_PREVIOUS_KEYS |
Optional retired keys, used only to decrypt. |
EMAIL_DELIVERY_DATABASE_URL |
The worker’s restricted outbox login. |
The Docker Compose setup job generates the delivery keys and the restricted login.
See Generated settings.
Both API and worker require the same EMAIL_DELIVERY_KEY_ID and a 64-character hex
EMAIL_DELIVERY_KEY. Previous keys are an optional JSON object mapping old IDs to
64-character hex keys. The same keyring protects workspace-local invitation copy
material. Retain each old key for at least seven days after the last invitation
issued with that key, regardless of whether email delivery succeeds. Keep it longer
while any outbox payload or workspace invitation copy envelope still references it,
including expired envelopes awaiting cleanup. Successful delivery erases only the
outbox payload, not an active invitation’s copy material.
The API uses its existing database login to enqueue magic links. The worker requires
a separate login with database CONNECT, schema app USAGE, and SELECT and UPDATE
on app.email_deliveries. It must have no identity-table access or inherited owner
role. Worker startup rejects a login that can read users, sessions, accounts, or
verifications. It does not need INSERT, DELETE, workspace, or provisioning privileges.
Set EMAIL_DELIVERY_DATABASE_URL to that login. Do not reuse DATABASE_URL for the worker.
The API is a trusted global identity and transport process. Its database role has SELECT, INSERT, and UPDATE on all delivery rows, exceeding its current magic-link INSERT requirement. It also holds the shared encryption keys. Workspace isolation does not contain a compromised API process. Public invitation routes still require current workspace-admin authority and use the workspace login for admission. Transport rows grant no membership; redemption requires the authoritative workspace invitation hash and verified recipient. The worker startup check rejects identity reads but is not a complete privilege audit. Provision the documented limited role.
For local development, supply the keys to the API and the restricted database URL and keys to the worker. An absent worker leaves durable pending email. Missing keys reject enqueue requests rather than sending plaintext workflow arguments.
Delivery recovery
Section titled “Delivery recovery”The dispatcher scans every five seconds. A claim leases the delivery for two minutes. Failures retry after 30 seconds with exponential backoff capped at one hour, until expiry. Magic links expire after 15 minutes; invitations after seven days. Ciphertext is removed after successful delivery, revocation, or the next expiry sweep. Revocation cannot recall a send whose claim already committed. Losing a provider acknowledgement may duplicate an email.
Members administration displays delivery status separately from invitation validity. Retrying an uncertain email creation with its request ID returns the existing invitation. A fresh creation request observes an existing pending invitation without sending again. Explicit Resend replaces that invitation and cancels its pending email. Active email invitations support authorized Copy link after delivery and refresh. The workspace stores separate authenticated ciphertext until consumption, revocation, replacement, or expiry. Maintenance scans up to 100 workspaces per minute and clears up to 1,000 expired copy payloads per workspace per scan. The ordinary email worker still has no workspace access.
Send a test email
Section titled “Send a test email”From a source checkout, export ACS_CONNECTION_STRING and ACS_SENDER_ADDRESS
from your secret store, then run:
pnpm --filter @tulipfarm/email buildnode packages/email/dist/send.js --to [email protected] --template magic-link --url https://tulipfarm.example.com/verify-email?token=exampleWith SMTP, export the EMAIL_PROVIDER=smtp and SMTP_* keys instead of the ACS keys
and run the same send.js commands. A local catcher such as Mailpit on port 1025
needs SMTP_REQUIRE_TLS=false.
The worker needs the same keys.