Compare commits

...

76 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
Agent
e83cd1f7d2 Rename Popular/HasPopular → Latest/HasLatest in digest ts/mjml/render-preview
The buildParameters function returns events sorted newest-first
(sortBy created_at, slice(0,100)), and the mjml section is already
labeled 'Latest Activity'. The object keys Popular/HasPopular no
longer reflect the semantics, so rename them to Latest/HasLatest.
2026-09-14 13:00:07 -04:00
7cdb84a0a1 Merge pull request 'Align README, docker-compose, .env.template and stale comment with SMTP mailer' (#6) from mailship-0da-readme-config-architecture-and-docker-co-6e1 into main
Reviewed-on: #6
Reviewed-by: matt <matt@lorentz.is>
2026-09-14 16:57:09 +00:00
mplorentz
c72b86e4ae Merge remote-tracking branch 'origin/main' into mailship-0da-readme-config-architecture-and-docker-co-6e1 2026-09-14 12:55:35 -04:00
Agent
2e26760126 fix: remove unused imports and variables flagged by eslint
Resolve 21 pre-existing @typescript-eslint/no-unused-vars errors across
5 files (actions.ts, alert.ts, digest.ts, env.ts, worker/email.ts) that
were blocking the script/checks gate. All removals are unused imports
and unused variable assignments with no runtime impact.
2026-09-10 17:03:35 -04:00
Agent
0eca031890 fix: update stale 'Postmark' comment to 'SMTP' in server.ts
The subscription error fallback comment still referenced Postmark
after the mailer migration. Update it to reflect the SMTP mailer.
2026-09-10 16:59:15 -04:00
694791bfdd Merge pull request 'Reschedule cron job when confirmed subscriber changes frequency' (#2) from mailship-e04-frequency-change-updates-db-but-never-re-63d into main
Reviewed-on: #2
Reviewed-by: matt <matt@lorentz.is>
2026-09-10 19:55:42 +00:00
8837d3fadd Merge pull request 'Scope CORS to browser routes only, fail closed on CORS_ORIGIN' (#4) from mailship-32x-cors-is-not-production-safe-wildcard-ori-52b into main
Reviewed-on: #4
Reviewed-by: matt <matt@lorentz.is>
2026-09-10 19:46:02 +00:00
93ffdf3531 Merge pull request 'fix: replace hardcoded #7161FF with {{brandAccent}} in digest email template' (#5) from mailship-hu5-stale-hardcoded-accent-7161ff-in-digest--a31 into main
Reviewed-on: #5
Reviewed-by: matt <matt@lorentz.is>
2026-09-10 19:35:04 +00:00
Agent
8866ef3256 merge: main into branch to pick up lint fixes 2026-09-10 12:27:45 -04:00
Agent
a4463fdab4 Merge remote-tracking branch 'origin/main' into mailship-32x-cors-is-not-production-safe-wildcard-ori-52b 2026-09-10 12:25:26 -04:00
f04e378ea1 Merge pull request 'Strip out the web UI (login + subscription filter management)' (#1) from mailship-d5i-strip-out-the-web-ui-login-subscription--1d8 into main
Reviewed-on: #1
2026-09-10 16:24:30 +00:00
Agent
def67c4b6c merge origin/main: resolve digest.ts conflict (identical eslint cleanup) 2026-09-10 12:23:37 -04:00
Agent
57add6dcbd Merge remote-tracking branch 'origin/main' into mailship-32x-cors-is-not-production-safe-wildcard-ori-52b 2026-09-10 12:02:17 -04:00
a6fd29cd7d Merge pull request 'fix: resolve eslint unused-variable errors, add branding, idempotent subscriptions, and event verification' (#3) from mailship-66x-fix-pre-existing-eslint-unused-variable--93f into main
Reviewed-on: #3
Reviewed-by: matt <matt@lorentz.is>
2026-09-10 16:01:04 +00:00
Agent
97e92232c7 chore: remove unused import of netContext from env.ts
eslint flagged netContext as imported but never used (pre-existing).
Removing the unused import so the repo's eslint gate passes on the
modified area.
2026-09-10 11:31:44 -04:00
Agent
3e667fe3c6 fix: reschedule cron job when confirmed subscriber changes frequency
When a confirmed subscriber calls PUT /subscription/email with a changed
frequency, db.updateSubscription updates the row but the running CronJob
captured the original frequency in createJob and was never rescheduled.
A subscriber switching daily→weekly kept the daily cadence until restart.

Fix: in actions.ts:registerSubscription, call worker.registerSubscription(sub)
when the subscription is already confirmed, so addJob stops the old job and
creates a new one with the updated frequency.

Changes:
- src/actions.ts: add else branch calling worker.registerSubscription when
  sub.confirmed_at is set
- test/reschedule-on-frequency-change.test.js: new failing-before/passing-after
  test verifying the cron expression is updated after frequency change

Closes mailship-e04
2026-09-10 11:30:56 -04:00
Agent
49b7b0d83b Align README, docker-compose, .env.template with SMTP mailer
Replace all Postmark references (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS)
with SMTP configuration (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
SMTP_FROM) across documentation, deployment config, template, and test.

- README.md: architecture diagram (Postmark → SMTP) and configuration table
- docker-compose.yml: POSTMARK_* env vars replaced with SMTP_* required vars
- .env.template: POSTMARK_* entries replaced with SMTP_* entries
- test/integration.sh: POSTMARK_* test exports replaced with SMTP_* test values
2026-09-10 11:30:50 -04:00
Agent
fdc4579aa7 fix: scope CORS to browser routes only, require CORS_ORIGIN (fail closed)
The server was setting Access-Control-Allow-Origin: * on every route,
including the unauthenticated GET /subscription/email which returns the
subscriber's email address. Any website could query known pubkeys and
harvest emails.

Changes:
- src/env.ts: require CORS_ORIGIN env var (fail closed, no wildcard)
- src/server.ts: scope CORS middleware to browser-facing routes only,
  skip /notify (server-to-server), add Vary: Origin header, import
  CORS_ORIGIN from env instead of defaulting to '*'
- .env.template: document new CORS_ORIGIN variable
- test/cors.test.sh: verify CORS on browser routes, no CORS on
  server-to-server routes, Vary: Origin presence
2026-09-10 11:29:18 -04:00
Agent
97eaf8ab36 fix(digest): replace hardcoded #7161FF with {{brandAccent}}
Lines 8 and 22 of digest.mjml hardcoded #7161FF for the event-item
border-left and footer link color, while mailer.ts already passes
brandAccent into the template (used at lines 15 and 34). When
BRAND_ACCENT is customized, the border and footer links stayed the
default purple.

Drive both from {{brandAccent}} so they respect the customization.

Fixes bead mailship-hu5
2026-09-10 11:23:31 -04:00
Agent
2fb738981e Fix pre-existing eslint unused-import errors to pass script/checks gate
Remove 20 no-unused-vars violations across 5 files:
- src/actions.ts: unused Subscription type import, getCronExpression
- src/alert.ts: unused cron-parser and @welshman/lib imports
- src/digest.ts: unused now, nth, nthEq, dateToSeconds, getIdFilters,
  getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter,
  call, loadRelaySelections
- src/env.ts: unused netContext import
- src/worker/email.ts: assigned-but-unused purgeJob variable

These were pre-existing errors unrelated to the web UI removal.
2026-09-10 10:36:54 -04:00
mplorentz
44dcc22a04 Minor readme edits 2026-09-10 10:23:40 -04:00
Agent
abc993e595 Remove orphaned web/dist/ reference from .dockerignore 2026-09-10 10:09:13 -04:00
Agent
75f5908039 Strip out the web UI (login + subscription filter management)
Remove the browser admin SPA under web/ that let users log in via
a Nostr signer and manage subscription filters. The server is now
a headless API only.

Changes:
- Delete web/ directory entirely (SPA source, config, deps)
- Remove express.static('web/dist') serving from server.ts
- Simplify GET / handler to always return JSON (no fallback)
- Remove build:web from package.json build pipeline
- Remove web build steps from Dockerfile
- Remove web references from build-in-production.sh

Kept: core subscription/unsubscribe/confirm/notify backend and
transactional src/pages/*.html (part of email flow, not the UI).
2026-09-10 10:08:14 -04:00
57 changed files with 2125 additions and 1056 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

View file

@ -1,6 +1,5 @@
node_modules/
dist/
web/dist/
.git/
.gitattributes
.gitignore

View file

@ -12,7 +12,14 @@ BRAND_LOGO=
INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
DEFAULT_RELAYS=relay.damus.io,nos.lol
SEARCH_RELAYS=relay.nostr.band
POSTMARK_API_KEY=
POSTMARK_SENDER_ADDRESS=
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# CORS_ORIGIN must be set to the exact origin your browser client runs on
# (e.g. https://app.example.com). There is no wildcard fallback — the server
# will refuse to start without it.
CORS_ORIGIN=
PORT=4738
DATA_DIR=./data

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

@ -25,12 +25,7 @@ COPY src/ ./src/
COPY src/pages/ ./src/pages/
COPY src/emails/ ./src/emails/
# Build web UI
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/
COPY web/src/ ./web/src/
RUN cd web && pnpm install --frozen-lockfile && pnpm run build
# Build TypeScript (runs tsc && build:html && build:web)
# Build TypeScript (runs tsc && build:html)
RUN pnpm run build
# Production image
@ -51,13 +46,18 @@ RUN pnpm install --frozen-lockfile --prod
# Copy build artifacts
COPY --from=build /app/dist/ ./dist/
COPY --from=build /app/web/dist/ ./web/dist/
COPY --from=build /app/src/pages/ ./dist/pages/
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
@ -19,7 +21,7 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
UUID in path ├── fetch event from relay
is the auth ├── store in SQLite (dedup)
│
cron fires ──▶ render digest ──▶ Postmark ──▶ email
cron fires ──▶ render digest ──▶ SMTP ──▶ email
```
## Configuration
@ -30,55 +32,93 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
| `MAILSHIP_NAME` | ✓ | Name of this Mailship instance |
| `MAILSHIP_URL` | ✓ | Public URL of this instance |
| `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) |
| `SMTP_HOST` | ✓ | SMTP server hostname |
| `SMTP_PORT` | ✓ | SMTP server port |
| `SMTP_USER` | ✓ | SMTP username |
| `SMTP_PASSWORD` | ✓ | SMTP password |
| `SMTP_FROM` | ✓ | From email address for outgoing mail |
| `EVENT_VIEWER_URL` | | Base URL of the app event links open in (defaults to Flotilla at `https://app.flotilla.social`, or anything handling the same `/spaces/<relay>/<hash>` and `/<nevent>` URL shapes) |
| `BRAND_NAME` | | Name used in email branding (default: `Flotilla`) |
| `BRAND_ACCENT` | | Accent color string used in email branding (default: `#7161FF`) |
| `BRAND_LOGO` | | URL of the logo image shown in the email header. Defaults to `<EVENT_VIEWER_URL>/logo.png`; if empty/unset, a colored brand name is shown instead |
| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails |
| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark |
| `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.
@ -87,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
@ -127,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,12 +0,0 @@
#!/usr/bin/env bash
# Remove link overrides
node remove-pnpm-overrides.js package.json
node remove-pnpm-overrides.js web/package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
(cd web && pnpm i --no-frozen-lockfile)
# Build everything
pnpm run build

View file

@ -10,8 +10,11 @@ services:
- MAILSHIP_NAME=${MAILSHIP_NAME:-Mailship}
- MAILSHIP_URL=${MAILSHIP_URL:?required}
- BASE_URL=${BASE_URL:?required}
- POSTMARK_API_KEY=${POSTMARK_API_KEY:?required}
- POSTMARK_SENDER_ADDRESS=${POSTMARK_SENDER_ADDRESS:?required}
- SMTP_HOST=${SMTP_HOST:?required}
- SMTP_PORT=${SMTP_PORT:?required}
- SMTP_USER=${SMTP_USER:?required}
- SMTP_PASSWORD=${SMTP_PASSWORD:?required}
- SMTP_FROM=${SMTP_FROM:?required}
- DEFAULT_RELAYS=${DEFAULT_RELAYS:-wss://relay.damus.io,wss://relay.primal.net}
- INDEXER_RELAYS=${INDEXER_RELAYS:-wss://purplepag.es,wss://relay.damus.io}
- SEARCH_RELAYS=${SEARCH_RELAYS:-wss://relay.nostr.band}

View file

@ -3,8 +3,7 @@
"type": "module",
"version": "1.0.0",
"scripts": {
"build": "tsc && pnpm run build:html && pnpm run build:web",
"build:web": "cd web && pnpm run build",
"build": "tsc && pnpm run build:html",
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
"check": "tsc --noEmit && eslint src",
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
@ -12,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",
@ -30,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",
@ -42,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",
@ -57,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

@ -16,16 +16,15 @@ const sample = {
Duration: '24 hours',
Total: 12,
TopProfiles: 'bob, carol',
HasPopular: true,
Popular: [
HasLatest: true,
Latest: [
{
Link: 'https://app.flotilla.social/nevent1qqs...',
Timestamp: 'Aug 25, 2026 at 9:00 AM',
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,19 +13,40 @@ 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}`
// Only send a confirmation when the subscription is new, unconfirmed, or
// its email address changed. An already-confirmed, unchanged subscription
// (or one where only the frequency changed) skips it.
if (!sub.confirmed_at) {
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.
worker.registerSubscription(sub)
}
return { key: sub.key, callback }
@ -39,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

@ -1,15 +1,18 @@
export type Subscription = {
id: string
key: string
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) => {
@ -21,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,
@ -18,7 +16,6 @@ import {
displayPubkey,
getTagValue,
} from '@welshman/util'
import { displayDuration, createElement } from './util.js'
import type { Subscription } from './alert.js'
import { sendDigest } from './mailer.js'
@ -26,6 +23,7 @@ import { EVENT_VIEWER_URL } from './env.js'
import {
profilesByPubkey,
loadProfile,
repository,
} from './repository.js'
type DigestData = {
@ -50,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)
@ -78,8 +76,8 @@ export class Digest {
return {
Total: events.length,
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
Popular: sorted.map((e) => getEventVariables(e)),
HasPopular: sorted.length > 0,
Latest: sorted.map((e) => getEventVariables(e)),
HasLatest: sorted.length > 0,
UserName: displayProfile(userProfile, this.sub.email.split('@')[0]),
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
}
@ -87,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
@ -102,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

@ -5,7 +5,7 @@
<mj-style inline="inline">
.header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; }
.subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; }
.event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; }
.event-item { margin-bottom: 20px; border-left: 3px solid {{brandAccent}}; padding-left: 12px; }
.event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.event-meta-left { display: flex; align-items: center; }
.event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; }
@ -19,7 +19,7 @@
.event-stats { margin-top: 8px; color: #64748b; font-size: 13px; }
.stat-item { display: inline-flex; align-items: center; margin-right: 12px; }
.footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; }
.footer a { color: #7161FF; text-decoration: underline; }
.footer a { color: {{brandAccent}}; text-decoration: underline; }
.logo { max-width: 48px; max-height: 48px; }
a { text-decoration: none; }
</mj-style>
@ -44,11 +44,11 @@
</mj-column>
</mj-section>
{{#HasPopular}}
{{#HasLatest}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>
<mj-text css-class="section-header">Latest Activity</mj-text>
{{#Popular}}
{{#Latest}}
<mj-text>
<div class="event-item">
<div class="event-meta">
@ -64,18 +64,15 @@
<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>
</mj-text>
{{/Popular}}
{{/Latest}}
</mj-column>
</mj-section>
{{/HasPopular}}
{{/HasLatest}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>

View file

@ -15,22 +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 PORT = process.env.PORT
export const CORS_ORIGIN = process.env.CORS_ORIGIN
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,40 +1,123 @@
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 } 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
// configured origin so the client can register.
const corsOrigin = process.env.CORS_ORIGIN ?? '*'
// Server-to-server routes (/notify) intentionally do NOT get CORS headers.
const corsMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Skip server-to-server routes
if (req.path.startsWith('/notify')) {
return next()
}
server.use((req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', corsOrigin)
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN)
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
res.setHeader('Vary', 'Origin')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
})
}
server.use('/', corsMiddleware)
server.use(express.json())
server.use(express.static('web/dist'))
// Rate limit for registration endpoints
server.use(
'/subscription',
@ -71,16 +154,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl
}
addRoute('get', '/', async (req: Request, res: Response) => {
try {
const {existsSync} = await import('fs')
const webIndex = new URL('../web/dist/index.html', import.meta.url)
if (existsSync(webIndex)) {
return res.send(await render('../web/dist/index.html'))
}
} catch {
// Fall through to JSON
}
res.json({
name: 'Mailship',
description: 'Email notification server for Nostr',
@ -89,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)
@ -104,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' })
@ -127,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
@ -147,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)
@ -156,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 })
@ -180,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) {
@ -209,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 })
}
}
@ -223,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
@ -252,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

@ -6,6 +6,12 @@ import * as db from '../database.js'
const jobsById = new Map<string, CronJob>()
// Test-only accessor to inspect stored jobs
export const getJobCronSource = (id: string): string | undefined => {
const source = jobsById.get(id)?.cronTime.source
return typeof source === 'string' ? source : undefined
}
export const runJob = async (sub: Subscription) => {
try {
if (!sub.confirmed_at || sub.unsubscribed_at) {
@ -27,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')
@ -40,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)
})
})

156
test/cors.test.sh Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env bash
# Passing test: CORS is scoped to browser routes only, no wildcard default.
#
# The fix: src/env.ts now requires CORS_ORIGIN (fail closed, no wildcard).
# src/server.ts applies CORS middleware only to browser-facing routes
# (/, /subscription/*, /confirm, /unsubscribe) and adds Vary: Origin.
# Server-to-server routes (/notify) get no CORS headers.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PORT="${PORT:-4742}"
BASE_URL="http://localhost:$PORT"
PASS=0
FAIL=0
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
cleanup() {
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
rm -rf "$PROJECT_DIR/test-data-cors"
}
trap cleanup EXIT
# Build if needed
cd "$PROJECT_DIR"
if [ ! -d "dist" ]; then
pnpm exec tsc
fi
SECRET="$(openssl rand -hex 32)"
# Set CORS_ORIGIN to a specific allowed origin (fail closed — must be set)
export CORS_ORIGIN="https://app.example.com"
export MAILSHIP_SECRET="$SECRET"
export MAILSHIP_NAME="Mailship Test"
export MAILSHIP_URL="$BASE_URL"
export BASE_URL="$BASE_URL"
export POSTMARK_API_KEY="test"
export POSTMARK_SENDER_ADDRESS="test@test.com"
export DEFAULT_RELAYS="wss://relay.damus.io"
export INDEXER_RELAYS="wss://purplepag.es"
export SEARCH_RELAYS="wss://relay.nostr.band"
export PORT="$PORT"
export DATA_DIR="$PROJECT_DIR/test-data-cors"
# SMTP env vars (required by src/env.ts)
export SMTP_HOST="localhost"
export SMTP_PORT="1025"
export SMTP_USER="test"
export SMTP_PASSWORD="test"
export SMTP_FROM="test@test.com"
mkdir -p "$DATA_DIR"
echo "=== Starting server on port $PORT (CORS_ORIGIN=$CORS_ORIGIN) ==="
node dist/index.js &
SERVER_PID=$!
# Poll until server responds
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf "http://localhost:$PORT/" > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
sleep 1
done
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo -e "${RED}Server failed to start${NC}"
exit 1
fi
echo ""
echo "========================================="
echo " CORS TESTS"
echo "========================================="
echo ""
pass() {
PASS=$((PASS + 1))
echo -e " ${GREEN}✓${NC} $1"
}
fail() {
FAIL=$((FAIL + 1))
echo -e " ${RED}✗${NC} $1"
}
# Test 1: Browser-facing GET /subscription/email returns the configured origin
echo "1. CORS on GET /subscription/email"
CORS_HEADER=$(curl -s -o /dev/null -D - \
"http://localhost:$PORT/subscription/email?pubkey=test_pubkey_123" \
-H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r')
if echo "$CORS_HEADER" | grep -q 'https://app.example.com'; then
pass "subscription/email returns configured origin (not wildcard)"
elif echo "$CORS_HEADER" | grep -q '\*'; then
fail "BUG: subscription/email still returns wildcard '${CORS_HEADER}'"
else
fail "subscription/email missing Access-Control-Allow-Origin (got: ${CORS_HEADER:-<none>})"
fi
# Test 2: Vary: Origin is present on browser routes
echo ""
echo "2. Vary: Origin header on browser route"
VARY=$(curl -s -o /dev/null -D - \
"http://localhost:$PORT/" \
-H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'vary' || true | head -1 | tr -d '\r')
if echo "$VARY" | grep -qi 'origin'; then
pass "Vary: Origin is present on GET /"
else
fail "Missing Vary: Origin on GET / (got: ${VARY:-<none>})"
fi
# Test 3: Server-to-server /notify has NO CORS headers (relay callback)
echo ""
echo "3. No CORS on server-to-server POST /notify/:id"
CORS_NOTIFY=$(curl -s -o /dev/null -D - \
"http://localhost:$PORT/notify/test-id" \
-X POST -H "Content-Type: application/json" \
-H "Origin: https://evil.com" \
-d '{"id":"abc","relay":"wss://relay.primal.net"}' 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r')
if [ -z "$CORS_NOTIFY" ]; then
pass "/notify has no Access-Control-Allow-Origin (server-to-server route)"
else
fail "BUG: /notify returns '${CORS_NOTIFY}' — server-to-server route should have no CORS"
fi
# Test 4: CORS methods on preflight for subscription route
echo ""
echo "4. CORS preflight on PUT /subscription/email"
METHODS=$(curl -s -o /dev/null -D - \
"http://localhost:$PORT/subscription/email" \
-X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" 2>/dev/null | grep -ia 'access-control-allow-methods' || true | head -1 | tr -d '\r')
if echo "$METHODS" | grep -qi 'PUT'; then
pass "OPTIONS preflight returns allowed methods"
else
fail "Preflight missing allowed methods (got: ${METHODS:-<none>})"
fi
echo ""
echo "========================================="
echo " RESULTS: $PASS passed, $FAIL failed"
echo "========================================="
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0

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

@ -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)
})
})

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

@ -38,20 +38,44 @@ 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"
export POSTMARK_API_KEY="test"
export POSTMARK_SENDER_ADDRESS="test@test.com"
export SMTP_HOST="localhost"
export SMTP_PORT="587"
export SMTP_USER="test@test.com"
export SMTP_PASSWORD="test"
export SMTP_FROM="test@test.com"
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"
@ -114,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)
@ -129,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"
@ -139,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)
@ -170,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"
@ -196,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

@ -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'],
},
})

View file

@ -1,3 +0,0 @@
VITE_NOTIFIER_PUBKEY=
VITE_NOTIFIER_RELAY=
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band

24
web/.gitignore vendored
View file

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,21 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] },
{ files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
{
files: ["src/**/*.{js,mjs,cjs,ts}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"},
],
},
},
]);

View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anchor Alerts</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div id="app" class="max-w-2xl mx-auto"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,34 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"check": "tsc --noEmit && eslint src",
"format": "eslint src --fix"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/mithril": "^2.2.7",
"eslint": "^9.25.1",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.31.1",
"vite": "^6.3.1"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.4",
"@welshman/feeds": "^0.6.3",
"@welshman/lib": "^0.6.3",
"@welshman/net": "^0.6.3",
"@welshman/signer": "^0.6.3",
"@welshman/store": "^0.6.3",
"@welshman/util": "^0.6.3",
"events": "^3.3.0",
"mithril": "^2.2.15",
"svelte": "^5.27.2",
"tailwindcss": "^4.1.4"
}
}

Binary file not shown.

View file

@ -1,2 +0,0 @@
allowBuilds:
esbuild: true

View file

@ -1,544 +0,0 @@
import './style.css'
import m from "mithril"
import {writable} from 'svelte/store'
import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib'
import {withGetter} from '@welshman/store'
import {Router} from '@welshman/router'
import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds'
import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util'
import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {ISigner} from '@welshman/signer'
import {Nip07Signer, decrypt} from '@welshman/signer'
// Constants
const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined
const INDEXER_RELAYS = (import.meta.env.VITE_INDEXER_RELAYS || 'purplepag.es,relay.damus.io,relay.nostr.band').split(',').map(normalizeRelayUrl)
const ALERT = 32830
const ALERT_STATUS = 32831
const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!)
const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/
const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/
const PLUS_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
const TRASH_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
</svg>`
const ARROW_LEFT_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
// Types and state
type Alert = {
event: TrustedEvent
tags: string[][]
}
type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
type AlertValues = {
feedAddress: string
freq: string
time: string,
email: string
secret: string
}
type State = {
failedToLogin: boolean
signer: ISigner
pubkey: string | undefined
alerts: Alert[]
alertDraft?: AlertValues,
alertStatuses: AlertStatus[]
alertsLoading: boolean
}
const state = withGetter(
writable({
failedToLogin: false,
signer: new Nip07Signer(),
pubkey: getJson('pubkey'),
alerts: [],
alertStatuses: [],
alertsLoading: false,
} as State)
)
// Actions
const login = async () => {
const {signer} = state.get()
try {
const pubkey = await signer.getPubkey()
state.update(assoc('pubkey', pubkey))
setJson('pubkey', pubkey)
} catch (e) {
state.update(assoc('failedToLogin', true))
}
}
const loadAlerts = async () => {
const {signer, pubkey} = state.get()
if (!NOTIFIER_RELAY) {
state.update(assoc('alertsLoading', false))
return
}
state.update(assoc('alertsLoading', true))
const events = await load({
relays: [NOTIFIER_RELAY],
filters: [
{kinds: [ALERT], authors: [pubkey!]},
{kinds: [ALERT_STATUS], "#p": [pubkey!]},
],
})
const alerts = await Promise.all(
events
.filter(spec({kind: ALERT}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
const alertStatuses = await Promise.all(
events
.filter(spec({kind: ALERT_STATUS}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses}))
}
const deleteAlert = async (alert: Alert) => {
if (!NOTIFIER_RELAY) return
if (confirm("Are you sure you want to delete this alert?")) {
state.update(assoc('alertsLoading', true))
await publish({
relays: [NOTIFIER_RELAY],
event: await state.get().signer!.sign(
makeEvent(DELETE, {
tags: [
["k", String(alert.event.kind)],
["a", getAddress(alert.event)]
],
})
),
})
await loadAlerts()
}
}
export type AlertParams = {
feeds: Feed[]
freq: string
time: string
email: string
secret: string
}
export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => {
const {signer} = state.get()
const [hour, minute] = time.split(':')
const utcHour = (parseInt(hour) - TZ_OFFSET) % 24
const dow = freq === 'daily' ? '*' : freq
const cron = `0 ${minute} ${utcHour} * * ${dow}`
const tags = [
["cron", cron],
["email", email],
["channel", "email"],
["locale", LOCALE],
["timezone", TIMEZONE],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690",
"wss://relay.nostr.band/",
"web",
],
]
for (const feed of feeds) {
tags.push(["feed", JSON.stringify(feed)])
}
return signer.sign(
makeEvent(ALERT, {
content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
)
}
export const publishAlert = async (params: AlertParams) => {
if (!NOTIFIER_RELAY) return
await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
}
// Components
const Loader = {
view: () => m("div", { class: "flex justify-center py-4" }, [
m("div", {
class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600"
})
])
}
const Login = {
view: () =>
m("button", {
onclick: login,
class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Connect with Nostr"),
}
const AlertStatus: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const {alertStatuses} = state.get()
const address = getAddress(alert.event)
const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address)
const status = getTagValue('status', alertStatus?.tags || [])
const message = getTagValue('message', alertStatus?.tags || [])
const getStatusClasses = () => {
const baseClasses = "rounded-full px-3 py-1 text-sm border"
if (status === 'ok') return `${baseClasses} border-green-500 text-green-500`
if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500`
return `${baseClasses} border-red-500 text-red-500`
}
const getStatusDisplay = () => {
if (!status) return 'Inactive'
if (status === 'ok') return 'Active'
if (status === 'pending') return 'Pending'
return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase())
}
return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay())
},
}
const AlertListItem: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const cron = getTagValue('cron', alert.tags)
const feeds = getTagValues('feed', alert.tags)
const channel = getTagValue('channel', alert.tags)
const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]"
let frequency = cron || "Unknown"
if (cron) {
if (CRON_DAILY_PATTERN.test(cron)) {
frequency = 'Daily'
} else if (CRON_WEEKLY_PATTERN.test(cron)) {
frequency = 'Weekly'
}
}
return m("div", { class: "flex items-start justify-between p-4" }, [
m("button", {
onclick: () => deleteAlert(alert),
class: "mr-4 mt-1",
tooltip: "Delete alert"
}, [m.trust(TRASH_ICON)]),
m("div", { class: "space-y-2 flex-grow" }, [
m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`),
m("div", { class: "text-sm text-gray-500" }, `Events ${description}`)
]),
m(AlertStatus, {alert}),
])
}
}
const AlertList = {
oninit: loadAlerts,
view: () => {
const {alerts, alertsLoading} = state.get()
const content = alertsLoading
? m(Loader)
: alerts.length > 0
? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id}))
: m("div", { class: "text-center text-gray-500 py-8" }, [
"You don't have any alerts set up.",
])
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center justify-between mb-6" }, [
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"),
m("a", {
href: "#!/alerts/new",
class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors",
}, [
m.trust(PLUS_ICON),
"Add Alert"
])
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, content)
])
}
}
const AlertCreate = {
oninit: () => {
state.update(assoc('alertDraft', {
email: getTagValue('email', state.get().alerts[0]?.tags || []) || "",
freq: 'daily',
time: '17:00',
feedAddress: "",
secret: "",
}))
},
view: () => {
const {pubkey, alertDraft, alertsLoading} = state.get()
const {email, feedAddress, freq, time, secret} = alertDraft!
const update = (newValues: Partial<AlertValues>) => {
state.update(assoc('alertDraft', {...alertDraft, ...newValues}))
}
const submit = async (e: Event) => {
e.preventDefault()
state.update(assoc('alertsLoading', true))
try {
if (!email.includes("@")) return alert("Please provide a valid email address")
const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress)))
if (!address) return alert("Please provide a valid feed address")
if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`)
const selections = await load({
relays: INDEXER_RELAYS,
filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}],
})
const router = Router.get()
const filters = getIdFilters([address.toString()])
const scenario = router.merge([
router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))),
router.FromRelays(address.relays),
router.FromRelays(INDEXER_RELAYS),
])
const relays = scenario.limit(10).getUrls()
const [event] = await load({relays, filters})
if (!event) return alert("Sorry, we weren't able to find that feed")
const feedStrings = getTagValues('feed', event.tags)
if (feedStrings.length === 0) return alert('At least one feed is required')
const feeds = removeNil(feedStrings.map(parseJson))
if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)")
const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError)
if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`)
await publishAlert({freq, time, email, feeds, secret})
m.route.set("/alerts")
} catch (error) {
alert("Failed to create alert. Please try again.")
console.error('Error creating alert:', error)
} finally {
state.update(assoc('alertsLoading', false))
}
}
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center gap-4 mb-6" }, [
m("button", {
onclick: () => m.route.set("/alerts"),
class: "text-gray-600 hover:text-gray-900 cursor-pointer",
tooltip: "Back to alerts"
}, m.trust(ARROW_LEFT_ICON)),
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert")
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, [
m("form", { class: "space-y-6", onsubmit: submit }, [
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"),
m("input", {
type: "email",
placeholder: "Enter your email address",
value: email,
oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
m("div", {class: "w-full flex gap-2"}, [
m("div", {class: "flex-grow"}, [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"),
m("select", {
value: freq,
onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}, [
m("option", { value: 'daily' }, "Daily"),
m("option", { value: '0' }, "Weekly on Sunday"),
m("option", { value: '1' }, "Weekly on Monday"),
m("option", { value: '2' }, "Weekly on Tuesday"),
m("option", { value: '3' }, "Weekly on Wednesday"),
m("option", { value: '4' }, "Weekly on Thursday"),
m("option", { value: '5' }, "Weekly on Friday"),
m("option", { value: '6' }, "Weekly on Saturday"),
])
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"),
m("input", {
value: time,
onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}),
type: "time",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"),
m("div", { class: "space-y-2" }, [
m("input", {
type: "text",
placeholder: "naddr1...",
value: feedAddress,
oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}),
m("p", { class: "text-sm text-gray-500" }, [
"Visit ",
m("a", {
href: "https://coracle.social/feeds",
target: "_blank",
class: "text-purple-600 hover:text-purple-800"
}, "coracle.social/feeds"),
" to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here."
])
])
]),
m("div", { class: "flex justify-end" }, [
m("button", {
type: "submit",
disabled: alertsLoading,
class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
}, alertsLoading ? "Creating..." : "Create Alert")
])
])
])
])
}
}
const FailedToLogin = {
view: () =>
m("div", { class: "space-y-6 text-center" }, [
m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [
m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"),
m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."),
m("div", { class: "space-y-3" }, [
m("button", {
onclick: () => window.location.reload(),
class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors"
}, "Try Again"),
m("a", {
href: "https://nostrapps.com/#signers",
target: "_blank",
class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Install a Nostr Signer")
])
])
])
}
const Layout: m.Component<{children: m.Children}> = {
view: vnode => {
const {children} = vnode.attrs
const {failedToLogin, pubkey} = state.get()
if (failedToLogin) {
return m(FailedToLogin)
}
if (!pubkey) {
return m("div", { class: "text-center space-y-4" }, [
m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"),
m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"),
m(Login)
])
}
return children
}
}
m.route(document.querySelector('#app')!, "/alerts", {
"/alerts": {
view: () => {
return m(Layout, {children: [m(AlertList)]})
},
},
"/alerts/new": {
view: () => {
return m(Layout, {children: [m(AlertCreate)]})
}
},
})
state.subscribe(() => m.redraw())
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => {
return state.get().signer?.sign(event)
},
}),
)
Object.assign(window, {setJson, getJson})

View file

@ -1,42 +0,0 @@
@import "tailwindcss";
a, button {
@apply cursor-pointer;
}
/* Tooltip styling */
[tooltip] {
@apply cursor-pointer relative;
}
[tooltip]:hover::after {
content: attr(tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
z-index: 1000;
pointer-events: none;
/* Animation properties */
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
}
/* Keyframes for fade in animation */
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-3px);
}
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
server: {
port: 2893,
},
plugins: [
tailwindcss(),
],
})