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 diff --git a/.env.template b/.env.template index 4f469f2..44a4557 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 @@ -10,5 +17,9 @@ SMTP_PORT= SMTP_USER= SMTP_PASSWORD= SMTP_FROM= +# CORS_ORIGIN must be set to the exact origin your browser client runs on +# (e.g. https://app.example.com). There is no wildcard fallback — the server +# will refuse to start without it. +CORS_ORIGIN= PORT=4738 DATA_DIR=./data 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/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/README.md b/README.md index a7d388a..51c9fa7 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} │ @@ -35,6 +35,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email) | `SMTP_USER` | ✓ | SMTP username | | `SMTP_PASSWORD` | ✓ | SMTP password | | `SMTP_FROM` | ✓ | From email address for outgoing mail | +| `EVENT_VIEWER_URL` | | Base URL of the app event links open in (defaults to Flotilla at `https://app.flotilla.social`, or anything handling the same `/spaces//` 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 | | `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays | | `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays | | `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays | @@ -42,8 +46,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 } @@ -51,6 +57,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. @@ -82,6 +96,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/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 94039eb..9ac4de2 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "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}\"", "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..c74d6ea --- /dev/null +++ b/script/render-preview.mjs @@ -0,0 +1,50 @@ +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', + settingsUrl: 'https://app.flotilla.social/settings/alerts', +} + +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/actions.ts b/src/actions.ts index dd0c082..975accb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -21,8 +21,14 @@ 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) + 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/database.ts b/src/database.ts index b50fe56..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,42 +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) { - // Update existing + 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) - // Create new - 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( @@ -139,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( @@ -152,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[] @@ -198,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 @@ -218,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) { @@ -228,7 +289,7 @@ export const insertEvent = instrument( } throw err } - }, + } ) export const getEventsForSubscription = instrument( @@ -238,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 + } +) diff --git a/src/digest.ts b/src/digest.ts index 538c7d5..334c00b 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -9,17 +9,19 @@ import { import { parse, truncate, renderAsHtml } from '@welshman/content' import { TrustedEvent, + normalizeRelayUrl, getParentId, NOTE, COMMENT, REACTION, displayProfile, displayPubkey, + getTagValue, } from '@welshman/util' -import { Router } from '@welshman/router' 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, loadProfile, @@ -28,6 +30,7 @@ import { type DigestData = { events: TrustedEvent[] context: TrustedEvent[] + relayByEventId: Map } export class Digest { @@ -39,7 +42,7 @@ export class Digest { loadHandler = async () => { // Default handler for building event links - return 'https://coracle.social/' + return `${EVENT_VIEWER_URL}/` } buildParameters = async (data: DigestData) => { @@ -47,7 +50,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), @@ -64,7 +67,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)) @@ -73,8 +77,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))), } } @@ -82,18 +87,21 @@ 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)) + pubkeys.add(this.sub.pubkey) for (const pk of pubkeys) { try { await loadProfile(pk) + await waitForProfile(pk) } catch { // pass } } - 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)) @@ -103,8 +111,17 @@ 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) { + 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('')) { @@ -114,6 +131,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)) @@ -143,4 +167,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 diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index a6959cd..0e4ef68 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -1,36 +1,53 @@ - 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 {{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-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; } + .footer a { color: {{brandAccent}}; text-decoration: underline; } + .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,34 +58,32 @@ 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. + You can manage your subscriptions here or unsubscribe. - - - Unsubscribe -
diff --git a/src/env.ts b/src/env.ts index fc7d3ae..a2a5e6b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,15 +16,22 @@ 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 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) 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/mailer.ts b/src/mailer.ts index 1114471..a67b24d 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -1,12 +1,26 @@ 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' +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, @@ -15,29 +29,72 @@ 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 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

+ return transporter + .sendMail({ + from: SMTP_FROM, + to: sub.email, + subject: `Confirm your email for ${BRAND_NAME} notifications`, + html: ` + + + + +
+ + + + + + + +
+ ${BRAND_LOGO ? `${BRAND_NAME}` : ''} +

${BRAND_NAME}

+
+

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

+

+ Confirm +

+

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

+
+
`, - 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', { + 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}`, + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + }), + }) + .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/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 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 diff --git a/src/server.ts b/src/server.ts index bdc913c..5ee771e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,39 +1,46 @@ 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, 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' import { load } from '@welshman/net' import { getIdFilters } from '@welshman/util' +import { verifyEvent } from 'nostr-tools/pure' // Endpoints 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-Methods', 'GET,POST,DELETE,OPTIONS') + 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()) -server.use(express.static('web/dist')) - // Rate limit for registration endpoints server.use( '/subscription', @@ -56,7 +63,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) => { @@ -70,16 +77,6 @@ const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Hand } 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', @@ -88,8 +85,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('@')) { @@ -113,7 +136,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 SMTP 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) { @@ -144,7 +170,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' }) @@ -161,19 +187,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) { @@ -187,7 +224,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`, }) ) } @@ -195,12 +236,23 @@ 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!' - 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 @@ -230,4 +282,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 diff --git a/src/worker/email.ts b/src/worker/email.ts index df776a5..7ef4076 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/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 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 diff --git a/test/integration.sh b/test/integration.sh index e6f629a..d48122a 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -120,7 +120,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) 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 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 dc6d6ad..0000000 Binary files a/web/pnpm-lock.yaml and /dev/null differ diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml deleted file mode 100644 index 6810e3a..0000000 --- a/web/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - esbuild: true \ No newline at end of file diff --git a/web/src/main.ts b/web/src/main.ts deleted file mode 100644 index 85ae4c8..0000000 --- a/web/src/main.ts +++ /dev/null @@ -1,544 +0,0 @@ -import './style.css' - -import m from "mithril" -import {writable} from 'svelte/store' -import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib' -import {withGetter} from '@welshman/store' -import {Router} from '@welshman/router' -import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds' -import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util' -import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' -import type {ISigner} from '@welshman/signer' -import {Nip07Signer, decrypt} from '@welshman/signer' - -// Constants - -const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY - -const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined - -const INDEXER_RELAYS = import.meta.env.VITE_INDEXER_RELAYS.split(',').map(normalizeRelayUrl) - -const ALERT = 32830 - -const ALERT_STATUS = 32831 - -const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!) - -const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/ - -const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/ - -const PLUS_ICON = ` - - -` - -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(), - ], -})