# Send email

> Configure how a self-hosted TulipFarm sends sign-in links and invitations through SMTP or Azure Communication Services, and how delivery retries.

Source: https://tulipfarm.ai/docs/self-hosting/email/

[`packages/email`](https://github.com/TulipFarm/tulipfarm/blob/main/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

### 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`](https://github.com/TulipFarm/tulipfarm/blob/main/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](https://tulipfarm.ai/docs/self-hosting/sign-in/index.md#local-development-sign-in).

Without any transport, people can still sign in with Google, or with a password
when [password sign-in](https://tulipfarm.ai/docs/self-hosting/sign-in/index.md#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 `noreply@example.com`. 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

| 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](https://tulipfarm.ai/docs/self-hosting/configuration/index.md#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

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

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

```sh
pnpm --filter @tulipfarm/email build
node packages/email/dist/send.js --to you@example.com --template test
node packages/email/dist/send.js --to you@example.com --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.
