2026-08-18 16:21:22 +00:00
# Mailship
2026-09-18 16:59:38 +00:00
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.
2026-09-10 14:23:40 +00:00
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.
2026-08-18 16:21:22 +00:00
## Architecture
```
2026-09-03 15:47:21 +00:00
Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
2026-08-18 16:21:22 +00:00
│ 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)
│
Align README, docker-compose, .env.template with SMTP mailer
Replace all Postmark references (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS)
with SMTP configuration (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
SMTP_FROM) across documentation, deployment config, template, and test.
- README.md: architecture diagram (Postmark → SMTP) and configuration table
- docker-compose.yml: POSTMARK_* env vars replaced with SMTP_* required vars
- .env.template: POSTMARK_* entries replaced with SMTP_* entries
- test/integration.sh: POSTMARK_* test exports replaced with SMTP_* test values
2026-09-10 15:30:50 +00:00
cron fires ──▶ render digest ──▶ SMTP ──▶ email
2026-08-18 16:21:22 +00:00
```
## 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) |
Align README, docker-compose, .env.template with SMTP mailer
Replace all Postmark references (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS)
with SMTP configuration (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
SMTP_FROM) across documentation, deployment config, template, and test.
- README.md: architecture diagram (Postmark → SMTP) and configuration table
- docker-compose.yml: POSTMARK_* env vars replaced with SMTP_* required vars
- .env.template: POSTMARK_* entries replaced with SMTP_* entries
- test/integration.sh: POSTMARK_* test exports replaced with SMTP_* test values
2026-09-10 15:30:50 +00:00
| `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 |
2026-08-26 21:57:26 +00:00
| `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 |
2026-08-18 16:21:22 +00:00
| `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays |
| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays |
| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays |
2026-09-14 17:14:05 +00:00
| `PORT` | | Port to run on (default: 4738) |
2026-08-18 16:21:22 +00:00
## API
2026-09-03 15:47:21 +00:00
### 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
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
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
2026-09-23 14:50:04 +00:00
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.
2026-08-18 16:21:22 +00:00
```
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
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
2026-09-23 14:50:04 +00:00
Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
2026-09-14 17:04:26 +00:00
Auth: NIP-98 (Nostr < base64 > Authorization header)
2026-08-18 16:21:22 +00:00
Response: { key, callback }
```
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
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
2026-09-23 14:50:04 +00:00
**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).
2026-09-14 17:04:26 +00:00
### 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.
2026-09-03 15:47:21 +00:00
```
2026-09-14 17:04:26 +00:00
Auth: NIP-98 (Nostr < base64 > Authorization header)
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
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
2026-09-23 14:50:04 +00:00
Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }
2026-09-03 15:47:21 +00:00
```
2026-08-18 16:21:22 +00:00
### DELETE /subscription/:key
2026-09-14 17:04:26 +00:00
Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner.
2026-08-18 16:21:22 +00:00
```
2026-09-14 17:04:26 +00:00
Auth: NIP-98 (Nostr < base64 > Authorization header)
2026-08-18 16:21:22 +00:00
Response: { ok: true }
```
### POST /notify/:id
2026-09-18 16:59:38 +00:00
[NIP-9a ](https://github.com/nostr-protocol/nips/pull/2194 ) relay push callback. Called by relays or NPB when matching events are found.
2026-08-18 16:21:22 +00:00
```
2026-09-14 20:10:10 +00:00
Body: { id, relay, event? }
2026-08-18 16:21:22 +00:00
Response: { ok: true, stored: boolean }
Returns 404 if subscription not found or inactive.
```
2026-09-14 20:10:10 +00:00
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.
2026-09-14 17:42:11 +00:00
2026-08-18 16:21:22 +00:00
### GET /confirm?token=...
Confirm email address via link from confirmation email.
### GET /unsubscribe?token=...
Unsubscribe via link from digest email.
## Development
2026-09-18 14:32:18 +00:00
**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.
2026-08-18 16:21:22 +00:00
```sh
pnpm install
pnpm run build
pnpm run start
```
2026-08-26 21:57:26 +00:00
### 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
```
2026-08-18 16:40:42 +00:00
## 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
2026-09-14 17:29:54 +00:00
pnpm test:unit # Run unit tests (vitest)
pnpm test # Run integration tests (bash E2E)
pnpm test:server # Start server for manual testing
2026-08-18 16:40:42 +00:00
```
2026-10-01 20:23:10 +00:00
## 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
```