Skip to content
TulipFarmDocs

Last updated

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.

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=acs or EMAIL_PROVIDER=smtp selects explicitly.
  • When EMAIL_PROVIDER is unset, ACS is used if ACS_CONNECTION_STRING is set, otherwise SMTP if SMTP_HOST is set.
  • Any of EMAIL_PROVIDER, ACS_CONNECTION_STRING, or SMTP_HOST counts 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.

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.

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.

From a source checkout, export ACS_CONNECTION_STRING and ACS_SENDER_ADDRESS from your secret store, then run:

Terminal window
pnpm --filter @tulipfarm/email build
node packages/email/dist/send.js --to [email protected] --template test
node packages/email/dist/send.js --to [email protected] --template magic-link --url https://tulipfarm.example.com/verify-email?token=example

With 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.

Was this page helpful?