Agent-first notification service. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox, MCP server built in.
A connector turns a job into one provider request. Every connector returns
the same two things: a Delivery (provider name + provider message id) or a
ProviderError whose kind drives the worker (RateLimited, Transient,
Permanent, Suppressed). Adding a provider is one file implementing the
Connector trait in src/connectors/ plus its environment variables below;
retries, pacing, priority, metrics and evidence come for free.
Configuration is per instance (one instance per company, see
DEPLOYMENTS.md): the instance’s environment selects one provider per
channel. Names only here, never values.
EMAIL_PROVIDERCommon: EMAIL_FROM (default sender, verified at the provider),
EMAIL_FROM_NAME. A project can override both with from_email /
from_name (POST /v1/admin/projects).
EMAIL_PROVIDER |
Variables | Notes |
|---|---|---|
resend (default when RESEND_API_KEY is set) |
RESEND_API_KEY |
Native batch (/emails/batch, 100 per call), tags, headers, attachments on single send. Rate limit 10 req/s per team: keep EMAIL_RATE_PER_SEC ≤ 8 per replica. |
cloudflare |
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_EMAIL_API_TOKEN (token with the Email Sending permission) |
Cloudflare Email Service REST API (beta, Workers Paid). Headers, reply-to, cc, attachments (5 MiB total). A recipient reported in permanent_bounces fails the job permanently. No provider message id. |
smtp |
SMTP_HOST, SMTP_PORT (587), SMTP_USERNAME, SMTP_PASSWORD, SMTP_SECURITY = starttls | tls | none |
Any SMTP submission service: Amazon SES, Postmark, Brevo, Mailgun, OVH, Cloudflare (smtp.mx.cloudflare.net:465, tls), a relay. Multipart text+HTML, headers, attachments; the generated Message-ID is the provider message id. SMTP 4xx = transient, 5xx = permanent. |
agentmail |
AGENTMAIL_API_KEY, EMAIL_FROM = inbox address |
Agent inbox provider. |
log |
— | Nothing is sent. One info log line per message, provider="log" in metrics. Development and previews only. |
EMAIL_FALLBACK_PROVIDERA second email provider, configured with its own variables exactly as if it
were primary (EMAIL_FALLBACK_PROVIDER=smtp + SMTP_*, or cloudflare +
CLOUDFLARE_*, or resend + RESEND_API_KEY). The sender domain must be
verified at both providers.
When the primary answers 429, 5xx or fails at the network level, the message
goes out through the fallback in the same worker tick and a breaker
opens: for EMAIL_FAILOVER_COOLDOWN_SECS (60) every email uses the fallback,
then the primary is tried again. Permanent errors (bad address, unverified
sender) never fail over: they would fail at any provider. Metrics:
notifyd_email_failovers_total{from,to,outcome}; the digest reports the
breaker state and suggests a fallback when none is configured.
SMS_PROVIDERCommon: SMS_FROM (E.164 number or alphanumeric sender), pacing
SMS_RATE_PER_SEC (10).
SMS_PROVIDER |
Variables |
|---|---|
telnyx |
TELNYX_API_KEY, TELNYX_MESSAGING_PROFILE_ID (optional) |
twilio |
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN |
TELNYX_WHATSAPP_API_KEY (falls back to TELNYX_API_KEY), WHATSAPP_FROM
(WhatsApp-enabled E.164 number), TELNYX_MESSAGING_PROFILE_ID (optional),
pacing WHATSAPP_RATE_PER_SEC (10). Free-form text is allowed within the
24-hour conversation window; outside it, pass a Meta-approved template in the
job payload: {"whatsapp": {"template": {...}}}.
VAPID_PRIVATE_KEY or VAPID_PRIVATE_KEY_PEM, VAPID_PUBLIC_KEY,
VAPID_SUBJECT (mailto:…) for browsers; FCM_SERVER_KEY for FCM legacy.
A subscription the push service rejects permanently (404/410, bad request) is
deleted, so it stops failing every send. Pacing PUSH_RATE_PER_SEC (50).
No configuration: messages are stored in Postgres and pushed to connected
browsers over SSE (GET /v1/inbox/:subscriber_id/stream). Live events fan
out through Postgres NOTIFY, so any number of replicas may serve the
stream. An unknown subscriber is a permanent error.
PUBLIC_URL (e.g. https://api-os.philoeparis.com/notifyd) is the base of the
links this instance hosts: the one-click unsubscribe endpoint /u/<token> on
bulk email. Without it, bulk email leaves without List-Unsubscribe headers
and the digest says so.
ADMIN_API_KEY (required) opens /v1/admin/* and the MCP endpoint with
every tool. READONLY_API_KEY (optional) opens the GET endpoints, the
metrics and the read-only MCP tools only.
| Variable | Default | Meaning |
|---|---|---|
WORKER_MAX_ATTEMPTS |
5 | Attempts before a transiently failing job is marked failed (30 s, 2 min, 10 min, 30 min, 2 h with ±20 % jitter). |
WORKER_BATCH_SIZE |
50 | Jobs claimed per poll. |
WORKER_POLL_INTERVAL_MS |
500 | Poll interval. |
EMAIL_RATE_PER_SEC, SMS_RATE_PER_SEC, WHATSAPP_RATE_PER_SEC, PUSH_RATE_PER_SEC |
8, 10, 10, 50 | Provider requests per second per replica (a batch call counts once). 0 disables pacing for that channel. |
RATE_LIMIT_PAUSE_SECS |
2 | Lane pause after a 429 without Retry-After. |
EMAIL_FALLBACK_PROVIDER, EMAIL_FAILOVER_COOLDOWN_SECS |
none, 60 | Second email provider and how long the primary rests after tripping the breaker. |
src/connectors/<provider>.rs implementing Connector: channel(),
provider(), send(), and send_batch() / batch_max() when the provider
has a bulk endpoint.http_outcome(provider, response, message_id) for
HTTP APIs: it classifies 429 (with Retry-After), 5xx/408
(transient) and other 4xx (permanent) in one place.create_email_connector for email) and in
EmailConfig::from_env / SmsConfig::from_env, then document its
variables in this file and pass them through docker-compose.yml.cloudflare.rs, smtp.rs).