Compare commits

..

54 commits

Author SHA1 Message Date
9ad5c4eef4 Merge pull request 'Port cron-minutely fixes: reactivate tombstoned subs + confirm-email rate limit' (#29) from mailship-fc-merge-cron-fixes-df6 into main
Some checks failed
CI / checks (push) Successful in 39s
Docker / docker (push) Has been cancelled
Reviewed-on: #29
Reviewed-by: matt <matt@lorentz.is>
2026-09-23 17:56:11 +00:00
mplorentz
34c5e28fd1 Rate-limit confirmation emails and restore daily/weekly digest cron
All checks were successful
CI / checks (pull_request) Successful in 41s
- Add last_confirm_sent_at to subscriptions, with a migration for
  existing databases. registerSubscription now sends at most one
  confirmation email per 10 minutes per subscription, so a page-refresh
  or client retry storm can't spam an inbox. The cooldown resets when
  the address changes or a tombstoned row is reactivated (fresh key),
  so genuinely new addresses always get an email immediately.
- Restore getCronExpression to the documented daily (17:00 UTC) and
  weekly (Monday 17:00 UTC) schedules with the 6-field cron syntax the
  cron package expects, fixing the pre-existing reschedule tests that the
  'run every minute' testing hack had broken.
- Add confirm-email-cooldown.test.ts covering first send, refresh storms,
  frequency changes, email changes, reactivation, and the 10-minute constant.

(cherry picked from commit 051c1e88fb)
2026-09-23 12:14:28 -04:00
mplorentz
0ff8984a94 fix reactivating old rows
(cherry picked from commit 5675ebdf52)
2026-09-23 12:13:09 -04:00
mplorentz
4a74a5284b Re-activate tomstoned subscriptions when email is the same
(cherry picked from commit fd815282c2)
2026-09-23 12:12:51 -04:00
5007a79a3a Merge pull request 'Allow users to choose digest schedule (time of day, day of week, timezone)' (#28) from mailship-200-allow-users-to-choose-digest-schedule-ti-60c into main
Some checks are pending
Docker / docker (push) Waiting to run
CI / checks (push) Successful in 16m26s
Reviewed-on: #28
Reviewed-by: matt <matt@lorentz.is>
2026-09-23 15:57:56 +00:00
Agent
e34f1cd821 feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
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
2026-09-23 10:50:04 -04:00
27bb4a5342 Merge pull request 'digest email: replace reply/reaction counts with posting relay URL' (#27) from mailship-ihb-digest-email-replace-reply-reaction-coun-281 into main
Some checks are pending
Docker / docker (push) Waiting to run
CI / checks (push) Successful in 38s
Reviewed-on: #27
Reviewed-by: matt <matt@lorentz.is>
2026-09-22 14:58:00 +00:00
Agent
b7592044c0 digest email: replace reply/reaction counts with relay URL
All checks were successful
CI / checks (pull_request) Successful in 39s
- Replace `{{Replies}}`/`{{Reactions}}` stat items in digest.mjml with
  a single `{{RelayUrl}}` item showing the posting relay domain
- src/digest.ts: add `RelayUrl` to template variables, derive from
  existing `relayByEventId` map; strip unused `spec`, `getParentId`,
  `repliesByParentId`, and `context` destructure
- test/digest-reply-stats.test.ts: update assertion to check
  `parentEntry.RelayUrl` instead of `Replies`/`Reactions`
- script/render-preview.mjs: update sample data to use `RelayUrl`
2026-09-22 10:04:35 -04:00
dbe4f6741d Merge pull request 'Forgejo Action: build and publish Docker image on merge to main' (#25) from mailship-7o9-forgejo-action-build-publish-docker-imag-63e into main
Some checks failed
CI / checks (push) Successful in 38s
Docker / docker (push) Has been cancelled
Reviewed-on: #25
2026-09-18 17:40:37 +00:00
cab5cab6fa Update .forgejo/workflows/docker-publish.yml
All checks were successful
CI / checks (pull_request) Successful in 37s
2026-09-18 17:40:26 +00:00
7b75b2f4aa Merge pull request 'Fix broken NIP-9a link in README and document Node/single-instance requirements' (#21) from mailship-jh5-readme-nip-9a-link-is-empty-broken-897 into main
All checks were successful
CI / checks (push) Successful in 36s
Reviewed-on: #21
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 17:11:24 +00:00
Agent
bcce525a83 README: link NIP-9a to the spec PR (2194) — not yet merged to master
All checks were successful
CI / checks (pull_request) Successful in 38s
2026-09-18 12:59:38 -04:00
98e1de2ba0 Merge pull request 'Remove dead build-in-production.sh (anchor fork artifact)' (#26) from mailship-xyg-remove-dead-build-in-production-sh-ancho-e90 into main
All checks were successful
CI / checks (push) Successful in 37s
Reviewed-on: #26
2026-09-18 16:37:06 +00:00
Agent
77ce571d52 Remove dead build-in-production.sh (anchor fork artifact)
All checks were successful
CI / checks (pull_request) Successful in 35s
This file was a leftover from the anchor/mailship fork. Its function
(removing pnpm overrides, building) is already handled by the Dockerfile
via remove-pnpm-overrides.js. The --no-frozen-lockfile workaround is a
Render CI quirk irrelevant to Docker builds.

Zero references anywhere in the codebase — confirmed via grep.
2026-09-18 12:33:43 -04:00
Agent
5deace1967 feat: add Forgejo Action workflow to build and publish Docker image
All checks were successful
CI / checks (pull_request) Successful in 36s
Add a Forgejo Actions workflow (docker-publish.yml) that builds the
mailship Docker image on every merge to main and publishes it to the
Forgejo container registry at forgejo.lorentz.is/matt/mailship.

Tags pushed: :latest and :<commit-sha>

Serves the README's self-hosting path (docker pull) and future
pull-based deploys.
2026-09-18 12:31:15 -04:00
82532eb6f0 Merge pull request 'gitignore .beads/interactions.jsonl (untrack beads audit sidecar)' (#24) from mailship-pcy-gitignore-beads-interactions-jsonl-untra-efb into main
All checks were successful
CI / checks (push) Successful in 35s
Reviewed-on: #24
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 16:30:29 +00:00
Agent
d55e3f941b gitignore .beads/interactions.jsonl (untrack beads audit sidecar)
All checks were successful
CI / checks (pull_request) Successful in 36s
.beads/interactions.jsonl is beads' agent audit trail sidecar — bd writes
one line per mutation (claim, assign, comment, status change). The July bd
release's `bd init` created it as a git-tracked file and omitted it from
.beads/.gitignore by design, so every fragua run that claims or comments
dirtied the worktree with an 'M .beads/interactions.jsonl'.

Newer beads made the sidecar opt-in (audit.enabled default false) and
gitignore it. This commit backports that convention:

1. Adds 'interactions.jsonl' to .beads/.gitignore
2. git rm --cached to stop tracking (file kept on disk)
2026-09-18 12:22:31 -04:00
b1a8258cfa Merge pull request 'fix: validate relay URL before calling load() to prevent remote DoS via unhandled rejection' (#22) from mailship-iij-post-notify-with-non-wss-relay-url-crash-655 into main
All checks were successful
CI / checks (push) Successful in 37s
Reviewed-on: #22
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 15:38:46 +00:00
e22a5f457e Merge pull request 'Fix digest email showing "0 replies / 0 reactions" for every event' (#23) from mailship-d08-digest-email-shows-0-replies-0-reactions-10f into main
All checks were successful
CI / checks (push) Successful in 35s
Reviewed-on: #23
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 15:37:53 +00:00
43f2a04de4 Merge pull request 'hardening: run production container as non-root user' (#19) from mailship-c3s-docker-run-production-container-as-non-r-4e7 into main
All checks were successful
CI / checks (push) Successful in 35s
Reviewed-on: #19
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 15:33:56 +00:00
08a1fd5232 Merge branch 'main' into mailship-jh5-readme-nip-9a-link-is-empty-broken-897
All checks were successful
CI / checks (pull_request) Successful in 36s
2026-09-18 15:27:39 +00:00
612a4f5af2 Merge pull request 'chore: remove unused/misplaced dependencies (bcrypt, express-ws, ts-node-dev, @types/node)' (#20) from mailship-6m4-remove-unused-misplaced-dependencies-bcr-d33 into main
All checks were successful
CI / checks (push) Successful in 36s
Reviewed-on: #20
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 15:20:45 +00:00
f5f962f6b1 Merge pull request 'Fix: distinguish already-confirmed confirm links from invalid ones; prevent duplicate subscription rows on re-register' (#18) from mailship-2px-confirm-link-shows-email-not-confirmed-a-349 into main
All checks were successful
CI / checks (push) Successful in 35s
Reviewed-on: #18
Reviewed-by: matt <matt@lorentz.is>
2026-09-18 15:08:00 +00:00
Agent
ae836da033 fix: load reply/reaction events from repository into digest context
All checks were successful
CI / checks (pull_request) Successful in 34s
Instead of setting context = events (which made repliesByParentId always
empty), query the repository for events that #e-tag our matched event
IDs with kinds NOTE, COMMENT, or REACTION. This gives buildParameters
real reply/reaction data to count.

Fixes the bug where every event in the digest email showed
'0 replies / 0 reactions'.

Added test/digest-reply-stats.test.ts that:
- Publishes reply and reaction events into a mock repository
- Calls sendFromStoredEvents with only the parent event
- Asserts that the resulting digest parameters show Replies >= 1 and
  Reactions >= 1
2026-09-18 10:46:27 -04:00
Agent
0a5b7ad14d fix: validate relay URL before calling load() and soften unhandledRejection handler
All checks were successful
CI / checks (pull_request) Successful in 34s
POST /notify/:id accepted an attacker-supplied relay URL with a non-ws://
scheme (e.g. http://…). The URL was passed straight to @welshman/net's
load(), whose internal batcher throws 'Invalid relay url' asynchronously
(via setTimeout). That error escaped the route's try/catch as an unhandled
rejection, triggering process.exit(1) in src/index.ts — the entire server
died. Any registered subscriber could crash the service repeatedly.

Two fixes applied (either alone breaks the attack):

1. Validate relay scheme in the route handler before calling load()
   (src/server.ts). Uses isRelayUrl() from @welshman/util, which accepts
   only wss:// and ws:// schemes. Returns 400 immediately for invalid
   URLs, preventing the bad URL from ever reaching the batcher.

2. Soften process.on('unhandledRejection') in src/index.ts to log and
   continue instead of calling process.exit(1). Defence in depth: if any
   other async error escapes a try/catch, the server stays up.

Test: test/notify-non-wss-relay-crash.test.ts — starts an ephemeral
server, creates a confirmed subscription, POSTs with http://127.0.0.1:9,
expects 400 with an error message.
2026-09-18 10:39:04 -04:00
Agent
37ec3e8122 fix(README): repair empty NIP-9a links, add Node & single-instance constraints
All checks were successful
CI / checks (pull_request) Successful in 41s
- Replace empty NIP-9a URL (first paragraph and API section header)
  with a link to https://github.com/nostr-protocol/nips/blob/master/9a.md
- Add Node >= 22 requirement to Development section
- Add single-instance constraint note about in-process cron jobs
2026-09-18 10:32:18 -04:00
Agent
3d24260419 Remove unused/misplaced dependencies (bcrypt, express-ws, ts-node-dev, @types/node)
All checks were successful
CI / checks (pull_request) Successful in 48s
- Remove bcrypt (^5.1.1) — no imports anywhere in source
- Remove express-ws (^5.0.2) — no imports (server uses REST + welshman)
- Remove ts-node-dev (^2.0.0) — deprecated, replaced by tsx, was in deps
- Remove @types/express-ws (dev) — no longer needed without express-ws
- Move @types/node (^22.18.1) from dependencies → devDependencies
- Prune bcrypt from pnpm.onlyBuiltDependencies list

All quality gates pass: tsc --noEmit, eslint, build, and 16 unit tests.
2026-09-18 10:32:09 -04:00
Agent
dfdc6d489c hardening: run production container as non-root user
All checks were successful
CI / checks (pull_request) Successful in 38s
Add a dedicated 'app' user/group in the production image stage so the
application runs without root privileges. The build stage retains root
for apk add of build-time dependencies.

Changes:
- Create 'app' user and group via addgroup/adduser
- Change ownership of /data to app:app
- Set USER app before EXPOSE and CMD

Closes mailship-c3s
2026-09-18 10:30:52 -04:00
Agent
9fa1f73316 fix: distinguish already-confirmed tokens from invalid ones; prevent duplicate active subscriptions
All checks were successful
CI / checks (pull_request) Successful in 36s
Problem
-------
1. Re-clicking an already-confirmed confirmation link (e.g. /confirm?token=…)
   returned  from confirmSubscription because the SQL WHERE
   clause required . The caller then threw an
   ActionError('invalid or expired') which rendered the 'Email not confirmed'
   error page — misleading for someone who had already confirmed.

2. A second PUT /subscription/email with the same email+frequency could
   silently bypass the upsert path when getSubscriptionByPubkey found the
   active row but updateSubscription returned it unchanged (email and
   frequency matched). While the unique index prevented a true duplicate
   INSERT, the code path was fragile and the regression test was missing.

Changes
-------
database.ts:
- confirmSubscription now returns { sub, alreadyConfirmed } | undefined.
  First it tries the existing UPDATE (unconfirmed tokens only). If that
  returns no rows, it looks up the key directly: if the row exists and is
  already confirmed, returns { sub, alreadyConfirmed: true }. If the row
  doesn't exist or is unsubscribed, returns undefined (invalid/expired).
- Exported new ConfirmResult type for callers.

actions.ts:
- confirmSubscriptionAction destructures the new return type.
- Only registers the cron job on fresh confirmation (not re-confirms).
- Returns the ConfirmResult so the route can distinguish the two cases.

server.ts:
- /confirm route checks result.alreadyConfirmed and renders
  confirm-already.html instead of confirm-success.html.

pages/confirm-already.html:
- New page with title 'Email already confirmed' and an info message
  explaining the address was already confirmed.

Tests:
- test/confirm-already-confirmed.test.ts — NEW (3 tests): first confirm
  succeeds with alreadyConfirmed=false; second confirm returns
  alreadyConfirmed=true; nonexistent token returns undefined.
- test/duplicate-subscription.test.ts — NEW (4 tests): full cycle of
  register → confirm → re-register → assert one active row with
  unchanged key, verifying the upsert is idempotent.
- Adapted 3 existing test files to destructure the new ConfirmResult.
2026-09-17 16:57:30 -04:00
hudson
8235513822 Set trust proxy so NIP-98 URL matching works behind traefik (X-Forwarded-Proto)
All checks were successful
CI / checks (push) Successful in 43s
2026-09-16 15:15:45 -04:00
mplorentz
c21ed9f71d Merge branch 'main' of ssh://forgejo.lorentz.is:4201/matt/mailship
All checks were successful
CI / checks (push) Successful in 38s
2026-09-16 14:06:54 -04:00
f4a3873f23 Merge pull request 'Document include_event body on POST /notify/:id' (#15) from mailship-6u7-document-include-event-body-on-post-noti-292 into main
All checks were successful
CI / checks (push) Successful in 36s
Reviewed-on: #15
Reviewed-by: matt <matt@lorentz.is>
2026-09-16 14:59:08 +00:00
2ee4e53bd9 Merge pull request 'Extract shared brandingVars() helper to eliminate duplication' (#17) from mailship-90c-extract-shared-brandingvars-helper-brand-e34 into main
All checks were successful
CI / checks (push) Successful in 36s
Reviewed-on: #17
Reviewed-by: matt <matt@lorentz.is>
2026-09-16 14:37:20 +00:00
Agent
4b43664411 extract shared brandingVars() helper to eliminate duplication
All checks were successful
CI / checks (pull_request) Successful in 35s
The { brandName, brandAccent, brandLogo, settingsUrl } object was built
identically 3 times in the /confirm handler. Extract a brandingVars()
helper so it is defined once and reused via spread.

Closes mailship-90c
2026-09-16 09:56:13 -04:00
e86ffbed15 Merge pull request 'Normalize EVENT_VIEWER_URL trailing slash once in env.ts' (#16) from mailship-aee-extract-shared-normalized-event-viewer-u-671 into main
All checks were successful
CI / checks (push) Successful in 52s
Reviewed-on: #16
Reviewed-by: matt <matt@lorentz.is>
2026-09-16 13:44:38 +00:00
Agent
dbde1ec02d Normalize EVENT_VIEWER_URL trailing slash once in env.ts, remove 6 ad-hoc strips
All checks were successful
CI / checks (pull_request) Successful in 35s
EVENT_VIEWER_URL.replace(/\/$/, '') was duplicated across:
- env.ts (BRAND_LOGO, 1x)
- server.ts (settingsUrl in confirm routes, 3x)
- mailer.ts (settingsUrl in sendConfirm/sendDigest, 2x)

Now trailing-slash normalization happens at the export source in env.ts,
so all consumers get a clean URL without ad-hoc stripping.
2026-09-16 09:28:03 -04:00
0059ec263b Merge pull request 'Route BASE_URL through env module to prevent callback URL drift' (#14) from mailship-lap-inconsistent-env-access-server-ts-reads--747 into main
All checks were successful
CI / checks (push) Successful in 38s
Reviewed-on: #14
Reviewed-by: matt <matt@lorentz.is>
2026-09-16 13:23:33 +00:00
Agent
560c7ef9bc document include_event body on POST /notify/:id
All checks were successful
CI / checks (pull_request) Successful in 33s
The endpoint accepts an optional  field for NIP-98 include_event
support. When provided, the event is verified inline (id match + signature
check). When omitted, the event is fetched from the relay. Documents the
{ ok: true, stored: boolean } response and dedup behavior.
2026-09-14 16:10:10 -04:00
Agent
a0a284b0a3 Route BASE_URL through env module instead of reading process.env directly
All checks were successful
CI / checks (pull_request) Successful in 33s
server.ts was building callback URLs from raw process.env.BASE_URL at
lines 175 and 217, while env.ts already validates and exports BASE_URL
as a typed constant. This adds BASE_URL to the import from ./env.js and
replaces both direct process.env references so the callback URL cannot
drift from the validated value.
2026-09-14 16:08:40 -04:00
c4b1a3fb4c Merge pull request 'Run checks in CI via Forgejo Actions' (#12) from mailship-e77-run-checks-in-ci-via-forgejo-actions-605 into main
All checks were successful
CI / checks (push) Successful in 33s
Reviewed-on: #12
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 20:00:59 +00:00
2c257dedcd Merge pull request 'Standardize POST /notify/:id response shape — always include stored field' (#13) from mailship-nl0-post-notify-response-shape-ok-skipped-la-387 into main
Reviewed-on: #13
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 20:00:32 +00:00
Agent
b54fb4f891 POST /notify/🆔 standardize response shape — always include stored field
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
2026-09-14 13:42:11 -04:00
Agent
53b0182c7b feat(ci): add Forgejo Actions workflow for CI checks
All checks were successful
CI / checks (pull_request) Successful in 32s
Creates .forgejo/workflows/ci.yml that runs the repo's single-source-of-truth
check gate (./script/checks) on every push to main and every pull request.

Triggers: push to main, pull_request
Concurrency: group by workflow+ref, cancel-in-progress true
Steps: actions/checkout@v4, actions/setup-node@v4 (node-version-file: .nvmrc),
corepack + pnpm i --frozen-lockfile, then ./script/checks.

Part of bead mailship-e77.
2026-09-14 13:40:08 -04:00
a4bc587d64 Merge pull request 'Standardize tests on vitest' (#11) from mailship-clt-standardize-tests-on-vitest-ddc into main
Reviewed-on: #11
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 17:36:00 +00:00
091fe6e7f3 Merge pull request 'PORT: align default to 4738 throughout stack' (#10) from mailship-lcy-port-readme-says-default-3000-env-ts-thr-8fe into main
Reviewed-on: #10
2026-09-14 17:34:36 +00:00
d98d034989 Merge pull request 'Implement NIP-98 HTTP auth for GET/PUT/DELETE /subscription/email' (#9) from mailship-uxf-implement-nip-98-auth-for-get-put-delete-9b9 into main
Reviewed-on: #9
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 17:33:47 +00:00
Agent
13b1e9d00f Standardize tests on vitest
- Add vitest as dev dep (pnpm add -D vitest), create vitest.config.ts
  with globals:true and setupFiles
- Migrate digest-template, normalize-relay-url, reschedule-on-frequency-change,
  and event-arrival-race from raw JS + hand-rolled asserts to vitest
  describe/it/expect, as .test.ts (port all assertions without weakening)
- Create test/setup.ts with env vars needed by env.ts/mailer.ts
- DELETE test/web-ui.test.js: dead test for removed web UI (PR #1)
- package.json: add test:unit script (vitest run), keep test = bash E2E
- script/checks: add pnpm test:unit after build
- README: document pnpm test:unit / pnpm test
2026-09-14 13:32:19 -04:00
Agent
02dd5944c8 align PORT default: make optional with 4738, update README
- src/env.ts: remove required-PORT throw, default to '4738' when unset
- README.md: change documented default from 3000 to 4738

All other files (.env.template, Dockerfile, docker-compose.yml)
already use 4738 — no further changes needed.
2026-09-14 13:14:05 -04:00
cfbb29cb96 Merge pull request 'Fix digest job race: events received between fetch and delete are dropped unsent' (#8) from mailship-091-event-arrival-race-in-digest-job-events--614 into main
Reviewed-on: #8
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 17:12:10 +00:00
Agent
737f949f9b fix digest job race: delete sent events by ID, not timestamp
Bug: runJob computed since = sub.last_digest_at, fetched events with
received_at > since, sent the digest (slow), then deleted ALL events
with received_at > since. Any /notify event that arrived between the
fetch and the delete was also received_at > since, so it was deleted
without ever being sent in a digest.

Two changes:

1. Delete by exact event IDs (src/database.ts, src/worker/email.ts):
   Added deleteEventsByIds(subscriptionId, eventIds) which deletes
   only the events that were actually fetched + sent. The old
   timestamp-based delete is retained but no longer called from runJob.

2. Re-fetch subscription on each cron tick (src/worker/email.ts):
   createJob's closure captured the original sub, so sub.last_digest_at
   stayed stale in memory. Every subsequent tick recomputed since from
   the old value, re-fetching and re-sending duplicate events. Now each
   tick re-fetches the subscription from the DB via getSubscriptionById
   before calling runJob.

Fixes bead mailship-091
2026-09-14 13:07:34 -04:00
Agent
b94018c11e Fix integration test: add missing CORS_ORIGIN env var
The test was failing because src/env.ts requires CORS_ORIGIN to be set,
but the integration test never exported it. This is a pre-existing setup
gap exposed by running the test — not a NIP-98 regression.
2026-09-14 13:07:16 -04:00
f3ccade029 Merge pull request 'Rename digest keys Popular/HasPopular → Latest/HasLatest' (#7) from mailship-hq0-rename-stale-digest-template-keys-popula-194 into main
Reviewed-on: #7
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 17:05:53 +00:00
Agent
40ac047629 Implement NIP-98 HTTP auth for GET/PUT/DELETE /subscription/email
Three browser-facing endpoints now require a kind-27235 HTTP auth event
(NIP-98) proving the caller controls the pubkey:

- GET  /subscription/email — pubkey extracted from auth header instead
       of query param; returns subscription for the authed pubkey.
- PUT  /subscription/email — pubkey extracted from auth header instead
       of trusting a client-supplied body field.
- DELETE /subscription/:key — verifies auth pubkey matches subscription
       owner (returns 403 if mismatch).

Server-side: decode base64 'Nostr <b64>' Authorization header, JSON.parse,
check kind === 27235, verifyEvent (nostr-tools/pure), then check u /
method / payload tags against the request URL / method / body.

README updated to reflect 'implemented' auth (not 'planned').
Integration test updated to generate NIP-98 auth headers via a new helper
script (script/nip98-auth-header.mjs).
2026-09-14 13:04:26 -04:00
mplorentz
44dcc22a04 Minor readme edits 2026-09-10 10:23:40 -04:00
43 changed files with 1908 additions and 442 deletions

4
.beads/.gitignore vendored
View file

@ -71,6 +71,10 @@ backup/
*.db-shm
db.sqlite
bd.db
# Interactions log (runtime, not versioned)
interactions.jsonl
# NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default

31
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,31 @@
# Forgejo Actions CI — runs the repo's check gate on every push/PR
# Single source of truth: ./script/checks defines what "passing CI" means.
---
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies
run: |
corepack enable
pnpm i --frozen-lockfile
- name: Run check gate
run: ./script/checks

View file

@ -0,0 +1,36 @@
# Forgejo Action — build + publish Docker image to Forgejo container registry on merge to main
---
name: Docker
on:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
runs-on: docker-builder
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Forgejo Container Registry
uses: docker/login-action@v3
with:
registry: forgejo.lorentz.is
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
forgejo.lorentz.is/matt/mailship:${{ github.sha }}
forgejo.lorentz.is/matt/mailship:latest

View file

@ -52,6 +52,12 @@ COPY --from=build /app/src/emails/ ./dist/emails/
# Create data directory for SQLite
RUN mkdir -p /data
# Create non-root user for security hardening
RUN addgroup -S app && adduser -S -G app app
RUN chown -R app:app /data
USER app
EXPOSE 4738
ENV NODE_ENV=production

View file

@ -1,6 +1,8 @@
# Mailship
A Nostr email notification server. Receives events pushed from relays via NIP-9a, stores them, and sends daily or weekly digest emails.
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
@ -42,46 +44,81 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
| `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: 3000) |
| `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.
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, pubkey }
Auth: NIP-98 (planned)
Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback }
```
### GET /subscription/email?pubkey=...
Look up an existing subscription, so clients can avoid re-registering (and
re-confirming) when settings haven't changed. Returns 404 if none exists.
**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.
```
Response: { key, callback, email, frequency, confirmed }
Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }
```
### DELETE /subscription/:key
Unsubscribe.
Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner.
```
Auth: NIP-98 (planned)
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.
[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 }
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.
@ -90,6 +127,11 @@ 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
@ -130,10 +172,7 @@ docker run -d \
## Tests
```sh
pnpm test # Run integration tests
pnpm test:server # Start server for manual testing
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.

View file

@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Build everything
pnpm run build

View file

@ -11,15 +11,16 @@
"preview:digest": "node script/render-preview.mjs",
"run-alert": "node dist/run.js",
"test": "bash test/integration.sh",
"test:unit": "vitest run",
"test:server": "bash test/integration.sh --server-only"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.3",
"@types/express-ws": "^3.0.5",
"@types/mjml": "^4.7.4",
"@types/mustache": "^4.2.6",
"@types/node": "^22.18.1",
"@types/nodemailer": "^8.0.1",
"@types/sanitize-html": "^2.16.0",
"@types/ws": "^8.18.1",
@ -29,10 +30,10 @@
"globals": "^15.15.0",
"onchange": "^7.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"vitest": "^5.0.0"
},
"dependencies": {
"@types/node": "^22.18.1",
"@welshman/content": "^0.6.3",
"@welshman/feeds": "^0.6.3",
"@welshman/lib": "^0.6.3",
@ -41,13 +42,11 @@
"@welshman/signer": "^0.6.3",
"@welshman/store": "^0.6.3",
"@welshman/util": "^0.6.3",
"bcrypt": "^5.1.1",
"cron": "^4.3.3",
"cron-parser": "^5.3.1",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-ws": "^5.0.2",
"localstorage-polyfill": "^1.0.1",
"mjml": "^4.15.3",
"mustache": "^4.2.0",
@ -56,12 +55,10 @@
"sanitize-html": "^2.17.0",
"sqlite3": "^5.1.7",
"succinct-async": "^1.0.4",
"ts-node-dev": "^2.0.0",
"ws": "^8.18.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"sqlite3"
]
}

Binary file not shown.

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# mailship CI checks — type-check + lint + build
# mailship CI checks — type-check + lint + build + unit tests
pnpm run check
pnpm run build
pnpm run build
pnpm test:unit

View file

@ -0,0 +1,34 @@
#!/usr/bin/env node
// Generates a NIP-98 Authorization header value ("Nostr <base64>") for
// testing purposes.
//
// Usage:
// node script/nip98-auth-header.mjs <secret-hex> <url> <method> [body]
//
// Example:
// export AUTH=$(node script/nip98-auth-header.mjs \
// "$SECRET" "$BASE_URL/subscription/email" PUT '{"email":"a@b.com","frequency":"daily"}')
// curl -H "Authorization: $AUTH" ...
import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'
import { Nip01Signer } from '@welshman/signer'
const [, , secret, url, method, body] = process.argv
if (!secret || !url) {
console.error('Usage: node script/nip98-auth-header.mjs <secret-hex> <url> <method> [body]')
process.exit(1)
}
const signer = Nip01Signer.fromSecret(secret)
// Create the unsigned auth event template
const event = await makeHttpAuth(url, method || 'GET', body || undefined)
// Stamp (created_at, pubkey, id) and sign
const signed = await signer.sign(event)
// Encode as "Nostr <base64>"
const header = makeHttpAuthHeader(signed)
console.log(header)

View file

@ -24,8 +24,7 @@ const sample = {
Icon: 'https://i.pravatar.cc/150?img=32',
Name: 'carol',
Content: '<p>Does anyone know how relay-based groups work?</p>',
Replies: 5,
Reactions: 8,
RelayUrl: 'relay.damus.io',
},
{
Link: 'https://app.flotilla.social/spaces/nos.lol/community?at=1700000000',
@ -34,8 +33,7 @@ const sample = {
Name: 'bob',
Content:
'<p>Excited to share our new community space on Flotilla! <a href="https://flotilla.social">Check it out</a>.</p>',
Replies: 14,
Reactions: 32,
RelayUrl: 'nos.lol',
},
],
unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123',

View file

@ -13,17 +13,36 @@ export type RegisterSubscriptionParams = {
pubkey: string
email: string
frequency: string
hour?: number
minute?: number
dayOfWeek?: number
timezone?: string
}
// Floor on how often a confirmation email can be sent for the same subscription.
// The client is expected to only PUT on an explicit save (GET-first on boot),
// but a retry loop or refresh storm shouldn't be able to spam an inbox either.
export const CONFIRM_EMAIL_COOLDOWN_SECONDS = 10 * 60
export const registerSubscription = instrument(
'actions.registerSubscription',
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency)
async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency, hour, minute, dayOfWeek, timezone)
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
if (!sub.confirmed_at) {
// New or email-changed subscription — send a confirmation email.
await mailer.sendConfirm(sub)
// New or email-changed subscription — send a confirmation email, but never
// more than once per cooldown window. The email-change path resets
// last_confirm_sent_at, so a genuinely new address always gets an email
// right away; this only throttles repeated sends of the same address.
const lastSent = sub.last_confirm_sent_at
const cooldownElapsed =
!lastSent || Math.floor(Date.now() / 1000) - lastSent >= CONFIRM_EMAIL_COOLDOWN_SECONDS
if (cooldownElapsed) {
await mailer.sendConfirm(sub)
await db.markConfirmSent(sub.id)
}
} else {
// Already confirmed (e.g. frequency-only change) — reschedule the
// cron job so it uses the new cadence immediately.
@ -41,13 +60,19 @@ export type ConfirmSubscriptionParams = {
export const confirmSubscriptionAction = instrument(
'actions.confirmSubscription',
async ({ token }: ConfirmSubscriptionParams) => {
const sub = await db.confirmSubscription(token)
const result = await db.confirmSubscription(token)
if (!sub) {
if (!result) {
throw new ActionError('That confirmation code is invalid or has expired.')
}
worker.registerSubscription(sub)
// Only register the cron job when this is a fresh confirmation.
// If already confirmed, the job is already running.
if (!result.alreadyConfirmed) {
worker.registerSubscription(result.sub)
}
return result
},
)

View file

@ -4,10 +4,15 @@ export type Subscription = {
pubkey: string
email: string
frequency: string
hour: number
minute: number
day_of_week?: number
timezone: string
created_at: number
confirmed_at?: number
unsubscribed_at?: number
last_digest_at?: number
last_confirm_sent_at?: number
}
export const getSubscriptionError = (sub: Subscription) => {
@ -19,14 +24,39 @@ export const getSubscriptionError = (sub: Subscription) => {
return 'Frequency must be "daily" or "weekly"'
}
// Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC.
// Validation: just ensure frequency is valid, cron is generated internally.
if (sub.hour < 0 || sub.hour > 23 || !Number.isInteger(sub.hour)) {
return 'Hour must be an integer between 0 and 23'
}
if (sub.minute < 0 || sub.minute > 59 || !Number.isInteger(sub.minute)) {
return 'Minute must be an integer between 0 and 59'
}
if (sub.frequency === 'weekly') {
if (sub.day_of_week === undefined || sub.day_of_week === null) {
return 'dayOfWeek is required for weekly frequency'
}
const dow = sub.day_of_week
if (dow < 0 || dow > 7 || !Number.isInteger(dow)) {
return 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)'
}
}
if (!sub.timezone || typeof sub.timezone !== 'string') {
return 'A valid IANA timezone is required'
}
try {
Intl.DateTimeFormat(undefined, { timeZone: sub.timezone })
} catch {
return `"${sub.timezone}" is not a valid IANA timezone`
}
}
export const getCronExpression = (frequency: string, hour = 17, minute = 0) => {
export const getCronExpression = (frequency: string, hour = 17, minute = 0, dayOfWeek?: number) => {
if (frequency === 'daily') {
return `0 ${minute} ${hour} * * *`
}
// Weekly on Monday
return `0 ${minute} ${hour} * * 1`
// Weekly: default to Monday (1) when not specified
return `0 ${minute} ${hour} * * ${dayOfWeek ?? 1}`
}

View file

@ -9,7 +9,7 @@ import type { Subscription } from './alert.js'
const DATA_DIR = process.env.DATA_DIR || '.'
const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
type Param = number | string | boolean
type Param = number | string | boolean | null | undefined
type Row = Record<string, any>
@ -58,10 +58,15 @@ export const migrate = () =>
pubkey TEXT NOT NULL,
email TEXT NOT NULL,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 17,
minute INTEGER NOT NULL DEFAULT 0,
day_of_week INTEGER,
timezone TEXT NOT NULL DEFAULT 'UTC',
created_at INTEGER NOT NULL,
confirmed_at INTEGER,
unsubscribed_at INTEGER,
last_digest_at INTEGER
last_digest_at INTEGER,
last_confirm_sent_at INTEGER
)
`
)
@ -77,6 +82,14 @@ export const migrate = () =>
)
`
)
// Add last_confirm_sent_at for existing databases created before the
// confirmation-email cooldown migration.
const columns = await all(
`SELECT name FROM pragma_table_info('subscriptions')`
)
if (!columns.some((c: any) => c.name === 'last_confirm_sent_at')) {
await run(`ALTER TABLE subscriptions ADD COLUMN last_confirm_sent_at INTEGER`)
}
await run(
`CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)`
)
@ -108,6 +121,20 @@ export const migrate = () =>
WHERE unsubscribed_at IS NULL
`
)
// Idempotent migration: add schedule columns to existing databases.
// ALTER TABLE ADD COLUMN fails if the column already exists, so we
// run each one and ignore "duplicate column" errors.
const addCol = (colDef: string) =>
run(`ALTER TABLE subscriptions ADD COLUMN ${colDef}`).catch((err: any) => {
if (!err?.message?.includes('duplicate column')) throw err
})
await addCol('hour INTEGER NOT NULL DEFAULT 17')
await addCol('minute INTEGER NOT NULL DEFAULT 0')
await addCol('day_of_week INTEGER')
await addCol("timezone TEXT NOT NULL DEFAULT 'UTC'")
resolve()
})
} catch (err) {
@ -125,54 +152,122 @@ const parseSubscription = (row: any): Subscription | undefined => {
export const updateSubscription = instrument(
'database.updateSubscription',
async (existing: Subscription, email: string, frequency: string) => {
if (existing.email === email && existing.frequency === frequency) {
async (existing: Subscription, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
const scheduleChanged =
(hour !== undefined && hour !== existing.hour) ||
(minute !== undefined && minute !== existing.minute) ||
(dayOfWeek !== undefined && dayOfWeek !== existing.day_of_week) ||
(timezone !== undefined && timezone !== existing.timezone)
if (existing.email === email && existing.frequency === frequency && !scheduleChanged) {
return existing
}
const hr = hour ?? existing.hour
const mn = minute ?? existing.minute
const dow = dayOfWeek ?? existing.day_of_week
const tz = timezone ?? existing.timezone
// Update by id, not pubkey, so tombstoned rows for the same account are never
// re-activated alongside this one (the unique index would reject that anyway).
if (existing.email === email) {
return parseSubscription(
await get(
`UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL
`UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
WHERE id = ? RETURNING *`,
[frequency, existing.id]
[frequency, hr, mn, dow, tz, existing.id]
)
)
}
// Address changed: it's a new inbox to verify, so reset the confirm-email
// cooldown or the new address would inherit the old one's lockout.
return parseSubscription(
await get(
`UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL
`UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL, last_confirm_sent_at = NULL
WHERE id = ? RETURNING *`,
[email, frequency, existing.id]
[email, frequency, hr, mn, dow, tz, existing.id]
)
)
}
)
// Record that a confirmation email was sent, for the send-cooldown.
export const markConfirmSent = instrument(
'database.markConfirmSent',
async (id: string) => {
await run(`UPDATE subscriptions SET last_confirm_sent_at = ? WHERE id = ?`, [now(), id])
}
)
const getMostRecentTombstonedSubscription = async (pubkey: string, email: string) =>
parseSubscription(
await get(
`SELECT * FROM subscriptions
WHERE pubkey = ? AND email = ? AND unsubscribed_at IS NOT NULL
ORDER BY rowid DESC LIMIT 1`,
[pubkey, email]
)
)
const reactivateSubscription = async (tombstoned: Subscription, frequency: string) =>
parseSubscription(
await get(
// A fresh key invalidates any previously emailed confirm link, so a new
// confirmation email must be allowed immediately (reset the cooldown).
`UPDATE subscriptions SET key = ?, frequency = ?, unsubscribed_at = NULL, last_confirm_sent_at = NULL
WHERE id = ? RETURNING *`,
[crypto.randomBytes(32).toString('hex'), frequency, tombstoned.id]
)
)
export const insertSubscription = instrument(
'database.insertSubscription',
async (pubkey: string, email: string, frequency: string) => {
async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
const existing = await getSubscriptionByPubkey(pubkey)
if (existing) {
return assertResult(await updateSubscription(existing, email, frequency))
return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone))
}
// No active row. If this account previously subscribed to this email and
// was unsubscribed, reactivate the most recent such row instead of inserting
// a fresh one. Otherwise every turn-on → turn-off → turn-on cycle would
// create a new row, abandoning the confirmed state (and the already-known key).
const tombstoned = await getMostRecentTombstonedSubscription(pubkey, email)
if (tombstoned) {
try {
return assertResult(await reactivateSubscription(tombstoned, frequency))
} catch (err: any) {
if (err.message?.includes('UNIQUE constraint')) {
const concurrent = await getSubscriptionByPubkey(pubkey)
if (concurrent) {
return assertResult(await updateSubscription(concurrent, email, frequency))
}
}
throw err
}
}
try {
return assertResult(
parseSubscription(
await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
[
crypto.randomUUID(),
crypto.randomBytes(32).toString('hex'),
pubkey,
email,
frequency,
hour ?? 17,
minute ?? 0,
dayOfWeek ?? null,
timezone ?? 'UTC',
now(),
]
)
@ -186,7 +281,7 @@ export const insertSubscription = instrument(
const concurrent = await getSubscriptionByPubkey(pubkey)
if (concurrent) {
return assertResult(await updateSubscription(concurrent, email, frequency))
return assertResult(await updateSubscription(concurrent, email, frequency, hour, minute, dayOfWeek, timezone))
}
}
@ -195,16 +290,37 @@ export const insertSubscription = instrument(
}
)
export type ConfirmResult = {
sub: Subscription
alreadyConfirmed: boolean
}
export const confirmSubscription = instrument(
'database.confirmSubscription',
async (key: string) => {
return parseSubscription(
async (key: string): Promise<ConfirmResult | undefined> => {
// Try to update an unconfirmed, active row
const updated = parseSubscription(
await get(
`UPDATE subscriptions SET confirmed_at = unixepoch()
WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`,
[key]
)
)
if (updated) {
return { sub: updated, alreadyConfirmed: false }
}
// No unconfirmed row was updated. Check if the key exists and is
// already confirmed — the user is re-clicking a used link. If the row
// is unsubscribed, treat it as invalid (expired).
const existing = await getSubscriptionByKey(key)
if (!existing || existing.unsubscribed_at) {
return undefined
}
return { sub: existing, alreadyConfirmed: true }
}
)
@ -255,6 +371,20 @@ export const getActiveSubscriptions = instrument('database.getActiveSubscription
return rows.map(parseSubscription) as Subscription[]
})
// Every row ever created for a pubkey — both active and tombstoned. Exposed
// primarily for tests asserting re-subscribe never grows the table.
export const getAllSubscriptionsByPubkey = instrument(
'database.getAllSubscriptionsByPubkey',
async (pubkey: string) => {
const rows = await all<Row>(
`SELECT * FROM subscriptions WHERE pubkey = ? ORDER BY created_at`,
[pubkey]
)
return rows.map(parseSubscription) as Subscription[]
}
)
export const updateLastDigestAt = instrument(
'database.updateLastDigestAt',
async (id: string, timestamp: number) => {
@ -319,6 +449,18 @@ export const deleteEventsForSubscription = instrument(
}
)
export const deleteEventsByIds = instrument(
'database.deleteEventsByIds',
async (subscriptionId: string, eventIds: string[]) => {
if (eventIds.length === 0) return
const placeholders = eventIds.map(() => '?').join(',')
await run(
`DELETE FROM events WHERE subscription_id = ? AND id IN (${placeholders})`,
[subscriptionId, ...eventIds]
)
}
)
export const purgeEventsOlderThan = instrument(
'database.purgeEventsOlderThan',
async (timestamp: number) => {

View file

@ -1,6 +1,5 @@
import { neventEncode, decode } from 'nostr-tools/nip19'
import {
spec,
sortBy,
groupBy,
displayList,
@ -10,7 +9,6 @@ import { parse, truncate, renderAsHtml } from '@welshman/content'
import {
TrustedEvent,
normalizeRelayUrl,
getParentId,
NOTE,
COMMENT,
REACTION,
@ -25,6 +23,7 @@ import { EVENT_VIEWER_URL } from './env.js'
import {
profilesByPubkey,
loadProfile,
repository,
} from './repository.js'
type DigestData = {
@ -49,23 +48,23 @@ export class Digest {
const getEventVariables = (event: TrustedEvent) => {
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
const relayUrl = data.relayByEventId.get(event.id)
return {
Link: buildLink(event, handler, data.relayByEventId.get(event.id)),
Link: buildLink(event, handler, relayUrl),
Timestamp: formatter.format(secondsToDate(event.created_at)),
Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
Name: displayProfileByPubkey(event.pubkey),
Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(),
Replies:
repliesByParentId.get(event.id)?.filter((e) => [COMMENT, NOTE].includes(e.kind))
?.length || 0,
Reactions: repliesByParentId.get(event.id)?.filter(spec({ kind: REACTION }))?.length || 0,
RelayUrl: relayUrl
? normalizeRelayUrl(relayUrl).replace(/^wss:\/\//, '').replace(/\/$/, '')
: '',
}
}
const { events, context } = data
const { events } = data
const formatter = getFormatter()
const handler = await this.loadHandler()
const repliesByParentId = groupBy(getParentId, context)
const eventsByPubkey = groupBy((e) => e.pubkey, events)
const userProfile = profilesByPubkey.get().get(this.sub.pubkey)
const sorted = sortBy((e) => e.created_at, events).slice(0, 100)
@ -86,7 +85,6 @@ export class Digest {
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
const events = storedEvents.map(se => se.event)
const context = [...events] // For now, context == events (no reply loading)
const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay]))
// Load profiles for event authors
@ -101,6 +99,13 @@ export class Digest {
}
}
// Load reply/reaction context: events that tag our matched events
const eventIds = events.map(e => e.id)
const replyEvents = repository.query([
{ '#e': eventIds, kinds: [NOTE, COMMENT, REACTION] },
])
const context = [...events, ...replyEvents]
const data = { events, context, relayByEventId } as DigestData
if (data.events.length > 0) {

View file

@ -64,10 +64,7 @@
<div class="event-content">{{{Content}}}</div>
<div class="event-stats">
<span class="stat-item">
{{Replies}} replies
</span>
<span class="stat-item">
{{Reactions}} reactions
Posted to {{RelayUrl}}
</span>
</div>
</div>

View file

@ -15,24 +15,24 @@ if (!process.env.SMTP_FROM) throw new Error('SMTP_FROM is not defined.')
if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined.')
if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.')
if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.')
if (!process.env.PORT) throw new Error('PORT is not defined.')
if (!process.env.PORT) console.log('PORT not set, defaulting to 4738')
if (!process.env.CORS_ORIGIN) throw new Error('CORS_ORIGIN is not defined.')
if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.')
export const MAILSHIP_URL = process.env.MAILSHIP_URL
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
export const BASE_URL = process.env.BASE_URL
export const EVENT_VIEWER_URL = process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social'
export const EVENT_VIEWER_URL = (process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social').replace(/\/$/, '')
export const BRAND_ACCENT = process.env.BRAND_ACCENT || '#7161FF'
export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla'
export const BRAND_LOGO =
process.env.BRAND_LOGO || `${EVENT_VIEWER_URL.replace(/\/$/, '')}/logo.png`
process.env.BRAND_LOGO || `${EVENT_VIEWER_URL}/logo.png`
export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET)
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl)
export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl)
export const CORS_ORIGIN = process.env.CORS_ORIGIN
export const PORT = process.env.PORT
export const PORT = process.env.PORT || '4738'
export const SMTP_HOST = process.env.SMTP_HOST
export const SMTP_PORT = process.env.SMTP_PORT
export const SMTP_USER = process.env.SMTP_USER

View file

@ -7,7 +7,9 @@ import { registerSubscription } from './worker/index.js'
process.on('unhandledRejection', (error: Error) => {
console.error('Unhandled rejection:', error.stack)
process.exit(1)
// Do not process.exit(1) — an async rejection from a library's internal
// timer (e.g. @welshman/net's batcher) would let an attacker crash the
// entire server with a single malformed request. Log and continue.
})
process.on('uncaughtException', (error: Error) => {
@ -15,6 +17,7 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1)
})
migrate().then(async () => {
server.listen(PORT, () => {
console.log('Running on port', PORT)

View file

@ -29,7 +29,7 @@ const transporter = nodemailer.createTransport({
export const sendConfirm = (sub: Subscription) => {
const href = `${BASE_URL}/confirm?token=${sub.key}`
const settingsUrl = `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`
const settingsUrl = `${EVENT_VIEWER_URL}/settings/alerts`
return transporter
.sendMail({
@ -86,7 +86,7 @@ export const sendDigest = async (sub: Subscription, variables: Record<string, an
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`,
}),
})
.catch(error => {

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Already Confirmed</title>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f0f2f5;
color: #1e293b;
}
.container {
width: 100%;
max-width: 480px;
margin: 16px;
text-align: center;
padding: 40px 32px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.logo { height: 40px; width: auto; margin: 0 auto 16px; display: block; }
.brand {
font-size: 24px;
font-weight: 700;
margin: 0 0 28px;
color: {{brandAccent}};
}
.title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
color: #1e293b;
}
.message {
font-size: 15px;
line-height: 1.6;
color: #64748b;
margin: 0;
}
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
{{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
<div class="brand">{{brandName}}</div>
<h1 class="title">Email already confirmed</h1>
<p class="message">
This email address has already been confirmed. You're all set — no further action needed.
Visit <a href="{{settingsUrl}}">{{brandName}}</a> to manage your notification settings.
</p>
</div>
</body>
</html>

View file

@ -1,18 +1,95 @@
import { instrument } from 'succinct-async'
import express, { Request, Response, NextFunction } from 'express'
import rateLimit from 'express-rate-limit'
import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js'
import { appSigner, BASE_URL, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js'
import { render } from './templates.js'
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
import { load } from '@welshman/net'
import { getIdFilters } from '@welshman/util'
import { getIdFilters, isRelayUrl } from '@welshman/util'
import crypto from 'crypto'
import { verifyEvent } from 'nostr-tools/pure'
// Endpoints
// ── NIP-98 HTTP Auth ────────────────────────────────────────────────────
// Verify a NIP-98 Authorization header and return the authenticated pubkey,
// or null if the header is missing, malformed, or invalid.
//
// The client constructs the auth event via @welshman/util:
// makeHttpAuth(url, method, body) → event
// makeHttpAuthHeader(event) → "Nostr <base64>"
//
// We decode, verify kind=27235, verifyEvent, then check u / method / payload
// tags against the actual request URL / method / body.
const verifyNip98Auth = async (req: Request): Promise<string | null> => {
const authHeader = req.headers.authorization
if (!authHeader) return null
// Format: "Nostr <base64>"
const match = authHeader.match(/^Nostr\s+(.+)$/)
if (!match) return null
// Decode base64
let eventJson: string
try {
eventJson = Buffer.from(match[1], 'base64').toString('utf-8')
} catch {
return null
}
// Parse event
let event: any
try {
event = JSON.parse(eventJson)
} catch {
return null
}
// Must be kind 27235 (HTTP Auth)
if (event.kind !== 27235) return null
// Verify event signature and id hash
if (!verifyEvent(event)) return null
const tags = event.tags || []
// Find required tags
const uTag = tags.find((t: string[]) => t[0] === 'u')
const methodTag = tags.find((t: string[]) => t[0] === 'method')
const payloadTag = tags.find((t: string[]) => t[0] === 'payload')
// Build the full URL the server received
const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
// u tag must match the request URL exactly
if (!uTag || uTag[1] !== expectedUrl) return null
// method tag must match the HTTP method (upper case)
if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null
// For requests with a body, check payload tag is the SHA256 of the body
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) {
if (req.body && Object.keys(req.body).length > 0) {
const bodyStr = JSON.stringify(req.body)
const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex')
if (!payloadTag || payloadTag[1] !== expectedPayload) return null
}
}
return event.pubkey as string
}
// ── Endpoints ──────────────────────────────────────────────────────────
export const server: express.Application = express()
// Behind a TLS-terminating reverse proxy (traefik in the ansible deploy).
// Without this, req.protocol stays "http" and verifyNip98Auth builds an
// expectedUrl of http://…, which no browser client will ever sign (clients
// sign https://…). Trust one proxy hop so req.protocol honors
// X-Forwarded-Proto and NIP-98 URL matching works.
server.set('trust proxy', 1)
// CORS middleware for browser-facing routes only.
// The browser hits /subscription with an Authorization header and Content-Type:
// application/json, which triggers a CORS preflight. Answer it and allow the
@ -85,13 +162,15 @@ addRoute('get', '/', async (req: Request, res: Response) => {
})
})
// Look up an existing email subscription for a pubkey, so clients can avoid
// re-registering (and re-confirming) when settings haven't changed.
// Look up an existing email subscription for the authenticated pubkey, so
// clients can avoid re-registering (and re-confirming) when settings
// haven't changed. Requires NIP-98 HTTP auth proving the caller controls
// the pubkey.
addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
const { pubkey } = req.query
const pubkey = await verifyNip98Auth(req)
if (!pubkey || typeof pubkey !== 'string') {
return res.status(400).json({ error: 'pubkey is required' })
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
const sub = await getSubscriptionByPubkey(pubkey)
@ -100,20 +179,32 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'Subscription not found' })
}
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
const callback = `${BASE_URL}/notify/${sub.id}`
res.json({
key: sub.key,
callback,
email: sub.email,
frequency: sub.frequency,
hour: sub.hour,
minute: sub.minute,
dayOfWeek: sub.day_of_week,
timezone: sub.timezone,
confirmed: Boolean(sub.confirmed_at),
})
})
// Subscribe to email digests (idempotent PUT upsert)
// Subscribe to email digests (idempotent PUT upsert). Requires NIP-98 HTTP
// auth proving the caller controls the pubkey — the pubkey is extracted from
// the auth event, not from the request body.
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
const { email, frequency, pubkey } = req.body
const { email, frequency, hour, minute, dayOfWeek, timezone } = req.body
const pubkey = await verifyNip98Auth(req)
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'A valid email address is required' })
@ -123,17 +214,38 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
}
if (!pubkey) {
return res.status(400).json({ error: 'pubkey is required' })
// Validate optional schedule fields
if (hour !== undefined) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
return res.status(400).json({ error: 'Hour must be an integer between 0 and 23' })
}
}
// TODO: Verify NIP-98 auth header
// const auth = req.headers.authorization
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' })
// Verify using @welshman/util makeHttpAuth
if (minute !== undefined) {
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
return res.status(400).json({ error: 'Minute must be an integer between 0 and 59' })
}
}
if (dayOfWeek !== undefined) {
if (frequency !== 'weekly') {
return res.status(400).json({ error: 'dayOfWeek is only valid for weekly frequency' })
}
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 7) {
return res.status(400).json({ error: 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)' })
}
}
if (timezone !== undefined) {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone })
} catch {
return res.status(400).json({ error: `"${timezone}" is not a valid IANA timezone` })
}
}
try {
const result = await registerSubscription({ pubkey, email, frequency })
const result = await registerSubscription({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone })
res.json(result)
} catch (error: any) {
// The subscription was still created, but sending the confirmation email
@ -143,7 +255,7 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
// Look up the actual subscription key from the DB
const sub = await getSubscriptionByPubkey(pubkey)
if (sub) {
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
const callback = `${BASE_URL}/notify/${sub.id}`
res.json({ key: sub.key, callback })
} else {
console.error('Failed to register subscription:', error)
@ -152,17 +264,26 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
}
})
// Delete subscription
// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls
// the pubkey that owns this subscription.
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
const { key } = req.params
const pubkey = await verifyNip98Auth(req)
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
const sub = await getSubscriptionByKey(key)
if (!sub) {
return res.status(404).json({ error: 'Subscription not found' })
}
// TODO: Verify NIP-98 auth header matches sub.pubkey
if (sub.pubkey !== pubkey) {
return res.status(403).json({ error: 'Forbidden: you do not own this subscription' })
}
await unsubscribeAction({ token: key })
res.json({ ok: true })
@ -176,6 +297,15 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'id and relay are required' })
}
// Reject non-ws:// relay URLs. load() from @welshman/net throws
// Invalid relay url asynchronously inside a batcher timer, which
// escapes the route's try/catch and becomes an unhandledRejection
// that would crash the server. Validate early to avoid calling
// load() with an unsupported scheme.
if (!isRelayUrl(relay)) {
return res.status(400).json({ error: 'Invalid relay URL. Only wss:// or ws:// relays are supported.' })
}
const sub = await getSubscriptionById(req.params.id)
if (!sub) {
@ -205,8 +335,8 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
storedEvent = fetched
if (!storedEvent) {
// Event not found at relay — don't 404, just skip
return res.json({ ok: true, skipped: true })
// Event not found at relay — don't 404, reflect that nothing was stored
return res.json({ ok: true, stored: false })
}
}
@ -219,28 +349,37 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
}
})
// ── Branding helper ──────────────────────────────────────────────────────
const brandingVars = () => ({
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`,
})
// Confirmation
addRoute('get', '/confirm', async (req: Request, res: Response) => {
if (typeof req.query.token !== 'string') {
return res.send(
await render('pages/confirm-error.html', {
message: 'No confirmation token was provided. Please check the link in your email and try again.',
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
...brandingVars(),
})
)
}
try {
await confirmSubscriptionAction({ token: req.query.token })
const result = await confirmSubscriptionAction({ token: req.query.token })
if (result.alreadyConfirmed) {
return res.send(await render('pages/confirm-already.html', {
...brandingVars(),
}))
}
res.send(await render('pages/confirm-success.html', {
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
...brandingVars(),
}))
} catch (error) {
const isActionError = error instanceof ActionError
@ -248,10 +387,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => {
res.send(await render('pages/confirm-error.html', {
message,
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
...brandingVars(),
}))
if (!isActionError) {

View file

@ -33,8 +33,11 @@ export const runJob = async (sub: Subscription) => {
const digest = new Digest(sub)
await digest.sendFromStoredEvents(events)
// Clean up processed events
await db.deleteEventsForSubscription(sub.id, since)
// Collect the exact IDs that were fetched + sent, then delete ONLY those.
// Deleting by timestamp (received_at > since) would also remove any event
// that arrived between the fetch and the delete — the race condition.
const sentIds = events.map(e => e.id)
await db.deleteEventsByIds(sub.id, sentIds)
await db.updateLastDigestAt(sub.id, Math.floor(Date.now() / 1000))
console.log('worker: job completed', sub.id, 'in', Date.now() - start, 'ms')
@ -46,17 +49,22 @@ export const runJob = async (sub: Subscription) => {
}
const createJob = (sub: Subscription) => {
const cron = getCronExpression(sub.frequency)
const cron = getCronExpression(sub.frequency, sub.hour, sub.minute, sub.day_of_week)
const run = async () => {
await runJob(sub)
// Re-fetch the subscription to pick up the latest last_digest_at.
// The closure-captured `sub` is stale — its last_digest_at never
// advances, so every tick would re-fetch and re-send old events.
const fresh = await db.getSubscriptionById(sub.id)
if (!fresh) return
await runJob(fresh)
}
return CronJob.from({
cronTime: cron,
onTick: run,
start: true,
timeZone: 'UTC',
timeZone: sub.timezone ?? 'UTC',
})
}

View file

@ -0,0 +1,44 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
const pubkey = 'reconfirm-test-' + Date.now()
const email = 'reconfirm-test-' + Date.now() + '@example.com'
let token: string
describe('Confirm link idempotency — already-confirmed token', () => {
beforeAll(async () => {
await db.migrate()
})
it('creates and confirms a subscription for the first time', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
expect(sub).toBeTruthy()
token = sub.key
const result = await db.confirmSubscription(token)
expect(result).toBeTruthy()
expect(result!.sub.confirmed_at).toBeTruthy()
expect(result!.alreadyConfirmed).toBe(false)
})
it('returns a distinct result (not undefined) when confirming an already-confirmed token', async () => {
// BUG: confirmSubscription used `WHERE confirmed_at IS NULL`, so
// re-confirming an already-confirmed token matched zero rows and
// the UPDATE returned nothing → parseSubscription returned undefined.
// The caller then threw ActionError('invalid or expired') and the
// user saw "Email not confirmed" — which is misleading.
//
// FIX: confirmSubscription now detects the already-confirmed case
// and returns { sub, alreadyConfirmed: true } so the handler can
// render an "already confirmed" info page instead of an error page.
const result = await db.confirmSubscription(token)
expect(result).not.toBeUndefined()
expect(result!.alreadyConfirmed).toBe(true)
expect(result!.sub.confirmed_at).toBeTruthy()
})
it('returns undefined for a nonexistent token', async () => {
const result = await db.confirmSubscription('nonexistent-token-' + Date.now())
expect(result).toBeUndefined()
})
})

View file

@ -0,0 +1,109 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import * as mailer from '../src/mailer.js'
// Regression guards for the row-reactivation fix (src/database.ts):
// * same-email re-subscribe → reactivates the SAME row and must NOT email a
// stale confirmation code to an unrelated address
// * new-email re-subscribe → MUST create a fresh row (fresh key) so the
// confirmation email carries a code bound to the new address
//
// We mock the mailer so these run hermetically (the unit env's SMTP is a dummy).
vi.mock('../src/mailer.js', async (importOriginal) => {
const actual: any = await importOriginal()
return { ...actual, sendConfirm: vi.fn(async () => {}) }
})
const pubkey = 'new-email-' + Date.now() + '-' + Math.random().toString(36).slice(2)
const oldEmail = `${pubkey}-old@example.com`
const newEmail = `${pubkey}-new@example.com`
let subscribedIds: string[] = []
const subscribeIdsFor = async () => {
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
subscribedIds = rows.map((r: any) => r.id)
return rows
}
describe('Re-subscribe with a NEW email dispatches the correct confirmation code', () => {
beforeAll(async () => {
await db.migrate()
vi.mocked(mailer.sendConfirm).mockClear()
})
afterAll(async () => {
const key = (await db.getAllSubscriptionsByPubkey(pubkey))[0]?.key
if (key) await db.unsubscribeSubscription(key)
})
it('registers + confirms the original email', async () => {
const result = await registerSubscription({ pubkey, email: oldEmail, frequency: 'daily' })
expect(result.key).toBeTruthy()
await db.confirmSubscription(result.key)
expect(vi.mocked(mailer.sendConfirm)).toHaveBeenCalledTimes(1)
})
it('unsubscribes (DELETE flow)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
await db.unsubscribeSubscription(active!.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registering with the NEW email creates a fresh row, not a reactivation', async () => {
vi.mocked(mailer.sendConfirm).mockClear()
const result = await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' })
// A NEW confirmation email was sent — to the NEW address, with the key
// returned to the caller (which the caller uses to confirm).
const sent = vi.mocked(mailer.sendConfirm).mock.calls[0][0]
expect(sent.email).toBe(newEmail)
expect(sent.key).toBe(result.key)
// And that key actually confirms the new-email row (not the old one).
const confirmed = await db.confirmSubscription(result.key)
expect(confirmed!.sub.email).toBe(newEmail)
expect(confirmed!.alreadyConfirmed).toBe(false)
})
it('leaves the old row and new row as distinct rows', async () => {
const rows = await subscribeIdsFor()
expect(rows).toHaveLength(2)
expect(new Set(subscribedIds).size).toBe(2)
})
})
describe('Reactivating the SAME email never emails an unrelated address', () => {
const k = 'same-email-' + Date.now() + '-' + Math.random().toString(36).slice(2)
const email = `${k}@example.com`
beforeAll(async () => {
vi.mocked(mailer.sendConfirm).mockClear()
})
afterAll(async () => {
const key = (await db.getAllSubscriptionsByPubkey(k))[0]?.key
if (key) await db.unsubscribeSubscription(key)
})
it('register + confirm + unsubscribe, then re-register the same email', async () => {
const r1 = await registerSubscription({ pubkey: k, email, frequency: 'daily' })
await db.confirmSubscription(r1.key)
const row1 = (await db.getAllSubscriptionsByPubkey(k))[0]
await db.unsubscribeSubscription(r1.key)
vi.mocked(mailer.sendConfirm).mockClear()
const r2 = await registerSubscription({ pubkey: k, email, frequency: 'daily' })
// Same row, still confirmed, but a FRESH key is issued — no new
// confirmation email, and old tokens for this row are invalidated.
const rows = await db.getAllSubscriptionsByPubkey(k)
expect(rows).toHaveLength(1)
expect(r2.key).not.toBe(r1.key)
expect(rows[0].id).toBe(row1.id)
expect(rows[0].confirmed_at).toBeTruthy()
expect(vi.mocked(mailer.sendConfirm)).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,101 @@
import { describe, it, expect, beforeAll, vi, afterEach, beforeEach } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription, CONFIRM_EMAIL_COOLDOWN_SECONDS } from '../src/actions.js'
import * as mailer from '../src/mailer.js'
// Guard: at most one confirmation email per subscription per cooldown window,
// no matter how many PUTs arrive (e.g. a page-refresh storm while unconfirmed).
vi.mock('../src/mailer.js', async (importOriginal) => {
const actual: any = await importOriginal()
return { ...actual, sendConfirm: vi.fn(async () => {}) }
})
const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
const pubkey = unique('cooldown')
const email = `${unique('cooldown')}@example.com`
const confirmCalls = () => vi.mocked(mailer.sendConfirm).mock.calls.length
describe('Confirmation email cooldown', () => {
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('sends a confirmation email on the first register', async () => {
const res = await registerSubscription({ pubkey, email, frequency: 'daily' })
expect(res.key).toBeTruthy()
expect(confirmCalls()).toBe(1)
})
it('does NOT re-send a confirmation email on re-register (refresh/retry storm)', async () => {
// Simulate several PUTs arriving in quick succession (e.g. page reloads).
for (let i = 0; i < 5; i++) {
await registerSubscription({ pubkey, email, frequency: 'daily' })
}
expect(confirmCalls()).toBe(0)
})
it('a new frequency (setting change) does not re-send', async () => {
await registerSubscription({ pubkey, email, frequency: 'weekly' })
expect(confirmCalls()).toBe(0)
})
it('changing the email address sends immediately (cooldown reset)', async () => {
const newEmail = `${unique('cooldown')}@example.com`
await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('a fresh unconfirmed subscription is still throttled after confirm-sent marker', async () => {
// New pubkey: first PUT sends one email; immediate re-PUT is suppressed.
const pk2 = unique('cooldown2')
const em2 = `${unique('cooldown2')}@example.com`
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('cooldown window constant is 10 minutes', () => {
expect(CONFIRM_EMAIL_COOLDOWN_SECONDS).toBe(600)
})
})
describe('Cooldown reset on subscription reactivation (same email, off/on cycle)', () => {
const k = unique('cooldown-reactivate')
const e = `${unique('cooldown-reactivate')}@example.com`
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('register, confirm, unsubscribe, re-register same email → fresh key emails immediately', async () => {
const r1 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
const active = await db.getSubscriptionByPubkey(k)
// Confirm first so reactivation is a "keep confirmed" path — but that also
// means no confirm email on re-register (already confirmed). Verify the row
// is reactivated with a fresh key, not a duplicate.
const confirmed = await db.confirmSubscription(active!.key)
expect(confirmed).toBeTruthy()
await db.unsubscribeSubscription(active!.key)
vi.mocked(mailer.sendConfirm).mockClear()
const r2 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
expect(r2.key).not.toBe(r1.key) // fresh key issued
expect(confirmCalls()).toBe(0) // still confirmed → no new email
const rows = await db.getAllSubscriptionsByPubkey(k)
expect(rows).toHaveLength(1)
})
})

View file

@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TrustedEvent } from '@welshman/util'
// ── Shared state accessible from both vi.mock factories and test body ──────
const mockRepo = vi.hoisted(() => {
const events: TrustedEvent[] = []
return {
events, // shared mutable array
query: vi.fn((filters: any[]) => {
return events.filter(e => {
for (const f of filters) {
// #e filter: event must have an 'e' tag whose value matches one of the filter values
if (f['#e']) {
const eTagVals = e.tags.filter(t => t[0] === 'e').map(t => t[1])
if (!f['#e'].some((id: string) => eTagVals.includes(id))) return false
}
// kinds filter
if (f.kinds && !f.kinds.includes(e.kind)) return false
}
return true
})
}),
publish: vi.fn((event: TrustedEvent) => {
events.push(event)
return true
}),
}
})
// ── Mocks (hoisted before imports) ─────────────────────────────────────────
// ── Mock profiles map (pre-populated so waitForProfile doesn't time out) ──
const mockProfilesMap = vi.hoisted(() => new Map())
vi.mock('../src/repository.js', () => ({
profilesByPubkey: { get: () => mockProfilesMap },
loadProfile: vi.fn().mockResolvedValue(undefined),
repository: mockRepo,
}))
vi.mock('../src/util.js', () => {
const createElementMock = vi.fn().mockImplementation((tagName: string) => {
const el: any = { tagName, children: [], innerText: '' }
el.appendChild = (child: any) => { el.children.push(child) }
el.toString = () =>
`<${tagName}>${el.innerText}${el.children.map((c: any) => c.toString()).join('')}</${tagName}>`
return el
})
return {
displayDuration: vi.fn().mockReturnValue('1 hour'),
createElement: createElementMock,
}
})
// Captured digest parameters (set by the mailer mock)
let lastDigestParams: Record<string, any> | undefined
vi.mock('../src/mailer.js', () => ({
sendDigest: vi.fn((_sub: any, variables: Record<string, any>) => {
lastDigestParams = variables
}),
}))
// ── Imports (after mocks) ──────────────────────────────────────────────────
import { Digest } from '../src/digest.js'
import type { Subscription } from '../src/alert.js'
// Valid 64-char hex strings for nostr IDs and pubkeys
const PARENT_ID = 'aaa' + 'a'.repeat(61) // 64 hex chars
const REPLY_ID = 'bbb' + 'b'.repeat(61)
const REACTION_ID = 'ccc' + 'c'.repeat(61)
const PARENT_PK = 'ddd' + 'd'.repeat(61)
const REPLIER_PK = 'eee' + 'e'.repeat(61)
const REACTER_PK = 'fff' + 'f'.repeat(61)
function makeEvent(overrides: Partial<TrustedEvent>): TrustedEvent {
const eid = overrides.id || 'a'.repeat(64)
return {
id: eid,
kind: 1,
pubkey: PARENT_PK,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'test content',
...overrides,
id: eid,
} as TrustedEvent
}
describe('digest reply/reaction stats', () => {
let sub: Subscription
beforeEach(() => {
// Reset shared state
mockRepo.events.length = 0
lastDigestParams = undefined
sub = {
id: 'sub-1',
key: 'key-1',
pubkey: PARENT_PK,
email: 'test@example.com',
frequency: 'daily',
created_at: Math.floor(Date.now() / 1000) - 3600,
confirmed_at: Math.floor(Date.now() / 1000) - 3600,
}
})
it('should count replies and reactions loaded from repository context', async () => {
// Create a parent event
const parentEvent = makeEvent({
id: PARENT_ID,
kind: 1,
pubkey: PARENT_PK,
content: 'Hello world, this is the parent event',
created_at: Math.floor(Date.now() / 1000) - 600,
})
// Create a reply event referencing the parent via an 'e' tag
const replyEvent = makeEvent({
id: REPLY_ID,
kind: 1,
pubkey: REPLIER_PK,
content: 'This is a reply to the parent',
created_at: Math.floor(Date.now() / 1000) - 500,
tags: [['e', PARENT_ID, '', 'root']],
})
// Create a reaction event (kind 7 = REACTION)
const reactionEvent = makeEvent({
id: REACTION_ID,
kind: 7,
pubkey: REACTER_PK,
content: '+',
created_at: Math.floor(Date.now() / 1000) - 400,
tags: [['e', PARENT_ID, '', 'root']],
})
// Publish reply/reaction events to the mock repository so the fix can find them
mockRepo.publish(replyEvent)
mockRepo.publish(reactionEvent)
// Pre-populate profiles so waitForProfile resolves immediately
mockProfilesMap.set(PARENT_PK, { pubkey: PARENT_PK, name: 'parent-user', picture: '' })
mockProfilesMap.set(REPLIER_PK, { pubkey: REPLIER_PK, name: 'replier', picture: '' })
mockProfilesMap.set(REACTER_PK, { pubkey: REACTER_PK, name: 'reacter', picture: '' })
const storedEvents = [
{ id: 'se-1', event: parentEvent, relay: 'wss://relay.example.com' },
]
const digest = new Digest(sub)
await digest.sendFromStoredEvents(storedEvents)
// sendDigest should have been called with the template parameters
expect(lastDigestParams).toBeDefined()
const latest = lastDigestParams!.Latest
expect(latest.length).toBeGreaterThanOrEqual(1)
const parentEntry = latest[0]
// RelayUrl should be the relay domain stripped of protocol and trailing slash
expect(parentEntry.RelayUrl).toBe('relay.example.com')
})
})

View file

@ -1,74 +0,0 @@
#!/usr/bin/env node
// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}}
//
// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for
// .event-item border-left and .footer a color hardcode #7161FF even though
// {{brandAccent}} is passed into the template by mailer.ts and used
// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item
// border and footer links stay the default purple.
//
// The fix: replace both hardcoded #7161FF values with {{brandAccent}}.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml');
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Read the MJML template
const source = readFileSync(templatePath, 'utf8');
const lines = source.split('\n');
console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS');
// Check .event-item border-left doesn't have #7161FF
const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item'));
const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF'));
assert(
'.event-item border-left does NOT hardcode #7161FF',
!hasEventItemHardcoded,
hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : ''
);
// Check .footer a color doesn't have #7161FF
const footerAIdx = lines.findIndex(l => l.includes('.footer a'));
const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF'));
assert(
'.footer a color does NOT hardcode #7161FF',
!hasFooterHardcoded,
hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : ''
);
// Check .event-item border-left uses {{brandAccent}}
const eventItemLine = lines[eventItemLineIdx];
assert(
'.event-item border-left uses {{brandAccent}}',
eventItemLine && eventItemLine.includes('{{brandAccent}}'),
eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found'
);
// Check .footer a color uses {{brandAccent}}
const footerALine = lines[footerAIdx];
assert(
'.footer a color uses {{brandAccent}}',
footerALine && footerALine.includes('{{brandAccent}}'),
footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found'
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml')
const source = readFileSync(templatePath, 'utf8')
const lines = source.split('\n')
describe('digest.mjml brandAccent usage', () => {
it('.event-item border-left does NOT hardcode #7161FF', () => {
const hasHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF'))
expect(hasHardcoded).toBe(false)
})
it('.footer a color does NOT hardcode #7161FF', () => {
const hasHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF'))
expect(hasHardcoded).toBe(false)
})
it('.event-item border-left uses {{brandAccent}}', () => {
const eventItemLine = lines.find(l => l.includes('.event-item'))
expect(eventItemLine).toBeDefined()
expect(eventItemLine).toContain('{{brandAccent}}')
})
it('.footer a color uses {{brandAccent}}', () => {
const footerALine = lines.find(l => l.includes('.footer a'))
expect(footerALine).toBeDefined()
expect(footerALine).toContain('{{brandAccent}}')
})
})

View file

@ -0,0 +1,50 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
const pubkey = 'dup-test-' + Date.now()
const email = 'dup-test-' + Date.now() + '@example.com'
let token: string
describe('Duplicate subscription prevention — idempotent re-register after confirm', () => {
beforeAll(async () => {
await db.migrate()
})
it('registers a subscription (fresh)', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
expect(sub).toBeTruthy()
expect(sub!.confirmed_at).toBeFalsy()
expect(sub!.unsubscribed_at).toBeFalsy()
token = sub!.key
})
it('confirms the subscription', async () => {
const result = await db.confirmSubscription(token)
expect(result).toBeTruthy()
expect(result!.alreadyConfirmed).toBe(false)
expect(result!.sub.confirmed_at).toBeTruthy()
})
it('re-registers with the same email and frequency (idempotent PUT)', async () => {
// This simulates a second PUT from the client with identical params.
// The bug would create a second active row + send a new confirmation email.
const sub = await db.insertSubscription(pubkey, email, 'daily')
expect(sub).toBeTruthy()
// Must return the existing confirmed row, NOT a fresh unconfirmed row
expect(sub!.confirmed_at).toBeTruthy()
expect(sub!.unsubscribed_at).toBeFalsy()
// The key must remain unchanged — a new row would have a different key
expect(sub!.key).toBe(token)
})
it('has exactly one active row for this pubkey', async () => {
// getSubscriptionByPubkey filters by unsubscribed_at IS NULL and
// returns at most one row (enforced by the partial unique index).
// If a second active row exists, the index is absent or bypassed.
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active).toBeTruthy()
expect(active!.confirmed_at).toBeTruthy()
expect(active!.key).toBe(token)
})
})

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
const pubkey = 'race-test-' + Date.now()
const email = 'race-test-' + Date.now() + '@example.com'
let sub: any = null
let since = 0
const eventA_id = 'race-event-a-' + Date.now()
const eventB_id = 'race-event-b-' + Date.now()
// Captured after the initial fetch, before Event B is inserted
let fetchedBeforeB: string[] = []
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
describe('Event-arrival race in digest job', () => {
beforeAll(async () => {
await db.migrate()
// Create and confirm subscription
const s = await db.insertSubscription(pubkey, email, 'daily')
expect(s).toBeTruthy()
const confirmed = await db.confirmSubscription(s.key)
expect(confirmed).toBeTruthy()
sub = confirmed!.sub
// Set last_digest_at to 60 seconds ago (the "since" value runJob would use)
since = Math.floor(Date.now() / 1000) - 60
await db.updateLastDigestAt(sub.id, since)
// Wait 1.1s so event received_at timestamps are strictly > since
await sleep(1100)
})
it('stores Event A (triggers digest)', async () => {
const eventA = {
id: eventA_id,
kind: 1,
pubkey: 'abc',
content: 'event A content',
created_at: Math.floor(Date.now() / 1000),
tags: [],
}
const storedA = await db.insertEvent(eventA_id, sub.id, eventA, 'wss://relay.damus.io')
expect(storedA).toBe(true)
})
// Fetch events BEFORE Event B is inserted (simulating runJob's fetch
// before a /notify arrives during the digest send). Save the IDs we
// fetched so we delete exactly those later.
it('fetches events and captures IDs (simulating runJob fetch)', async () => {
const fetched = await db.getEventsForSubscription(sub.id, since)
expect(fetched.some((e: any) => e.id === eventA_id)).toBe(true)
fetchedBeforeB = fetched.map((e: any) => e.id)
})
it('stores Event B AFTER fetch (simulating arrival during digest send)', async () => {
await sleep(100)
const eventB = {
id: eventB_id,
kind: 1,
pubkey: 'def',
content: 'event B content — arrived during send',
created_at: Math.floor(Date.now() / 1000),
tags: [],
}
const storedB = await db.insertEvent(eventB_id, sub.id, eventB, 'wss://relay.damus.io')
expect(storedB).toBe(true)
})
// Delete using the IDs captured before Event B was inserted.
// This simulates the fix: deleteEventsByIds, not timestamp-based delete.
it('deletes only previously-fetched event IDs (the fix) and leaves event B', async () => {
await db.deleteEventsByIds(sub.id, fetchedBeforeB)
// Event B must survive (it arrived after fetch and was never sent)
const remaining = await db.getEventsForSubscription(sub.id, since - 10)
const eventB_survived = remaining.some((e: any) => e.id === eventB_id)
expect(eventB_survived).toBe(true)
})
it('Event A is deleted (it was fetched and sent)', async () => {
const remaining = await db.getEventsForSubscription(sub.id, since - 10)
const eventA_survived = remaining.some((e: any) => e.id === eventA_id)
expect(eventA_survived).toBe(false)
})
})

127
test/integration.sh Executable file → Normal file
View file

@ -38,11 +38,31 @@ if [ ! -d "dist" ]; then
npx tsc
fi
# Generate a random secret for the test
SECRET="$(openssl rand -hex 32)"
# Generate secrets for the test: one for the server, one for the client
SERVER_SECRET="$(openssl rand -hex 32)"
CLIENT_SECRET="$(openssl rand -hex 32)"
# Derive the client pubkey so we can look up subscriptions later
CLIENT_PUBKEY=$(node -e "
import {Nip01Signer} from '@welshman/signer';
const s = Nip01Signer.fromSecret('$CLIENT_SECRET');
s.getPubkey().then(p => console.log(p));
")
echo "Client pubkey: $CLIENT_PUBKEY"
# Helper to build a NIP-98 auth header
nip98_auth() {
local url="$1" method="$2" body="${3:-}"
if [ -n "$body" ]; then
node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" "$body"
else
node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method"
fi
}
# Export env vars for the server
export MAILSHIP_SECRET="$SECRET"
export MAILSHIP_SECRET="$SERVER_SECRET"
export MAILSHIP_NAME="Mailship Test"
export MAILSHIP_URL="$BASE_URL"
export BASE_URL="$BASE_URL"
@ -55,6 +75,7 @@ export DEFAULT_RELAYS="wss://relay.damus.io"
export INDEXER_RELAYS="wss://purplepag.es"
export SEARCH_RELAYS="wss://relay.nostr.band"
export PORT="$PORT"
export CORS_ORIGIN="$BASE_URL"
export DATA_DIR="$PROJECT_DIR/test-data"
mkdir -p "$DATA_DIR"
@ -117,11 +138,13 @@ echo "1. Health check"
HEALTH=$(curl -s "$BASE_URL/")
check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship"
# Test 2: Register subscription
# Test 2: Register subscription with NIP-98 auth
echo ""
echo "2. Register subscription"
echo "2. Register subscription (NIP-98 auth)"
AUTH_PUT=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}')
REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
-d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}')
-H "Authorization: $AUTH_PUT" \
-d '{"email":"test@example.com","frequency":"daily"}')
KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)
CALLBACK=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null)
@ -132,9 +155,31 @@ else
fail "Registration missing key or callback (got: $REG)"
fi
# Test 3: Confirm subscription
# Test 3: PUT without auth returns 401
echo ""
echo "3. Confirm subscription"
echo "3. PUT without auth returns 401"
NO_AUTH=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
-d '{"email":"test@example.com","frequency":"daily"}')
check_field "No-auth PUT returns 401" "$NO_AUTH" "error" "NIP-98 authorization required"
# Test 4: GET /subscription/email with auth
echo ""
echo "4. GET subscription with auth"
AUTH_GET=$(nip98_auth "$BASE_URL/subscription/email" GET)
GET_RESP=$(curl -s "$BASE_URL/subscription/email" \
-H "Authorization: $AUTH_GET")
check_field "GET returns our email" "$GET_RESP" "email" "test@example.com"
check_field "GET returns frequency" "$GET_RESP" "frequency" "daily"
# Test 5: GET without auth returns 401
echo ""
echo "5. GET without auth returns 401"
GET_NO_AUTH=$(curl -s "$BASE_URL/subscription/email")
check_field "No-auth GET returns 401" "$GET_NO_AUTH" "error" "NIP-98 authorization required"
# Test 6: Confirm subscription
echo ""
echo "6. Confirm subscription"
CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1)
if echo "$CONFIRM" | grep -qi "success"; then
pass "Confirmation page shows success"
@ -142,20 +187,20 @@ else
fail "Confirmation page doesn't show success"
fi
# Test 4: Check SQLite state
# Test 7: Check SQLite state
echo ""
echo "4. Database state"
CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null)
echo "7. Database state"
CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then
pass "Subscription confirmed in DB"
else
fail "Subscription not confirmed in DB"
fi
# Test 5: Push event to notify endpoint
# Test 8: Push event to notify endpoint
echo ""
echo "5. Push event via notify"
SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null)
echo "8. Push event via notify"
SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
# Fetch a real event from a relay
EVENT_ID=$(nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null)
@ -173,25 +218,25 @@ else
check_field "Event stored in DB" "$NOTIFY" "stored" "True"
fi
# Test 6: Dedup
# Test 9: Dedup
echo ""
echo "6. Dedup"
echo "9. Dedup"
if [ -n "$EVENT_ID" ]; then
DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
check_field "Duplicate event rejected" "$DEDUP" "stored" "False"
fi
# Test 7: 404 for nonexistent subscription
# Test 10: 404 for nonexistent subscription
echo ""
echo "7. 404 for nonexistent subscription"
echo "10. 404 for nonexistent subscription"
NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \
-d '{"id":"abc","relay":"wss://relay.primal.net"}')
check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found"
# Test 8: Unsubscribe
# Test 11: Unsubscribe
echo ""
echo "8. Unsubscribe"
echo "11. Unsubscribe"
UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY")
if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then
pass "Unsubscribe page renders"
@ -199,21 +244,53 @@ else
fail "Unsubscribe page didn't render"
fi
# Test 9: Notify after unsubscribe returns 404
# Test 12: Notify after unsubscribe returns 404
echo ""
echo "9. No push after unsubscribe"
echo "12. No push after unsubscribe"
if [ -n "$EVENT_ID" ]; then
AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active"
fi
# Test 10: Delete subscription
# Test 13: Delete subscription with auth
echo ""
echo "10. Delete subscription"
DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1)
echo "13. Delete subscription with NIP-98 auth"
AUTH_DEL=$(nip98_auth "$BASE_URL/subscription/$KEY" DELETE)
DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE -H "Authorization: $AUTH_DEL")
check_field "Delete returns ok" "$DELETE" "ok" "True"
# Test 14: Delete without auth returns 401
echo ""
echo "14. Delete without auth returns 401"
DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE)
check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required"
# Test 15: Re-subscribe after delete reactivates the same row (no duplicate)
# Regression: DELETE tombstones the row; re-subscribing with the SAME pubkey +
# email must reactivate it, not insert a second row (off/on cycle previously
# accumulated one row per cycle).
echo ""
echo "15. Re-subscribe after delete (de-dupe regression)"
OLD_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NOT NULL ORDER BY created_at DESC LIMIT 1;" 2>/dev/null)
AUTH_RE=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}')
RE_REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
-H "Authorization: $AUTH_RE" \
-d '{"email":"test@example.com","frequency":"daily"}')
NEW_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NULL LIMIT 1;" 2>/dev/null)
if [ -n "$OLD_ID" ] && [ "$NEW_ID" = "$OLD_ID" ]; then
pass "Re-subscribe reactivates the same row"
else
fail "Re-subscribe created a duplicate row (old=$OLD_ID, new=$NEW_ID)"
fi
TOTAL_ROWS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
if [ "$TOTAL_ROWS" = "1" ]; then
pass "Exactly one row for this pubkey"
else
fail "Expected 1 row for this pubkey, got $TOTAL_ROWS"
fi
# Summary
echo ""
echo "========================================="

View file

@ -1,68 +0,0 @@
#!/usr/bin/env node
// FAILING test: calling normalizeRelayUrl(undefined) crashes with
// TypeError: can't access property "match", A is undefined
//
// The bug: main.ts called normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
// without guarding against the env var being undefined. When the env var is
// not set, normalizeRelayUrl crashes because it calls url.match(...) on
// undefined.
//
// The fix: replace the bare call with a ternary guard:
// const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
// ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
// : undefined
//
// This test validates that the guard pattern works correctly: when the env var
// is falsy (undefined, empty), the app gracefully sets NOTIFIER_RELAY to
// undefined without crashing. When it's a valid URL, normalization works.
import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js';
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Test 1: Guarded normalizeRelayUrl with undefined (the exact fix pattern)
console.log('1. Guarded normalizeRelayUrl with undefined (the fix)');
const undefinedInput = undefined;
const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined;
assert(
'guarded normalizeRelayUrl with undefined should not crash, result is undefined',
guarded1 === undefined,
`got ${guarded1}`
);
// Test 2: Guard with empty string
console.log('');
console.log('2. Guarded normalizeRelayUrl with empty string');
const emptyInput = '';
const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined;
assert(
'guarded normalizeRelayUrl with "" should not crash, result is undefined',
guarded2 === undefined,
`got ${guarded2}`
);
// Test 3: Guard with a valid relay still works
console.log('');
console.log('3. Guarded normalizeRelayUrl with valid relay');
const validInput = 'wss://relay.damus.io';
const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined;
assert(
'guarded normalizeRelayUrl with valid input still normalizes correctly',
guarded3 === 'wss://relay.damus.io/',
`got ${guarded3}`
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { normalizeRelayUrl } from '@welshman/util'
describe('Guarded normalizeRelayUrl', () => {
it('guarded with undefined should not crash, result is undefined', () => {
const undefinedInput: string | undefined = undefined
const guarded = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined
expect(guarded).toBeUndefined()
})
it('guarded with empty string should not crash, result is undefined', () => {
const emptyInput = ''
const guarded = emptyInput ? normalizeRelayUrl(emptyInput) : undefined
expect(guarded).toBeUndefined()
})
it('guarded with valid relay still normalizes correctly', () => {
const validInput = 'wss://relay.damus.io'
const guarded = validInput ? normalizeRelayUrl(validInput) : undefined
expect(guarded).toBe('wss://relay.damus.io/')
})
})

View file

@ -0,0 +1,82 @@
// POST /notify with non-wss relay URL crashes the server (remote DoS)
//
// Bug: When POST /notify/:id receives a relay URL with a non-wss scheme
// (e.g. http://…), the handler calls `load()` from @welshman/net. Inside
// `load`, the batcher schedules an async `_execute` via setTimeout(200ms).
// When `getAdapter` throws `Invalid relay url`, the error escapes as an
// unhandledPromiseRejection because the batcher's `_execute` async function
// is called from setTimeout with no `.catch()`. The global
// `process.on('unhandledRejection')` handler in `src/index.ts` then calls
// `process.exit(1)`, killing the entire server.
//
// Fix applied (2 of 3 fixes):
// 1. Validate relay scheme before calling load() — the route handler now
// checks isRelayUrl() and returns 400 for non-ws:// schemes.
// 2. Don't process.exit(1) on unhandledRejection — log and continue
// (defence in depth for any other async edge-case).
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { server } from '../src/server.js'
import { createServer, type Server } from 'http'
// Partially mock @welshman/net so that `load()` returns an empty array,
// preventing real relay connections during the test, while preserving all
// other exports from the library.
vi.mock('@welshman/net', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
load: vi.fn().mockResolvedValue([]),
}
})
describe('notify_non_wss_relay', () => {
let httpServer: Server
let baseUrl: string
let subId: string
beforeAll(async () => {
await db.migrate()
// Create and confirm a subscription we can use for the notify call
const pubkey = 'nws-test-pk-' + Date.now()
const email = 'nws-test-' + Date.now() + '@example.com'
const sub = await db.insertSubscription(pubkey, email, 'daily')
const confirmed = await db.confirmSubscription(sub.key)
subId = confirmed.id
// Start the express server on a random available port
await new Promise<void>((resolve) => {
httpServer = createServer(server)
httpServer.listen(0, () => {
const addr = httpServer.address()
if (addr && typeof addr === 'object') {
baseUrl = `http://localhost:${addr.port}`
}
resolve()
})
})
})
afterAll(async () => {
httpServer?.close()
})
it('rejects non-wss relay URL with 400 instead of crashing the server', async () => {
const res = await fetch(`${baseUrl}/notify/${subId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'nonexistent-' + Date.now(),
relay: 'http://127.0.0.1:9',
}),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body).toHaveProperty('error')
expect(body.error).toMatch(/Invalid relay/)
})
})

View file

@ -0,0 +1,78 @@
// POST /notify/:id response shape test
//
// Verifies that the endpoint always includes a `stored` boolean
// in its response, matching the documented contract in README.md:
// Response: { ok: true, stored: boolean }
//
// Bug: when the event is not found at the relay, the handler returns
// { ok: true, skipped: true }
// missing the documented `stored` field.
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { server } from '../src/server.js'
import { createServer, type Server } from 'http'
// Partially mock @welshman/net so that `load()` returns an empty array,
// simulating the case where the relay does not have the requested event,
// while preserving all other exports that other modules depend on.
vi.mock('@welshman/net', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
load: vi.fn().mockResolvedValue([]),
}
})
describe('notify_response_shape', () => {
let httpServer: Server
let baseUrl: string
let subId: string
beforeAll(async () => {
await db.migrate()
// Create and confirm a subscription we can use for the notify call
const pubkey = 'shape-test-pk-' + Date.now()
const email = 'shape-test-' + Date.now() + '@example.com'
const sub = await db.insertSubscription(pubkey, email, 'daily')
const confirmed = await db.confirmSubscription(sub.key)
subId = confirmed!.sub.id
// Start the express server on a random available port
await new Promise<void>((resolve) => {
httpServer = createServer(server)
httpServer.listen(0, () => {
const addr = httpServer.address()
if (addr && typeof addr === 'object') {
baseUrl = `http://localhost:${addr.port}`
}
resolve()
})
})
})
afterAll(async () => {
httpServer?.close()
})
it('returns stored=false instead of skipped=true when event not found', async () => {
const res = await fetch(`${baseUrl}/notify/${subId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'nonexistent-' + Date.now(),
relay: 'wss://relay.damus.io',
}),
})
const body = await res.json()
// The documented contract says: { ok: true, stored: boolean }
// The current buggy code returns: { ok: true, skipped: true }
expect(body).not.toHaveProperty('skipped')
expect(body).toHaveProperty('stored')
expect(body.stored).toBe(false)
expect(body.ok).toBe(true)
})
})

View file

@ -1,83 +0,0 @@
#!/usr/bin/env node
// FAILING test: frequency change does not reschedule the running cron job.
//
// The fix: actions.ts:registerSubscription calls worker.registerSubscription()
// after a DB update on an already-confirmed subscription, so addJob creates
// a new CronJob with the updated frequency on the fly.
//
// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity)
// Step 3: Register cron job with daily (simulating confirmSubscriptionAction)
// Step 4: Call registerSubscription with new frequency — the bug path
// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly
import * as db from '../dist/database.js'
import { registerSubscription } from '../dist/actions.js'
import { getJobCronSource, removeJob } from '../dist/worker/email.js'
import { registerSubscription as regSub } from '../dist/worker/index.js'
let passed = 0
let failed = 0
function assert(label, ok, detail) {
if (ok) {
console.log(` ? ${label}`)
passed++
} else {
console.log(` ? ${label} -- ${detail || ''}`)
failed++
}
}
async function main() {
await db.migrate()
const pubkey = 'freq-test-' + Date.now()
const email = 'freq-test-' + Date.now() + '@example.com'
// Step 1: Insert subscription directly (bypass mailer) and confirm
console.log('1. Create confirmed subscription with daily frequency')
const sub = await db.insertSubscription(pubkey, email, 'daily')
assert('subscription created', !!sub, 'insert returned null')
if (!sub) { process.exit(1) }
const confirmed = await db.confirmSubscription(sub.key)
assert('subscription confirmed', !!confirmed, 'confirm returned null')
if (!confirmed) { process.exit(1) }
// Step 2: Register cron job with daily (simulating confirmSubscriptionAction)
console.log('\n2. Register cron job with daily frequency')
regSub(confirmed)
const dailySource = getJobCronSource(confirmed.id)
assert(
'cron source is daily',
dailySource === '0 0 17 * * *',
`expected 0 0 17 * * *, got ${dailySource}`
)
// Step 3: Register subscription again with weekly — the bug path.
// BEFORE FIX: registerSubscription skips worker call because
// sub.confirmed_at is set → cron stays daily
// AFTER FIX: registerSubscription calls worker.registerSubscription
// → addJob reschedules → cron becomes weekly
console.log('\n3. Change frequency to weekly via registerSubscription')
await registerSubscription({ pubkey, email, frequency: 'weekly' })
const weeklySource = getJobCronSource(confirmed.id)
assert(
'cron source is weekly after frequency change',
weeklySource === '0 0 17 * * 1',
`expected 0 0 17 * * 1, got ${weeklySource}`
)
// Cleanup
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
console.log('')
console.log(`Results: ${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)
}
main().catch(err => {
console.error('Unhandled error in test:', err)
process.exit(1)
})

View file

@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import { getJobCronSource, removeJob } from '../src/worker/email.js'
import { registerSubscription as regSub } from '../src/worker/index.js'
const pubkey = 'freq-test-' + Date.now()
const email = 'freq-test-' + Date.now() + '@example.com'
let sub: any = null
describe('Frequency change reschedules cron job', () => {
beforeAll(async () => {
await db.migrate()
})
it('creates confirmed subscription with daily frequency', async () => {
const s = await db.insertSubscription(pubkey, email, 'daily')
expect(s).toBeTruthy()
sub = s
const confirmed = await db.confirmSubscription(sub.key)
expect(confirmed).toBeTruthy()
sub = confirmed!.sub
})
it('registers cron job with daily frequency', () => {
regSub(sub)
const dailySource = getJobCronSource(sub.id)
expect(dailySource).toBe('0 0 17 * * *')
})
it('changes frequency to weekly via registerSubscription', async () => {
await registerSubscription({ pubkey, email, frequency: 'weekly' })
const weeklySource = getJobCronSource(sub.id)
expect(weeklySource).toBe('0 0 17 * * 1')
})
})
afterAll(async () => {
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
})

View file

@ -0,0 +1,188 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
// Regression test for the "two rows created when turning email alerts back on" bug.
//
// Decoded production sequence (Sep 18 2026):
// 13:42:05 register → row 1 (unconfirmed), confirm email #1
// 13:44:35 DELETE → row 1 tombstoned (unsubscribed_at set)
// 13:44:36 register → OLD BUG: a brand-new row 2 was inserted, confirm email #2
// 14:02:32 confirm → row 2 finally confirmed
//
// Fix: when the same pubkey re-subscribes to the same email after being
// unsubscribed, reactivate the tombstoned row instead of inserting a new one,
// preserving the existing confirmed state and key.
const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
describe('Re-subscribe after DELETE reactivates the same subscription (off/on cycle)', () => {
const pubkey = unique('resub')
const email = `${unique('resub')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('creates an unconfirmed subscription', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
expect(sub).toBeTruthy()
expect(sub.confirmed_at).toBeFalsy()
expect(sub.unsubscribed_at).toBeFalsy()
})
it('confirms it (user clicks the confirm link)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
const result = await db.confirmSubscription(active!.key)
expect(result).toBeTruthy()
expect(result!.alreadyConfirmed).toBe(false)
expect(result!.sub.confirmed_at).toBeTruthy()
})
it('unsubscribes it (the flotilla DELETE path)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
const gone = await db.unsubscribeSubscription(active!.key)
expect(gone).toBeTruthy()
expect(gone!.unsubscribed_at).toBeTruthy()
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registers the SAME email — must reactivate row, NOT create a second row', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'daily')
// Restores the very same row
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active).toBeTruthy()
expect(active!.key).toBe(resigned.key)
expect(active!.unsubscribed_at).toBeFalsy()
// Confirmed state is preserved — no second confirmation email needed
expect(active!.confirmed_at).toBeTruthy()
// Exhaustive: there exists exactly one row total for this pubkey
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe(active!.id)
})
})
describe('Re-subscribe after DELETE before ever confirming', () => {
const pubkey = unique('resub-unconfirmed')
const email = `${unique('resub-unconfirmed')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers then unsubscribes without confirming', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.unsubscribeSubscription(sub.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registers the same email — reactivates same row, still unconfirmed', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'daily')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.key).toBe(resigned.key)
expect(active!.id).toBe(resigned.id)
expect(active!.unsubscribed_at).toBeFalsy()
// Was never confirmed, and re-subscribing does not skip confirmation
expect(active!.confirmed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
})
})
describe('Re-subscribe with a DIFFERENT email after DELETE', () => {
const pubkey = unique('resub-2')
const email = `${unique('resub-2')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers, confirms, and unsubscribes', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.confirmSubscription(sub.key)
await db.unsubscribeSubscription(sub.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('a new email creates a new row, leaving the old one tombstoned', async () => {
const newEmail = `${unique('resub-2-new')}@example.com`
const resigned = await db.insertSubscription(pubkey, newEmail, 'daily')
expect(resigned.email).toBe(newEmail)
expect(resigned.confirmed_at).toBeFalsy() // new address must re-confirm
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(2)
expect(rows[0].email).toBe(email) // old, tombstoned
expect(rows[0].unsubscribed_at).toBeTruthy()
expect(rows[1].email).toBe(newEmail) // new, active
expect(rows[1].unsubscribed_at).toBeFalsy()
})
})
describe('Re-subscribe with a changed frequency reactivates and updates cadence', () => {
const pubkey = unique('resub-freq')
const email = `${unique('resub-freq')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers confirm-free, unsubscribes', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.unsubscribeSubscription(sub.key)
})
it('re-registers with weekly — reactivates the same row at the new cadence', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'weekly')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.key).toBe(resigned.key)
expect(active!.frequency).toBe('weekly')
expect(active!.unsubscribed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
})
})
describe('Re-subscribe with mixed emails for one pubkey picks the right row', () => {
const pubkey = unique('resub-mixed')
const emailA = `${unique('resub-mixed-a')}@example.com`
const emailB = `${unique('resub-mixed-b')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('creates and tombstones two different emails, then re-subscribes email B', async () => {
// Email A cycle
const subA = await db.insertSubscription(pubkey, emailA, 'daily')
await db.unsubscribeSubscription(subA.key)
// Email B cycle
const subB = await db.insertSubscription(pubkey, emailB, 'daily')
const bId = subB!.id
await db.unsubscribeSubscription(subB.key)
// Re-subscribe with email B — must reactivate B's own row, not A's,
// and must not resurrect the wrong email.
const resigned = await db.insertSubscription(pubkey, emailB, 'daily')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.id).toBe(bId)
expect(active!.id).not.toBe(subA!.id)
expect(active!.email).toBe(emailB)
expect(active!.unsubscribed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(2)
expect(rows.filter(r => r.email === emailA)).toHaveLength(1)
expect(rows.filter(r => r.email === emailB)).toHaveLength(1)
})
})

View file

@ -0,0 +1,97 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import { getCronExpression } from '../src/alert.js'
import { getJobCronSource, removeJob } from '../src/worker/email.js'
import { registerSubscription as regSub } from '../src/worker/index.js'
const pubkey = 'schedule-test-' + Date.now()
const email = 'schedule-test-' + Date.now() + '@example.com'
let sub: any = null
describe('Schedule fields', () => {
beforeAll(async () => {
await db.migrate()
})
it('inserts subscription with custom hour/minute/timezone', async () => {
const s = await db.insertSubscription(pubkey, email, 'daily', 7, 30, undefined, 'America/New_York')
expect(s).toBeTruthy()
expect(s!.hour).toBe(7)
expect(s!.minute).toBe(30)
expect(s!.timezone).toBe('America/New_York')
expect(s!.day_of_week).toBeNull()
sub = s
})
it('inserts subscription with custom weekly dayOfWeek', async () => {
const pk2 = 'schedule-test-weekly-' + Date.now()
const em2 = pk2 + '@example.com'
const s = await db.insertSubscription(pk2, em2, 'weekly', 9, 0, 6, 'UTC')
expect(s).toBeTruthy()
expect(s!.hour).toBe(9)
expect(s!.minute).toBe(0)
expect(s!.day_of_week).toBe(6)
expect(s!.timezone).toBe('UTC')
})
it('inserts subscription with defaults when schedule omitted', async () => {
const pk3 = 'schedule-test-defaults-' + Date.now()
const em3 = pk3 + '@example.com'
const s = await db.insertSubscription(pk3, em3, 'daily')
expect(s).toBeTruthy()
expect(s!.hour).toBe(17)
expect(s!.minute).toBe(0)
expect(s!.timezone).toBe('UTC')
expect(s!.day_of_week).toBeNull()
})
it('getCronExpression returns daily with custom hour/minute', () => {
expect(getCronExpression('daily', 7, 30)).toBe('0 30 7 * * *')
})
it('getCronExpression returns weekly with custom dayOfWeek', () => {
expect(getCronExpression('weekly', 9, 0, 6)).toBe('0 0 9 * * 6')
})
it('getCronExpression defaults to Monday for weekly', () => {
expect(getCronExpression('weekly', 17, 0)).toBe('0 0 17 * * 1')
})
it('confirms and registers job with custom schedule', async () => {
// Confirm the daily subscription created above
const confirmed = await db.confirmSubscription(sub.key)
expect(confirmed).toBeTruthy()
sub = confirmed!.sub
regSub(sub)
const dailySource = getJobCronSource(sub.id)
// Expect 0 30 7 * * * (from custom hour=7, minute=30)
expect(dailySource).toBe('0 30 7 * * *')
})
it('updateSubscription preserves schedule fields through frequency change', async () => {
const updated = await db.updateSubscription(sub, email, 'weekly', undefined, undefined, 2, undefined)
expect(updated).toBeTruthy()
expect(updated!.frequency).toBe('weekly')
// hour/minute should keep the previously set values (7/30) since we passed undefined
expect(updated!.hour).toBe(7)
expect(updated!.minute).toBe(30)
expect(updated!.day_of_week).toBe(2)
expect(updated!.timezone).toBe('America/New_York')
})
it('registerSubscription preserves schedule fields', async () => {
await registerSubscription({ pubkey, email, frequency: 'daily', hour: 10, minute: 15, timezone: 'Europe/London' })
const reloaded = await db.getSubscriptionByPubkey(pubkey)
expect(reloaded).toBeTruthy()
expect(reloaded!.hour).toBe(10)
expect(reloaded!.minute).toBe(15)
expect(reloaded!.timezone).toBe('Europe/London')
})
})
afterAll(async () => {
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
})

23
test/setup.ts Normal file
View file

@ -0,0 +1,23 @@
// Vitest setup: set required env vars before test modules are loaded.
// env.ts checks these at module load time; they must be present when
// actions.ts / mailer.ts / etc. are imported.
import { mkdirSync } from 'fs'
const dataDir = 'test-data-unit'
mkdirSync(dataDir, { recursive: true })
process.env.MAILSHIP_URL = 'http://localhost:3000'
process.env.MAILSHIP_NAME = 'Test Mailship'
process.env.MAILSHIP_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
process.env.SMTP_HOST = 'localhost'
process.env.SMTP_PORT = '587'
process.env.SMTP_USER = 'test@test.com'
process.env.SMTP_PASSWORD = 'test'
process.env.SMTP_FROM = 'test@test.com'
process.env.DEFAULT_RELAYS = 'wss://relay.damus.io'
process.env.INDEXER_RELAYS = 'wss://purplepag.es'
process.env.SEARCH_RELAYS = 'wss://relay.nostr.band'
process.env.PORT = '3000'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.BASE_URL = 'http://localhost:3000'
process.env.DATA_DIR = dataDir

View file

@ -1,61 +0,0 @@
#!/usr/bin/env node
// Failing test: web UI crashes on load when VITE_NOTIFIER_RELAY is not set.
//
// The bug: `normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)` throws
// TypeError: Cannot read properties of undefined (reading 'match')
// when the env var is not set.
//
// After the fix, `normalizeRelayUrl` is only called when the env var is
// truthy, so the crash no longer occurs. This test verifies the guarded
// call pattern matches the one in main.ts.
import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js';
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Test 1: Guarded normalizeRelayUrl — the pattern used in main.ts
console.log('1. Guarded normalizeRelayUrl (main.ts pattern)');
const undefinedInput = undefined; // simulates unset VITE_NOTIFIER_RELAY
const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined;
assert(
'guarded normalizeRelayUrl with undefined should not crash, result is undefined',
guarded1 === undefined,
`got ${guarded1}`
);
// Test 2: Guard with empty string
console.log('');
console.log('2. Guarded normalizeRelayUrl with empty string');
const emptyInput = '';
const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined;
assert(
'guarded normalizeRelayUrl with "" should not crash, result is undefined',
guarded2 === undefined,
`got ${guarded2}`
);
// Test 3: Guard with a valid relay still works
console.log('');
console.log('3. Guarded normalizeRelayUrl with valid relay');
const validInput = 'wss://relay.damus.io';
const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined;
assert(
'guarded normalizeRelayUrl with valid input still normalizes correctly',
guarded3 === 'wss://relay.damus.io/',
`got ${guarded3}`
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
})