Merge remote-tracking branch 'origin/main' into mailship-0da-readme-config-architecture-and-docker-co-6e1
This commit is contained in:
commit
c72b86e4ae
34 changed files with 905 additions and 920 deletions
|
|
@ -1,6 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
web/dist/
|
||||
.git/
|
||||
.gitattributes
|
||||
.gitignore
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
30
README.md
30
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/<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
2
build-in-production.sh
Executable file → Normal 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
|
||||
|
|
|
|||
|
|
@ -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
50
script/render-preview.mjs
Normal 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}`)
|
||||
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
|||
171
src/database.ts
171
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])
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
src/mailer.ts
101
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: `
|
||||
<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
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
131
src/server.ts
131
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<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)
|
||||
}
|
||||
|
|
@ -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
156
test/cors.test.sh
Normal 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
|
||||
74
test/digest-template.test.js
Normal file
74
test/digest-template.test.js
Normal 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);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
83
test/reschedule-on-frequency-change.test.js
Normal file
83
test/reschedule-on-frequency-change.test.js
Normal 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)
|
||||
})
|
||||
|
|
@ -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
24
web/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
@ -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"},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
|
@ -1,2 +0,0 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
544
web/src/main.ts
544
web/src/main.ts
|
|
@ -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})
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 2893,
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Loading…
Reference in a new issue