All checks were successful
CI / checks (pull_request) Successful in 44s
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
178 lines
6.5 KiB
Markdown
178 lines
6.5 KiB
Markdown
# 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
|
||
```
|