Add DB migration (hour, minute, day_of_week, timezone columns) with idempotent ALTER TABLE upgrade path for existing databases. Extend getCronExpression with optional dayOfWeek parameter; weekly defaults to Monday (1) when not specified. Worker createJob now passes stored schedule fields to cron expression and uses the subscription's IANA timezone instead of hardcoded 'UTC'. PUT /subscription/email accepts optional hour (0-23), minute (0-59), dayOfWeek (1-7, only for weekly), timezone (IANA). GET response includes the new fields. Omitted fields fall back to defaults (17:00 UTC). Closes mailship-200
6.5 KiB
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.idmust match theidstring, and the event signature must be cryptographically valid (verifyEventfrom 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