# Mailship A Nostr email notification server. Receives events pushed from relays via [NIP-9a](https://github.com/nostr-protocol/nips/pull/2194), stores them, and sends daily or weekly digest emails. This repo is a fork of [anchor](https://github.com/coracle-social/anchor). It has been built primarily to support email notifications in [Flotilla](https://flotilla.social), 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//` and `/` 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 `/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 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 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 Authorization header) Response: { ok: true } ``` ### POST /notify/:id [NIP-9a](https://github.com/nostr-protocol/nips/pull/2194) 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. ```sh 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: ```sh pnpm run preview:digest open digest-preview.html ``` ## Docker ### Quick start ```sh cp .env.template .env # Fill in your secrets in .env docker compose up -d ``` ### Build and run manually ```sh docker build -t mailship . docker run -d \ -p 4738:4738 \ -v mailship-data:/data \ --env-file .env \ mailship ``` ## Tests ```sh pnpm test:unit # Run unit tests (vitest) pnpm test # Run integration tests (bash E2E) pnpm test:server # Start server for manual testing ``` ## Deployment ### Production Image Build Mailship uses a **multi-stage Docker build** to produce a minimal production image. | Stage | Base Image | Purpose | |---|---|---| | `build` | `node:22-alpine` | Compile TypeScript, install all dependencies | | `production` | `node:22-alpine` | Runtime — only production deps, compiled JS, static assets | The Dockerfile is structured to keep the final image small by discarding build tooling (`python3`, `make`, `g++`, `git`) and development dependencies after compilation. ```sh docker build -t mailship . ``` The image listens on **port 4738**, expects a writable **`/data`** volume for the SQLite database, and runs as a non-root user (`app`) for security hardening. ### Docker Compose (Recommended) Copy the environment template, fill in your secrets, and start: ```sh cp .env.template .env # Edit .env with your production values (see Required Environment Variables below) docker compose up -d ``` The compose file (`docker-compose.yml`) configures: - **Automatic restarts** via `restart: unless-stopped` - **Named volume** `mailship-data` mounted at `/data` for database persistence - **Port 4738** published to the host - **Environment variables** passed from your `.env` file with `.env` substitution - **Required vars** (`MAILSHIP_SECRET`, `MAILSHIP_URL`, `BASE_URL`, `SMTP_*`) — the compose file uses `${VAR:?required}` to fail early if any are missing To pull the latest image and recreate the container after a rebuild: ```sh docker compose build --pull # or if using a pre-built image: docker compose pull docker compose up -d ``` ### Required Environment Variables The table below marks variables that are especially important in **production**. Defaults are safe for development but may leak information or misroute mail in a production deployment. | Variable | Required in production | Notes | |---|---|---| | `MAILSHIP_SECRET` | ✓ | Must be a **private key in hex** — never share or commit this value | | `MAILSHIP_URL` | ✓ | Set to the **public-facing URL** of your instance (e.g. `https://mail.example.com`) | | `BASE_URL` | ✓ | Same as `MAILSHIP_URL` in most setups; used for callback URLs in email | | `SMTP_HOST` | ✓ | Production SMTP server (e.g. `smtp.sendgrid.net`, `email-smtp.us-east-1.amazonaws.com`) | | `SMTP_PORT` | ✓ | 587 (STARTTLS) or 465 (TLS); verify with your provider | | `SMTP_USER` | ✓ | SMTP authentication username | | `SMTP_PASSWORD` | ✓ | SMTP authentication password — use a secret manager or env injection, not a file | | `SMTP_FROM` | ✓ | The `From:` address recipients will see (e.g. `"Mailship" `) | | `DEFAULT_RELAYS` | | Comma-separated relay list; production should tune these to reliable relays | | `INDEXER_RELAYS` | | Same — use relays you trust for discovery | | `SEARCH_RELAYS` | | Same — use a relay that supports NIP-50 search | | `CORS_ORIGIN` | ✓ | **Must** be set to the exact origin your browser client runs on (e.g. `https://app.example.com`). The server refuses to start without it. | | `DATA_DIR` | | Default `/data` in the Docker image; change only if you mount a different volume path | | `BRAND_NAME`, `BRAND_ACCENT`, `BRAND_LOGO` | | Branding overrides — safe to leave at defaults | | `EVENT_VIEWER_URL` | | Controls where event links in emails point; defaults to Flotilla, set to your app's URL | | `PORT` | | Internal port (default `4738`) — no need to change unless remapping in compose | > **Secrets in production:** Never commit `.env` or embed secrets in the compose > file. Use your infrastructure's secret store (e.g. Docker secrets, HashiCorp Vault, > Ansible Vault, 1Password CLI) and inject variables at deploy time. ### Persistent Data Volume Mailship stores its data in a **SQLite database** located at `$DATA_DIR/mailship.db` (`/data/mailship.db` by default in the Docker image). - **The database is the single source of truth** for subscriptions, events, and delivery state. Losing it means losing all subscriptions and digest history. - **Backup regularly** — a simple `sqlite3 /data/mailship.db .dump` or file-level copy (while the service is idle) is sufficient. For zero-downtime backups, use SQLite's `.backup` command or VACUUM INTO. - **Do not share the volume** across multiple Mailship instances — the in-process cron scheduler assumes a single writer. - **Volume location** when using Docker Compose: ```sh docker volume inspect mailship_mailship-data # inspect mount point docker run --rm -v mailship_mailship-data:/data alpine ls -la /data # browse contents ``` To migrate or snapshot, stop the container and copy the database file: ```sh docker compose down docker run --rm -v mailship_mailship-data:/data -v $(pwd):/backup alpine cp /data/mailship.db /backup/mailship-$(date +%F).db docker compose up -d ```