- 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
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
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.
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.
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
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
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
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
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).
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.
Adds a test that validates the fix for the web UI crash when
VITE_NOTIFIER_RELAY is not set. The test exercises the guarded
pattern (ternary guard before calling normalizeRelayUrl) that
prevents the TypeError crash on undefined input.
Closes mailship-4ic
NOTIFIER_RELAY is string | undefined after the earlier guard fix.
deleteAlert and publishAlert passed it directly to publish() which
expects string[]. Add early-return guards to both functions.
Fixes the 2 new TS2322 errors introduced by the previous commit.
The web UI crashed on load when VITE_NOTIFIER_RELAY was not set
because normalizeRelayUrl(undefined) calls url.match(...) on the
undefined argument, producing:
TypeError: can't access property 'match', A is undefined
Fix: guard with a truthy check before calling normalizeRelayUrl,
and early-return from loadAlerts when NOTIFIER_RELAY is undefined.
Fixes bead mailship-4ic