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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
web/dist/
|
|
||||||
.git/
|
.git/
|
||||||
.gitattributes
|
.gitattributes
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
MAILSHIP_SECRET=
|
MAILSHIP_SECRET=
|
||||||
MAILSHIP_NAME=Mailship
|
MAILSHIP_NAME=Flotilla
|
||||||
MAILSHIP_URL=http://localhost:4738
|
MAILSHIP_URL=http://localhost:4738
|
||||||
BASE_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
|
INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
|
||||||
DEFAULT_RELAYS=relay.damus.io,nos.lol
|
DEFAULT_RELAYS=relay.damus.io,nos.lol
|
||||||
SEARCH_RELAYS=relay.nostr.band
|
SEARCH_RELAYS=relay.nostr.band
|
||||||
|
|
@ -10,5 +17,9 @@ SMTP_PORT=
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
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
|
PORT=4738
|
||||||
DATA_DIR=./data
|
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
|
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
|
## Non-Interactive Shell Commands
|
||||||
|
|
||||||
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
|
**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/pages/ ./src/pages/
|
||||||
COPY src/emails/ ./src/emails/
|
COPY src/emails/ ./src/emails/
|
||||||
|
|
||||||
# Build web UI
|
# Build TypeScript (runs tsc && build:html)
|
||||||
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)
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
|
|
@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
# Copy build artifacts
|
# Copy build artifacts
|
||||||
COPY --from=build /app/dist/ ./dist/
|
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/pages/ ./dist/pages/
|
||||||
COPY --from=build /app/src/emails/ ./dist/emails/
|
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
|
Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
|
||||||
│ NIP-98 auth
|
│ NIP-98 auth
|
||||||
│ returns {key, callback}
|
│ returns {key, callback}
|
||||||
│
|
│
|
||||||
|
|
@ -35,6 +35,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
|
||||||
| `SMTP_USER` | ✓ | SMTP username |
|
| `SMTP_USER` | ✓ | SMTP username |
|
||||||
| `SMTP_PASSWORD` | ✓ | SMTP password |
|
| `SMTP_PASSWORD` | ✓ | SMTP password |
|
||||||
| `SMTP_FROM` | ✓ | From email address for outgoing mail |
|
| `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 |
|
| `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays |
|
||||||
| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays |
|
| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays |
|
||||||
| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays |
|
| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays |
|
||||||
|
|
@ -42,8 +46,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### POST /subscription/email
|
### PUT /subscription/email
|
||||||
Register for email digests.
|
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 }
|
Body: { email, frequency, pubkey }
|
||||||
|
|
@ -51,6 +57,14 @@ Auth: NIP-98 (planned)
|
||||||
Response: { key, callback }
|
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
|
### DELETE /subscription/:key
|
||||||
Unsubscribe.
|
Unsubscribe.
|
||||||
|
|
||||||
|
|
@ -82,6 +96,16 @@ pnpm run build
|
||||||
pnpm run start
|
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
|
## Docker
|
||||||
|
|
||||||
### Quick start
|
### 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
|
# Remove link overrides
|
||||||
node remove-pnpm-overrides.js package.json
|
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
|
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
||||||
pnpm i --no-frozen-lockfile
|
pnpm i --no-frozen-lockfile
|
||||||
(cd web && pnpm i --no-frozen-lockfile)
|
|
||||||
|
|
||||||
# Build everything
|
# Build everything
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && pnpm run build:html && pnpm run build:web",
|
"build": "tsc && pnpm run build:html",
|
||||||
"build:web": "cd web && pnpm run build",
|
|
||||||
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
|
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
|
||||||
"check": "tsc --noEmit && eslint src",
|
"check": "tsc --noEmit && eslint src",
|
||||||
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
|
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"preview:digest": "node script/render-preview.mjs",
|
||||||
"run-alert": "node dist/run.js",
|
"run-alert": "node dist/run.js",
|
||||||
"test": "bash test/integration.sh",
|
"test": "bash test/integration.sh",
|
||||||
"test:server": "bash test/integration.sh --server-only"
|
"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 sub = await db.insertSubscription(pubkey, email, frequency)
|
||||||
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
||||||
|
|
||||||
// Send confirmation email
|
if (!sub.confirmed_at) {
|
||||||
await mailer.sendConfirm(sub)
|
// 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 }
|
return { key: sub.key, callback }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
171
src/database.ts
171
src/database.ts
|
|
@ -63,7 +63,7 @@ export const migrate = () =>
|
||||||
unsubscribed_at INTEGER,
|
unsubscribed_at INTEGER,
|
||||||
last_digest_at INTEGER
|
last_digest_at INTEGER
|
||||||
)
|
)
|
||||||
`,
|
`
|
||||||
)
|
)
|
||||||
await run(
|
await run(
|
||||||
`
|
`
|
||||||
|
|
@ -75,10 +75,38 @@ export const migrate = () =>
|
||||||
received_at INTEGER NOT NULL,
|
received_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (id, subscription_id)
|
PRIMARY KEY (id, subscription_id)
|
||||||
)
|
)
|
||||||
`,
|
`
|
||||||
)
|
)
|
||||||
await run(
|
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()
|
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(
|
export const insertSubscription = instrument(
|
||||||
'database.insertSubscription',
|
'database.insertSubscription',
|
||||||
async (pubkey: string, email: string, frequency: string) => {
|
async (pubkey: string, email: string, frequency: string) => {
|
||||||
const existing = await getSubscriptionByPubkey(pubkey)
|
const existing = await getSubscriptionByPubkey(pubkey)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update existing
|
return assertResult(await updateSubscription(existing, email, frequency))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
return assertResult(
|
return assertResult(
|
||||||
parseSubscription(
|
parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL
|
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
|
||||||
WHERE pubkey = ? RETURNING *`,
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
|
||||||
[email, frequency, pubkey],
|
[
|
||||||
),
|
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
|
if (concurrent) {
|
||||||
return assertResult(
|
return assertResult(await updateSubscription(concurrent, email, frequency))
|
||||||
parseSubscription(
|
}
|
||||||
await get(
|
}
|
||||||
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
|
throw err
|
||||||
[
|
}
|
||||||
crypto.randomUUID(),
|
}
|
||||||
crypto.randomBytes(32).toString('hex'),
|
|
||||||
pubkey,
|
|
||||||
email,
|
|
||||||
frequency,
|
|
||||||
now(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const confirmSubscription = instrument(
|
export const confirmSubscription = instrument(
|
||||||
|
|
@ -139,11 +201,11 @@ export const confirmSubscription = instrument(
|
||||||
return parseSubscription(
|
return parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`UPDATE subscriptions SET confirmed_at = unixepoch()
|
`UPDATE subscriptions SET confirmed_at = unixepoch()
|
||||||
WHERE key = ? AND confirmed_at IS NULL RETURNING *`,
|
WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`,
|
||||||
[key],
|
[key]
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const unsubscribeSubscription = instrument(
|
export const unsubscribeSubscription = instrument(
|
||||||
|
|
@ -152,43 +214,42 @@ export const unsubscribeSubscription = instrument(
|
||||||
return parseSubscription(
|
return parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`,
|
`UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`,
|
||||||
[key],
|
[key]
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getSubscriptionById = instrument(
|
export const getSubscriptionById = instrument(
|
||||||
'database.getSubscriptionById',
|
'database.getSubscriptionById',
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id]))
|
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id]))
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getSubscriptionByKey = instrument(
|
export const getSubscriptionByKey = instrument(
|
||||||
'database.getSubscriptionByKey',
|
'database.getSubscriptionByKey',
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key]))
|
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key]))
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getSubscriptionByPubkey = instrument(
|
export const getSubscriptionByPubkey = instrument(
|
||||||
'database.getSubscriptionByPubkey',
|
'database.getSubscriptionByPubkey',
|
||||||
async (pubkey: string) => {
|
async (pubkey: string) => {
|
||||||
return parseSubscription(
|
return parseSubscription(
|
||||||
await get(
|
await get(`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, [
|
||||||
`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`,
|
pubkey,
|
||||||
[pubkey],
|
])
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => {
|
export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => {
|
||||||
const rows = await all(
|
const rows = await all(
|
||||||
`SELECT * FROM subscriptions
|
`SELECT * FROM subscriptions
|
||||||
WHERE confirmed_at IS NOT NULL
|
WHERE confirmed_at IS NOT NULL
|
||||||
AND unsubscribed_at IS NULL`,
|
AND unsubscribed_at IS NULL`
|
||||||
)
|
)
|
||||||
|
|
||||||
return rows.map(parseSubscription) as Subscription[]
|
return rows.map(parseSubscription) as Subscription[]
|
||||||
|
|
@ -198,7 +259,7 @@ export const updateLastDigestAt = instrument(
|
||||||
'database.updateLastDigestAt',
|
'database.updateLastDigestAt',
|
||||||
async (id: string, timestamp: number) => {
|
async (id: string, timestamp: number) => {
|
||||||
await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id])
|
await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id])
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
|
|
@ -218,7 +279,7 @@ export const insertEvent = instrument(
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO events (id, subscription_id, event, relay, received_at)
|
`INSERT INTO events (id, subscription_id, event, relay, received_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
[eventId, subscriptionId, JSON.stringify(event), relay, now()],
|
[eventId, subscriptionId, JSON.stringify(event), relay, now()]
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -228,7 +289,7 @@ export const insertEvent = instrument(
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getEventsForSubscription = instrument(
|
export const getEventsForSubscription = instrument(
|
||||||
|
|
@ -238,29 +299,29 @@ export const getEventsForSubscription = instrument(
|
||||||
`SELECT * FROM events
|
`SELECT * FROM events
|
||||||
WHERE subscription_id = ? AND received_at > ?
|
WHERE subscription_id = ? AND received_at > ?
|
||||||
ORDER BY received_at DESC`,
|
ORDER BY received_at DESC`,
|
||||||
[subscriptionId, since],
|
[subscriptionId, since]
|
||||||
)
|
)
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
event: JSON.parse(row.event as any),
|
event: JSON.parse(row.event as any),
|
||||||
}))
|
}))
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const deleteEventsForSubscription = instrument(
|
export const deleteEventsForSubscription = instrument(
|
||||||
'database.deleteEventsForSubscription',
|
'database.deleteEventsForSubscription',
|
||||||
async (subscriptionId: string, since: number) => {
|
async (subscriptionId: string, since: number) => {
|
||||||
await run(
|
await run(`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, [
|
||||||
`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`,
|
subscriptionId,
|
||||||
[subscriptionId, since],
|
since,
|
||||||
)
|
])
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const purgeEventsOlderThan = instrument(
|
export const purgeEventsOlderThan = instrument(
|
||||||
'database.purgeEventsOlderThan',
|
'database.purgeEventsOlderThan',
|
||||||
async (timestamp: number) => {
|
async (timestamp: number) => {
|
||||||
await run(`DELETE FROM events WHERE received_at < ?`, [timestamp])
|
await run(`DELETE FROM events WHERE received_at < ?`, [timestamp])
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,19 @@ import {
|
||||||
import { parse, truncate, renderAsHtml } from '@welshman/content'
|
import { parse, truncate, renderAsHtml } from '@welshman/content'
|
||||||
import {
|
import {
|
||||||
TrustedEvent,
|
TrustedEvent,
|
||||||
|
normalizeRelayUrl,
|
||||||
getParentId,
|
getParentId,
|
||||||
NOTE,
|
NOTE,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
REACTION,
|
REACTION,
|
||||||
displayProfile,
|
displayProfile,
|
||||||
displayPubkey,
|
displayPubkey,
|
||||||
|
getTagValue,
|
||||||
} from '@welshman/util'
|
} from '@welshman/util'
|
||||||
import { Router } from '@welshman/router'
|
|
||||||
import { displayDuration, createElement } from './util.js'
|
import { displayDuration, createElement } from './util.js'
|
||||||
import type { Subscription } from './alert.js'
|
import type { Subscription } from './alert.js'
|
||||||
import { sendDigest } from './mailer.js'
|
import { sendDigest } from './mailer.js'
|
||||||
|
import { EVENT_VIEWER_URL } from './env.js'
|
||||||
import {
|
import {
|
||||||
profilesByPubkey,
|
profilesByPubkey,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
|
@ -28,6 +30,7 @@ import {
|
||||||
type DigestData = {
|
type DigestData = {
|
||||||
events: TrustedEvent[]
|
events: TrustedEvent[]
|
||||||
context: TrustedEvent[]
|
context: TrustedEvent[]
|
||||||
|
relayByEventId: Map<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Digest {
|
export class Digest {
|
||||||
|
|
@ -39,7 +42,7 @@ export class Digest {
|
||||||
|
|
||||||
loadHandler = async () => {
|
loadHandler = async () => {
|
||||||
// Default handler for building event links
|
// Default handler for building event links
|
||||||
return 'https://coracle.social/'
|
return `${EVENT_VIEWER_URL}/`
|
||||||
}
|
}
|
||||||
|
|
||||||
buildParameters = async (data: DigestData) => {
|
buildParameters = async (data: DigestData) => {
|
||||||
|
|
@ -47,7 +50,7 @@ export class Digest {
|
||||||
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
|
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Link: buildLink(event, handler),
|
Link: buildLink(event, handler, data.relayByEventId.get(event.id)),
|
||||||
Timestamp: formatter.format(secondsToDate(event.created_at)),
|
Timestamp: formatter.format(secondsToDate(event.created_at)),
|
||||||
Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
|
Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
|
||||||
Name: displayProfileByPubkey(event.pubkey),
|
Name: displayProfileByPubkey(event.pubkey),
|
||||||
|
|
@ -64,7 +67,8 @@ export class Digest {
|
||||||
const handler = await this.loadHandler()
|
const handler = await this.loadHandler()
|
||||||
const repliesByParentId = groupBy(getParentId, context)
|
const repliesByParentId = groupBy(getParentId, context)
|
||||||
const eventsByPubkey = groupBy((e) => e.pubkey, events)
|
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(
|
const topProfiles = sortBy(
|
||||||
([k, ev]) => -ev.length,
|
([k, ev]) => -ev.length,
|
||||||
Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k))
|
Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k))
|
||||||
|
|
@ -73,8 +77,9 @@ export class Digest {
|
||||||
return {
|
return {
|
||||||
Total: events.length,
|
Total: events.length,
|
||||||
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
|
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
|
||||||
Popular: popular.map((e) => getEventVariables(e)),
|
Popular: sorted.map((e) => getEventVariables(e)),
|
||||||
HasPopular: popular.length > 0,
|
HasPopular: sorted.length > 0,
|
||||||
|
UserName: displayProfile(userProfile, this.sub.email.split('@')[0]),
|
||||||
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
|
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,18 +87,21 @@ export class Digest {
|
||||||
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
|
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
|
||||||
const events = storedEvents.map(se => se.event)
|
const events = storedEvents.map(se => se.event)
|
||||||
const context = [...events] // For now, context == events (no reply loading)
|
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
|
// Load profiles for event authors
|
||||||
const pubkeys = new Set(events.map(e => e.pubkey))
|
const pubkeys = new Set(events.map(e => e.pubkey))
|
||||||
|
pubkeys.add(this.sub.pubkey)
|
||||||
for (const pk of pubkeys) {
|
for (const pk of pubkeys) {
|
||||||
try {
|
try {
|
||||||
await loadProfile(pk)
|
await loadProfile(pk)
|
||||||
|
await waitForProfile(pk)
|
||||||
} catch {
|
} catch {
|
||||||
// pass
|
// pass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { events, context } as DigestData
|
const data = { events, context, relayByEventId } as DigestData
|
||||||
|
|
||||||
if (data.events.length > 0) {
|
if (data.events.length > 0) {
|
||||||
await sendDigest(this.sub, await this.buildParameters(data))
|
await sendDigest(this.sub, await this.buildParameters(data))
|
||||||
|
|
@ -103,8 +111,17 @@ export class Digest {
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
|
|
||||||
const buildLink = (event: TrustedEvent, handler: string) => {
|
const buildLink = (event: TrustedEvent, handler: string, storedRelay?: string) => {
|
||||||
const relays = Router.get().Event(event).getUrls()
|
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 })
|
const nevent = neventEncode({ ...event, relays })
|
||||||
|
|
||||||
if (handler.includes('<bech32>')) {
|
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) =>
|
const displayProfileByPubkey = (pubkey: string) =>
|
||||||
displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey))
|
displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey))
|
||||||
|
|
||||||
|
|
@ -143,4 +167,18 @@ const getFormatter = () => {
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
timeZone: 'UTC',
|
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>
|
<mjml>
|
||||||
<mj-head>
|
<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">
|
<mj-style inline="inline">
|
||||||
.header { font-size: 24px; font-weight: bold; }
|
.header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; }
|
||||||
.subheader { font-size: 16px; color: #555; }
|
.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 #F45E43; padding-left: 10px; }
|
.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 { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
.event-meta-left { display: flex; align-items: center; }
|
.event-meta-left { display: flex; align-items: center; }
|
||||||
.event-author { font-weight: bold; margin-right: 4px; }
|
.event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; }
|
||||||
.event-content { white-space: pre-wrap; }
|
.event-content { font-family: Inter, Helvetica, Arial, sans-serif; white-space: pre-wrap; color: #334155; font-size: 14px; }
|
||||||
.event-timestamp { color: #777; font-size: 12px; }
|
.event-timestamp { color: #94a3b8; font-size: 12px; }
|
||||||
.event-link { font-size: 12px; }
|
.event-link { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 600; }
|
||||||
.section-header { font-size: 18px; font-weight: bold; margin-top: 15px; }
|
.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; }
|
.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-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; }
|
a { text-decoration: none; }
|
||||||
</mj-style>
|
</mj-style>
|
||||||
</mj-head>
|
</mj-head>
|
||||||
<mj-body>
|
<mj-body background-color="#f0f2f5">
|
||||||
<mj-section background-color="#f0f0f0" padding="20px">
|
<mj-section background-color="#ffffff" padding="32px 32px 8px 32px">
|
||||||
<mj-column>
|
<mj-column>
|
||||||
<mj-text css-class="header">Hello {{name}},</mj-text>
|
{{#brandLogo}}
|
||||||
<mj-text css-class="subheader">
|
<mj-image src="{{brandLogo}}" alt="{{brandName}}" align="center" width="40px" padding="0 0 8px 0" />
|
||||||
Below is a summary of activity over the last {{Duration}}.
|
{{/brandLogo}}
|
||||||
We found {{Total}} new posts from {{TopProfiles}}.
|
<mj-text css-class="header" align="center">
|
||||||
|
<span style="color: {{brandAccent}};">{{brandName}}</span>
|
||||||
</mj-text>
|
</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}}
|
{{#HasPopular}}
|
||||||
<mj-text css-class="section-header">Most Popular</mj-text>
|
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text css-class="section-header">Latest Activity</mj-text>
|
||||||
{{#Popular}}
|
{{#Popular}}
|
||||||
<mj-text>
|
<mj-text>
|
||||||
<div class="event-item">
|
<div class="event-item">
|
||||||
|
|
@ -41,34 +58,32 @@
|
||||||
<span class="event-timestamp">at {{Timestamp}}</span>
|
<span class="event-timestamp">at {{Timestamp}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-link">
|
<div class="event-link">
|
||||||
<a href="{{Link}}">View post</a>
|
<a href="{{Link}}">Open</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-content">{{{Content}}}</div>
|
<div class="event-content">{{{Content}}}</div>
|
||||||
<div class="event-stats">
|
<div class="event-stats">
|
||||||
<span class="stat-item">
|
<span class="stat-item">
|
||||||
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAASFBMVEUAAAAiIkQdJ04bJkwbKU0dJkscKEwbJ00cKEsdJkwcJ0wbKEscJkwcJ0scJ00bJ00dJk0bJ0wcJkwcKE0cJ0wcJ0wcJ0z///8ZxKH0AAAAF3RSTlMADxovOD1AQlJXW19ld4iWoKizwenq9eQPMKMAAAABYktHRBcL1piPAAAAOElEQVQY02NgoBZg40fls4qw4+WziAnxggA3I1xAmA8EBEU5YEaKsoFpZlEmNBEBHjRruTip4wsADUgCEGuuAxQAAAAASUVORK5CYII=" />
|
{{Replies}} replies
|
||||||
{{Replies}}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="stat-item">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mj-text>
|
</mj-text>
|
||||||
{{/Popular}}
|
{{/Popular}}
|
||||||
<mj-divider border-color="#cccccc" />
|
</mj-column>
|
||||||
{{/HasPopular}}
|
</mj-section>
|
||||||
|
{{/HasPopular}}
|
||||||
|
|
||||||
<mj-text>
|
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
|
||||||
You're receiving this email because you subscribed to notifications.
|
<mj-column>
|
||||||
We'll continue to send you updates based on your subscription preferences.
|
<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-text>
|
||||||
|
|
||||||
<mj-button background-color="#F45E43" color="#ffffff" href="{{unsubscribeUrl}}">
|
|
||||||
Unsubscribe
|
|
||||||
</mj-button>
|
|
||||||
</mj-column>
|
</mj-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
</mj-body>
|
</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.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.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.')
|
||||||
if (!process.env.PORT) throw new Error('PORT 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.')
|
if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.')
|
||||||
|
|
||||||
export const MAILSHIP_URL = process.env.MAILSHIP_URL
|
export const MAILSHIP_URL = process.env.MAILSHIP_URL
|
||||||
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
|
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
|
||||||
export const BASE_URL = process.env.BASE_URL
|
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 appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET)
|
||||||
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
|
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
|
||||||
export const INDEXER_RELAYS = process.env.INDEXER_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 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 PORT = process.env.PORT
|
||||||
export const SMTP_HOST = process.env.SMTP_HOST
|
export const SMTP_HOST = process.env.SMTP_HOST
|
||||||
export const SMTP_PORT = process.env.SMTP_PORT
|
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 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 type { Subscription } from './alert.js'
|
||||||
import { render } from './templates.js'
|
import { render } from './templates.js'
|
||||||
|
|
||||||
|
const secure = Number(SMTP_PORT) === 465
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: Number(SMTP_PORT),
|
port: Number(SMTP_PORT),
|
||||||
secure: true,
|
secure,
|
||||||
|
requireTLS: !secure,
|
||||||
auth: {
|
auth: {
|
||||||
user: SMTP_USER,
|
user: SMTP_USER,
|
||||||
pass: SMTP_PASSWORD,
|
pass: SMTP_PASSWORD,
|
||||||
|
|
@ -15,29 +29,72 @@ const transporter = nodemailer.createTransport({
|
||||||
|
|
||||||
export const sendConfirm = (sub: Subscription) => {
|
export const sendConfirm = (sub: Subscription) => {
|
||||||
const href = `${BASE_URL}/confirm?token=${sub.key}`
|
const href = `${BASE_URL}/confirm?token=${sub.key}`
|
||||||
|
const settingsUrl = `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`
|
||||||
|
|
||||||
return transporter.sendMail({
|
return transporter
|
||||||
from: SMTP_FROM,
|
.sendMail({
|
||||||
to: sub.email,
|
from: SMTP_FROM,
|
||||||
subject: 'Confirm your email digest',
|
to: sub.email,
|
||||||
html: `
|
subject: `Confirm your email for ${BRAND_NAME} notifications`,
|
||||||
<h3>Welcome to ${MAILSHIP_NAME}!</h3>
|
html: `
|
||||||
<p>Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:</p>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f0f2f5;padding:32px 16px;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||||
<p><a href="${href}">Confirm Digest</a></p>
|
<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>) => {
|
export const sendDigest = async (sub: Subscription, variables: Record<string, any>) => {
|
||||||
return transporter.sendMail({
|
return transporter
|
||||||
from: SMTP_FROM,
|
.sendMail({
|
||||||
to: sub.email,
|
from: SMTP_FROM,
|
||||||
subject: 'New activity',
|
to: sub.email,
|
||||||
html: await render('emails/digest.mjml', {
|
subject: 'New activity',
|
||||||
...variables,
|
html: await render('emails/digest.mjml', {
|
||||||
name: sub.email.split('@')[0],
|
...variables,
|
||||||
unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`,
|
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>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<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>
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #f5f5f5;
|
background: #f0f2f5;
|
||||||
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 40px 32px;
|
||||||
background: white;
|
background: #ffffff;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
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 {
|
.message {
|
||||||
margin-top: 1rem;
|
font-size: 15px;
|
||||||
color: #e74c3c;
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Confirmation Failed</h2>
|
{{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
|
||||||
<div class="message">{{message}}</div>
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,36 +1,62 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<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>
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #f5f5f5;
|
background: #f0f2f5;
|
||||||
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 40px 32px;
|
||||||
background: white;
|
background: #ffffff;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
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 {
|
.message {
|
||||||
margin-top: 1rem;
|
font-size: 15px;
|
||||||
color: #2ecc71;
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Email Alert Confirmed</h2>
|
{{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
|
||||||
<div class="message">
|
<div class="brand">{{brandName}}</div>
|
||||||
Your alert has been successfully confirmed. You will now receive notifications.
|
<h1 class="title">Email confirmed</h1>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
131
src/server.ts
131
src/server.ts
|
|
@ -1,39 +1,46 @@
|
||||||
import { instrument } from 'succinct-async'
|
import { instrument } from 'succinct-async'
|
||||||
import express, { Request, Response, NextFunction } from 'express'
|
import express, { Request, Response, NextFunction } from 'express'
|
||||||
import rateLimit from 'express-rate-limit'
|
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 { render } from './templates.js'
|
||||||
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
|
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
|
||||||
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
|
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
|
||||||
import { load } from '@welshman/net'
|
import { load } from '@welshman/net'
|
||||||
import { getIdFilters } from '@welshman/util'
|
import { getIdFilters } from '@welshman/util'
|
||||||
|
import { verifyEvent } from 'nostr-tools/pure'
|
||||||
|
|
||||||
// Endpoints
|
// Endpoints
|
||||||
|
|
||||||
export const server: express.Application = express()
|
export const server: express.Application = express()
|
||||||
|
|
||||||
|
// CORS middleware for browser-facing routes only.
|
||||||
// The browser hits /subscription with an Authorization header and Content-Type:
|
// The browser hits /subscription with an Authorization header and Content-Type:
|
||||||
// application/json, which triggers a CORS preflight. Answer it and allow the
|
// application/json, which triggers a CORS preflight. Answer it and allow the
|
||||||
// configured origin so the client can register.
|
// 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', CORS_ORIGIN)
|
||||||
res.setHeader('Access-Control-Allow-Origin', corsOrigin)
|
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||||
res.setHeader('Access-Control-Max-Age', '86400')
|
res.setHeader('Access-Control-Max-Age', '86400')
|
||||||
|
res.setHeader('Vary', 'Origin')
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return res.sendStatus(204)
|
return res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
server.use('/', corsMiddleware)
|
||||||
|
|
||||||
server.use(express.json())
|
server.use(express.json())
|
||||||
|
|
||||||
server.use(express.static('web/dist'))
|
|
||||||
|
|
||||||
// Rate limit for registration endpoints
|
// Rate limit for registration endpoints
|
||||||
server.use(
|
server.use(
|
||||||
'/subscription',
|
'/subscription',
|
||||||
|
|
@ -56,7 +63,7 @@ server.use(
|
||||||
|
|
||||||
type Handler = (req: Request, res: Response) => Promise<any>
|
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](
|
server[method](
|
||||||
path,
|
path,
|
||||||
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
|
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) => {
|
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({
|
res.json({
|
||||||
name: 'Mailship',
|
name: 'Mailship',
|
||||||
description: 'Email notification server for Nostr',
|
description: 'Email notification server for Nostr',
|
||||||
|
|
@ -88,8 +85,34 @@ addRoute('get', '/', async (req: Request, res: Response) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to email digests
|
// Look up an existing email subscription for a pubkey, so clients can avoid
|
||||||
addRoute('post', '/subscription/email', async (req: Request, res: Response) => {
|
// 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
|
const { email, frequency, pubkey } = req.body
|
||||||
|
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
|
|
@ -113,7 +136,10 @@ addRoute('post', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
const result = await registerSubscription({ pubkey, email, frequency })
|
const result = await registerSubscription({ pubkey, email, frequency })
|
||||||
res.json(result)
|
res.json(result)
|
||||||
} catch (error: any) {
|
} 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
|
// Look up the actual subscription key from the DB
|
||||||
const sub = await getSubscriptionByPubkey(pubkey)
|
const sub = await getSubscriptionByPubkey(pubkey)
|
||||||
if (sub) {
|
if (sub) {
|
||||||
|
|
@ -144,7 +170,7 @@ addRoute('delete', '/subscription/:key', async (req: Request, res: Response) =>
|
||||||
|
|
||||||
// NIP-9a relay push callback
|
// NIP-9a relay push callback
|
||||||
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
|
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
|
||||||
const { id, relay } = req.body
|
const { id, relay, event } = req.body
|
||||||
|
|
||||||
if (!id || !relay) {
|
if (!id || !relay) {
|
||||||
return res.status(400).json({ error: 'id and relay are required' })
|
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' })
|
return res.status(404).json({ error: 'Subscription not active' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the full event from the relay
|
|
||||||
try {
|
try {
|
||||||
const [event] = await load({
|
let storedEvent = event
|
||||||
relays: [relay],
|
|
||||||
filters: getIdFilters([id]),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!event) {
|
if (storedEvent) {
|
||||||
// Event not found at relay — don't 404, just skip
|
// If the subscription requested include_event, verify and use it directly
|
||||||
return res.json({ ok: true, skipped: true })
|
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 })
|
return res.json({ ok: true, stored })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -187,7 +224,11 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => {
|
||||||
if (typeof req.query.token !== 'string') {
|
if (typeof req.query.token !== 'string') {
|
||||||
return res.send(
|
return res.send(
|
||||||
await render('pages/confirm-error.html', {
|
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 {
|
try {
|
||||||
await confirmSubscriptionAction({ token: req.query.token })
|
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) {
|
} catch (error) {
|
||||||
const isActionError = error instanceof ActionError
|
const isActionError = error instanceof ActionError
|
||||||
const message = isActionError ? String(error) : 'Oops, something went wrong on our end!'
|
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) {
|
if (!isActionError) {
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -230,4 +282,11 @@ server.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
} else {
|
} else {
|
||||||
next()
|
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>()
|
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) => {
|
export const runJob = async (sub: Subscription) => {
|
||||||
try {
|
try {
|
||||||
if (!sub.confirmed_at || sub.unsubscribed_at) {
|
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
|
# Test 2: Register subscription
|
||||||
echo ""
|
echo ""
|
||||||
echo "2. Register subscription"
|
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"}')
|
-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)
|
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