Commit graph

50 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
Agent
4497a7dee9 fix: remove pre-eslint unused-variable errors across src/
Remove 21 unused imports/variables that caused eslint failures:
- actions.ts (2): Subscription type import, getCronExpression import
- alert.ts (4): CronExpressionParser, tryCatch, int, HOUR
- digest.ts (12): now, nth, nthEq, dateToSeconds, getIdFilters,
  getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter,
  call, loadRelaySelections
- env.ts (1): netContext
- worker/email.ts (1): purgeJob variable (kept CronJob side-effect)

All unused symbols were pre-existing, not related to changed files.
tsc --noEmit passes; script/checks now returns 0 on the src check.
2026-09-10 11:58:05 -04: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
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
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
mplorentz
d5f54845b6 dedupe and serialize subscription writes. 2026-09-03 13:44:02 -04:00
mplorentz
5abec46cb7 Make subscription upsert idempotent via PUT
Switch the subscribe endpoint from POST to an idempotent PUT so clients can
always upsert without a stateful lookup first.

- Add GET /subscription/email?pubkey= lookup so clients can avoid re-POSTing.
- insertSubscription no longer clears confirmed_at unless the email address
  changes; a frequency change keeps the existing confirmation.
- registerSubscription only sends a confirmation email when the subscription
  is new, unconfirmed, or the email address changed.
- Update integration test and README for the PUT endpoint.
2026-09-03 11:47:21 -04:00
mplorentz
ff8e1d7a0d Update styling on confirmation failed page 2026-09-02 16:51:01 -04:00
mplorentz
3a0aff2f30 Update confirmation page 2026-08-27 15:23:18 -04:00
mplorentz
cf335649c0 Wait longer for profile photoes. 2026-08-27 15:18:52 -04:00
mplorentz
14bc273d98 update digest footer 2026-08-27 15:01:13 -04:00
mplorentz
777ab81195 Add relay to nevent in digest links 2026-08-27 14:49:56 -04:00
mplorentz
d143a563ba Process nostr events included directly in the callback. 2026-08-27 11:28:03 -04:00
mplorentz
4e8918c1f3 Add branding to email confirmation screen 2026-08-27 10:30:46 -04:00
mplorentz
f7e0c99764 Update subscription confirmation email template 2026-08-27 10:17:38 -04:00
mplorentz
6e35717072 Rework email template 2026-08-26 17:57:26 -04:00
mplorentz
77e35c26c7 log errors 2026-08-26 17:21:00 -04:00
mplorentz
1865128f41 Switch postmark api to smtp 2026-08-24 16:12:32 -04:00
mplorentz
a799e1ba1c add permissive CORS headers 2026-08-20 12:42:31 -04:00
Agent
f220301154 Add integration test script, fix root handler, add DATA_DIR support
- test/integration.sh: runs full E2E flow against fresh server
- pnpm test and pnpm test:server scripts added
- Root endpoint handles missing web UI gracefully (returns JSON)
- database.ts reads DATA_DIR env var for test isolation
- .gitignore updated for test artifacts
2026-08-18 12:35:20 -04:00
Agent
25645e1d0e Fix unsubscribe page path, remove pool override, handle Postmark error gracefully
- Fix unsubscribe page path (unsubscribe-success.html → unsubscribe.html)
- Remove netContext.pool.get override that crashed load()
- Handle Postmark registration error gracefully by returning subscription from DB
- Add getSubscriptionByPubkey import to server.ts
2026-08-18 12:26:07 -04:00
Agent
21e7058862 Initial mailship fork from anchor
Fork anchor, strip all push notification code (APNs, FCM, WebPush),
rename from anchor to mailship, add new database schema for
subscriptions + events tables, and add HTTP API for email
notification registration and NIP-9a relay push callbacks.

- POST /subscription/email — register for email digests
- DELETE /subscription/:key — unsubscribe
- POST /notify/:id — NIP-9a relay push callback
- GET /confirm?token=... — confirm email
- GET /unsubscribe?token=... — unsubscribe

Co-authored-by: mplorentz
2026-08-18 12:21:22 -04:00