Email notifications for Nostr private groups
Find a file
matt 9ad5c4eef4
Some checks failed
CI / checks (push) Successful in 39s
Docker / docker (push) Has been cancelled
Merge pull request 'Port cron-minutely fixes: reactivate tombstoned subs + confirm-email rate limit' (#29) from mailship-fc-merge-cron-fixes-df6 into main
Reviewed-on: #29
Reviewed-by: matt <matt@lorentz.is>
2026-09-23 17:56:11 +00:00
.agents/skills/beads bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
.beads gitignore .beads/interactions.jsonl (untrack beads audit sidecar) 2026-09-18 12:22:31 -04:00
.claude bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
.codex bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
.forgejo/workflows Update .forgejo/workflows/docker-publish.yml 2026-09-18 17:40:26 +00:00
.fragua Onboard to fragua 2026-08-25 10:37:14 -04:00
script digest email: replace reply/reaction counts with relay URL 2026-09-22 10:04:35 -04:00
src Rate-limit confirmation emails and restore daily/weekly digest cron 2026-09-23 12:14:28 -04:00
test Rate-limit confirmation emails and restore daily/weekly digest cron 2026-09-23 12:14:28 -04:00
.dockerignore Remove orphaned web/dist/ reference from .dockerignore 2026-09-10 10:09:13 -04:00
.env.template Merge remote-tracking branch 'origin/main' into mailship-0da-readme-config-architecture-and-docker-co-6e1 2026-09-14 12:55:35 -04:00
.gitattributes Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
.gitignore bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
.nvmrc Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
.prettierignore Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
.prettierrc Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
AGENTS.md Rework email template 2026-08-26 17:57:26 -04:00
CLAUDE.md bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
docker-compose.yml Align README, docker-compose, .env.template with SMTP mailer 2026-09-10 11:30:50 -04:00
Dockerfile hardening: run production container as non-root user 2026-09-18 10:30:52 -04:00
eslint.config.mjs Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
LICENSE Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
package.json Remove unused/misplaced dependencies (bcrypt, express-ws, ts-node-dev, @types/node) 2026-09-18 10:32:09 -04:00
pnpm-lock.yaml Remove unused/misplaced dependencies (bcrypt, express-ws, ts-node-dev, @types/node) 2026-09-18 10:32:09 -04:00
pnpm-workspace.yaml Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
README.md feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone) 2026-09-23 10:50:04 -04:00
remove-pnpm-overrides.js Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
tsconfig.json Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
vitest.config.ts Standardize tests on vitest 2026-09-14 13:32:19 -04:00

Mailship

A Nostr email notification server. Receives events pushed from relays via NIP-9a, stores them, and sends daily or weekly digest emails.

This repo is a fork of anchor. It has been built primarily to support email notifications in Flotilla, but supports alternate branding and could be set up to work with any Nostr relay supporting NIP-9a.

Architecture

Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
  │                   NIP-98 auth
  │                   returns {key, callback}
  │
  └──kind 30390──▶ relay or NPB
                         │
                    event matches filter
                         │
                         ▼
                    POST /notify/:subId ──▶ Mailship
                         {id, relay}         │
                           UUID in path      ├── fetch event from relay
                           is the auth       ├── store in SQLite (dedup)
                                             │
                                        cron fires ──▶ render digest ──▶ SMTP ──▶ email

Configuration

Variable Required Description
MAILSHIP_SECRET ✓ A nostr private key hex string for the server's identity
MAILSHIP_NAME ✓ Name of this Mailship instance
MAILSHIP_URL ✓ Public URL of this instance
BASE_URL ✓ Base URL for callback URLs (same as MAILSHIP_URL typically)
SMTP_HOST ✓ SMTP server hostname
SMTP_PORT ✓ SMTP server port
SMTP_USER ✓ SMTP username
SMTP_PASSWORD ✓ SMTP password
SMTP_FROM ✓ From email address for outgoing mail
EVENT_VIEWER_URL Base URL of the app event links open in (defaults to Flotilla at https://app.flotilla.social, or anything handling the same /spaces/<relay>/<hash> and /<nevent> URL shapes)
BRAND_NAME Name used in email branding (default: Flotilla)
BRAND_ACCENT Accent color string used in email branding (default: #7161FF)
BRAND_LOGO URL of the logo image shown in the email header. Defaults to <EVENT_VIEWER_URL>/logo.png; if empty/unset, a colored brand name is shown instead
DEFAULT_RELAYS ✓ Comma-separated list of default relays
INDEXER_RELAYS ✓ Comma-separated list of indexer relays
SEARCH_RELAYS ✓ Comma-separated list of search relays
PORT Port to run on (default: 4738)

API

PUT /subscription/email

Idempotently register or update an email subscription. Re-sends the confirmation email only when the subscription is new or the email address changed; a frequency or schedule change keeps the existing confirmation. The pubkey is extracted from the NIP-98 Authorization header — the body does not include a pubkey field.

Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback }

Optional schedule fields (defaults: hour=17, minute=0, timezone="UTC"):

Field Type Constraints Default
hour integer 0–23 17
minute integer 0–59 0
dayOfWeek integer 1=Mon … 7=Sun (0 also accepted as Sun); only valid when frequency="weekly" 1 (Monday) for weekly, omitted for daily
timezone string IANA timezone name, e.g. "America/New_York" "UTC"

When omitted, each field falls back to its default. dayOfWeek is silently ignored for daily frequency (the stored value is NULL).

DOW convention: dayOfWeek follows cron: 1=Monday, 2=Tuesday, …, 7=Sunday. 0 is also accepted as Sunday (standard cron alias).

GET /subscription/email

Look up an existing subscription for the authenticated pubkey, so clients can avoid re-registering (and re-confirming) when settings haven't changed. Returns 404 if none exists.

Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }

DELETE /subscription/:key

Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner.

Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { ok: true }

POST /notify/:id

NIP-9a relay push callback. Called by relays or NPB when matching events are found.

Body: { id, relay, event? }
Response: { ok: true, stored: boolean }
Returns 404 if subscription not found or inactive.

The optional event field supports NIP-98 include_event — relays can embed the full event inline to bypass fetching. When event is provided:

  • event.id must match the id string, and the event signature must be cryptographically valid (verifyEvent from nostr-tools).
  • If either check fails, the endpoint returns 400 { error: 'Invalid event' }.

When event is omitted, the server fetches the event from the relay using id and relay. If the relay has no matching event (deleted, expired, or never published), the endpoint returns { ok: true, stored: false } — the event is silently skipped rather than erroring.

After obtaining the event (from body or relay), it is stored in the local database. If the event is already known (deduplication), stored is false; otherwise stored is true. The stored field is always present in a 200 response.

GET /confirm?token=...

Confirm email address via link from confirmation email.

GET /unsubscribe?token=...

Unsubscribe via link from digest email.

Development

Requirements: Node >= 22

Single instance: Mailship uses in-process cron jobs for digest scheduling. Running more than one instance concurrently may cause duplicate or missed emails.

pnpm install
pnpm run build
pnpm run start

Previewing the digest email

To iterate on the email template, render it with sample data and open the result in your browser:

pnpm run preview:digest
open digest-preview.html

Docker

Quick start

cp .env.template .env
# Fill in your secrets in .env
docker compose up -d

Build and run manually

docker build -t mailship .
docker run -d \
  -p 4738:4738 \
  -v mailship-data:/data \
  --env-file .env \
  mailship

Tests

pnpm test:unit       # Run unit tests (vitest)
pnpm test            # Run integration tests (bash E2E)
pnpm test:server     # Start server for manual testing