Initial mailship fork from anchor

Fork anchor, strip all push notification code (APNs, FCM, WebPush),
rename from anchor to mailship, add new database schema for
subscriptions + events tables, and add HTTP API for email
notification registration and NIP-9a relay push callbacks.

- POST /subscription/email — register for email digests
- DELETE /subscription/:key — unsubscribe
- POST /notify/:id — NIP-9a relay push callback
- GET /confirm?token=... — confirm email
- GET /unsubscribe?token=... — unsubscribe

Co-authored-by: mplorentz
This commit is contained in:
Agent 2026-08-18 12:21:22 -04:00
commit 21e7058862
45 changed files with 2483 additions and 0 deletions

10
.env.template Normal file
View file

@ -0,0 +1,10 @@
MAILSHIP_SECRET=
MAILSHIP_NAME=Mailship
MAILSHIP_URL=http://localhost:4738
BASE_URL=http://localhost:4738
INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
DEFAULT_RELAYS=relay.damus.io,nos.lol
SEARCH_RELAYS=relay.nostr.band
POSTMARK_API_KEY=
POSTMARK_SENDER_ADDRESS=
PORT=4738

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
pnpm-lock.yaml -diff

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.env
node_modules
yarn-error.log
anchor.db
*firebase*
*.p8
dist
emails/*.html
mailship.db
dist/

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
lts/jod

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
*.html
*.mjml

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Coracle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

84
README.md Normal file
View file

@ -0,0 +1,84 @@
# Mailship
A Nostr email notification server. Receives events pushed from relays via NIP-9a, stores them, and sends daily or weekly digest emails.
## Architecture
```
Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
│ NIP-98 auth
│ returns {key, callback}
│
└──kind 30390──▶ relay or NPB
│
event matches filter
│
▼
POST /notify/:subId ──▶ Mailship
{id, relay} │
UUID in path ├── fetch event from relay
is the auth ├── store in SQLite (dedup)
│
cron fires ──▶ render digest ──▶ Postmark ──▶ email
```
## Configuration
| Variable | Required | Description |
|---|---|---|
| `MAILSHIP_SECRET` | ✓ | A nostr private key hex string for the server's identity |
| `MAILSHIP_NAME` | ✓ | Name of this Mailship instance |
| `MAILSHIP_URL` | ✓ | Public URL of this instance |
| `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) |
| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails |
| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark |
| `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays |
| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays |
| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays |
| `PORT` | | Port to run on (default: 3000) |
## API
### POST /subscription/email
Register for email digests.
```
Body: { email, frequency, pubkey }
Auth: NIP-98 (planned)
Response: { key, callback }
```
### DELETE /subscription/:key
Unsubscribe.
```
Auth: NIP-98 (planned)
Response: { ok: true }
```
### POST /notify/:id
NIP-9a relay push callback. Called by relays or NPB when matching events are found.
```
Body: { id, relay }
Response: { ok: true, stored: boolean }
Returns 404 if subscription not found or inactive.
```
### GET /confirm?token=...
Confirm email address via link from confirmation email.
### GET /unsubscribe?token=...
Unsubscribe via link from digest email.
## Development
```sh
pnpm install
pnpm run build
pnpm run start
```
## Forked from Anchor
Mailship is a fork of [Anchor](https://github.com/coracle-social/anchor), stripped of push notification support and adapted for NIP-9a relay push event intake.

12
build-in-production.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Remove link overrides
node remove-pnpm-overrides.js package.json
node remove-pnpm-overrides.js web/package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
(cd web && pnpm i --no-frozen-lockfile)
# Build everything
pnpm run build

31
eslint.config.mjs Normal file
View file

@ -0,0 +1,31 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
/** @type {import('eslint').Linter.Config[]} */
export default [
{
...pluginJs.configs.recommended,
files: ["src/**/*.{js,ts}"],
languageOptions: {
sourceType: "module",
globals: globals.node,
parser: tsParser,
parserOptions: {
project: "./tsconfig.json"
}
},
plugins: {
"@typescript-eslint": tsPlugin
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': "off",
"@typescript-eslint/no-unused-vars": [
"error",
{args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"},
],
},
},
];

68
package.json Normal file
View file

@ -0,0 +1,68 @@
{
"name": "mailship",
"type": "module",
"version": "1.0.0",
"scripts": {
"build": "tsc && pnpm run build:html && pnpm run build:web",
"build:web": "cd web && pnpm run build",
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
"check": "tsc --noEmit && eslint src",
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
"start": "node dist/index.js",
"run-alert": "node dist/run.js"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.3",
"@types/express-ws": "^3.0.5",
"@types/mjml": "^4.7.4",
"@types/mustache": "^4.2.6",
"@types/sanitize-html": "^2.16.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"eslint": "^9.35.0",
"globals": "^15.15.0",
"onchange": "^7.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
},
"dependencies": {
"@types/node": "^22.18.1",
"@welshman/content": "^0.6.3",
"@welshman/feeds": "^0.6.3",
"@welshman/lib": "^0.6.3",
"@welshman/net": "^0.6.3",
"@welshman/router": "^0.6.3",
"@welshman/signer": "^0.6.3",
"@welshman/store": "^0.6.3",
"@welshman/util": "^0.6.3",
"bcrypt": "^5.1.1",
"cron": "^4.3.3",
"cron-parser": "^5.3.1",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"express-ws": "^5.0.2",
"localstorage-polyfill": "^1.0.1",
"mjml": "^4.15.3",
"mustache": "^4.2.0",
"nostr-tools": "^2.16.2",
"postmark": "^4.0.5",
"sanitize-html": "^2.17.0",
"sqlite3": "^5.1.7",
"succinct-async": "^1.0.4",
"ts-node-dev": "^2.0.0",
"ws": "^8.18.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"sqlite3"
]
}
}

BIN
pnpm-lock.yaml Normal file

Binary file not shown.

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
allowBuilds:
bcrypt: true
sqlite3: true

20
remove-pnpm-overrides.js Normal file
View file

@ -0,0 +1,20 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith('package.json')) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
delete pkg.pnpm.overrides
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log(`Removed pnpm.overrides from ${pkgName}`)
} else {
console.log(`No pnpm.overrides found in ${pkgName}`)
}

63
src/actions.ts Normal file
View file

@ -0,0 +1,63 @@
import { instrument } from 'succinct-async'
import type { Subscription } from './alert.js'
import { getCronExpression } from './alert.js'
import * as mailer from './mailer.js'
import * as worker from './worker/index.js'
import * as db from './database.js'
export class ActionError extends Error {
toString() {
return this.message
}
}
export type RegisterSubscriptionParams = {
pubkey: string
email: string
frequency: string
}
export const registerSubscription = instrument(
'actions.registerSubscription',
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency)
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
// Send confirmation email
await mailer.sendConfirm(sub)
return { key: sub.key, callback }
},
)
export type ConfirmSubscriptionParams = {
token: string
}
export const confirmSubscriptionAction = instrument(
'actions.confirmSubscription',
async ({ token }: ConfirmSubscriptionParams) => {
const sub = await db.confirmSubscription(token)
if (!sub) {
throw new ActionError('That confirmation code is invalid or has expired.')
}
worker.registerSubscription(sub)
},
)
export type UnsubscribeParams = {
token: string
}
export const unsubscribeAction = instrument(
'actions.unsubscribe',
async ({ token }: UnsubscribeParams) => {
const sub = await db.unsubscribeSubscription(token)
if (sub) {
worker.unregisterSubscription(sub)
}
},
)

35
src/alert.ts Normal file
View file

@ -0,0 +1,35 @@
import { CronExpressionParser } from 'cron-parser'
import { tryCatch, int, HOUR } from '@welshman/lib'
export type Subscription = {
id: string
key: string
pubkey: string
email: string
frequency: string
created_at: number
confirmed_at?: number
unsubscribed_at?: number
last_digest_at?: number
}
export const getSubscriptionError = (sub: Subscription) => {
if (!sub.email?.includes('@')) {
return 'Please provide a valid email address'
}
if (!['daily', 'weekly'].includes(sub.frequency)) {
return 'Frequency must be "daily" or "weekly"'
}
// Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC.
// Validation: just ensure frequency is valid, cron is generated internally.
}
export const getCronExpression = (frequency: string, hour = 17, minute = 0) => {
if (frequency === 'daily') {
return `0 ${minute} ${hour} * * *`
}
// Weekly on Monday
return `0 ${minute} ${hour} * * 1`
}

265
src/database.ts Normal file
View file

@ -0,0 +1,265 @@
/* eslint @typescript-eslint/no-unused-vars: 0 */
import sqlite3 from 'sqlite3'
import crypto from 'crypto'
import { instrument } from 'succinct-async'
import { now } from '@welshman/lib'
import type { Subscription } from './alert.js'
const db = new sqlite3.Database('mailship.db')
type Param = number | string | boolean
type Row = Record<string, any>
const run = (query: string, params: Param[] = []) =>
new Promise((resolve, reject) => {
db.run(query, params, function (err) {
return err ? reject(err) : resolve(this.changes > 0)
})
})
// prettier-ignore
const all = <T=Row>(query: string, params: Param[] = []) =>
new Promise<T[]>((resolve, reject) => {
db.all(query, params, (err, rows: T[]) => (err ? reject(err) : resolve(rows)))
})
// prettier-ignore
const get = <T=Row>(query: string, params: Param[] = []) =>
new Promise<T | undefined>((resolve, reject) => {
db.get(query, params, (err, row) => {
if (err) {
reject(err)
} else if (row) {
resolve(row as T)
} else {
resolve(undefined)
}
})
})
async function assertResult<T>(p: T | Promise<T>) {
return (await p)!
}
// Migrations
export const migrate = () =>
new Promise<void>(async (resolve, reject) => {
try {
db.serialize(async () => {
await run(
`
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL,
email TEXT NOT NULL,
frequency TEXT NOT NULL DEFAULT 'daily',
created_at INTEGER NOT NULL,
confirmed_at INTEGER,
unsubscribed_at INTEGER,
last_digest_at INTEGER
)
`,
)
await run(
`
CREATE TABLE IF NOT EXISTS events (
id TEXT NOT NULL,
subscription_id TEXT NOT NULL,
event JSON NOT NULL,
relay TEXT NOT NULL,
received_at INTEGER NOT NULL,
PRIMARY KEY (id, subscription_id)
)
`,
)
await run(
`CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)`,
)
resolve()
})
} catch (err) {
reject(err)
}
})
// Subscriptions
const parseSubscription = (row: any): Subscription | undefined => {
if (row) {
return row as Subscription
}
}
export const insertSubscription = instrument(
'database.insertSubscription',
async (pubkey: string, email: string, frequency: string) => {
const existing = await getSubscriptionByPubkey(pubkey)
if (existing) {
// Update existing
return assertResult(
parseSubscription(
await get(
`UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL
WHERE pubkey = ? RETURNING *`,
[email, frequency, pubkey],
),
),
)
}
// Create new
return assertResult(
parseSubscription(
await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
[
crypto.randomUUID(),
crypto.randomBytes(32).toString('hex'),
pubkey,
email,
frequency,
now(),
],
),
),
)
},
)
export const confirmSubscription = instrument(
'database.confirmSubscription',
async (key: string) => {
return parseSubscription(
await get(
`UPDATE subscriptions SET confirmed_at = unixepoch()
WHERE key = ? AND confirmed_at IS NULL RETURNING *`,
[key],
),
)
},
)
export const unsubscribeSubscription = instrument(
'database.unsubscribeSubscription',
async (key: string) => {
return parseSubscription(
await get(
`UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`,
[key],
),
)
},
)
export const getSubscriptionById = instrument(
'database.getSubscriptionById',
async (id: string) => {
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id]))
},
)
export const getSubscriptionByKey = instrument(
'database.getSubscriptionByKey',
async (key: string) => {
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key]))
},
)
export const getSubscriptionByPubkey = instrument(
'database.getSubscriptionByPubkey',
async (pubkey: string) => {
return parseSubscription(
await get(
`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`,
[pubkey],
),
)
},
)
export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => {
const rows = await all(
`SELECT * FROM subscriptions
WHERE confirmed_at IS NOT NULL
AND unsubscribed_at IS NULL`,
)
return rows.map(parseSubscription) as Subscription[]
})
export const updateLastDigestAt = instrument(
'database.updateLastDigestAt',
async (id: string, timestamp: number) => {
await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id])
},
)
// Events
export type StoredEvent = {
id: string
subscription_id: string
event: any
relay: string
received_at: number
}
export const insertEvent = instrument(
'database.insertEvent',
async (eventId: string, subscriptionId: string, event: any, relay: string) => {
try {
await run(
`INSERT INTO events (id, subscription_id, event, relay, received_at)
VALUES (?, ?, ?, ?, ?)`,
[eventId, subscriptionId, JSON.stringify(event), relay, now()],
)
return true
} catch (err: any) {
// PRIMARY KEY collision = dedup, not an error
if (err.message?.includes('UNIQUE constraint')) {
return false
}
throw err
}
},
)
export const getEventsForSubscription = instrument(
'database.getEventsForSubscription',
async (subscriptionId: string, since: number) => {
const rows = await all<StoredEvent>(
`SELECT * FROM events
WHERE subscription_id = ? AND received_at > ?
ORDER BY received_at DESC`,
[subscriptionId, since],
)
return rows.map((row) => ({
...row,
event: JSON.parse(row.event as any),
}))
},
)
export const deleteEventsForSubscription = instrument(
'database.deleteEventsForSubscription',
async (subscriptionId: string, since: number) => {
await run(
`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`,
[subscriptionId, since],
)
},
)
export const purgeEventsOlderThan = instrument(
'database.purgeEventsOlderThan',
async (timestamp: number) => {
await run(`DELETE FROM events WHERE received_at < ?`, [timestamp])
},
)

155
src/digest.ts Normal file
View file

@ -0,0 +1,155 @@
import { neventEncode, decode } from 'nostr-tools/nip19'
import {
spec,
now,
sortBy,
groupBy,
displayList,
nth,
nthEq,
dateToSeconds,
secondsToDate,
} from '@welshman/lib'
import { parse, truncate, renderAsHtml } from '@welshman/content'
import {
TrustedEvent,
getParentId,
getIdFilters,
getReplyFilters,
NOTE,
COMMENT,
REACTION,
displayProfile,
displayPubkey,
} from '@welshman/util'
import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net'
import { Router, addMinimalFallbacks } from '@welshman/router'
import { call } from '@welshman/lib'
import { displayDuration, createElement } from './util.js'
import type { Subscription } from './alert.js'
import { sendDigest } from './mailer.js'
import {
profilesByPubkey,
loadRelaySelections,
loadProfile,
} from './repository.js'
type DigestData = {
events: TrustedEvent[]
context: TrustedEvent[]
}
export class Digest {
since: number
constructor(readonly sub: Subscription) {
this.since = sub.last_digest_at || sub.created_at
}
loadHandler = async () => {
// Default handler for building event links
return 'https://coracle.social/'
}
buildParameters = async (data: DigestData) => {
const getEventVariables = (event: TrustedEvent) => {
const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 })
return {
Link: buildLink(event, handler),
Timestamp: formatter.format(secondsToDate(event.created_at)),
Icon: profilesByPubkey.get().get(event.pubkey)?.picture,
Name: displayProfileByPubkey(event.pubkey),
Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(),
Replies:
repliesByParentId.get(event.id)?.filter((e) => [COMMENT, NOTE].includes(e.kind))
?.length || 0,
Reactions: repliesByParentId.get(event.id)?.filter(spec({ kind: REACTION }))?.length || 0,
}
}
const { events, context } = data
const formatter = getFormatter()
const handler = await this.loadHandler()
const repliesByParentId = groupBy(getParentId, context)
const eventsByPubkey = groupBy((e) => e.pubkey, events)
const popular = sortBy((e) => -(repliesByParentId.get(e.id)?.length || 0), events).slice(0, 12)
const topProfiles = sortBy(
([k, ev]) => -ev.length,
Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k))
)
return {
Total: events.length,
Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since),
Popular: popular.map((e) => getEventVariables(e)),
HasPopular: popular.length > 0,
TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))),
}
}
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
const events = storedEvents.map(se => se.event)
const context = [...events] // For now, context == events (no reply loading)
// Load profiles for event authors
const pubkeys = new Set(events.map(e => e.pubkey))
for (const pk of pubkeys) {
try {
await loadProfile(pk)
} catch {
// pass
}
}
const data = { events, context } as DigestData
if (data.events.length > 0) {
await sendDigest(this.sub, await this.buildParameters(data))
}
}
}
// Utilities
const buildLink = (event: TrustedEvent, handler: string) => {
const relays = Router.get().Event(event).getUrls()
const nevent = neventEncode({ ...event, relays })
if (handler.includes('<bech32>')) {
return handler.replace('<bech32>', nevent)
} else {
return handler + nevent
}
}
const displayProfileByPubkey = (pubkey: string) =>
displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey))
const renderEntity = (entity: string) => {
let display = entity.slice(0, 16) + '…'
try {
const { type, data } = decode(entity)
if (type === 'npub') {
display = '@' + displayProfileByPubkey(data)
}
if (type === 'nprofile') {
display = '@' + displayProfileByPubkey(data.pubkey)
}
} catch (e) {
// Pass
}
return display
}
const getFormatter = () => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'UTC',
})
}

75
src/emails/digest.mjml Normal file
View file

@ -0,0 +1,75 @@
<mjml>
<mj-head>
<mj-title>New Activity</mj-title>
<mj-style inline="inline">
.header { font-size: 24px; font-weight: bold; }
.subheader { font-size: 16px; color: #555; }
.event-item { margin-bottom: 20px; border-left: 3px solid #F45E43; padding-left: 10px; }
.event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.event-meta-left { display: flex; align-items: center; }
.event-author { font-weight: bold; margin-right: 4px; }
.event-content { white-space: pre-wrap; }
.event-timestamp { color: #777; font-size: 12px; }
.event-link { font-size: 12px; }
.section-header { font-size: 18px; font-weight: bold; margin-top: 15px; }
.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; }
.stat-item { display: inline-flex; align-items: center; margin-right: 12px; }
.stat-icon { width: 16px; height: 16px; margin-right: 4px; vertical-align: middle; }
a { text-decoration: none; }
</mj-style>
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0" padding="20px">
<mj-column>
<mj-text css-class="header">Hello {{name}},</mj-text>
<mj-text css-class="subheader">
Below is a summary of activity over the last {{Duration}}.
We found {{Total}} new posts from {{TopProfiles}}.
</mj-text>
<mj-divider border-color="#cccccc" />
{{#HasPopular}}
<mj-text css-class="section-header">Most Popular</mj-text>
{{#Popular}}
<mj-text>
<div class="event-item">
<div class="event-meta">
<div class="event-meta-left">
{{#Icon}}<img src="{{Icon}}" class="profile-image" />{{/Icon}}
<span class="event-author">{{Name}}</span>
<span class="event-timestamp">at {{Timestamp}}</span>
</div>
<div class="event-link">
<a href="{{Link}}">View post</a>
</div>
</div>
<div class="event-content">{{{Content}}}</div>
<div class="event-stats">
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAASFBMVEUAAAAiIkQdJ04bJkwbKU0dJkscKEwbJ00cKEsdJkwcJ0wbKEscJkwcJ0scJ00bJ00dJk0bJ0wcJkwcKE0cJ0wcJ0wcJ0z///8ZxKH0AAAAF3RSTlMADxovOD1AQlJXW19ld4iWoKizwenq9eQPMKMAAAABYktHRBcL1piPAAAAOElEQVQY02NgoBZg40fls4qw4+WziAnxggA3I1xAmA8EBEU5YEaKsoFpZlEmNBEBHjRruTip4wsADUgCEGuuAxQAAAAASUVORK5CYII=" />
{{Replies}}
</span>
<span class="stat-item">
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAn1BMVEUAAAAAAIAAAFUzMzMrK1UkJEkVK1UkJEkiIkQgIFAeLUscK0cbKFEbJEkaI08aK00ZKUocJUobKU0bKEwaKEsaJ04bJ0sbJ0sdJ0wcJ00cJk0cJkwcJk0bKEwbKEscKEwcJ00cJ0wcJ0wcJ0sbJk0bJ0wcJ0wcJ0sbJ0wdJ0wcJ00cKE0cJ0wbJ0wbJ0scJ0wcJ0wcJ0wcJ0wcJ0z///++FK4PAAAANHRSTlMAAgMFBgcMDg8QERITHB0eHzc4OTo7QVVhY2RleHl6h4iJiouMnq+wsbK+wcLD3+Dh4vHyB17REwAAAAFiS0dENKmx6f0AAACTSURBVBgZBcEJQoJAAADAIUhQkjxKJZdKyzPLXfj/35oBPDZNAYCXy5BSf14CHr7um5KyjfsMfP9MgPq2h9f7BKBOC1w2ALydKIaSLtAFqj43+0MIhID4bBYBIDWKoQJg3OecW9aH1eqwZnvEMtZG79drN1KnOexuNfD0+wGyXWwrxtv0mQEWpz6l/jgHQD6d5sA/VqYMPJkJl3UAAAAASUVORK5CYII=" />
{{Reactions}}
</span>
</div>
</div>
</mj-text>
{{/Popular}}
<mj-divider border-color="#cccccc" />
{{/HasPopular}}
<mj-text>
You're receiving this email because you subscribed to notifications.
We'll continue to send you updates based on your subscription preferences.
</mj-text>
<mj-button background-color="#F45E43" color="#ffffff" href="{{unsubscribeUrl}}">
Unsubscribe
</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>

40
src/env.ts Normal file
View file

@ -0,0 +1,40 @@
import 'dotenv/config'
import { always } from '@welshman/lib'
import { normalizeRelayUrl } from '@welshman/util'
import { netContext } from '@welshman/net'
import { Nip01Signer } from '@welshman/signer'
import { routerContext } from '@welshman/router'
if (!process.env.MAILSHIP_URL) throw new Error('MAILSHIP_URL is not defined.')
if (!process.env.MAILSHIP_NAME) throw new Error('MAILSHIP_NAME is not defined.')
if (!process.env.MAILSHIP_SECRET) throw new Error('MAILSHIP_SECRET is not defined.')
if (!process.env.POSTMARK_API_KEY) throw new Error('POSTMARK_API_KEY is not defined.')
if (!process.env.POSTMARK_SENDER_ADDRESS) throw new Error('POSTMARK_SENDER_ADDRESS is not defined.')
if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined.')
if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.')
if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.')
if (!process.env.PORT) throw new Error('PORT is not defined.')
if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.')
export const MAILSHIP_URL = process.env.MAILSHIP_URL
export const MAILSHIP_NAME = process.env.MAILSHIP_NAME
export const BASE_URL = process.env.BASE_URL
export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET)
export const POSTMARK_API_KEY = process.env.POSTMARK_API_KEY
export const POSTMARK_SENDER_ADDRESS = process.env.POSTMARK_SENDER_ADDRESS
export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl)
export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl)
export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl)
export const PORT = process.env.PORT
appSigner.getPubkey().then(pubkey => {
console.log(`Running as ${pubkey}`)
})
netContext.pool.get = (url: string) => {
throw new Error('Attempted to use default pool')
}
routerContext.getDefaultRelays = always(DEFAULT_RELAYS)
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
routerContext.getSearchRelays = always(SEARCH_RELAYS)

26
src/index.ts Normal file
View file

@ -0,0 +1,26 @@
import 'localstorage-polyfill'
import { PORT } from './env.js'
import { server } from './server.js'
import { migrate, getActiveSubscriptions } from './database.js'
import { registerSubscription } from './worker/index.js'
process.on('unhandledRejection', (error: Error) => {
console.error('Unhandled rejection:', error.stack)
process.exit(1)
})
process.on('uncaughtException', (error: Error) => {
console.error('Uncaught exception:', error.stack)
process.exit(1)
})
migrate().then(async () => {
server.listen(PORT, () => {
console.log('Running on port', PORT)
})
for (const sub of await getActiveSubscriptions()) {
registerSubscription(sub)
}
})

35
src/mailer.ts Normal file
View file

@ -0,0 +1,35 @@
import { ServerClient } from 'postmark'
import { POSTMARK_SENDER_ADDRESS, POSTMARK_API_KEY, MAILSHIP_NAME, BASE_URL } from './env.js'
import type { Subscription } from './alert.js'
import { render } from './templates.js'
const client = new ServerClient(POSTMARK_API_KEY)
export const sendConfirm = (sub: Subscription) => {
const href = `${BASE_URL}/confirm?token=${sub.key}`
return client.sendEmail({
From: POSTMARK_SENDER_ADDRESS,
To: sub.email,
Subject: 'Confirm your email digest',
HtmlBody: `
<h3>Welcome to ${MAILSHIP_NAME}!</h3>
<p>Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:</p>
<p><a href="${href}">Confirm Digest</a></p>
`,
TextBody: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`,
})
}
export const sendDigest = async (sub: Subscription, variables: Record<string, any>) => {
return client.sendEmail({
From: POSTMARK_SENDER_ADDRESS,
To: sub.email,
Subject: 'New activity',
HtmlBody: await render('emails/digest.mjml', {
...variables,
name: sub.email.split('@')[0],
unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`,
}),
})
}

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>Confirmation Failed</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.message {
margin-top: 1rem;
color: #e74c3c;
}
</style>
</head>
<body>
<div class="container">
<h2>Confirmation Failed</h2>
<div class="message">{{message}}</div>
</div>
</body>
</html>

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>Confirmation Successful</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.message {
margin-top: 1rem;
color: #2ecc71;
}
</style>
</head>
<body>
<div class="container">
<h2>Email Alert Confirmed</h2>
<div class="message">
Your alert has been successfully confirmed. You will now receive notifications.
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>Unsubscribe Successful</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.message {
margin-top: 1rem;
color: #2ecc71;
}
</style>
</head>
<body>
<div class="container">
<h2>Unsubscribe Successful</h2>
<div class="message">
You have been successfully unsubscribed from all notifications.
</div>
</div>
</body>
</html>

199
src/repository.ts Normal file
View file

@ -0,0 +1,199 @@
import { max, inc, pluck, uniq } from '@welshman/lib'
import {
TrustedEvent,
RELAYS,
PROFILE,
FOLLOWS,
readList,
readProfile,
asDecryptedEvent,
PublishedList,
PublishedProfile,
getPubkeyTagValues,
getListTags,
} from '@welshman/util'
import { makeLoader, LoadOptions, Repository, Pool, SocketAdapter } from '@welshman/net'
import { Router, getFilterSelections } from '@welshman/router'
import { deriveEventsMapped, collection } from '@welshman/store'
import { getFeedArgs, isScopeFeed, walkFeed, isWOTFeed, Scope, Feed } from '@welshman/feeds'
// Utilities for loading data
export const repository = Repository.get()
const pool = new Pool()
const load = makeLoader({
delay: 500,
timeout: 5000,
threshold: 0.8,
context: {
getAdapter: (url: string) => new SocketAdapter(pool.get(url))
},
})
export const sharedLoad = (request: LoadOptions) =>
load({
...request,
onEvent: (event, url) => {
request.onEvent?.(event, url)
repository.publish(event)
},
})
export const relaySelections = deriveEventsMapped<PublishedList>(repository, {
filters: [{ kinds: [RELAYS] }],
itemToEvent: (item) => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const { indexStore: relaySelectionsByPubkey, loadItem: loadRelaySelections } = collection({
name: 'relaySelections',
store: relaySelections,
getKey: (relaySelections) => relaySelections.event.pubkey,
load: (pubkey: string) => {
return sharedLoad({
relays: Router.get().Index().getUrls(),
filters: [{ kinds: [RELAYS], authors: [pubkey] }],
})
},
})
export const profiles = deriveEventsMapped<PublishedProfile>(repository, {
filters: [{ kinds: [PROFILE] }],
eventToItem: readProfile,
itemToEvent: (item) => item.event,
})
export const { indexStore: profilesByPubkey, loadItem: loadProfile } = collection({
name: 'profiles',
store: profiles,
getKey: (profile) => profile.event.pubkey,
load: (pubkey: string) => {
const { merge, Index, FromPubkey } = Router.get()
return sharedLoad({
relays: merge([Index(), FromPubkey(pubkey)])
.limit(10)
.getUrls(),
filters: [{ kinds: [PROFILE], authors: [pubkey] }],
})
},
})
export const follows = deriveEventsMapped<PublishedList>(repository, {
filters: [{ kinds: [FOLLOWS] }],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
itemToEvent: (item) => item.event,
})
export const { indexStore: followsByPubkey, loadItem: loadFollows } = collection({
name: 'follows',
store: follows,
getKey: (follows) => follows.event.pubkey,
load: (pubkey: string) => {
const { merge, Index, FromPubkey } = Router.get()
return sharedLoad({
relays: merge([Index(), FromPubkey(pubkey)]).getUrls(),
filters: [{ kinds: [FOLLOWS], authors: [pubkey] }],
})
},
})
export const getFollows = (pubkey: string) =>
getPubkeyTagValues(getListTags(followsByPubkey.get().get(pubkey)))
export const getNetwork = (pubkey: string) => {
const pubkeys = new Set(getFollows(pubkey))
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of getFollows(follow)) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
}
return Array.from(network)
}
export const getFollowers = (pubkey: string) =>
uniq(pluck<string>('pubkey', repository.query([{ kinds: [FOLLOWS], '#p': [pubkey] }])))
export const loadWot = async (pubkey: string, feed: Feed) => {
const { merge, Index, ForPubkey } = Router.get()
let needsFollows = false
let needsFollowers = false
let needsNetwork = false
walkFeed(feed, (f) => {
needsFollows = needsFollows || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Follows))
needsFollowers = needsFollowers || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Followers))
needsNetwork =
needsNetwork || isWOTFeed(f) || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Network))
})
const promises: Promise<any>[] = []
if (needsFollows || needsNetwork) {
promises.push(loadFollows(pubkey))
}
if (needsFollowers) {
promises.push(
sharedLoad({
filters: [{ kinds: [FOLLOWS], '#p': [pubkey] }],
relays: merge([Index(), ForPubkey(pubkey)]).getUrls(),
})
)
}
if (needsNetwork) {
promises.push(
...getFilterSelections([{ kinds: [FOLLOWS], authors: getFollows(pubkey) }]).map(sharedLoad)
)
}
await Promise.all(promises)
}
export const makeGetPubkeysForScope = (pubkey: string) => (scope: string) => {
switch (scope) {
case Scope.Self:
return [pubkey]
case Scope.Follows:
return getFollows(pubkey)
case Scope.Network:
return getNetwork(pubkey)
case Scope.Followers:
return getFollowers(pubkey)
default:
return []
}
}
export const makeGetPubkeysForWOTRange = (pubkey: string) => (minimum: number, maximum: number) => {
const graph = new Map<string, number>()
for (const follow of getFollows(pubkey)) {
for (const pubkey of getFollows(follow)) {
graph.set(pubkey, inc(graph.get(pubkey)))
}
}
const pubkeys = []
const maxWot = max(Array.from(graph.values()))
const thresholdMin = maxWot * minimum
const thresholdMax = maxWot * maximum
for (const [tpk, score] of graph.entries()) {
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk)
}
}
return pubkeys
}

28
src/run.ts Normal file
View file

@ -0,0 +1,28 @@
import { last } from '@welshman/lib'
import type { Subscription } from './alert.js'
import { getSubscriptionByKey } from './database.js'
import { runJob } from './worker/email.js'
const key = last(process.argv)
if (!key) {
console.error('Please provide a subscription key')
process.exit(1)
}
getSubscriptionByKey(key)
.then((sub: Subscription | undefined) => {
if (!sub) {
console.error('Invalid subscription key')
return false
}
return runJob(sub)
})
.then((success: boolean) => {
process.exit(success ? 0 : 1)
})
.catch((error: Error) => {
console.error(error)
process.exit(1)
})

197
src/server.ts Normal file
View file

@ -0,0 +1,197 @@
import { instrument } from 'succinct-async'
import express, { Request, Response, NextFunction } from 'express'
import rateLimit from 'express-rate-limit'
import { appSigner } from './env.js'
import { render } from './templates.js'
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
import { getSubscriptionById, insertEvent, getSubscriptionByKey } from './database.js'
import { load } from '@welshman/net'
import { getIdFilters } from '@welshman/util'
// Endpoints
export const server: express.Application = express()
server.use(express.json())
server.use(express.static('web/dist'))
// Rate limit for registration endpoints
server.use(
'/subscription',
rateLimit({
limit: 30,
windowMs: 5 * 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
// Rate limit for notify endpoint
server.use(
'/notify',
rateLimit({
limit: 300,
windowMs: 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
type Handler = (req: Request, res: Response) => Promise<any>
const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Handler) => {
server[method](
path,
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
try {
await handler(req, res)
} catch (e) {
next(e)
}
})
)
}
addRoute('get', '/', async (req: Request, res: Response) => {
res.send(await render('../web/dist/index.html'))
})
// Subscribe to email digests
addRoute('post', '/subscription/email', async (req: Request, res: Response) => {
const { email, frequency, pubkey } = req.body
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'A valid email address is required' })
}
if (!['daily', 'weekly'].includes(frequency)) {
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
}
if (!pubkey) {
return res.status(400).json({ error: 'pubkey is required' })
}
// TODO: Verify NIP-98 auth header
// const auth = req.headers.authorization
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' })
// Verify using @welshman/util makeHttpAuth
try {
const result = await registerSubscription({ pubkey, email, frequency })
res.json(result)
} catch (error: any) {
// If the error is just Postmark failing, the subscription was still created
if (error.message?.includes('APIKey')) {
res.json({ key: 'fallback', callback: `${process.env.BASE_URL}/notify/fallback` })
} else {
console.error('Failed to register subscription:', error)
res.status(500).json({ error: 'Failed to register subscription' })
}
}
})
// Delete subscription
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
const { key } = req.params
const sub = await getSubscriptionByKey(key)
if (!sub) {
return res.status(404).json({ error: 'Subscription not found' })
}
// TODO: Verify NIP-98 auth header matches sub.pubkey
await unsubscribeAction({ token: key })
res.json({ ok: true })
})
// NIP-9a relay push callback
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
const { id, relay } = req.body
if (!id || !relay) {
return res.status(400).json({ error: 'id and relay are required' })
}
const sub = await getSubscriptionById(req.params.id)
if (!sub) {
// 404 signals the relay to delete the subscription
return res.status(404).json({ error: 'Subscription not found' })
}
if (!sub.confirmed_at || sub.unsubscribed_at) {
return res.status(404).json({ error: 'Subscription not active' })
}
// Fetch the full event from the relay
try {
const [event] = await load({
relays: [relay],
filters: getIdFilters([id]),
})
if (!event) {
// 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)
return res.json({ ok: true, stored })
} catch (error) {
console.error(`Failed to process notification for subscription ${sub.id}:`, error)
return res.status(500).json({ error: 'Internal server error' })
}
})
// Confirmation
addRoute('get', '/confirm', async (req: Request, res: Response) => {
if (typeof req.query.token !== 'string') {
return res.send(
await render('pages/confirm-error.html', {
message: 'No confirmation token was provided.',
})
)
}
try {
await confirmSubscriptionAction({ token: req.query.token })
res.send(await render('pages/confirm-success.html'))
} catch (error) {
const isActionError = error instanceof ActionError
const message = isActionError ? String(error) : 'Oops, something went wrong on our end!'
res.send(await render('pages/confirm-error.html', { message }))
if (!isActionError) {
throw error
}
}
})
// Unsubscribe
addRoute('get', '/unsubscribe', async (req: Request, res: Response) => {
try {
await unsubscribeAction({ token: req.query.token as string })
} catch (error) {
// pass
}
res.send(await render('pages/unsubscribe-success.html'))
})
server.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err) {
if (err instanceof ActionError) {
res.status(400).json({ error: err.message })
} else {
console.log('Unhandled error', err)
res.status(500).json({ error: 'Internal server error' })
}
} else {
next()
}
})

35
src/templates.ts Normal file
View file

@ -0,0 +1,35 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import Mustache from 'mustache'
import mjml2html from 'mjml'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Cache templates to avoid reading from disk on every render
const templateCache = new Map<string, string>()
const loadTemplate = async (name: string) => {
if (!templateCache.has(name)) {
const templatePath = path.join(__dirname, name)
const template = await fs.promises.readFile(templatePath, 'utf8')
templateCache.set(name, template)
}
return templateCache.get(name)!
}
export const render = async (name: string, view: Record<string, any> = {}) => {
const template = await loadTemplate(name)
const mustacheRendered = Mustache.render(template, view)
// If it's an MJML file, render it with MJML
if (name.endsWith('.mjml')) {
const { html } = mjml2html(mustacheRendered)
return html
}
return mustacheRendered
}

4
src/types.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "succinct-async" {
export function instrument<T extends (...args: any[]) => any>(n: string, f: T): T
export const succinctAsyncConfig: any
}

138
src/util.ts Normal file
View file

@ -0,0 +1,138 @@
import sanitizeHtml from 'sanitize-html'
import { CronExpressionParser } from 'cron-parser'
export function getCronDate(cronString: string, n: number) {
const interval = CronExpressionParser.parse(cronString, { tz: 'UTC' })
let date = interval.next().toDate()
for (let i = 0; i < Math.abs(n); i++) {
if (n > 0) {
date = interval.next().toDate()
} else {
date = interval.prev().toDate()
}
}
return date
}
export function displayDuration(seconds: number) {
const minute = 60
const hour = minute * 60
const day = hour * 24
const week = day * 7
const month = day * 30
const year = day * 365
if (seconds < minute) {
return `${Math.round(seconds)} seconds`
} else if (seconds < hour) {
const minutes = Math.round(seconds / minute)
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`
} else if (seconds < day) {
const hours = Math.round(seconds / hour)
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`
} else if (seconds < week) {
const days = Math.round(seconds / day)
return `${days} ${days === 1 ? 'day' : 'days'}`
} else if (seconds < month) {
const weeks = Math.round(seconds / week)
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}`
} else if (seconds < year) {
const months = Math.round(seconds / month)
return `${months} ${months === 1 ? 'month' : 'months'}`
} else {
const years = Math.round(seconds / year)
return `${years} ${years === 1 ? 'year' : 'years'}`
}
}
interface ElementAttributes {
[key: string]: string
}
interface ElementChild {
outerHTML: string
}
export interface CustomElement {
tagName: string
attributes: ElementAttributes
children: ElementChild[]
_innerText: string
setAttribute(name: string, value: string): void
getAttribute(name: string): string | undefined
appendChild(child: ElementChild): void
href: string
target: string
innerText: string
innerHTML: string
outerHTML: string
}
export function createElement(tagName: string) {
const element: CustomElement = {
tagName: tagName.toLowerCase(),
attributes: {},
children: [],
_innerText: '',
setAttribute(name: string, value: string) {
this.attributes[name] = value
},
getAttribute(name: string): string | undefined {
return this.attributes[name]
},
appendChild(child: ElementChild) {
this.children.push(child)
},
get href() {
return this.attributes.href || ''
},
set href(value: string) {
this.attributes.href = value
},
get target() {
return this.attributes.target || ''
},
set target(value: string) {
this.attributes.target = value
},
get innerText() {
return this._innerText
},
set innerText(value: string) {
this._innerText = sanitizeHtml(value, {
allowedTags: [],
allowedAttributes: {},
})
},
get innerHTML() {
return this._innerText
},
set innerHTML(value: string) {
this._innerText = value
},
get outerHTML() {
const attributesString = Object.entries(this.attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
return `<${this.tagName}${attributesString ? ' ' + attributesString : ''}>${this._innerText}</${this.tagName}>`
},
}
return element
}

77
src/worker/email.ts Normal file
View file

@ -0,0 +1,77 @@
import { CronJob } from 'cron'
import type { Subscription } from '../alert.js'
import { getCronExpression } from '../alert.js'
import { Digest } from '../digest.js'
import * as db from '../database.js'
const jobsById = new Map<string, CronJob>()
export const runJob = async (sub: Subscription) => {
try {
if (!sub.confirmed_at || sub.unsubscribed_at) {
console.log('worker: job skipped', sub.id, 'not confirmed or unsubscribed')
return false
}
console.log('worker: job starting', sub.id)
const start = Date.now()
const since = sub.last_digest_at || sub.created_at
const events = await db.getEventsForSubscription(sub.id, since)
if (events.length === 0) {
console.log('worker: job skipped', sub.id, 'ok', 'no data received')
return false
}
const digest = new Digest(sub)
await digest.sendFromStoredEvents(events)
// Clean up processed events
await db.deleteEventsForSubscription(sub.id, since)
await db.updateLastDigestAt(sub.id, Math.floor(Date.now() / 1000))
console.log('worker: job completed', sub.id, 'in', Date.now() - start, 'ms')
return true
} catch (e) {
console.log('worker: job failed', sub.id, e)
return false
}
}
const createJob = (sub: Subscription) => {
const cron = getCronExpression(sub.frequency)
const run = async () => {
await runJob(sub)
}
return CronJob.from({
cronTime: cron,
onTick: run,
start: true,
timeZone: 'UTC',
})
}
export const addJob = (sub: Subscription) => {
jobsById.get(sub.id)?.stop()
jobsById.set(sub.id, createJob(sub))
}
export const removeJob = (sub: Subscription) => {
jobsById.get(sub.id)?.stop()
jobsById.delete(sub.id)
}
// Daily purge of events older than 7 days
const purgeJob = CronJob.from({
cronTime: '0 0 3 * * *', // 3am UTC daily
onTick: async () => {
const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600
await db.purgeEventsOlderThan(weekAgo)
console.log('worker: purged events older than 7 days')
},
start: true,
timeZone: 'UTC',
})

14
src/worker/index.ts Normal file
View file

@ -0,0 +1,14 @@
import type { Subscription } from '../alert.js'
import { addJob, removeJob } from './email.js'
export const registerSubscription = (sub: Subscription) => {
console.log('registering digest job', sub.id)
addJob(sub)
}
export const unregisterSubscription = (sub: Subscription) => {
console.log('unregistering digest job', sub.id)
removeJob(sub)
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"include": ["src/**/*"],
"compilerOptions": {
"target": "es6",
"module": "nodenext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}

3
web/.env.template Normal file
View file

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

24
web/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# 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?

21
web/eslint.config.js Normal file
View file

@ -0,0 +1,21 @@
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"},
],
},
},
]);

15
web/index.html Normal file
View file

@ -0,0 +1,15 @@
<!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>

34
web/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"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"
}
}

BIN
web/pnpm-lock.yaml Normal file

Binary file not shown.

534
web/src/main.ts Normal file
View file

@ -0,0 +1,534 @@
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 = normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
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()
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 (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) =>
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})

42
web/src/style.css Normal file
View file

@ -0,0 +1,42 @@
@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 Normal file
View file

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

22
web/tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"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"]
}

11
web/vite.config.js Normal file
View file

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