From 2088e1be4bf33563a0687ef3a312b6332a00b8de Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 25 Aug 2026 14:43:00 -0400 Subject: [PATCH 01/21] Fix web ui error --- web/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main.ts b/web/src/main.ts index 85ae4c8..13124a3 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -17,7 +17,7 @@ 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.split(',').map(normalizeRelayUrl) +const INDEXER_RELAYS = (import.meta.env.VITE_INDEXER_RELAYS || 'purplepag.es,relay.damus.io,relay.nostr.band').split(',').map(normalizeRelayUrl) const ALERT = 32830 From 77e35c26c70ed035741b5673ef92e6272703b426 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 25 Aug 2026 15:15:33 -0400 Subject: [PATCH 02/21] log errors --- src/mailer.ts | 57 +++++++++++++++++++++++++++++++++++---------------- src/server.ts | 5 ++++- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/mailer.ts b/src/mailer.ts index 1114471..35739a1 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -3,10 +3,13 @@ import { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, MAILSHIP_NAM import type { Subscription } from './alert.js' import { render } from './templates.js' +const secure = Number(SMTP_PORT) === 465 + const transporter = nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT), - secure: true, + secure, + requireTLS: !secure, auth: { user: SMTP_USER, pass: SMTP_PASSWORD, @@ -16,28 +19,46 @@ const transporter = nodemailer.createTransport({ export const sendConfirm = (sub: Subscription) => { const href = `${BASE_URL}/confirm?token=${sub.key}` - return transporter.sendMail({ - from: SMTP_FROM, - to: sub.email, - subject: 'Confirm your email digest', - html: ` + return transporter + .sendMail({ + from: SMTP_FROM, + to: sub.email, + subject: 'Confirm your email digest', + html: `

Welcome to ${MAILSHIP_NAME}!

Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:

Confirm Digest

`, - text: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`, - }) + text: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`, + }) + .catch(error => { + console.error('mailer: confirmation email failed', { + to: sub.email, + smtp: { host: SMTP_HOST, port: SMTP_PORT }, + error: error?.message || error, + }) + throw error + }) } export const sendDigest = async (sub: Subscription, variables: Record) => { - return transporter.sendMail({ - from: SMTP_FROM, - to: sub.email, - subject: 'New activity', - html: await render('emails/digest.mjml', { - ...variables, - name: sub.email.split('@')[0], - unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`, - }), - }) + return transporter + .sendMail({ + from: SMTP_FROM, + to: sub.email, + subject: 'New activity', + html: await render('emails/digest.mjml', { + ...variables, + name: sub.email.split('@')[0], + unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`, + }), + }) + .catch(error => { + console.error('mailer: digest email failed', { + to: sub.email, + smtp: { host: SMTP_HOST, port: SMTP_PORT }, + error: error?.message || error, + }) + throw error + }) } \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 8671380..204fb19 100644 --- a/src/server.ts +++ b/src/server.ts @@ -113,7 +113,10 @@ addRoute('post', '/subscription/email', async (req: Request, res: Response) => { const result = await registerSubscription({ pubkey, email, frequency }) res.json(result) } catch (error: any) { - // If the error is just Postmark failing, the subscription was still created + // The subscription was still created, but sending the confirmation email + // may have failed. Always log it so SMTP issues are visible. + console.error('Failed to send confirmation email for', pubkey, error?.message || error) + // Look up the actual subscription key from the DB const sub = await getSubscriptionByPubkey(pubkey) if (sub) { From 6e357170729400e0034960ef3bda5923080a8d67 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 26 Aug 2026 17:57:26 -0400 Subject: [PATCH 03/21] Rework email template --- .env.template | 9 ++++- AGENTS.md | 11 ++++++ README.md | 14 +++++++ package.json | 1 + script/render-preview.mjs | 49 +++++++++++++++++++++++ src/digest.ts | 29 ++++++++++++-- src/emails/digest.mjml | 81 +++++++++++++++++++++++---------------- src/env.ts | 5 +++ src/mailer.ts | 45 +++++++++++++++++++--- 9 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 script/render-preview.mjs diff --git a/.env.template b/.env.template index 885b96f..9112de5 100644 --- a/.env.template +++ b/.env.template @@ -1,7 +1,14 @@ MAILSHIP_SECRET= -MAILSHIP_NAME=Mailship +MAILSHIP_NAME=Flotilla MAILSHIP_URL=http://localhost:4738 BASE_URL=http://localhost:4738 +# Branding used in email templates. EVENT_VIEWER_URL is the base URL of the +# app you link events into (Flotilla by default, or anything handling the same +# /spaces// and / URL shapes). +EVENT_VIEWER_URL=https://app.flotilla.social +BRAND_NAME=Flotilla +BRAND_ACCENT=#7161FF +BRAND_LOGO= INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band DEFAULT_RELAYS=relay.damus.io,nos.lol SEARCH_RELAYS=relay.nostr.band diff --git a/AGENTS.md b/AGENTS.md index d6d9644..9f1d643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,17 @@ bd close # Complete work bd dolt push # Push beads data to remote ``` +## Email Template Preview + +To preview the rendered digest email while iterating on `src/emails/digest.mjml`: + +```bash +pnpm run preview:digest # renders to digest-preview.html +open digest-preview.html +``` + +After changing the template, rerun the script (or it doesn't watch) and refresh the browser. + ## Non-Interactive Shell Commands **ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. diff --git a/README.md b/README.md index 563d760..1b04755 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email) | `MAILSHIP_NAME` | ✓ | Name of this Mailship instance | | `MAILSHIP_URL` | ✓ | Public URL of this instance | | `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) | +| `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//` and `/` URL shapes) | +| `BRAND_NAME` | | Name used in email branding (default: `Flotilla`) | +| `BRAND_ACCENT` | | Accent color string used in email branding (default: `#7161FF`) | +| `BRAND_LOGO` | | URL of the logo image shown in the email header. Defaults to `/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 | @@ -79,6 +83,16 @@ pnpm run build pnpm run start ``` +### Previewing the digest email + +To iterate on the email template, render it with sample data and open the +result in your browser: + +```sh +pnpm run preview:digest +open digest-preview.html +``` + ## Docker ### Quick start diff --git a/package.json b/package.json index 94039eb..b2b588f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "check": "tsc --noEmit && eslint src", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", "start": "node dist/index.js", + "preview:digest": "node script/render-preview.mjs", "run-alert": "node dist/run.js", "test": "bash test/integration.sh", "test:server": "bash test/integration.sh --server-only" diff --git a/script/render-preview.mjs b/script/render-preview.mjs new file mode 100644 index 0000000..1e88abc --- /dev/null +++ b/script/render-preview.mjs @@ -0,0 +1,49 @@ +import {readFileSync, writeFileSync} from 'fs' +import {fileURLToPath} from 'url' +import {dirname, join} from 'path' +import Mustache from 'mustache' +import mjml2html from 'mjml' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml') + +const sample = { + name: 'alice', + brandName: 'Flotilla', + brandAccent: '#7161FF', + brandLogo: 'https://app.flotilla.social/logo.png', + UserName: 'Alice', + Duration: '24 hours', + Total: 12, + TopProfiles: 'bob, carol', + HasPopular: true, + Popular: [ + { + Link: 'https://app.flotilla.social/nevent1qqs...', + Timestamp: 'Aug 25, 2026 at 9:00 AM', + Icon: 'https://i.pravatar.cc/150?img=32', + Name: 'carol', + Content: '

Does anyone know how relay-based groups work?

', + Replies: 5, + Reactions: 8, + }, + { + Link: 'https://app.flotilla.social/spaces/nos.lol/community?at=1700000000', + Timestamp: 'Aug 25, 2026 at 10:00 AM', + Icon: 'https://i.pravatar.cc/150?img=68', + Name: 'bob', + Content: + '

Excited to share our new community space on Flotilla! Check it out.

', + Replies: 14, + Reactions: 32, + }, + ], + unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123', +} + +const source = readFileSync(templatePath, 'utf8') +const {html} = mjml2html(Mustache.render(source, sample)) +const outPath = join(__dirname, '..', 'digest-preview.html') + +writeFileSync(outPath, html) +console.log(`Rendered preview written to ${outPath}`) diff --git a/src/digest.ts b/src/digest.ts index a693aaa..7e6e0a3 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -13,6 +13,7 @@ import { import { parse, truncate, renderAsHtml } from '@welshman/content' import { TrustedEvent, + normalizeRelayUrl, getParentId, getIdFilters, getReplyFilters, @@ -21,6 +22,7 @@ import { REACTION, displayProfile, displayPubkey, + getTagValue, } from '@welshman/util' import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' import { Router, addMinimalFallbacks } from '@welshman/router' @@ -28,6 +30,7 @@ import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' +import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, loadRelaySelections, @@ -48,7 +51,7 @@ export class Digest { loadHandler = async () => { // Default handler for building event links - return 'https://coracle.social/' + return `${EVENT_VIEWER_URL}/` } buildParameters = async (data: DigestData) => { @@ -73,7 +76,8 @@ export class Digest { const handler = await this.loadHandler() const repliesByParentId = groupBy(getParentId, context) const eventsByPubkey = groupBy((e) => e.pubkey, events) - const popular = sortBy((e) => -(repliesByParentId.get(e.id)?.length || 0), events).slice(0, 12) + const userProfile = profilesByPubkey.get().get(this.sub.pubkey) + const sorted = sortBy((e) => e.created_at, events).slice(0, 100) const topProfiles = sortBy( ([k, ev]) => -ev.length, Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k)) @@ -82,8 +86,9 @@ export class Digest { return { Total: events.length, Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since), - Popular: popular.map((e) => getEventVariables(e)), - HasPopular: popular.length > 0, + Popular: sorted.map((e) => getEventVariables(e)), + HasPopular: sorted.length > 0, + UserName: displayProfile(userProfile, this.sub.email.split('@')[0]), TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), } } @@ -94,6 +99,7 @@ export class Digest { // Load profiles for event authors const pubkeys = new Set(events.map(e => e.pubkey)) + pubkeys.add(this.sub.pubkey) for (const pk of pubkeys) { try { await loadProfile(pk) @@ -114,6 +120,14 @@ export class Digest { const buildLink = (event: TrustedEvent, handler: string) => { const relays = Router.get().Event(event).getUrls() + const groupId = getTagValue(["h"], event.tags) + + if (groupId && relays.length > 0) { + const relay = encodeRelay(relays[0]) + const nevent = neventEncode({ id: event.id, relays }) + return `${handler}spaces/${relay}/${encodeURIComponent(groupId)}?at=${event.created_at}#${nevent}` + } + const nevent = neventEncode({ ...event, relays }) if (handler.includes('')) { @@ -123,6 +137,13 @@ const buildLink = (event: TrustedEvent, handler: string) => { } } +const encodeRelay = (url: string) => + encodeURIComponent( + normalizeRelayUrl(url) + .replace(/^wss:\/\//, '') + .replace(/\/$/, ''), + ) + const displayProfileByPubkey = (pubkey: string) => displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey)) diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index a6959cd..53f08cc 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -1,36 +1,52 @@ - New Activity + New activity on Flotilla + - .header { font-size: 24px; font-weight: bold; } - .subheader { font-size: 16px; color: #555; } - .event-item { margin-bottom: 20px; border-left: 3px solid #F45E43; padding-left: 10px; } + .header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; } + .subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; } + .event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; } .event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .event-meta-left { display: flex; align-items: center; } - .event-author { font-weight: bold; margin-right: 4px; } - .event-content { white-space: pre-wrap; } - .event-timestamp { color: #777; font-size: 12px; } - .event-link { font-size: 12px; } - .section-header { font-size: 18px; font-weight: bold; margin-top: 15px; } + .event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; } + .event-content { font-family: Inter, Helvetica, Arial, sans-serif; white-space: pre-wrap; color: #334155; font-size: 14px; } + .event-timestamp { color: #94a3b8; font-size: 12px; } + .event-link { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 600; } + .event-link a { color: {{brandAccent}}; text-decoration: none; } + .section-header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 700; color: #1e293b; margin-top: 15px; } .profile-image { width: 20px; height: 20px; border-radius: 50%; margin-right: 3px; vertical-align: middle; } - .event-stats { margin-top: 8px; color: #666; font-size: 14px; } + .user-photo { width: 32px; height: 32px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } + .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; } .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } - .stat-icon { width: 16px; height: 16px; margin-right: 4px; vertical-align: middle; } + .footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; } + .logo { max-width: 48px; max-height: 48px; } a { text-decoration: none; } - - + + - Hello {{name}}, - - Below is a summary of activity over the last {{Duration}}. - We found {{Total}} new posts from {{TopProfiles}}. + {{#brandLogo}} + + {{/brandLogo}} + + {{brandName}} - + + + + + + {{UserName}}, here's what's happening in your communities. + + + + - {{#HasPopular}} - Most Popular + {{#HasPopular}} + + + Latest Activity {{#Popular}}
@@ -41,32 +57,33 @@ at {{Timestamp}}
{{{Content}}}
- - {{Replies}} + {{Replies}} replies - - {{Reactions}} + {{Reactions}} reactions
{{/Popular}} - - {{/HasPopular}} +
+
+ {{/HasPopular}} - - You're receiving this email because you subscribed to notifications. - We'll continue to send you updates based on your subscription preferences. + + + + + You're receiving this email because you subscribed to {{brandName}} notifications. + We'll send you updates based on your subscription preferences. - - + Unsubscribe diff --git a/src/env.ts b/src/env.ts index 218e4c2..3588110 100644 --- a/src/env.ts +++ b/src/env.ts @@ -22,6 +22,11 @@ if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.') export const MAILSHIP_URL = process.env.MAILSHIP_URL export const MAILSHIP_NAME = process.env.MAILSHIP_NAME export const BASE_URL = process.env.BASE_URL +export const EVENT_VIEWER_URL = process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social' +export const BRAND_ACCENT = process.env.BRAND_ACCENT || '#7161FF' +export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla' +export const BRAND_LOGO = + process.env.BRAND_LOGO || `${EVENT_VIEWER_URL.replace(/\/$/, '')}/logo.png` export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET) export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) diff --git a/src/mailer.ts b/src/mailer.ts index 35739a1..b8e65a0 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -1,5 +1,16 @@ import nodemailer from 'nodemailer' -import { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, MAILSHIP_NAME, BASE_URL } from './env.js' +import { + SMTP_HOST, + SMTP_PORT, + SMTP_USER, + SMTP_PASSWORD, + SMTP_FROM, + BASE_URL, + EVENT_VIEWER_URL, + BRAND_ACCENT, + BRAND_NAME, + BRAND_LOGO, +} from './env.js' import type { Subscription } from './alert.js' import { render } from './templates.js' @@ -23,11 +34,32 @@ export const sendConfirm = (sub: Subscription) => { .sendMail({ from: SMTP_FROM, to: sub.email, - subject: 'Confirm your email digest', + subject: `Confirm your ${BRAND_NAME} digest`, html: ` -

Welcome to ${MAILSHIP_NAME}!

-

Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:

-

Confirm Digest

+ + + + +
+ + + + + + + +
+ ${BRAND_LOGO ? `${BRAND_NAME}` : `

${BRAND_NAME}

`} +

Digest notifications

+
+

Welcome to ${BRAND_NAME}!

+

Please confirm that you would like to receive ${sub.frequency} digests by clicking the button below:

+

+ Confirm Digest +

+

Or visit: ${EVENT_VIEWER_URL}

+
+
`, text: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`, }) @@ -51,6 +83,9 @@ export const sendDigest = async (sub: Subscription, variables: Record { From f7e0c99764d5292985762929120ce72b229354ca Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 10:17:06 -0400 Subject: [PATCH 04/21] Update subscription confirmation email template --- src/mailer.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/mailer.ts b/src/mailer.ts index b8e65a0..55889a8 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -29,12 +29,13 @@ const transporter = nodemailer.createTransport({ export const sendConfirm = (sub: Subscription) => { const href = `${BASE_URL}/confirm?token=${sub.key}` + const settingsUrl = `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts` return transporter .sendMail({ from: SMTP_FROM, to: sub.email, - subject: `Confirm your ${BRAND_NAME} digest`, + subject: `Confirm your email for ${BRAND_NAME} notifications`, html: ` @@ -42,18 +43,17 @@ export const sendConfirm = (sub: Subscription) => {
-
- ${BRAND_LOGO ? `${BRAND_NAME}` : `

${BRAND_NAME}

`} -

Digest notifications

+ ${BRAND_LOGO ? `${BRAND_NAME}` : ''} +

${BRAND_NAME}

-

Welcome to ${BRAND_NAME}!

-

Please confirm that you would like to receive ${sub.frequency} digests by clicking the button below:

+
+

Please click below to confirm your email address. We'll send occasional emails with updates from your communities on ${BRAND_NAME}.

- Confirm Digest + Confirm

-

Or visit: ${EVENT_VIEWER_URL}

+

To disable notifications, ignore this email. Or update your settings at ${EVENT_VIEWER_URL.replace(/^https?:\/\//, '')}/settings/alerts.

@@ -61,7 +61,7 @@ export const sendConfirm = (sub: Subscription) => { `, - text: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`, + text: `Please click below to receive emails for updates from your communities on ${BRAND_NAME}.\n\nConfirm: ${href}\n\nTo disable notifications, ignore this email. Or update your settings at: ${settingsUrl}`, }) .catch(error => { console.error('mailer: confirmation email failed', { From 4e8918c1f35b29249b2a7d3f8189464a337a988e Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 10:30:46 -0400 Subject: [PATCH 05/21] Add branding to email confirmation screen --- src/server.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 204fb19..9be5d7f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { instrument } from 'succinct-async' import express, { Request, Response, NextFunction } from 'express' import rateLimit from 'express-rate-limit' -import { appSigner } from './env.js' +import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL } from './env.js' import { render } from './templates.js' import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' @@ -198,7 +198,12 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { try { await confirmSubscriptionAction({ token: req.query.token }) - res.send(await render('pages/confirm-success.html')) + res.send(await render('pages/confirm-success.html', { + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + })) } catch (error) { const isActionError = error instanceof ActionError const message = isActionError ? String(error) : 'Oops, something went wrong on our end!' From d143a563ba31f2a6392d2779300643d0b942cb50 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 11:28:03 -0400 Subject: [PATCH 06/21] Process nostr events included directly in the callback. --- src/server.ts | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/server.ts b/src/server.ts index 9be5d7f..1cc71de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, Act import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' import { load } from '@welshman/net' import { getIdFilters } from '@welshman/util' +import { verifyEvent } from 'nostr-tools/pure' // Endpoints @@ -147,7 +148,7 @@ addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => // NIP-9a relay push callback addRoute('post', '/notify/:id', async (req: Request, res: Response) => { - const { id, relay } = req.body + const { id, relay, event } = req.body if (!id || !relay) { return res.status(400).json({ error: 'id and relay are required' }) @@ -164,19 +165,30 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => { return res.status(404).json({ error: 'Subscription not active' }) } - // Fetch the full event from the relay try { - const [event] = await load({ - relays: [relay], - filters: getIdFilters([id]), - }) + let storedEvent = event - if (!event) { - // Event not found at relay — don't 404, just skip - return res.json({ ok: true, skipped: true }) + if (storedEvent) { + // If the subscription requested include_event, verify and use it directly + if (storedEvent.id !== id || !validEvent(storedEvent)) { + return res.status(400).json({ error: 'Invalid event' }) + } + } else { + // Otherwise fetch the full event from the relay + const [fetched] = await load({ + relays: [relay], + filters: getIdFilters([id]), + }) + + storedEvent = fetched + + if (!storedEvent) { + // Event not found at relay — don't 404, just skip + return res.json({ ok: true, skipped: true }) + } } - const stored = await insertEvent(id, sub.id, event, relay) + const stored = await insertEvent(id, sub.id, storedEvent, relay) return res.json({ ok: true, stored }) } catch (error) { @@ -238,4 +250,11 @@ server.use((err: Error, req: Request, res: Response, next: NextFunction) => { } else { next() } -}) \ No newline at end of file +}) + +// Validate an event's signature and that its id hash matches (defense against +// a malicious relay forwarding tampered content via include_event). +const validEvent = (event: any) => { + if (!event || typeof event !== 'object') return false + return verifyEvent(event) +} \ No newline at end of file From 777ab811953b981f89cdf436f4ff7b3dfa625b37 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 14:49:56 -0400 Subject: [PATCH 07/21] Add relay to nevent in digest links --- src/digest.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/digest.ts b/src/digest.ts index 7e6e0a3..d18c515 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -25,7 +25,6 @@ import { getTagValue, } from '@welshman/util' import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { Router, addMinimalFallbacks } from '@welshman/router' import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' @@ -40,6 +39,7 @@ import { type DigestData = { events: TrustedEvent[] context: TrustedEvent[] + relayByEventId: Map } export class Digest { @@ -59,7 +59,7 @@ export class Digest { const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 }) return { - Link: buildLink(event, handler), + Link: buildLink(event, handler, data.relayByEventId.get(event.id)), Timestamp: formatter.format(secondsToDate(event.created_at)), Icon: profilesByPubkey.get().get(event.pubkey)?.picture, Name: displayProfileByPubkey(event.pubkey), @@ -96,6 +96,7 @@ export class Digest { sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => { const events = storedEvents.map(se => se.event) const context = [...events] // For now, context == events (no reply loading) + const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay])) // Load profiles for event authors const pubkeys = new Set(events.map(e => e.pubkey)) @@ -108,7 +109,7 @@ export class Digest { } } - const data = { events, context } as DigestData + const data = { events, context, relayByEventId } as DigestData if (data.events.length > 0) { await sendDigest(this.sub, await this.buildParameters(data)) @@ -118,8 +119,9 @@ export class Digest { // Utilities -const buildLink = (event: TrustedEvent, handler: string) => { - const relays = Router.get().Event(event).getUrls() +const buildLink = (event: TrustedEvent, handler: string, storedRelay?: string) => { + const relays = storedRelay ? [storedRelay] : [] + const groupId = getTagValue(["h"], event.tags) if (groupId && relays.length > 0) { From 14bc273d98e4d74f23cc50f0e787093560aba459 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 15:01:13 -0400 Subject: [PATCH 08/21] update digest footer --- src/emails/digest.mjml | 8 +++----- src/mailer.ts | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index 53f08cc..5d6525d 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -19,6 +19,7 @@ .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; } .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } .footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; } + .footer a { color: #7161FF; text-decoration: underline; } .logo { max-width: 48px; max-height: 48px; } a { text-decoration: none; } @@ -57,7 +58,7 @@ at {{Timestamp}}
{{{Content}}}
@@ -81,11 +82,8 @@ You're receiving this email because you subscribed to {{brandName}} notifications. - We'll send you updates based on your subscription preferences. + You can manage your subscriptions here or unsubscribe. - - Unsubscribe -
diff --git a/src/mailer.ts b/src/mailer.ts index 55889a8..a67b24d 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -86,6 +86,7 @@ export const sendDigest = async (sub: Subscription, variables: Record { From cf335649c03da8f355747140a2b92e7334bbffa8 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 15:18:52 -0400 Subject: [PATCH 09/21] Wait longer for profile photoes. --- src/digest.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/digest.ts b/src/digest.ts index d18c515..334a2c8 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -104,6 +104,7 @@ export class Digest { for (const pk of pubkeys) { try { await loadProfile(pk) + await waitForProfile(pk) } catch { // pass } @@ -175,4 +176,18 @@ const getFormatter = () => { timeStyle: 'short', timeZone: 'UTC', }) +} + +// Poll until the profile lands in the repository or hits a timeout. The load +// promise can resolve before sharedLoad writes the event to the store, so only +// the profile's presence here guarantees its avatar/name. +const waitForProfile = async (pubkey: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (profilesByPubkey.get().get(pubkey)) { + return + } + await new Promise(r => setTimeout(r, 200)) + } } \ No newline at end of file From 3a0aff2f30466e5750f47f4f0f5a6d72ac79392b Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 27 Aug 2026 15:23:18 -0400 Subject: [PATCH 10/21] Update confirmation page --- script/render-preview.mjs | 1 + src/pages/confirm-success.html | 54 +++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/script/render-preview.mjs b/script/render-preview.mjs index 1e88abc..c74d6ea 100644 --- a/script/render-preview.mjs +++ b/script/render-preview.mjs @@ -39,6 +39,7 @@ const sample = { }, ], unsubscribeUrl: 'https://app.flotilla.social/unsubscribe?token=abc123', + settingsUrl: 'https://app.flotilla.social/settings/alerts', } const source = readFileSync(templatePath, 'utf8') diff --git a/src/pages/confirm-success.html b/src/pages/confirm-success.html index ce4c7e2..844237e 100644 --- a/src/pages/confirm-success.html +++ b/src/pages/confirm-success.html @@ -1,36 +1,62 @@ - Confirmation Successful + + + Email Confirmed
-

Email Alert Confirmed

-
- Your alert has been successfully confirmed. You will now receive notifications. -
+ {{#brandLogo}}{{/brandLogo}} +
{{brandName}}
+

Email confirmed

+

+ Your email has been successfully confirmed. You will start receiving notifications shortly. + Visit {{brandName}} to manage your notification settings. +

- + \ No newline at end of file From ff8e1d7a0d6cada146a232b86030d15197104c7a Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 2 Sep 2026 16:51:01 -0400 Subject: [PATCH 11/21] Update styling on confirmation failed page --- src/pages/confirm-error.html | 53 ++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/pages/confirm-error.html b/src/pages/confirm-error.html index 240a99b..072f48a 100644 --- a/src/pages/confirm-error.html +++ b/src/pages/confirm-error.html @@ -1,34 +1,63 @@ - Confirmation Failed + + + Email Confirmation Failed
-

Confirmation Failed

-
{{message}}
+ {{#brandLogo}}{{/brandLogo}} +
{{brandName}}
+

Email not confirmed

+

+ {{message}} +

+ Visit {{brandName}} to try again or manage your subscription. +

- + \ No newline at end of file From 5abec46cb765ddcde925fb5bf165b217fa915d4b Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 11:47:21 -0400 Subject: [PATCH 12/21] 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. --- README.md | 16 ++++++++++++--- src/actions.ts | 8 ++++++-- src/database.ts | 22 +++++++++++++++++++-- src/server.ts | 48 +++++++++++++++++++++++++++++++++++++++------ test/integration.sh | 2 +- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1b04755..4f6f479 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Nostr email notification server. Receives events pushed from relays via NIP-9a ## Architecture ``` -Flotilla ──HTTP──▶ Mailship (POST /subscription/email) +Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) │ NIP-98 auth │ returns {key, callback} │ @@ -43,8 +43,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email) ## API -### POST /subscription/email -Register for email digests. +### PUT /subscription/email +Idempotently register or update an email subscription. Re-sends the confirmation +email only when the subscription is new or the email address changed; a frequency +change keeps the existing confirmation. ``` Body: { email, frequency, pubkey } @@ -52,6 +54,14 @@ Auth: NIP-98 (planned) Response: { key, callback } ``` +### GET /subscription/email?pubkey=... +Look up an existing subscription, so clients can avoid re-registering (and +re-confirming) when settings haven't changed. Returns 404 if none exists. + +``` +Response: { key, callback, email, frequency, confirmed } +``` + ### DELETE /subscription/:key Unsubscribe. diff --git a/src/actions.ts b/src/actions.ts index 459cd01..ceec6fe 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -23,8 +23,12 @@ export const registerSubscription = instrument( const sub = await db.insertSubscription(pubkey, email, frequency) const callback = `${process.env.BASE_URL}/notify/${sub.id}` - // Send confirmation email - await mailer.sendConfirm(sub) + // Only send a confirmation when the subscription is new, unconfirmed, or + // its email address changed. An already-confirmed, unchanged subscription + // (or one where only the frequency changed) skips it. + if (!sub.confirmed_at) { + await mailer.sendConfirm(sub) + } return { key: sub.key, callback } }, diff --git a/src/database.ts b/src/database.ts index b50fe56..268675a 100644 --- a/src/database.ts +++ b/src/database.ts @@ -101,7 +101,26 @@ export const insertSubscription = instrument( const existing = await getSubscriptionByPubkey(pubkey) if (existing) { - // Update existing + // If nothing changed, keep confirmation and don't re-validate + if (existing.email === email && existing.frequency === frequency) { + return assertResult(parseSubscription(existing)) + } + + // Update existing. Only a change of email address invalidates the + // existing confirmation (the new address must be verified); changing + // the frequency keeps it confirmed. + if (existing.email === email) { + return assertResult( + parseSubscription( + await get( + `UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL + WHERE pubkey = ? RETURNING *`, + [frequency, pubkey], + ), + ), + ) + } + return assertResult( parseSubscription( await get( @@ -113,7 +132,6 @@ export const insertSubscription = instrument( ) } - // Create new return assertResult( parseSubscription( await get( diff --git a/src/server.ts b/src/server.ts index 1cc71de..eb3fe92 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,7 +20,7 @@ const corsOrigin = process.env.CORS_ORIGIN ?? '*' server.use((req: Request, res: Response, next: NextFunction) => { res.setHeader('Access-Control-Allow-Origin', corsOrigin) - res.setHeader('Access-Control-Allow-Methods', 'GET,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-Max-Age', '86400') @@ -57,7 +57,7 @@ server.use( type Handler = (req: Request, res: Response) => Promise -const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Handler) => { +const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => { server[method]( path, instrument(path, async (req: Request, res: Response, next: NextFunction) => { @@ -89,8 +89,34 @@ addRoute('get', '/', async (req: Request, res: Response) => { }) }) -// Subscribe to email digests -addRoute('post', '/subscription/email', async (req: Request, res: Response) => { +// Look up an existing email subscription for a pubkey, so clients can avoid +// re-registering (and re-confirming) when settings haven't changed. +addRoute('get', '/subscription/email', async (req: Request, res: Response) => { + const { pubkey } = req.query + + if (!pubkey || typeof pubkey !== 'string') { + return res.status(400).json({ error: 'pubkey is required' }) + } + + const sub = await getSubscriptionByPubkey(pubkey) + + if (!sub) { + return res.status(404).json({ error: 'Subscription not found' }) + } + + const callback = `${process.env.BASE_URL}/notify/${sub.id}` + + res.json({ + key: sub.key, + callback, + email: sub.email, + frequency: sub.frequency, + confirmed: Boolean(sub.confirmed_at), + }) +}) + +// Subscribe to email digests (idempotent PUT upsert) +addRoute('put', '/subscription/email', async (req: Request, res: Response) => { const { email, frequency, pubkey } = req.body if (!email || !email.includes('@')) { @@ -202,7 +228,11 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { if (typeof req.query.token !== 'string') { return res.send( await render('pages/confirm-error.html', { - message: 'No confirmation token was provided.', + message: 'No confirmation token was provided. Please check the link in your email and try again.', + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, }) ) } @@ -220,7 +250,13 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { const isActionError = error instanceof ActionError const message = isActionError ? String(error) : 'Oops, something went wrong on our end!' - res.send(await render('pages/confirm-error.html', { message })) + res.send(await render('pages/confirm-error.html', { + message, + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + })) if (!isActionError) { throw error diff --git a/test/integration.sh b/test/integration.sh index 88dcc3a..7d43141 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -117,7 +117,7 @@ check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship" # Test 2: Register subscription echo "" echo "2. Register subscription" -REG=$(curl -s "$BASE_URL/subscription/email" -X POST -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"}') KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null) From d5f54845b68e002bd57a4450560e7190731f7757 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 13:42:50 -0400 Subject: [PATCH 13/21] dedupe and serialize subscription writes. --- src/database.ts | 187 +++++++++++++++++++++++++++++------------------- 1 file changed, 115 insertions(+), 72 deletions(-) diff --git a/src/database.ts b/src/database.ts index 268675a..336e293 100644 --- a/src/database.ts +++ b/src/database.ts @@ -63,7 +63,7 @@ export const migrate = () => unsubscribed_at INTEGER, last_digest_at INTEGER ) - `, + ` ) await run( ` @@ -75,10 +75,38 @@ export const migrate = () => received_at INTEGER NOT NULL, PRIMARY KEY (id, subscription_id) ) - `, + ` ) 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)` + ) + // Tombstone older duplicate active rows, so the unique index below can be created + // even if a previous version of the server let races insert more than one. + await run( + ` + UPDATE subscriptions + SET unsubscribed_at = created_at + WHERE unsubscribed_at IS NULL + AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY pubkey ORDER BY created_at DESC, id DESC + ) AS rn + FROM subscriptions + WHERE unsubscribed_at IS NULL + ) + WHERE rn = 1 + ) + ` + ) + // At most one active subscription per pubkey. Enforced in the DB so a + // check-then-insert race can never create duplicate digest rows. + await run( + ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_active_pubkey + ON subscriptions (pubkey) + WHERE unsubscribed_at IS NULL + ` ) resolve() }) @@ -95,60 +123,76 @@ const parseSubscription = (row: any): Subscription | undefined => { } } +export const updateSubscription = instrument( + 'database.updateSubscription', + async (existing: Subscription, email: string, frequency: string) => { + if (existing.email === email && existing.frequency === frequency) { + return existing + } + + // Update by id, not pubkey, so tombstoned rows for the same account are never + // re-activated alongside this one (the unique index would reject that anyway). + if (existing.email === email) { + return parseSubscription( + await get( + `UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL + WHERE id = ? RETURNING *`, + [frequency, existing.id] + ) + ) + } + + return parseSubscription( + await get( + `UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL + WHERE id = ? RETURNING *`, + [email, frequency, existing.id] + ) + ) + } +) + export const insertSubscription = instrument( 'database.insertSubscription', async (pubkey: string, email: string, frequency: string) => { const existing = await getSubscriptionByPubkey(pubkey) if (existing) { - // If nothing changed, keep confirmation and don't re-validate - if (existing.email === email && existing.frequency === frequency) { - return assertResult(parseSubscription(existing)) - } - - // Update existing. Only a change of email address invalidates the - // existing confirmation (the new address must be verified); changing - // the frequency keeps it confirmed. - if (existing.email === email) { - return assertResult( - parseSubscription( - await get( - `UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL - WHERE pubkey = ? RETURNING *`, - [frequency, pubkey], - ), - ), - ) - } + return assertResult(await updateSubscription(existing, email, frequency)) + } + try { return assertResult( parseSubscription( await get( - `UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL - WHERE pubkey = ? RETURNING *`, - [email, frequency, pubkey], - ), - ), + `INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING *`, + [ + crypto.randomUUID(), + crypto.randomBytes(32).toString('hex'), + pubkey, + email, + frequency, + now(), + ] + ) + ) ) - } + } catch (err: any) { + // A concurrent request inserted the active subscription between our select + // and insert. The partial unique index forces one active row per pubkey, + // so fall back to updating the row that won the race. + if (err.message?.includes('UNIQUE constraint')) { + const concurrent = await getSubscriptionByPubkey(pubkey) - return assertResult( - parseSubscription( - await get( - `INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at) - VALUES (?, ?, ?, ?, ?, ?) RETURNING *`, - [ - crypto.randomUUID(), - crypto.randomBytes(32).toString('hex'), - pubkey, - email, - frequency, - now(), - ], - ), - ), - ) - }, + if (concurrent) { + return assertResult(await updateSubscription(concurrent, email, frequency)) + } + } + + throw err + } + } ) export const confirmSubscription = instrument( @@ -157,11 +201,11 @@ export const confirmSubscription = instrument( return parseSubscription( await get( `UPDATE subscriptions SET confirmed_at = unixepoch() - WHERE key = ? AND confirmed_at IS NULL RETURNING *`, - [key], - ), + WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`, + [key] + ) ) - }, + } ) export const unsubscribeSubscription = instrument( @@ -170,43 +214,42 @@ export const unsubscribeSubscription = instrument( return parseSubscription( await get( `UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`, - [key], - ), + [key] + ) ) - }, + } ) export const getSubscriptionById = instrument( 'database.getSubscriptionById', async (id: string) => { return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id])) - }, + } ) export const getSubscriptionByKey = instrument( 'database.getSubscriptionByKey', async (key: string) => { return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key])) - }, + } ) export const getSubscriptionByPubkey = instrument( 'database.getSubscriptionByPubkey', async (pubkey: string) => { return parseSubscription( - await get( - `SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, - [pubkey], - ), + await get(`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, [ + pubkey, + ]) ) - }, + } ) export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => { const rows = await all( `SELECT * FROM subscriptions WHERE confirmed_at IS NOT NULL - AND unsubscribed_at IS NULL`, + AND unsubscribed_at IS NULL` ) return rows.map(parseSubscription) as Subscription[] @@ -216,7 +259,7 @@ export const updateLastDigestAt = instrument( 'database.updateLastDigestAt', async (id: string, timestamp: number) => { await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id]) - }, + } ) // Events @@ -236,7 +279,7 @@ export const insertEvent = instrument( await run( `INSERT INTO events (id, subscription_id, event, relay, received_at) VALUES (?, ?, ?, ?, ?)`, - [eventId, subscriptionId, JSON.stringify(event), relay, now()], + [eventId, subscriptionId, JSON.stringify(event), relay, now()] ) return true } catch (err: any) { @@ -246,7 +289,7 @@ export const insertEvent = instrument( } throw err } - }, + } ) export const getEventsForSubscription = instrument( @@ -256,29 +299,29 @@ export const getEventsForSubscription = instrument( `SELECT * FROM events WHERE subscription_id = ? AND received_at > ? ORDER BY received_at DESC`, - [subscriptionId, since], + [subscriptionId, since] ) return rows.map((row) => ({ ...row, event: JSON.parse(row.event as any), })) - }, + } ) export const deleteEventsForSubscription = instrument( 'database.deleteEventsForSubscription', async (subscriptionId: string, since: number) => { - await run( - `DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, - [subscriptionId, since], - ) - }, + await run(`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, [ + subscriptionId, + since, + ]) + } ) export const purgeEventsOlderThan = instrument( 'database.purgeEventsOlderThan', async (timestamp: number) => { await run(`DELETE FROM events WHERE received_at < ?`, [timestamp]) - }, -) \ No newline at end of file + } +) From 75f5908039773422606e56848879e74c42451248 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:08:14 -0400 Subject: [PATCH 14/21] 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). --- Dockerfile | 8 +- build-in-production.sh | 2 - package.json | 3 +- src/server.ts | 12 - web/.env.template | 3 - web/.gitignore | 24 -- web/eslint.config.js | 21 -- web/index.html | 15 -- web/package.json | 34 --- web/pnpm-lock.yaml | Bin 78215 -> 0 bytes web/pnpm-workspace.yaml | 2 - web/src/main.ts | 544 ---------------------------------------- web/src/style.css | 42 ---- web/src/vite-env.d.ts | 1 - web/tsconfig.json | 22 -- web/vite.config.js | 11 - 16 files changed, 2 insertions(+), 742 deletions(-) mode change 100755 => 100644 build-in-production.sh delete mode 100644 web/.env.template delete mode 100644 web/.gitignore delete mode 100644 web/eslint.config.js delete mode 100644 web/index.html delete mode 100644 web/package.json delete mode 100644 web/pnpm-lock.yaml delete mode 100644 web/pnpm-workspace.yaml delete mode 100644 web/src/main.ts delete mode 100644 web/src/style.css delete mode 100644 web/src/vite-env.d.ts delete mode 100644 web/tsconfig.json delete mode 100644 web/vite.config.js diff --git a/Dockerfile b/Dockerfile index dc3287a..f5feb6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,12 +25,7 @@ COPY src/ ./src/ COPY src/pages/ ./src/pages/ COPY src/emails/ ./src/emails/ -# Build web UI -COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/ -COPY web/src/ ./web/src/ -RUN cd web && pnpm install --frozen-lockfile && pnpm run build - -# Build TypeScript (runs tsc && build:html && build:web) +# Build TypeScript (runs tsc && build:html) RUN pnpm run build # Production image @@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod # Copy build artifacts COPY --from=build /app/dist/ ./dist/ -COPY --from=build /app/web/dist/ ./web/dist/ COPY --from=build /app/src/pages/ ./dist/pages/ COPY --from=build /app/src/emails/ ./dist/emails/ diff --git a/build-in-production.sh b/build-in-production.sh old mode 100755 new mode 100644 index 37bf10d..639fba3 --- a/build-in-production.sh +++ b/build-in-production.sh @@ -2,11 +2,9 @@ # 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 diff --git a/package.json b/package.json index b2b588f..9ac4de2 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "type": "module", "version": "1.0.0", "scripts": { - "build": "tsc && pnpm run build:html && pnpm run build:web", - "build:web": "cd web && pnpm run build", + "build": "tsc && pnpm run build:html", "build:html": "cp -r src/pages dist/ && cp -r src/emails dist/", "check": "tsc --noEmit && eslint src", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", diff --git a/src/server.ts b/src/server.ts index eb3fe92..4e6e8fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,8 +33,6 @@ server.use((req: Request, res: Response, next: NextFunction) => { server.use(express.json()) -server.use(express.static('web/dist')) - // Rate limit for registration endpoints server.use( '/subscription', @@ -71,16 +69,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl } addRoute('get', '/', async (req: Request, res: Response) => { - try { - const {existsSync} = await import('fs') - const webIndex = new URL('../web/dist/index.html', import.meta.url) - if (existsSync(webIndex)) { - return res.send(await render('../web/dist/index.html')) - } - } catch { - // Fall through to JSON - } - res.json({ name: 'Mailship', description: 'Email notification server for Nostr', diff --git a/web/.env.template b/web/.env.template deleted file mode 100644 index 9f0b1f1..0000000 --- a/web/.env.template +++ /dev/null @@ -1,3 +0,0 @@ -VITE_NOTIFIER_PUBKEY= -VITE_NOTIFIER_RELAY= -VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/.gitignore +++ /dev/null @@ -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? diff --git a/web/eslint.config.js b/web/eslint.config.js deleted file mode 100644 index d785258..0000000 --- a/web/eslint.config.js +++ /dev/null @@ -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"}, - ], - }, - }, -]); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index b477b77..0000000 --- a/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Anchor Alerts - - -
-
-
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c783cf3..0000000 --- a/web/package.json +++ /dev/null @@ -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" - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index dc6d6add4e6f81f33c0ef9c19c53403b022976f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78215 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 - -` - -const TRASH_ICON = ` - - - - -` - -const ARROW_LEFT_ICON = ` - - -` - -// 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) => { - 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}) diff --git a/web/src/style.css b/web/src/style.css deleted file mode 100644 index 1c1b184..0000000 --- a/web/src/style.css +++ /dev/null @@ -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); - } -} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/web/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 9d2104f..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/web/vite.config.js b/web/vite.config.js deleted file mode 100644 index bd48de3..0000000 --- a/web/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - server: { - port: 2893, - }, - plugins: [ - tailwindcss(), - ], -}) From abc993e595ec8ca1fc4aaa820dad5c1730cd85d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:09:13 -0400 Subject: [PATCH 15/21] Remove orphaned web/dist/ reference from .dockerignore --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 49ad795..4b7fa98 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules/ dist/ -web/dist/ .git/ .gitattributes .gitignore From 2fb738981e65fad6a2af2d52bd5f9854f23cc9d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:36:54 -0400 Subject: [PATCH 16/21] 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. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 9 --------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..334c00b 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,12 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,7 @@ export const removeJob = (sub: Subscription) => { } // Daily purge of events older than 7 days -const purgeJob = CronJob.from({ +CronJob.from({ cronTime: '0 0 3 * * *', // 3am UTC daily onTick: async () => { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600 From 97eaf8ab360569390bc7a8ffe8e6cf8045d13a5c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:23:31 -0400 Subject: [PATCH 17/21] 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 --- src/emails/digest.mjml | 4 +- test/digest-template.test.js | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/digest-template.test.js diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index 5d6525d..0e4ef68 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -5,7 +5,7 @@ .header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; } .subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; } - .event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; } + .event-item { margin-bottom: 20px; border-left: 3px solid {{brandAccent}}; padding-left: 12px; } .event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .event-meta-left { display: flex; align-items: center; } .event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; } @@ -19,7 +19,7 @@ .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; } .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } .footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; } - .footer a { color: #7161FF; text-decoration: underline; } + .footer a { color: {{brandAccent}}; text-decoration: underline; } .logo { max-width: 48px; max-height: 48px; } a { text-decoration: none; } diff --git a/test/digest-template.test.js b/test/digest-template.test.js new file mode 100644 index 0000000..47de77f --- /dev/null +++ b/test/digest-template.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}} +// +// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for +// .event-item border-left and .footer a color hardcode #7161FF even though +// {{brandAccent}} is passed into the template by mailer.ts and used +// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item +// border and footer links stay the default purple. +// +// The fix: replace both hardcoded #7161FF values with {{brandAccent}}. + +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'); + +let passed = 0; +let failed = 0; + +function assert(label, ok, detail) { + if (ok) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.log(` ✗ ${label} — ${detail || ''}`); + failed++; + } +} + +// Read the MJML template +const source = readFileSync(templatePath, 'utf8'); +const lines = source.split('\n'); + +console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS'); + +// Check .event-item border-left doesn't have #7161FF +const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item')); +const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')); +assert( + '.event-item border-left does NOT hardcode #7161FF', + !hasEventItemHardcoded, + hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : '' +); + +// Check .footer a color doesn't have #7161FF +const footerAIdx = lines.findIndex(l => l.includes('.footer a')); +const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')); +assert( + '.footer a color does NOT hardcode #7161FF', + !hasFooterHardcoded, + hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : '' +); + +// Check .event-item border-left uses {{brandAccent}} +const eventItemLine = lines[eventItemLineIdx]; +assert( + '.event-item border-left uses {{brandAccent}}', + eventItemLine && eventItemLine.includes('{{brandAccent}}'), + eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found' +); + +// Check .footer a color uses {{brandAccent}} +const footerALine = lines[footerAIdx]; +assert( + '.footer a color uses {{brandAccent}}', + footerALine && footerALine.includes('{{brandAccent}}'), + footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found' +); + +console.log(''); +console.log(`Results: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); \ No newline at end of file From fdc4579aa76ab501aa52b118ed2305e5e835f088 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:29:18 -0400 Subject: [PATCH 18/21] 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 --- .env.template | 4 ++ src/env.ts | 2 + src/server.ts | 18 ++++-- test/cors.test.sh | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 test/cors.test.sh diff --git a/.env.template b/.env.template index 9112de5..483338c 100644 --- a/.env.template +++ b/.env.template @@ -12,6 +12,10 @@ BRAND_LOGO= INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band DEFAULT_RELAYS=relay.damus.io,nos.lol SEARCH_RELAYS=relay.nostr.band +# 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= POSTMARK_API_KEY= POSTMARK_SENDER_ADDRESS= PORT=4738 diff --git a/src/env.ts b/src/env.ts index 3588110..3892bcd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined. if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.') if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.') if (!process.env.PORT) throw new Error('PORT is not defined.') +if (!process.env.CORS_ORIGIN) throw new Error('CORS_ORIGIN is not defined.') if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.') export const MAILSHIP_URL = process.env.MAILSHIP_URL @@ -31,6 +32,7 @@ export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET) export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl) +export const CORS_ORIGIN = process.env.CORS_ORIGIN export const PORT = process.env.PORT export const SMTP_HOST = process.env.SMTP_HOST export const SMTP_PORT = process.env.SMTP_PORT diff --git a/src/server.ts b/src/server.ts index eb3fe92..6dbbaf9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { instrument } from 'succinct-async' import express, { Request, Response, NextFunction } from 'express' import rateLimit from 'express-rate-limit' -import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL } from './env.js' +import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js' import { render } from './templates.js' import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' @@ -13,23 +13,31 @@ import { verifyEvent } from 'nostr-tools/pure' export const server: express.Application = express() +// CORS middleware for browser-facing routes only. // The browser hits /subscription with an Authorization header and Content-Type: // application/json, which triggers a CORS preflight. Answer it and allow the // configured origin so the client can register. -const corsOrigin = process.env.CORS_ORIGIN ?? '*' +// Server-to-server routes (/notify) intentionally do NOT get CORS headers. +const corsMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Skip server-to-server routes + if (req.path.startsWith('/notify')) { + return next() + } -server.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('Access-Control-Allow-Origin', corsOrigin) + res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN) res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') res.setHeader('Access-Control-Max-Age', '86400') + res.setHeader('Vary', 'Origin') if (req.method === 'OPTIONS') { return res.sendStatus(204) } next() -}) +} + +server.use('/', corsMiddleware) server.use(express.json()) diff --git a/test/cors.test.sh b/test/cors.test.sh new file mode 100644 index 0000000..3684e55 --- /dev/null +++ b/test/cors.test.sh @@ -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:-})" +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:-})" +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:-})" +fi + +echo "" +echo "=========================================" +echo " RESULTS: $PASS passed, $FAIL failed" +echo "=========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 \ No newline at end of file From 3e667fe3c6a234f7c4e5c2b980ad14a99ac57d77 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:28:53 -0400 Subject: [PATCH 19/21] fix: reschedule cron job when confirmed subscriber changes frequency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/actions.ts | 8 +- src/worker/email.ts | 6 ++ test/reschedule-on-frequency-change.test.js | 83 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 test/reschedule-on-frequency-change.test.js diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..c28caee 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -23,11 +23,13 @@ export const registerSubscription = instrument( const sub = await db.insertSubscription(pubkey, email, frequency) 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) { + // New or email-changed subscription — send a confirmation email. await mailer.sendConfirm(sub) + } 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 } diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..7a9b0c6 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -6,6 +6,12 @@ import * as db from '../database.js' const jobsById = new Map() +// Test-only accessor to inspect stored jobs +export const getJobCronSource = (id: string): string | undefined => { + const source = jobsById.get(id)?.cronTime.source + return typeof source === 'string' ? source : undefined +} + export const runJob = async (sub: Subscription) => { try { if (!sub.confirmed_at || sub.unsubscribed_at) { diff --git a/test/reschedule-on-frequency-change.test.js b/test/reschedule-on-frequency-change.test.js new file mode 100644 index 0000000..f67229f --- /dev/null +++ b/test/reschedule-on-frequency-change.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// FAILING test: frequency change does not reschedule the running cron job. +// +// The fix: actions.ts:registerSubscription calls worker.registerSubscription() +// after a DB update on an already-confirmed subscription, so addJob creates +// a new CronJob with the updated frequency on the fly. +// +// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity) +// Step 3: Register cron job with daily (simulating confirmSubscriptionAction) +// Step 4: Call registerSubscription with new frequency — the bug path +// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly + +import * as db from '../dist/database.js' +import { registerSubscription } from '../dist/actions.js' +import { getJobCronSource, removeJob } from '../dist/worker/email.js' +import { registerSubscription as regSub } from '../dist/worker/index.js' + +let passed = 0 +let failed = 0 + +function assert(label, ok, detail) { + if (ok) { + console.log(` ? ${label}`) + passed++ + } else { + console.log(` ? ${label} -- ${detail || ''}`) + failed++ + } +} + +async function main() { + await db.migrate() + + const pubkey = 'freq-test-' + Date.now() + const email = 'freq-test-' + Date.now() + '@example.com' + + // Step 1: Insert subscription directly (bypass mailer) and confirm + console.log('1. Create confirmed subscription with daily frequency') + const sub = await db.insertSubscription(pubkey, email, 'daily') + assert('subscription created', !!sub, 'insert returned null') + if (!sub) { process.exit(1) } + + const confirmed = await db.confirmSubscription(sub.key) + assert('subscription confirmed', !!confirmed, 'confirm returned null') + if (!confirmed) { process.exit(1) } + + // Step 2: Register cron job with daily (simulating confirmSubscriptionAction) + console.log('\n2. Register cron job with daily frequency') + regSub(confirmed) + const dailySource = getJobCronSource(confirmed.id) + assert( + 'cron source is daily', + dailySource === '0 0 17 * * *', + `expected 0 0 17 * * *, got ${dailySource}` + ) + + // Step 3: Register subscription again with weekly — the bug path. + // BEFORE FIX: registerSubscription skips worker call because + // sub.confirmed_at is set → cron stays daily + // AFTER FIX: registerSubscription calls worker.registerSubscription + // → addJob reschedules → cron becomes weekly + console.log('\n3. Change frequency to weekly via registerSubscription') + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + const weeklySource = getJobCronSource(confirmed.id) + assert( + 'cron source is weekly after frequency change', + weeklySource === '0 0 17 * * 1', + `expected 0 0 17 * * 1, got ${weeklySource}` + ) + + // Cleanup + const updated = await db.getSubscriptionByPubkey(pubkey) + if (updated) removeJob(updated) + + console.log('') + console.log(`Results: ${passed} passed, ${failed} failed`) + process.exit(failed > 0 ? 1 : 0) +} + +main().catch(err => { + console.error('Unhandled error in test:', err) + process.exit(1) +}) \ No newline at end of file From 97e92232c7c65c2cc1f4d16901c98b5f94c535dd Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:31:44 -0400 Subject: [PATCH 20/21] 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. --- src/env.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/env.ts b/src/env.ts index 3892bcd..a2a5e6b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' From 4497a7dee95815fc8c6afb6bb349808058c1b07c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:58:05 -0400 Subject: [PATCH 21/21] 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. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 10 +--------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..daccaf2 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,13 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' + import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,7 @@ export const removeJob = (sub: Subscription) => { } // Daily purge of events older than 7 days -const purgeJob = CronJob.from({ +CronJob.from({ cronTime: '0 0 3 * * *', // 3am UTC daily onTick: async () => { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600