TypeScript API for identity resolution and inbound mail policy:
GET /.well-known/nostr.json?name=<local_part>resolves NIP-05 identities.POST /inbound/decisionanswers the inbound SMTP decision protocol.POST /outbound/decisionanswers the outbound (nostr → SMTP) decision protocol, enabled only whenOUTBOUND_DECISION_TOKENis set.PUT/GET/DELETE /aliases[/{name}]is the REST alias lifecycle (claim, list, release) authenticated with NIP-98 (Authorization: Nostr <base64 kind-27235 event>); the signing pubkey owns the aliases it claims, served on the alias domain (request Host). Enforces the per-planmax_aliaseslimit (free 2, premium 10). See docs/AliasProtocol.md and docs/AccountModel.md.POST /inbound/rolereceives mail addressed to reserved role mailboxes (abuse@,postmaster@, ...) from theharaka-webhookrole webhook and stores it for the operator to read in/admin. Enabled only whenWEBHOOK_SIGNING_KEYis set. The request isapplication/x-www-form-urlencoded(Mailgun-style:recipient,sender,from,subject,message-headers,timestamp,token,signature,body-mime); auth is the plugin'sHMAC-SHA256(timestamp+token)signature.
cp .env.example .envEnvironment variables:
PORT: HTTP port, default3000.DATABASE_URL: Postgres connection string.INBOUND_DECISION_TOKEN: shared secret required byPOST /inbound/decision.OUTBOUND_DECISION_TOKEN: optional shared secret that enables and protectsPOST /outbound/decision. When unset, the outbound route is not registered.OUTBOUND_MAX_BODY_BYTES: max accepted body size forPOST /outbound/decision, default33554432(32 MB). Must be larger than the biggest plan message size so the full.emlfits.ADMIN_PASSWORD: optional password that enables the/adminidentity management UI.WEBHOOK_SIGNING_KEY: optional HMAC key that enables and protectsPOST /inbound/role. When unset, the role route is not registered. Must equal theharaka-webhookplugin'sWEBHOOK_SIGNING_KEY(the plugin signs both webhooks with it), and the plugin'sWEBHOOK_ROLE_URLshould point at this route.ROLE_WEBHOOK_MAX_BODY_BYTES: max accepted body size forPOST /inbound/role, default33554432(32 MB), so the full.emlfits.
The data model separates the account (the user, keyed by pubkey) from the identity (a human-readable alias pointing at a pubkey). See docs/AccountModel.md for the full design.
accountsholds person-level state:active,mail_enabled,plan(NULL= the default plan) andrelays. The service is open: a pubkey with no account row behaves as active, mail enabled, default plan.identitiesmaps an alias (local_part@domain) to a pubkey and carries onlyvisibility(publicis resolvable through/.well-known/nostr.json,privateis hidden).planshold quotas (rate, max.emlsize, max recipients) andallowed_domains(which domains the plan may create/use addresses on).
Addresses come in two classes: a provisioned alias (alice@example.com, has an
identities row) and a pubkey-encoded address (npub...@, raw 64-char hex,
or base36-encoded pubkey) which resolves to its pubkey without a row. NIP-05
relays are served from the account of the resolved pubkey.
Run the API with an external Postgres database:
docker compose up -dRun the all-in-one stack with the API and Postgres:
docker compose -f docker-compose.aio.yml up -dApply migrations against the target database, in order:
for migration in migrations/*.sql; do psql "$DATABASE_URL" -f "$migration"; doneWhen using the all-in-one Compose file, the exposed local database URL is:
for migration in migrations/*.sql; do \
psql "postgres://nmail:nmail@localhost:5432/nmail" -f "$migration"; \
doneExample account and identity (the account row is created automatically when an identity is added, so it is only needed to override the defaults):
insert into accounts (pubkey, relays)
values (
'b479e0d9afe3cf3caf43f1ded62da06d248d171d93f04c759431879afc371457',
'["wss://relay.example.com"]'::jsonb
)
on conflict (pubkey) do nothing;
insert into identities (domain, local_part, pubkey)
values (
'example.com',
'alice',
'b479e0d9afe3cf3caf43f1ded62da06d248d171d93f04c759431879afc371457'
);Set ADMIN_PASSWORD to enable the built-in admin console:
ADMIN_PASSWORD=change-me npm run devOpen http://localhost:3000/admin and sign in with the configured password.
The console has four tabs:
- Identities: create, update, and delete alias to pubkey mappings (domain, local part, pubkey, visibility).
- Accounts: per pubkey, toggle
activeandmail_enabled, set the plan, and edit relays. Deleting an account row reverts the pubkey to the defaults. - Plans: edit quotas and allowed domains, add new plans, and choose the default.
- Domains: add or remove the domains the service handles.
If ADMIN_PASSWORD is not set, the admin routes are not registered.
npm install
npm run typecheck
npm test
npm run devnpm run dev loads .env automatically when the file exists.
Run the local development database:
docker compose -f docker-compose.dev.yml up -dThe dev database uses nmail:nmail on localhost:5432, matching the
DATABASE_URL from .env.example.
Apply the database migrations to the dev Postgres container:
for migration in migrations/*.sql; do \
docker compose -f docker-compose.dev.yml exec -T postgres \
psql -U nmail -d nmail < "$migration"; \
doneThen run the API directly on your machine:
npm run devThe production image is published to GitHub Container Registry:
docker pull ghcr.io/nogringo/nmail-api:mainTagged releases matching v*.*.* also publish semver tags.
Configure the SMTP receiver decision URL with:
WEBHOOK_DECISION_URL=http://nmail-api:3000/inbound/decision
WEBHOOK_DECISION_PAYLOAD_MODE=minimalSend INBOUND_DECISION_TOKEN with Authorization: Bearer <token> or
x-inbound-decision-token: <token>. If the SMTP receiver cannot send custom
headers, the endpoint also accepts ?token=<token> as a compatibility fallback.
Configure the nostr-to-SMTP bridge decision URL with:
DECISION_URL=http://nmail-api:3000/outbound/decision
DECISION_PAYLOAD_MODE=fullUse full mode so the bridge forwards the complete .eml (rawMime); the
message size limit can only be enforced when the .eml is present.
The bridge posts the authenticated seal pubkey as nostrSender and the message
MIME headers. The endpoint applies, in order:
- Ownership: the
Fromdomain must be a managed domain, and either a matchingidentitiesalias is owned bynostrSender(a provisioned alias, which keeps working regardless of the current plan), or the local part decodes tonostrSender(a pubkey-encoded address). Encoded addresses also auto-create a free account and must be on a domain allowed by the current plan. - Account: the sender account must be
activeandmail_enabled. - Plan limits for the sender pubkey: recipient count (
To+Cc+Bcc), message size (the.emlbyte length), and a sliding send-rate window (per minute, hour, day).
A passing message returns { "decision": "allow" } and is recorded for rate
limiting. A blocked message returns { "decision": "deny", "reason": ..., "message": ... }
with reason unauthorized_sender, account_disabled, domain_not_allowed,
too_many_recipients, message_too_large, or rate_limited. Lookup failures
return 503 so the bridge retries. Re-asking about an already-recorded
giftWrapId is idempotent and is not double counted.
Send OUTBOUND_DECISION_TOKEN with Authorization: Bearer <token>,
x-outbound-decision-token: <token>, or ?token=<token>.
Outbound limits are grouped into plans. Each account references a plan
(accounts.plan); accounts with no plan fall back to the default. Two plans are
seeded by migration 003:
| Plan | Per minute | Per hour | Per day | Max .eml size |
Max recipients | Max aliases |
|---|---|---|---|---|---|---|
free (default) |
5 | 30 | 50 | 10 MB | 5 | 2 |
premium |
10 | 100 | 500 | 25 MB | 10 | 10 |
Plans are managed from the admin UI (Plans tab) and per-pubkey plan choice
from the Accounts tab; new plans can be added. max_aliases caps how many
provisioned aliases an account may claim via PUT /aliases/{name}
(pubkey-encoded addresses are not aliases and do not count). Each plan also has
allowed_domains: the domains it may create or send pubkey-encoded addresses on
(empty = all managed domains; provisioned aliases are exempt and keep working
after a downgrade). The message size limit is measured on the encoded .eml,
matching how SMTP servers enforce SIZE; because attachments are base64-encoded
(about +37%), 10 MB of raw files is roughly a 13.7 MB .eml, so set the limit on
the message accordingly.