Commit graph

104 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