Bug: when the event was not found at the relay, the handler returned
{ ok: true, skipped: true }, which did not match the documented contract
{ ok: true, stored: boolean } in README.md.
Fix: change the 'skipped' response to { ok: true, stored: false }, so the
response shape is consistent across all code paths.
- src/server.ts: changed line 287 from { ok: true, skipped: true } to
{ ok: true, stored: false }, with updated comment
- README.md: added a note documenting the skip case (stored: false)
- test/notify-response-shape.test.ts: new test that asserts stored=false
and that skipped is never present
147 lines
No EOL
4.7 KiB
Markdown
147 lines
No EOL
4.7 KiB
Markdown
# Mailship
|
|
|
|
A Nostr email notification server. Receives events pushed from relays via NIP-9a, stores them, and sends daily or weekly digest emails.
|
|
|
|
## 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
|
|
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 }
|
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
|
Response: { key, callback }
|
|
```
|
|
|
|
### 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, 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 }
|
|
Response: { ok: true, stored: boolean }
|
|
Returns 404 if subscription not found or inactive.
|
|
```
|
|
|
|
When the event is not found at the relay (e.g. it was deleted or never arrived),
|
|
the endpoint returns `{ ok: true, stored: false }` — the event is silently skipped
|
|
rather than erroring. 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
|
|
|
|
```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
|
|
```
|
|
|
|
## Forked from Anchor
|
|
|
|
Mailship is a fork of [Anchor](https://github.com/coracle-social/anchor), stripped of push notification support and adapted for NIP-9a relay push event intake. |