Email notifications for Nostr private groups
Find a file
Agent 737f949f9b fix digest job race: delete sent events by ID, not timestamp
Bug: runJob computed since = sub.last_digest_at, fetched events with
received_at > since, sent the digest (slow), then deleted ALL events
with received_at > since. Any /notify event that arrived between the
fetch and the delete was also received_at > since, so it was deleted
without ever being sent in a digest.

Two changes:

1. Delete by exact event IDs (src/database.ts, src/worker/email.ts):
   Added deleteEventsByIds(subscriptionId, eventIds) which deletes
   only the events that were actually fetched + sent. The old
   timestamp-based delete is retained but no longer called from runJob.

2. Re-fetch subscription on each cron tick (src/worker/email.ts):
   createJob's closure captured the original sub, so sub.last_digest_at
   stayed stale in memory. Every subsequent tick recomputed since from
   the old value, re-fetching and re-sending duplicate events. Now each
   tick re-fetches the subscription from the DB via getSubscriptionById
   before calling runJob.

Fixes bead mailship-091
2026-09-14 13:07:34 -04:00
.agents/skills/beads bd init: initialize beads issue tracking 2026-08-24 16:11:52 -04:00
.beads bd init: initialize beads issue tracking 2026-08-24 16:11:52 -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
.fragua Onboard to fragua 2026-08-25 10:37:14 -04:00
script Update confirmation page 2026-08-27 15:23:18 -04:00
src fix digest job race: delete sent events by ID, not timestamp 2026-09-14 13:07:34 -04:00
test fix digest job race: delete sent events by ID, not timestamp 2026-09-14 13:07:34 -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
build-in-production.sh Strip out the web UI (login + subscription filter management) 2026-09-10 10:08:14 -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 Strip out the web UI (login + subscription filter management) 2026-09-10 10:08:14 -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 Strip out the web UI (login + subscription filter management) 2026-09-10 10:08:14 -04:00
pnpm-lock.yaml Switch postmark api to smtp 2026-08-24 16:12:32 -04:00
pnpm-workspace.yaml Initial mailship fork from anchor 2026-08-18 12:21:22 -04:00
README.md Merge remote-tracking branch 'origin/main' into mailship-0da-readme-config-architecture-and-docker-co-6e1 2026-09-14 12:55:35 -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

Mailship

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

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: 3000)

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 change keeps the existing confirmation.

Body: { email, frequency, pubkey }
Auth: NIP-98 (planned)
Response: { key, callback }

GET /subscription/email?pubkey=...

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

Response: { key, callback, email, frequency, confirmed }

DELETE /subscription/:key

Unsubscribe.

Auth: NIP-98 (planned)
Response: { ok: true }

POST /notify/:id

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

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

GET /confirm?token=...

Confirm email address via link from confirmation email.

GET /unsubscribe?token=...

Unsubscribe via link from digest email.

Development

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          # Run integration tests
pnpm test:server   # Start server for manual testing

Forked from Anchor

Mailship is a fork of Anchor, stripped of push notification support and adapted for NIP-9a relay push event intake.