Merge remote-tracking branch 'origin/main' into mailship-0da-readme-config-architecture-and-docker-co-6e1

This commit is contained in:
mplorentz 2026-09-14 12:55:35 -04:00
commit c72b86e4ae
34 changed files with 905 additions and 920 deletions

View file

@ -1,6 +1,5 @@
node_modules/
dist/
web/dist/
.git/
.gitattributes
.gitignore

View file

@ -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/<relay>/<group> and /<nevent> 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

View file

@ -23,6 +23,17 @@ bd close <id> # 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.

View file

@ -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/

View file

@ -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/<relay>/<hash>` and `/<nevent>` 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 `<EVENT_VIEWER_URL>/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

2
build-in-production.sh Executable file → Normal file
View file

@ -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

View file

@ -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"

50
script/render-preview.mjs Normal file
View file

@ -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: '<p>Does anyone know how relay-based groups work?</p>',
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:
'<p>Excited to share our new community space on Flotilla! <a href="https://flotilla.social">Check it out</a>.</p>',
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}`)

View file

@ -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 }
},

View file

@ -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])
},
)
}
)

View file

@ -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<string, string>
}
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('<bech32>')) {
@ -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))
}
}

View file

@ -1,36 +1,53 @@
<mjml>
<mj-head>
<mj-title>New Activity</mj-title>
<mj-title>New activity on Flotilla</mj-title>
<mj-font name="Inter" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700" />
<mj-style inline="inline">
.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; }
</mj-style>
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0" padding="20px">
<mj-body background-color="#f0f2f5">
<mj-section background-color="#ffffff" padding="32px 32px 8px 32px">
<mj-column>
<mj-text css-class="header">Hello {{name}},</mj-text>
<mj-text css-class="subheader">
Below is a summary of activity over the last {{Duration}}.
We found {{Total}} new posts from {{TopProfiles}}.
{{#brandLogo}}
<mj-image src="{{brandLogo}}" alt="{{brandName}}" align="center" width="40px" padding="0 0 8px 0" />
{{/brandLogo}}
<mj-text css-class="header" align="center">
<span style="color: {{brandAccent}};">{{brandName}}</span>
</mj-text>
<mj-divider border-color="#cccccc" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="8px 32px 24px 32px">
<mj-column>
<mj-text css-class="subheader" align="center">
{{UserName}}, here's what's happening in your communities.
</mj-text>
<mj-divider border-color="#e2e8f0" />
</mj-column>
</mj-section>
{{#HasPopular}}
<mj-text css-class="section-header">Most Popular</mj-text>
{{#HasPopular}}
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>
<mj-text css-class="section-header">Latest Activity</mj-text>
{{#Popular}}
<mj-text>
<div class="event-item">
@ -41,34 +58,32 @@
<span class="event-timestamp">at {{Timestamp}}</span>
</div>
<div class="event-link">
<a href="{{Link}}">View post</a>
<a href="{{Link}}">Open</a>
</div>
</div>
<div class="event-content">{{{Content}}}</div>
<div class="event-stats">
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAASFBMVEUAAAAiIkQdJ04bJkwbKU0dJkscKEwbJ00cKEsdJkwcJ0wbKEscJkwcJ0scJ00bJ00dJk0bJ0wcJkwcKE0cJ0wcJ0wcJ0z///8ZxKH0AAAAF3RSTlMADxovOD1AQlJXW19ld4iWoKizwenq9eQPMKMAAAABYktHRBcL1piPAAAAOElEQVQY02NgoBZg40fls4qw4+WziAnxggA3I1xAmA8EBEU5YEaKsoFpZlEmNBEBHjRruTip4wsADUgCEGuuAxQAAAAASUVORK5CYII=" />
{{Replies}}
{{Replies}} replies
</span>
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAn1BMVEUAAAAAAIAAAFUzMzMrK1UkJEkVK1UkJEkiIkQgIFAeLUscK0cbKFEbJEkaI08aK00ZKUocJUobKU0bKEwaKEsaJ04bJ0sbJ0sdJ0wcJ00cJk0cJkwcJk0bKEwbKEscKEwcJ00cJ0wcJ0wcJ0sbJk0bJ0wcJ0wcJ0sbJ0wdJ0wcJ00cKE0cJ0wbJ0wbJ0scJ0wcJ0wcJ0wcJ0wcJ0z///++FK4PAAAANHRSTlMAAgMFBgcMDg8QERITHB0eHzc4OTo7QVVhY2RleHl6h4iJiouMnq+wsbK+wcLD3+Dh4vHyB17REwAAAAFiS0dENKmx6f0AAACTSURBVBgZBcEJQoJAAADAIUhQkjxKJZdKyzPLXfj/35oBPDZNAYCXy5BSf14CHr7um5KyjfsMfP9MgPq2h9f7BKBOC1w2ALydKIaSLtAFqj43+0MIhID4bBYBIDWKoQJg3OecW9aH1eqwZnvEMtZG79drN1KnOexuNfD0+wGyXWwrxtv0mQEWpz6l/jgHQD6d5sA/VqYMPJkJl3UAAAAASUVORK5CYII=" />
{{Reactions}}
{{Reactions}} reactions
</span>
</div>
</div>
</mj-text>
{{/Popular}}
<mj-divider border-color="#cccccc" />
{{/HasPopular}}
</mj-column>
</mj-section>
{{/HasPopular}}
<mj-text>
You're receiving this email because you subscribed to notifications.
We'll continue to send you updates based on your subscription preferences.
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
<mj-column>
<mj-divider border-color="#e2e8f0" />
<mj-text css-class="footer">
You're receiving this email because you subscribed to {{brandName}} notifications.
You can manage your subscriptions <a href="{{settingsUrl}}">here</a> or <a href="{{unsubscribeUrl}}">unsubscribe</a>.
</mj-text>
<mj-button background-color="#F45E43" color="#ffffff" href="{{unsubscribeUrl}}">
Unsubscribe
</mj-button>
</mj-column>
</mj-section>
</mj-body>

View file

@ -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

View file

@ -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: `
<h3>Welcome to ${MAILSHIP_NAME}!</h3>
<p>Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:</p>
<p><a href="${href}">Confirm Digest</a></p>
return transporter
.sendMail({
from: SMTP_FROM,
to: sub.email,
subject: `Confirm your email for ${BRAND_NAME} notifications`,
html: `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f0f2f5;padding:32px 16px;font-family:Inter,Helvetica,Arial,sans-serif;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="padding:28px 32px 8px 32px;text-align:center;">
${BRAND_LOGO ? `<img src="${BRAND_LOGO}" alt="${BRAND_NAME}" width="40" style="width:40px;height:auto;display:block;margin:0 auto 12px;" />` : ''}
<h1 style="margin:0;font-size:24px;color:#1e293b;font-weight:700;"><span style="color:${BRAND_ACCENT};">${BRAND_NAME}</span></h1>
</td>
</tr>
<tr>
<td style="padding:16px 32px 24px;color:#334155;font-size:15px;line-height:1.6;">
<p style="margin:0 0 12px;">Please click below to confirm your email address. We'll send occasional emails with updates from your communities on ${BRAND_NAME}.</p>
<p style="margin:0;text-align:center;">
<a href="${href}" style="display:inline-block;background:${BRAND_ACCENT};color:#ffffff;padding:12px 28px;border-radius:8px;font-weight:600;text-decoration:none;">Confirm</a>
</p>
<p style="margin:16px 0 0;font-size:12px;color:#94a3b8;">To disable notifications, ignore this email. Or update your settings at <a href="${settingsUrl}" style="color:${BRAND_ACCENT};">${EVENT_VIEWER_URL.replace(/^https?:\/\//, '')}/settings/alerts</a>.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`,
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<string, any>) => {
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
})
}

View file

@ -1,34 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<title>Confirmation Failed</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Confirmation Failed</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
background: #f0f2f5;
color: #1e293b;
}
.container {
width: 100%;
max-width: 480px;
margin: 16px;
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 40px 32px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.logo { height: 40px; width: auto; margin: 0 auto 16px; display: block; }
.brand {
font-size: 24px;
font-weight: 700;
margin: 0 0 28px;
color: {{brandAccent}};
}
.title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
color: #dc2626;
}
.message {
margin-top: 1rem;
color: #e74c3c;
font-size: 15px;
line-height: 1.6;
color: #64748b;
margin: 0;
}
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
<h2>Confirmation Failed</h2>
<div class="message">{{message}}</div>
{{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
<div class="brand">{{brandName}}</div>
<h1 class="title">Email not confirmed</h1>
<p class="message">
{{message}}
<br /><br />
Visit <a href="{{settingsUrl}}">{{brandName}}</a> to try again or manage your subscription.
</p>
</div>
</body>
</html>
</html>

View file

@ -1,36 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<title>Confirmation Successful</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Confirmed</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
background: #f0f2f5;
color: #1e293b;
}
.container {
width: 100%;
max-width: 480px;
margin: 16px;
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 40px 32px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.logo { height: 40px; width: auto; margin: 0 auto 16px; display: block; }
.brand {
font-size: 24px;
font-weight: 700;
margin: 0 0 28px;
color: {{brandAccent}};
}
.title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
color: #1e293b;
}
.message {
margin-top: 1rem;
color: #2ecc71;
font-size: 15px;
line-height: 1.6;
color: #64748b;
margin: 0;
}
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
<h2>Email Alert Confirmed</h2>
<div class="message">
Your alert has been successfully confirmed. You will now receive notifications.
</div>
{{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
<div class="brand">{{brandName}}</div>
<h1 class="title">Email confirmed</h1>
<p class="message">
Your email has been successfully confirmed. You will start receiving notifications shortly.
Visit <a href="{{settingsUrl}}">{{brandName}}</a> to manage your notification settings.
</p>
</div>
</body>
</html>
</html>

View file

@ -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<any>
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()
}
})
})
// 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)
}

View file

@ -6,6 +6,12 @@ import * as db from '../database.js'
const jobsById = new Map<string, CronJob>()
// 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) {

156
test/cors.test.sh Normal file
View file

@ -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:-<none>})"
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:-<none>})"
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:-<none>})"
fi
echo ""
echo "========================================="
echo " RESULTS: $PASS passed, $FAIL failed"
echo "========================================="
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -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);

View file

@ -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)

View file

@ -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)
})

View file

@ -1,3 +0,0 @@
VITE_NOTIFIER_PUBKEY=
VITE_NOTIFIER_RELAY=
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band

24
web/.gitignore vendored
View file

@ -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?

View file

@ -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"},
],
},
},
]);

View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anchor Alerts</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div id="app" class="max-w-2xl mx-auto"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -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"
}
}

Binary file not shown.

View file

@ -1,2 +0,0 @@
allowBuilds:
esbuild: true

View file

@ -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 = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
const TRASH_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
</svg>`
const ARROW_LEFT_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
// 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<AlertValues>) => {
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})

View file

@ -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);
}
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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"]
}

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
server: {
port: 2893,
},
plugins: [
tailwindcss(),
],
})