mailship/README.md
Agent 0f1e568009
All checks were successful
CI / checks (pull_request) Successful in 1m6s
Add Deployment section to README
Document production deployment including:
- Multi-stage Docker image build process (build + production stages)
- Docker Compose setup with env substitution and restart policy
- Required production environment variables with notes
- Persistent SQLite data volume management, backup and migration
2026-10-01 16:23:10 -04:00

283 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<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](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" <notifications@example.com>`) |
| `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
```