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-shm
db.sqlite db.sqlite
bd.db bd.db
# Interactions log (runtime, not versioned)
interactions.jsonl
# NOTE: Do NOT add negation patterns here. # NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude. # They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default # Config files (metadata.json, config.yaml) are tracked by git by default

View file

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

View file

@ -12,7 +12,14 @@ BRAND_LOGO=
INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
DEFAULT_RELAYS=relay.damus.io,nos.lol DEFAULT_RELAYS=relay.damus.io,nos.lol
SEARCH_RELAYS=relay.nostr.band SEARCH_RELAYS=relay.nostr.band
POSTMARK_API_KEY= SMTP_HOST=
POSTMARK_SENDER_ADDRESS= 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 PORT=4738
DATA_DIR=./data 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/pages/ ./src/pages/
COPY src/emails/ ./src/emails/ COPY src/emails/ ./src/emails/
# Build web UI # Build TypeScript (runs tsc && build:html)
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)
RUN pnpm run build RUN pnpm run build
# Production image # Production image
@ -51,13 +46,18 @@ RUN pnpm install --frozen-lockfile --prod
# Copy build artifacts # Copy build artifacts
COPY --from=build /app/dist/ ./dist/ 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/pages/ ./dist/pages/
COPY --from=build /app/src/emails/ ./dist/emails/ COPY --from=build /app/src/emails/ ./dist/emails/
# Create data directory for SQLite # Create data directory for SQLite
RUN mkdir -p /data 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 EXPOSE 4738
ENV NODE_ENV=production ENV NODE_ENV=production

View file

@ -1,6 +1,8 @@
# Mailship # 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 ## Architecture
@ -19,7 +21,7 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
UUID in path ├── fetch event from relay UUID in path ├── fetch event from relay
is the auth ├── store in SQLite (dedup) is the auth ├── store in SQLite (dedup)
│ │
cron fires ──▶ render digest ──▶ Postmark ──▶ email cron fires ──▶ render digest ──▶ SMTP ──▶ email
``` ```
## Configuration ## Configuration
@ -30,55 +32,93 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
| `MAILSHIP_NAME` | ✓ | Name of this Mailship instance | | `MAILSHIP_NAME` | ✓ | Name of this Mailship instance |
| `MAILSHIP_URL` | ✓ | Public URL of this instance | | `MAILSHIP_URL` | ✓ | Public URL of this instance |
| `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) | | `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) | | `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_NAME` | | Name used in email branding (default: `Flotilla`) |
| `BRAND_ACCENT` | | Accent color string used in email branding (default: `#7161FF`) | | `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 | | `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 | | `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays |
| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays | | `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays |
| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays | | `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays |
| `PORT` | | Port to run on (default: 3000) | | `PORT` | | Port to run on (default: 4738) |
## API ## API
### PUT /subscription/email ### PUT /subscription/email
Idempotently register or update an email subscription. Re-sends the confirmation 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 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 } Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
Auth: NIP-98 (planned) Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback } Response: { key, callback }
``` ```
### GET /subscription/email?pubkey=... **Optional schedule fields (defaults: `hour=17`, `minute=0`, `timezone="UTC"`):**
Look up an existing subscription, so clients can avoid re-registering (and
re-confirming) when settings haven't changed. Returns 404 if none exists. | 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 ### 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 } Response: { ok: true }
``` ```
### POST /notify/:id ### 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 } Response: { ok: true, stored: boolean }
Returns 404 if subscription not found or inactive. 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=... ### GET /confirm?token=...
Confirm email address via link from confirmation email. Confirm email address via link from confirmation email.
@ -87,6 +127,11 @@ Unsubscribe via link from digest email.
## Development ## 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 ```sh
pnpm install pnpm install
pnpm run build pnpm run build
@ -127,10 +172,7 @@ docker run -d \
## Tests ## Tests
```sh ```sh
pnpm test # Run integration tests pnpm test:unit # Run unit tests (vitest)
pnpm test:server # Start server for manual testing 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_NAME=${MAILSHIP_NAME:-Mailship}
- MAILSHIP_URL=${MAILSHIP_URL:?required} - MAILSHIP_URL=${MAILSHIP_URL:?required}
- BASE_URL=${BASE_URL:?required} - BASE_URL=${BASE_URL:?required}
- POSTMARK_API_KEY=${POSTMARK_API_KEY:?required} - SMTP_HOST=${SMTP_HOST:?required}
- POSTMARK_SENDER_ADDRESS=${POSTMARK_SENDER_ADDRESS:?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} - DEFAULT_RELAYS=${DEFAULT_RELAYS:-wss://relay.damus.io,wss://relay.primal.net}
- INDEXER_RELAYS=${INDEXER_RELAYS:-wss://purplepag.es,wss://relay.damus.io} - INDEXER_RELAYS=${INDEXER_RELAYS:-wss://purplepag.es,wss://relay.damus.io}
- SEARCH_RELAYS=${SEARCH_RELAYS:-wss://relay.nostr.band} - SEARCH_RELAYS=${SEARCH_RELAYS:-wss://relay.nostr.band}

View file

@ -3,8 +3,7 @@
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"build": "tsc && pnpm run build:html && pnpm run build:web", "build": "tsc && pnpm run build:html",
"build:web": "cd web && pnpm run build",
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/", "build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
"check": "tsc --noEmit && eslint src", "check": "tsc --noEmit && eslint src",
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
@ -12,15 +11,16 @@
"preview:digest": "node script/render-preview.mjs", "preview:digest": "node script/render-preview.mjs",
"run-alert": "node dist/run.js", "run-alert": "node dist/run.js",
"test": "bash test/integration.sh", "test": "bash test/integration.sh",
"test:unit": "vitest run",
"test:server": "bash test/integration.sh --server-only" "test:server": "bash test/integration.sh --server-only"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/express-ws": "^3.0.5",
"@types/mjml": "^4.7.4", "@types/mjml": "^4.7.4",
"@types/mustache": "^4.2.6", "@types/mustache": "^4.2.6",
"@types/node": "^22.18.1",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@ -30,10 +30,10 @@
"globals": "^15.15.0", "globals": "^15.15.0",
"onchange": "^7.1.0", "onchange": "^7.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.2" "typescript": "^5.9.2",
"vitest": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@types/node": "^22.18.1",
"@welshman/content": "^0.6.3", "@welshman/content": "^0.6.3",
"@welshman/feeds": "^0.6.3", "@welshman/feeds": "^0.6.3",
"@welshman/lib": "^0.6.3", "@welshman/lib": "^0.6.3",
@ -42,13 +42,11 @@
"@welshman/signer": "^0.6.3", "@welshman/signer": "^0.6.3",
"@welshman/store": "^0.6.3", "@welshman/store": "^0.6.3",
"@welshman/util": "^0.6.3", "@welshman/util": "^0.6.3",
"bcrypt": "^5.1.1",
"cron": "^4.3.3", "cron": "^4.3.3",
"cron-parser": "^5.3.1", "cron-parser": "^5.3.1",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
"express-ws": "^5.0.2",
"localstorage-polyfill": "^1.0.1", "localstorage-polyfill": "^1.0.1",
"mjml": "^4.15.3", "mjml": "^4.15.3",
"mustache": "^4.2.0", "mustache": "^4.2.0",
@ -57,12 +55,10 @@
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"succinct-async": "^1.0.4", "succinct-async": "^1.0.4",
"ts-node-dev": "^2.0.0",
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"bcrypt",
"sqlite3" "sqlite3"
] ]
} }

Binary file not shown.

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# mailship CI checks — type-check + lint + build # mailship CI checks — type-check + lint + build + unit tests
pnpm run check 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', Duration: '24 hours',
Total: 12, Total: 12,
TopProfiles: 'bob, carol', TopProfiles: 'bob, carol',
HasPopular: true, HasLatest: true,
Popular: [ Latest: [
{ {
Link: 'https://app.flotilla.social/nevent1qqs...', Link: 'https://app.flotilla.social/nevent1qqs...',
Timestamp: 'Aug 25, 2026 at 9:00 AM', Timestamp: 'Aug 25, 2026 at 9:00 AM',
Icon: 'https://i.pravatar.cc/150?img=32', Icon: 'https://i.pravatar.cc/150?img=32',
Name: 'carol', Name: 'carol',
Content: '<p>Does anyone know how relay-based groups work?</p>', Content: '<p>Does anyone know how relay-based groups work?</p>',
Replies: 5, RelayUrl: 'relay.damus.io',
Reactions: 8,
}, },
{ {
Link: 'https://app.flotilla.social/spaces/nos.lol/community?at=1700000000', Link: 'https://app.flotilla.social/spaces/nos.lol/community?at=1700000000',
@ -34,8 +33,7 @@ const sample = {
Name: 'bob', Name: 'bob',
Content: Content:
'<p>Excited to share our new community space on Flotilla! <a href="https://flotilla.social">Check it out</a>.</p>', '<p>Excited to share our new community space on Flotilla! <a href="https://flotilla.social">Check it out</a>.</p>',
Replies: 14, RelayUrl: 'nos.lol',
Reactions: 32,
}, },
], ],
unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123', unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123',

View file

@ -13,19 +13,40 @@ export type RegisterSubscriptionParams = {
pubkey: string pubkey: string
email: string email: string
frequency: 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( export const registerSubscription = instrument(
'actions.registerSubscription', 'actions.registerSubscription',
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => { async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency) const sub = await db.insertSubscription(pubkey, email, frequency, hour, minute, dayOfWeek, timezone)
const callback = `${process.env.BASE_URL}/notify/${sub.id}` 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) { 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 } return { key: sub.key, callback }
@ -39,13 +60,19 @@ export type ConfirmSubscriptionParams = {
export const confirmSubscriptionAction = instrument( export const confirmSubscriptionAction = instrument(
'actions.confirmSubscription', 'actions.confirmSubscription',
async ({ token }: ConfirmSubscriptionParams) => { 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.') 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 = { export type Subscription = {
id: string id: string
key: string key: string
pubkey: string pubkey: string
email: string email: string
frequency: string frequency: string
hour: number
minute: number
day_of_week?: number
timezone: string
created_at: number created_at: number
confirmed_at?: number confirmed_at?: number
unsubscribed_at?: number unsubscribed_at?: number
last_digest_at?: number last_digest_at?: number
last_confirm_sent_at?: number
} }
export const getSubscriptionError = (sub: Subscription) => { export const getSubscriptionError = (sub: Subscription) => {
@ -21,14 +24,39 @@ export const getSubscriptionError = (sub: Subscription) => {
return 'Frequency must be "daily" or "weekly"' return 'Frequency must be "daily" or "weekly"'
} }
// Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC. if (sub.hour < 0 || sub.hour > 23 || !Number.isInteger(sub.hour)) {
// Validation: just ensure frequency is valid, cron is generated internally. 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') { if (frequency === 'daily') {
return `0 ${minute} ${hour} * * *` return `0 ${minute} ${hour} * * *`
} }
// Weekly on Monday // Weekly: default to Monday (1) when not specified
return `0 ${minute} ${hour} * * 1` 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 DATA_DIR = process.env.DATA_DIR || '.'
const db = new sqlite3.Database(DATA_DIR + '/mailship.db') 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> type Row = Record<string, any>
@ -58,10 +58,15 @@ export const migrate = () =>
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
frequency TEXT NOT NULL DEFAULT 'daily', 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, created_at INTEGER NOT NULL,
confirmed_at INTEGER, confirmed_at INTEGER,
unsubscribed_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( await run(
`CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)` `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 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() resolve()
}) })
} catch (err) { } catch (err) {
@ -125,54 +152,122 @@ const parseSubscription = (row: any): Subscription | undefined => {
export const updateSubscription = instrument( export const updateSubscription = instrument(
'database.updateSubscription', 'database.updateSubscription',
async (existing: Subscription, email: string, frequency: string) => { async (existing: Subscription, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
if (existing.email === email && existing.frequency === frequency) { 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 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 // 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). // re-activated alongside this one (the unique index would reject that anyway).
if (existing.email === email) { if (existing.email === email) {
return parseSubscription( return parseSubscription(
await get( 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 *`, 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( return parseSubscription(
await get( 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 *`, 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( export const insertSubscription = instrument(
'database.insertSubscription', '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) const existing = await getSubscriptionByPubkey(pubkey)
if (existing) { 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 { try {
return assertResult( return assertResult(
parseSubscription( parseSubscription(
await get( await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at) `INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
[ [
crypto.randomUUID(), crypto.randomUUID(),
crypto.randomBytes(32).toString('hex'), crypto.randomBytes(32).toString('hex'),
pubkey, pubkey,
email, email,
frequency, frequency,
hour ?? 17,
minute ?? 0,
dayOfWeek ?? null,
timezone ?? 'UTC',
now(), now(),
] ]
) )
@ -186,7 +281,7 @@ export const insertSubscription = instrument(
const concurrent = await getSubscriptionByPubkey(pubkey) const concurrent = await getSubscriptionByPubkey(pubkey)
if (concurrent) { 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( export const confirmSubscription = instrument(
'database.confirmSubscription', 'database.confirmSubscription',
async (key: string) => { async (key: string): Promise<ConfirmResult | undefined> => {
return parseSubscription( // Try to update an unconfirmed, active row
const updated = parseSubscription(
await get( await get(
`UPDATE subscriptions SET confirmed_at = unixepoch() `UPDATE subscriptions SET confirmed_at = unixepoch()
WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`, WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`,
[key] [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[] 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( export const updateLastDigestAt = instrument(
'database.updateLastDigestAt', 'database.updateLastDigestAt',
async (id: string, timestamp: number) => { 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( export const purgeEventsOlderThan = instrument(
'database.purgeEventsOlderThan', 'database.purgeEventsOlderThan',
async (timestamp: number) => { async (timestamp: number) => {

View file

@ -1,6 +1,5 @@
import { neventEncode, decode } from 'nostr-tools/nip19' import { neventEncode, decode } from 'nostr-tools/nip19'
import { import {
spec,
sortBy, sortBy,
groupBy, groupBy,
displayList, displayList,
@ -10,7 +9,6 @@ import { parse, truncate, renderAsHtml } from '@welshman/content'
import { import {
TrustedEvent, TrustedEvent,
normalizeRelayUrl, normalizeRelayUrl,
getParentId,
NOTE, NOTE,
COMMENT, COMMENT,
REACTION, REACTION,
@ -18,7 +16,6 @@ import {
displayPubkey, displayPubkey,
getTagValue, getTagValue,
} from '@welshman/util' } from '@welshman/util'
import { displayDuration, createElement } from './util.js' import { displayDuration, createElement } from './util.js'
import type { Subscription } from './alert.js' import type { Subscription } from './alert.js'
import { sendDigest } from './mailer.js' import { sendDigest } from './mailer.js'
@ -26,6 +23,7 @@ import { EVENT_VIEWER_URL } from './env.js'
import { import {
profilesByPubkey, profilesByPubkey,
loadProfile, loadProfile,
repository,
} from './repository.js' } from './repository.js'
type DigestData = { type DigestData = {
@ -50,23 +48,23 @@ export class Digest {
const getEventVariables = (event: TrustedEvent) => { const getEventVariables = (event: TrustedEvent) => {
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 }) const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
const relayUrl = data.relayByEventId.get(event.id)
return { return {
Link: buildLink(event, handler, data.relayByEventId.get(event.id)), Link: buildLink(event, handler, relayUrl),
Timestamp: formatter.format(secondsToDate(event.created_at)), Timestamp: formatter.format(secondsToDate(event.created_at)),
Icon: profilesByPubkey.get().get(event.pubkey)?.picture, Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
Name: displayProfileByPubkey(event.pubkey), Name: displayProfileByPubkey(event.pubkey),
Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(), Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(),
Replies: RelayUrl: relayUrl
repliesByParentId.get(event.id)?.filter((e) => [COMMENT, NOTE].includes(e.kind)) ? normalizeRelayUrl(relayUrl).replace(/^wss:\/\//, '').replace(/\/$/, '')
?.length || 0, : '',
Reactions: repliesByParentId.get(event.id)?.filter(spec({ kind: REACTION }))?.length || 0,
} }
} }
const { events, context } = data const { events } = data
const formatter = getFormatter() const formatter = getFormatter()
const handler = await this.loadHandler() const handler = await this.loadHandler()
const repliesByParentId = groupBy(getParentId, context)
const eventsByPubkey = groupBy((e) => e.pubkey, events) const eventsByPubkey = groupBy((e) => e.pubkey, events)
const userProfile = profilesByPubkey.get().get(this.sub.pubkey) const userProfile = profilesByPubkey.get().get(this.sub.pubkey)
const sorted = sortBy((e) => e.created_at, events).slice(0, 100) const sorted = sortBy((e) => e.created_at, events).slice(0, 100)
@ -78,8 +76,8 @@ export class Digest {
return { return {
Total: events.length, Total: events.length,
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since), Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
Popular: sorted.map((e) => getEventVariables(e)), Latest: sorted.map((e) => getEventVariables(e)),
HasPopular: sorted.length > 0, HasLatest: sorted.length > 0,
UserName: displayProfile(userProfile, this.sub.email.split('@')[0]), UserName: displayProfile(userProfile, this.sub.email.split('@')[0]),
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
} }
@ -87,7 +85,6 @@ export class Digest {
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => { sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
const events = storedEvents.map(se => se.event) 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])) const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay]))
// Load profiles for event authors // 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 const data = { events, context, relayByEventId } as DigestData
if (data.events.length > 0) { if (data.events.length > 0) {

View file

@ -5,7 +5,7 @@
<mj-style inline="inline"> <mj-style inline="inline">
.header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; } .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; } .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 { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.event-meta-left { display: flex; 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; } .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; } .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; }
.stat-item { display: inline-flex; align-items: center; margin-right: 12px; } .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 { 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; } .logo { max-width: 48px; max-height: 48px; }
a { text-decoration: none; } a { text-decoration: none; }
</mj-style> </mj-style>
@ -44,11 +44,11 @@
</mj-column> </mj-column>
</mj-section> </mj-section>
{{#HasPopular}} {{#HasLatest}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px"> <mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column> <mj-column>
<mj-text css-class="section-header">Latest Activity</mj-text> <mj-text css-class="section-header">Latest Activity</mj-text>
{{#Popular}} {{#Latest}}
<mj-text> <mj-text>
<div class="event-item"> <div class="event-item">
<div class="event-meta"> <div class="event-meta">
@ -64,18 +64,15 @@
<div class="event-content">{{{Content}}}</div> <div class="event-content">{{{Content}}}</div>
<div class="event-stats"> <div class="event-stats">
<span class="stat-item"> <span class="stat-item">
{{Replies}} replies Posted to {{RelayUrl}}
</span>
<span class="stat-item">
{{Reactions}} reactions
</span> </span>
</div> </div>
</div> </div>
</mj-text> </mj-text>
{{/Popular}} {{/Latest}}
</mj-column> </mj-column>
</mj-section> </mj-section>
{{/HasPopular}} {{/HasLatest}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px"> <mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column> <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.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.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.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.') if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.')
export const MAILSHIP_URL = process.env.MAILSHIP_URL export const MAILSHIP_URL = process.env.MAILSHIP_URL
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
export const BASE_URL = process.env.BASE_URL 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_ACCENT = process.env.BRAND_ACCENT || '#7161FF'
export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla' export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla'
export const BRAND_LOGO = 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 appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET)
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
export const INDEXER_RELAYS = process.env.INDEXER_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 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_HOST = process.env.SMTP_HOST
export const SMTP_PORT = process.env.SMTP_PORT export const SMTP_PORT = process.env.SMTP_PORT
export const SMTP_USER = process.env.SMTP_USER 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) => { process.on('unhandledRejection', (error: Error) => {
console.error('Unhandled rejection:', error.stack) 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) => { process.on('uncaughtException', (error: Error) => {
@ -15,6 +17,7 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1) process.exit(1)
}) })
migrate().then(async () => { migrate().then(async () => {
server.listen(PORT, () => { server.listen(PORT, () => {
console.log('Running on port', PORT) console.log('Running on port', PORT)

View file

@ -29,7 +29,7 @@ const transporter = nodemailer.createTransport({
export const sendConfirm = (sub: Subscription) => { export const sendConfirm = (sub: Subscription) => {
const href = `${BASE_URL}/confirm?token=${sub.key}` 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 return transporter
.sendMail({ .sendMail({
@ -86,7 +86,7 @@ export const sendDigest = async (sub: Subscription, variables: Record<string, an
brandName: BRAND_NAME, brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT, brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO, brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`,
}), }),
}) })
.catch(error => { .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 { instrument } from 'succinct-async'
import express, { Request, Response, NextFunction } from 'express' import express, { Request, Response, NextFunction } from 'express'
import rateLimit from 'express-rate-limit' 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 { render } from './templates.js'
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
import { load } from '@welshman/net' 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' 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() 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: // The browser hits /subscription with an Authorization header and Content-Type:
// application/json, which triggers a CORS preflight. Answer it and allow the // application/json, which triggers a CORS preflight. Answer it and allow the
// configured origin so the client can register. // 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', CORS_ORIGIN)
res.setHeader('Access-Control-Allow-Origin', corsOrigin)
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '86400') res.setHeader('Access-Control-Max-Age', '86400')
res.setHeader('Vary', 'Origin')
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return res.sendStatus(204) return res.sendStatus(204)
} }
next() next()
}) }
server.use('/', corsMiddleware)
server.use(express.json()) server.use(express.json())
server.use(express.static('web/dist'))
// Rate limit for registration endpoints // Rate limit for registration endpoints
server.use( server.use(
'/subscription', '/subscription',
@ -71,16 +154,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl
} }
addRoute('get', '/', async (req: Request, res: Response) => { 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({ res.json({
name: 'Mailship', name: 'Mailship',
description: 'Email notification server for Nostr', 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 // Look up an existing email subscription for the authenticated pubkey, so
// re-registering (and re-confirming) when settings haven't changed. // 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) => { addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
const { pubkey } = req.query const pubkey = await verifyNip98Auth(req)
if (!pubkey || typeof pubkey !== 'string') { if (!pubkey) {
return res.status(400).json({ error: 'pubkey is required' }) return res.status(401).json({ error: 'NIP-98 authorization required' })
} }
const sub = await getSubscriptionByPubkey(pubkey) 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' }) 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({ res.json({
key: sub.key, key: sub.key,
callback, callback,
email: sub.email, email: sub.email,
frequency: sub.frequency, frequency: sub.frequency,
hour: sub.hour,
minute: sub.minute,
dayOfWeek: sub.day_of_week,
timezone: sub.timezone,
confirmed: Boolean(sub.confirmed_at), 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) => { 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('@')) { if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'A valid email address is required' }) 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"' }) return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
} }
if (!pubkey) { // Validate optional schedule fields
return res.status(400).json({ error: 'pubkey is required' }) 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 if (minute !== undefined) {
// const auth = req.headers.authorization if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' }) return res.status(400).json({ error: 'Minute must be an integer between 0 and 59' })
// Verify using @welshman/util makeHttpAuth }
}
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 { try {
const result = await registerSubscription({ pubkey, email, frequency }) const result = await registerSubscription({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone })
res.json(result) res.json(result)
} catch (error: any) { } catch (error: any) {
// The subscription was still created, but sending the confirmation email // 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 // Look up the actual subscription key from the DB
const sub = await getSubscriptionByPubkey(pubkey) const sub = await getSubscriptionByPubkey(pubkey)
if (sub) { if (sub) {
const callback = `${process.env.BASE_URL}/notify/${sub.id}` const callback = `${BASE_URL}/notify/${sub.id}`
res.json({ key: sub.key, callback }) res.json({ key: sub.key, callback })
} else { } else {
console.error('Failed to register subscription:', error) 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) => { addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
const { key } = req.params 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) const sub = await getSubscriptionByKey(key)
if (!sub) { if (!sub) {
return res.status(404).json({ error: 'Subscription not found' }) 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 }) await unsubscribeAction({ token: key })
res.json({ ok: true }) 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' }) 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) const sub = await getSubscriptionById(req.params.id)
if (!sub) { if (!sub) {
@ -209,8 +335,8 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
storedEvent = fetched storedEvent = fetched
if (!storedEvent) { if (!storedEvent) {
// Event not found at relay — don't 404, just skip // Event not found at relay — don't 404, reflect that nothing was stored
return res.json({ ok: true, skipped: true }) 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 // Confirmation
addRoute('get', '/confirm', async (req: Request, res: Response) => { addRoute('get', '/confirm', async (req: Request, res: Response) => {
if (typeof req.query.token !== 'string') { if (typeof req.query.token !== 'string') {
return res.send( return res.send(
await render('pages/confirm-error.html', { await render('pages/confirm-error.html', {
message: 'No confirmation token was provided. Please check the link in your email and try again.', message: 'No confirmation token was provided. Please check the link in your email and try again.',
brandName: BRAND_NAME, ...brandingVars(),
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
}) })
) )
} }
try { 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', { res.send(await render('pages/confirm-success.html', {
brandName: BRAND_NAME, ...brandingVars(),
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
})) }))
} catch (error) { } catch (error) {
const isActionError = error instanceof ActionError 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', { res.send(await render('pages/confirm-error.html', {
message, message,
brandName: BRAND_NAME, ...brandingVars(),
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
})) }))
if (!isActionError) { if (!isActionError) {

View file

@ -6,6 +6,12 @@ import * as db from '../database.js'
const jobsById = new Map<string, CronJob>() 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) => { export const runJob = async (sub: Subscription) => {
try { try {
if (!sub.confirmed_at || sub.unsubscribed_at) { if (!sub.confirmed_at || sub.unsubscribed_at) {
@ -27,8 +33,11 @@ export const runJob = async (sub: Subscription) => {
const digest = new Digest(sub) const digest = new Digest(sub)
await digest.sendFromStoredEvents(events) await digest.sendFromStoredEvents(events)
// Clean up processed events // Collect the exact IDs that were fetched + sent, then delete ONLY those.
await db.deleteEventsForSubscription(sub.id, since) // 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)) await db.updateLastDigestAt(sub.id, Math.floor(Date.now() / 1000))
console.log('worker: job completed', sub.id, 'in', Date.now() - start, 'ms') 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 createJob = (sub: Subscription) => {
const cron = getCronExpression(sub.frequency) const cron = getCronExpression(sub.frequency, sub.hour, sub.minute, sub.day_of_week)
const run = async () => { 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({ return CronJob.from({
cronTime: cron, cronTime: cron,
onTick: run, onTick: run,
start: true, 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 npx tsc
fi fi
# Generate a random secret for the test # Generate secrets for the test: one for the server, one for the client
SECRET="$(openssl rand -hex 32)" 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 env vars for the server
export MAILSHIP_SECRET="$SECRET" export MAILSHIP_SECRET="$SERVER_SECRET"
export MAILSHIP_NAME="Mailship Test" export MAILSHIP_NAME="Mailship Test"
export MAILSHIP_URL="$BASE_URL" export MAILSHIP_URL="$BASE_URL"
export BASE_URL="$BASE_URL" export BASE_URL="$BASE_URL"
export POSTMARK_API_KEY="test" export SMTP_HOST="localhost"
export POSTMARK_SENDER_ADDRESS="test@test.com" 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 DEFAULT_RELAYS="wss://relay.damus.io"
export INDEXER_RELAYS="wss://purplepag.es" export INDEXER_RELAYS="wss://purplepag.es"
export SEARCH_RELAYS="wss://relay.nostr.band" export SEARCH_RELAYS="wss://relay.nostr.band"
export PORT="$PORT" export PORT="$PORT"
export CORS_ORIGIN="$BASE_URL"
export DATA_DIR="$PROJECT_DIR/test-data" export DATA_DIR="$PROJECT_DIR/test-data"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
@ -114,11 +138,13 @@ echo "1. Health check"
HEALTH=$(curl -s "$BASE_URL/") HEALTH=$(curl -s "$BASE_URL/")
check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship" check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship"
# Test 2: Register subscription # Test 2: Register subscription with NIP-98 auth
echo "" 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" \ 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) 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) 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)" fail "Registration missing key or callback (got: $REG)"
fi fi
# Test 3: Confirm subscription # Test 3: PUT without auth returns 401
echo "" 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) CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1)
if echo "$CONFIRM" | grep -qi "success"; then if echo "$CONFIRM" | grep -qi "success"; then
pass "Confirmation page shows success" pass "Confirmation page shows success"
@ -139,20 +187,20 @@ else
fail "Confirmation page doesn't show success" fail "Confirmation page doesn't show success"
fi fi
# Test 4: Check SQLite state # Test 7: Check SQLite state
echo "" echo ""
echo "4. Database state" echo "7. Database state"
CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then
pass "Subscription confirmed in DB" pass "Subscription confirmed in DB"
else else
fail "Subscription not confirmed in DB" fail "Subscription not confirmed in DB"
fi fi
# Test 5: Push event to notify endpoint # Test 8: Push event to notify endpoint
echo "" echo ""
echo "5. Push event via notify" echo "8. Push event via notify"
SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
# Fetch a real event from a relay # 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) 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" check_field "Event stored in DB" "$NOTIFY" "stored" "True"
fi fi
# Test 6: Dedup # Test 9: Dedup
echo "" echo ""
echo "6. Dedup" echo "9. Dedup"
if [ -n "$EVENT_ID" ]; then if [ -n "$EVENT_ID" ]; then
DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \
-d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}") -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
check_field "Duplicate event rejected" "$DEDUP" "stored" "False" check_field "Duplicate event rejected" "$DEDUP" "stored" "False"
fi fi
# Test 7: 404 for nonexistent subscription # Test 10: 404 for nonexistent subscription
echo "" 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" \ NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \
-d '{"id":"abc","relay":"wss://relay.primal.net"}') -d '{"id":"abc","relay":"wss://relay.primal.net"}')
check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found" check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found"
# Test 8: Unsubscribe # Test 11: Unsubscribe
echo "" echo ""
echo "8. Unsubscribe" echo "11. Unsubscribe"
UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY") UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY")
if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then
pass "Unsubscribe page renders" pass "Unsubscribe page renders"
@ -196,21 +244,53 @@ else
fail "Unsubscribe page didn't render" fail "Unsubscribe page didn't render"
fi fi
# Test 9: Notify after unsubscribe returns 404 # Test 12: Notify after unsubscribe returns 404
echo "" echo ""
echo "9. No push after unsubscribe" echo "12. No push after unsubscribe"
if [ -n "$EVENT_ID" ]; then if [ -n "$EVENT_ID" ]; then
AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ 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\"}") -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}")
check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active" check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active"
fi fi
# Test 10: Delete subscription # Test 13: Delete subscription with auth
echo "" echo ""
echo "10. Delete subscription" echo "13. Delete subscription with NIP-98 auth"
DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1) 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" 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 # Summary
echo "" echo ""
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(),
],
})