diff --git a/apps/homepage/static/llms.txt b/apps/homepage/static/llms.txt index 7fd52cd..768b873 100644 --- a/apps/homepage/static/llms.txt +++ b/apps/homepage/static/llms.txt @@ -147,15 +147,17 @@ and writes additionally require the relay to permit it (atmo.pub default: the us must have granted your app "manage its own settings" in the dashboard; reads are open). On a relay you run yourself, you can allow your own DID by default. + A **route** is a channel set, encoded as a `+`-joined string of `push`/`telegram`/`email` + (e.g. `"push+email"`), `"off"` for none, plus the inherit sentinels `"default"` + (app-wide → account default) and `"app"` (category → app-wide). - `setRouting` — body `{ userToken, route?, categories? }` - - `route`: `"push" | "telegram" | "push+telegram" | "off" | "default"` (app-wide; - `"default"` = inherit the user's account default) - - `categories`: `[{ "id": "", "route": "push"|…|"off"|"app" }]` - (`"app"` = inherit the app-wide route) + - `route`: app-wide route — a `+`-joined channel set, `"off"`, or `"default"`. Omit to leave unchanged. + - `categories`: `[{ "id": "", "route": "" | "off" | "app" }]` - → `{ "ok": true }` - `getRouting` — body `{ userToken }` → `{ "route": "", "defaultRoute": "", "categories": [{ "id", "description?", "route" }] }` + (each route is a `+`-joined channel set / `off` / sentinel as above) - `listNotifications` — body `{ userToken, limit?(1–100, default 50), cursor? }` → `{ "notifications": [{ "id","title","body","uri?","category?", "createdAt"(ISO datetime),"read"(bool),"delivered?"(int) }], "cursor?" }` diff --git a/apps/relay/migrations/0009_email.sql b/apps/relay/migrations/0009_email.sql new file mode 100644 index 0000000..43043a6 --- /dev/null +++ b/apps/relay/migrations/0009_email.sql @@ -0,0 +1,10 @@ +-- Email delivery channel: one address per user, verified by a short code emailed +-- via comail before the relay will deliver to it. See delivery/email.ts. +CREATE TABLE email_channels ( + recipient_did TEXT PRIMARY KEY, + address TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + verify_code TEXT, + verify_expires INTEGER, + created_at INTEGER NOT NULL +); diff --git a/apps/relay/src/db/queries.ts b/apps/relay/src/db/queries.ts index 3cc0c63..cdbb935 100644 --- a/apps/relay/src/db/queries.ts +++ b/apps/relay/src/db/queries.ts @@ -27,6 +27,78 @@ export interface LinkTokenRow { expires_at: number; } +// --- email channel (one verified address per user) ------------------------- + +export interface EmailChannelRow { + recipient_did: Did; + address: string; + verified: number; + verify_code: string | null; + verify_expires: number | null; + created_at: number; +} + +export function getEmailChannel(db: D1Database, did: Did): Promise { + return db + .prepare('SELECT * FROM email_channels WHERE recipient_did = ?') + .bind(did) + .first(); +} + +/** Set (or replace) a user's pending email + verification code (resets verified). */ +export async function upsertEmailChannel( + db: D1Database, + input: { did: Did; address: string; verifyCode: string; verifyExpires: number; createdAt: number }, +): Promise { + await db + .prepare( + `INSERT INTO email_channels (recipient_did, address, verified, verify_code, verify_expires, created_at) + VALUES (?, ?, 0, ?, ?, ?) + ON CONFLICT(recipient_did) DO UPDATE SET + address = excluded.address, + verified = 0, + verify_code = excluded.verify_code, + verify_expires = excluded.verify_expires, + created_at = excluded.created_at`, + ) + .bind(input.did, input.address, input.verifyCode, input.verifyExpires, input.createdAt) + .run(); +} + +/** Mark verified iff the code matches and hasn't expired. Returns true on success. */ +export async function verifyEmailChannel( + db: D1Database, + did: Did, + code: string, + nowMs: number, +): Promise { + const result = await db + .prepare( + `UPDATE email_channels SET verified = 1, verify_code = NULL, verify_expires = NULL + WHERE recipient_did = ? AND verified = 0 AND verify_code = ? AND verify_expires > ?`, + ) + .bind(did, code, nowMs) + .run(); + return changed(result); +} + +export async function deleteEmailChannel(db: D1Database, did: Did): Promise { + const result = await db + .prepare('DELETE FROM email_channels WHERE recipient_did = ?') + .bind(did) + .run(); + return changed(result); +} + +/** The user's verified email address, or null. Used by delivery. */ +export async function getVerifiedEmail(db: D1Database, did: Did): Promise { + const row = await db + .prepare('SELECT address FROM email_channels WHERE recipient_did = ? AND verified = 1') + .bind(did) + .first<{ address: string }>(); + return row?.address ?? null; +} + export interface SenderRow { did: Did; handle: string | null; diff --git a/apps/relay/src/delivery/dispatcher.ts b/apps/relay/src/delivery/dispatcher.ts index 5fcc309..bdaaea0 100644 --- a/apps/relay/src/delivery/dispatcher.ts +++ b/apps/relay/src/delivery/dispatcher.ts @@ -5,6 +5,7 @@ import { deleteChannelByPlatformUser, deletePushSubscription } from '../db/queri import type { DispatchJob, Env } from '../env'; import { callbackAppFor } from '../lib/apps'; +import { EmailError, sendEmail } from './email'; import { escapeMd, type InlineKeyboardMarkup, @@ -62,6 +63,17 @@ async function reapIfDead(env: Env, job: DispatchJob, err: unknown): Promise= 400 && + err.statusCode !== 429 + ) { + // Permanent (bad/rejected address) — stop retrying. The channel is kept so the + // user can fix it; 429 (rate limit) and 5xx fall through to retry. + console.error(`dispatch: dropping email to ${channel.address} (${err.statusCode} ${err.code})`); + return true; + } return false; } @@ -131,6 +143,15 @@ async function dispatch(env: Env, job: DispatchJob): Promise { return; } + if (job.channel.platform === 'email') { + await sendEmail(env, { + to: job.channel.address, + subject: job.title, + text: job.uri !== undefined ? `${job.body}\n\n${job.uri}` : job.body, + }); + return; + } + const text = `*${escapeMd(job.title)}*\n${escapeMd(job.body)}`; const replyMarkup: InlineKeyboardMarkup | undefined = job.uri !== undefined ? { inline_keyboard: [[{ text: 'Open', url: job.uri }]] } : undefined; diff --git a/apps/relay/src/delivery/email.ts b/apps/relay/src/delivery/email.ts new file mode 100644 index 0000000..9650f16 --- /dev/null +++ b/apps/relay/src/delivery/email.ts @@ -0,0 +1,73 @@ +// Email delivery via comail (https://comail.at). Plain REST send API: +// POST https://smtp.atmos.email/v1/send +// headers: Authorization: Bearer atmos_…, X-Atmos-DID: +// body: { from, to, subject, text, html?, replyTo?, category? } +// See https://comail.at/docs/send-api. +import type { Env } from '../env'; + +const COMAIL_SEND_API = 'https://smtp.atmos.email/v1/send'; + +/** Thrown when comail rejects the send (non-2xx, or the recipient is rejected). */ +export class EmailError extends Error { + readonly statusCode: number; + readonly code: string; + constructor(statusCode: number, code: string, detail: string) { + super(`email send failed: ${statusCode} ${code}${detail ? ` ${detail}` : ''}`); + this.name = 'EmailError'; + this.statusCode = statusCode; + this.code = code; + } +} + +export interface EmailMessage { + /** A single plain email address (the relay sends per-channel). */ + to: string; + subject: string; + text: string; + html?: string; + replyTo?: string; + /** comail category hint: 'login-link'|'password-reset'|'mfa-otp'|'verification'|'bulk'|'broadcast'. */ + category?: string; +} + +interface ComailResponse { + accepted?: { recipient: string; messageId: number }[]; + rejected?: { recipient: string; reason?: string }[]; + error?: string; + code?: string; +} + +/** + * Send one email via comail. Resolves with the comail message id, or throws + * {@link EmailError} on a non-2xx response or a rejected recipient. + */ +export async function sendEmail(env: Env, msg: EmailMessage): Promise<{ messageId: number }> { + const res = await fetch(COMAIL_SEND_API, { + method: 'POST', + headers: { + authorization: `Bearer ${env.COMAIL_API_KEY}`, + 'x-atmos-did': env.COMAIL_DID, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + from: env.COMAIL_FROM, + to: msg.to, + subject: msg.subject, + text: msg.text, + ...(msg.html !== undefined && { html: msg.html }), + ...(msg.replyTo !== undefined && { replyTo: msg.replyTo }), + ...(msg.category !== undefined && { category: msg.category }), + }), + }); + + const data = (await res.json().catch(() => ({}))) as ComailResponse; + if (!res.ok) { + throw new EmailError(res.status, data.code ?? 'UNKNOWN', data.error ?? ''); + } + const accepted = data.accepted?.[0]; + if (accepted === undefined) { + // 2xx but the address was rejected (suppressed/bounced) — treat as a failure. + throw new EmailError(res.status, 'REJECTED', data.rejected?.[0]?.reason ?? 'recipient rejected'); + } + return { messageId: accepted.messageId }; +} diff --git a/apps/relay/src/env.ts b/apps/relay/src/env.ts index 06d6dbb..bb8f285 100644 --- a/apps/relay/src/env.ts +++ b/apps/relay/src/env.ts @@ -15,8 +15,14 @@ export interface WebPushChannel { auth: string; } +/** An email delivery channel (a verified address). */ +export interface EmailChannel { + platform: 'email'; + address: string; +} + /** Where a notification can be delivered. */ -export type DeliveryChannel = TelegramChannel | WebPushChannel; +export type DeliveryChannel = TelegramChannel | WebPushChannel | EmailChannel; /** * Work item placed on `DISPATCH_QUEUE` and handled by the `queue` consumer. @@ -86,6 +92,14 @@ export interface Env { VAPID_SUBJECT: string; /** VAPID private signing key as JWK JSON (secret). */ VAPID_PRIVATE_JWK: string; + + // Email delivery via comail (https://comail.at) — POST https://smtp.atmos.email/v1/send. + /** comail API key `atmos_…` (secret). */ + COMAIL_API_KEY: string; + /** Account DID for the `X-Atmos-DID` header (var). */ + COMAIL_DID: string; + /** Enrolled sender address for the `from` field, e.g. "atmo.pub " (var). */ + COMAIL_FROM: string; } /** diff --git a/apps/relay/src/rpc/entrypoint.ts b/apps/relay/src/rpc/entrypoint.ts index e368722..f0c5329 100644 --- a/apps/relay/src/rpc/entrypoint.ts +++ b/apps/relay/src/rpc/entrypoint.ts @@ -8,6 +8,7 @@ import type { Capability, CategoryRoute, DeviceView, + EmailChannelView, ListNotificationsResult, MarkReadInput, NotifsRpc, @@ -73,6 +74,18 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { getSettings(did: Did): Promise { return ops.getSettings(this.env, did); } + linkEmail(did: Did, address: string) { + return ops.linkEmail(this.env, did, address); + } + verifyEmail(did: Did, code: string) { + return ops.verifyEmail(this.env, did, code); + } + unlinkEmail(did: Did) { + return ops.unlinkEmail(this.env, did); + } + getEmailChannel(did: Did): Promise { + return ops.getEmailChannel(this.env, did); + } registerWebPush(did: Did, sub: PushSubscriptionInput) { return ops.registerWebPush(this.env, did, sub); } diff --git a/apps/relay/src/rpc/ops.ts b/apps/relay/src/rpc/ops.ts index 216836f..25cebe5 100644 --- a/apps/relay/src/rpc/ops.ts +++ b/apps/relay/src/rpc/ops.ts @@ -12,6 +12,7 @@ import type { Capability, CategoryRoute, DeviceView, + EmailChannelView, ListNotificationsResult, MarkReadInput, NotificationView, @@ -32,9 +33,11 @@ import type { } from '@atmo/notifs-lexicons'; import { verifyAppLoginToken } from '../auth/appLogin'; +import { sendEmail } from '../delivery/email'; import * as q from '../db/queries'; import type { Env } from '../env'; import { appCatalog, callbackAppFor } from '../lib/apps'; +import { invalidRequest } from '../lib/errors'; import { newLinkToken } from '../lib/ids'; import { addMinutes, now, toIsoDatetime } from '../lib/time'; @@ -434,6 +437,59 @@ export async function setGrantManage( return { ok: true }; } +// --- email channel --------------------------------------------------------- + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const VERIFY_TTL_MS = 15 * 60 * 1000; + +function genVerifyCode(): string { + const n = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0; + return (n % 1_000_000).toString().padStart(6, '0'); +} + +/** + * Set the user's email and email them a verification code (via comail). Stored + * unverified until `verifyEmail` succeeds. Throws if comail rejects, so nothing + * is stored for an undeliverable address. + */ +export async function linkEmail(env: Env, did: Did, address: string): Promise<{ ok: boolean }> { + const addr = address.trim().toLowerCase(); + if (!EMAIL_RE.test(addr)) throw invalidRequest('Invalid email address'); + + const code = genVerifyCode(); + await sendEmail(env, { + to: addr, + subject: 'Verify your email for atmo.pub', + text: `Your atmo.pub verification code is ${code}.\n\nIt expires in 15 minutes. If you didn't request this, you can ignore this email.`, + category: 'verification', + }); + + const t = now(); + await q.upsertEmailChannel(env.DB, { + did, + address: addr, + verifyCode: code, + verifyExpires: t + VERIFY_TTL_MS, + createdAt: t, + }); + return { ok: true }; +} + +export async function verifyEmail(env: Env, did: Did, code: string): Promise<{ verified: boolean }> { + const verified = await q.verifyEmailChannel(env.DB, did, code.trim(), now()); + return { verified }; +} + +export async function unlinkEmail(env: Env, did: Did): Promise<{ ok: boolean }> { + await q.deleteEmailChannel(env.DB, did); + return { ok: true }; +} + +export async function getEmailChannel(env: Env, did: Did): Promise { + const row = await q.getEmailChannel(env.DB, did); + return row === null ? null : { address: row.address, verified: row.verified === 1 }; +} + /** * Cross-app login: verify a `pub.atmo.auth` service-auth token and return the * issuer DID, ensuring the user row exists. Pre-auth — no leading `did`, the DID diff --git a/apps/relay/src/xrpc/manage.ts b/apps/relay/src/xrpc/manage.ts index 6c87c83..3e88cd4 100644 --- a/apps/relay/src/xrpc/manage.ts +++ b/apps/relay/src/xrpc/manage.ts @@ -37,6 +37,7 @@ const OPS: Record = { listChannels: (env, did) => ops.listChannels(env, did), getSettings: (env, did) => ops.getSettings(env, did), listDevices: (env, did) => ops.listDevices(env, did), + getEmailChannel: (env, did) => ops.getEmailChannel(env, did), getRouting: (env, did) => ops.getRouting(env, did), listNotifications: (env, did, p) => ops.listNotifications(env, did, (p as { cursor?: string } | undefined)?.cursor), @@ -48,6 +49,9 @@ const OPS: Record = { linkChannel: (env, did, p) => ops.linkChannel(env, did, p as PubAtmoNotifyLinkChannel.$input), unlinkChannel: (env, did, p) => ops.unlinkChannel(env, did, p as PubAtmoNotifyUnlinkChannel.$input), + linkEmail: (env, did, p) => ops.linkEmail(env, did, (p as { address: string }).address), + verifyEmail: (env, did, p) => ops.verifyEmail(env, did, (p as { code: string }).code), + unlinkEmail: (env, did) => ops.unlinkEmail(env, did), updateSettings: (env, did, p) => ops.updateSettings(env, did, p as PubAtmoNotifyUpdateSettings.$input), registerWebPush: (env, did, p) => ops.registerWebPush(env, did, p as PushSubscriptionInput), diff --git a/apps/relay/src/xrpc/send.ts b/apps/relay/src/xrpc/send.ts index 9d1a9d2..8608561 100644 --- a/apps/relay/src/xrpc/send.ts +++ b/apps/relay/src/xrpc/send.ts @@ -94,14 +94,20 @@ export function makeSend(app: AppContext): ProcedureConfig() : new Set(route.split('+')); - const telegramChannels = useTelegram + const telegramChannels = channels.has('telegram') ? (await q.listChannelsForDid(app.env.DB, recipient)).filter((c) => c.platform === 'telegram') : []; - const pushSubs = usePush ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) : []; - const deliveredCount = telegramChannels.length + pushSubs.length; + const pushSubs = channels.has('push') + ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) + : []; + const emailAddress = channels.has('email') + ? await q.getVerifiedEmail(app.env.DB, recipient) + : null; + const deliveredCount = telegramChannels.length + pushSubs.length + (emailAddress ? 1 : 0); // No targets → accept but deliver to nobody. if (deliveredCount === 0) { @@ -136,6 +142,20 @@ export function makeSend(app: AppContext): ProcedureConfig { + installFetchMock(); + mockComailOk(); +}); + +it('linkEmail stores an unverified, normalized address', async () => { + const did: Did = 'did:plc:email-link'; + await ops.linkEmail(env, did, ' Me@Example.com '); + expect(await ops.getEmailChannel(env, did)).toEqual({ address: 'me@example.com', verified: false }); +}); + +it('rejects an invalid address', async () => { + const did: Did = 'did:plc:email-bad'; + await expect(ops.linkEmail(env, did, 'not-an-email')).rejects.toThrow(); + expect(await ops.getEmailChannel(env, did)).toBeNull(); +}); + +it('verifyEmail: right code verifies, wrong code does not', async () => { + const did: Did = 'did:plc:email-verify'; + await ops.linkEmail(env, did, 'a@b.com'); + const code = (await q.getEmailChannel(env.DB, did))?.verify_code; + if (!code) throw new Error('expected a verify code'); + + expect((await ops.verifyEmail(env, did, '000000')).verified).toBe(false); + expect((await ops.verifyEmail(env, did, code)).verified).toBe(true); + expect((await ops.getEmailChannel(env, did))?.verified).toBe(true); + // Delivery query now sees it. + expect(await q.getVerifiedEmail(env.DB, did)).toBe('a@b.com'); +}); + +it('verifyEmail fails once expired', async () => { + const did: Did = 'did:plc:email-expired'; + await q.upsertEmailChannel(env.DB, { + did, + address: 'c@d.com', + verifyCode: '123456', + verifyExpires: Date.now() - 1000, // already expired + createdAt: Date.now() - 2000, + }); + expect((await ops.verifyEmail(env, did, '123456')).verified).toBe(false); +}); + +it('unlinkEmail removes it', async () => { + const did: Did = 'did:plc:email-unlink'; + await ops.linkEmail(env, did, 'x@y.com'); + await ops.unlinkEmail(env, did); + expect(await ops.getEmailChannel(env, did)).toBeNull(); +}); diff --git a/apps/relay/test/email.test.ts b/apps/relay/test/email.test.ts new file mode 100644 index 0000000..ff9dec4 --- /dev/null +++ b/apps/relay/test/email.test.ts @@ -0,0 +1,30 @@ +import { env } from 'cloudflare:test'; +import { beforeEach, expect, it } from 'vitest'; + +import { EmailError, sendEmail } from '../src/delivery/email'; + +import { installFetchMock, mockComailError, mockComailOk, mockComailRejected } from './helpers'; + +// Reset routes before each test — all comail mocks match the same host, so the +// first-registered would otherwise win across tests. +beforeEach(() => { + installFetchMock(); +}); + +const msg = { to: 'user@example.com', subject: 'Hello', text: 'Sent via comail' }; + +it('sends an email and returns the comail message id', async () => { + mockComailOk(448); + const { messageId } = await sendEmail(env, msg); + expect(messageId).toBe(448); +}); + +it('throws EmailError on a comail error response', async () => { + mockComailError(429, 'RATE_LIMITED'); + await expect(sendEmail(env, msg)).rejects.toBeInstanceOf(EmailError); +}); + +it('throws EmailError when the recipient is rejected (2xx, empty accepted)', async () => { + mockComailRejected(); + await expect(sendEmail(env, msg)).rejects.toBeInstanceOf(EmailError); +}); diff --git a/apps/relay/test/helpers.ts b/apps/relay/test/helpers.ts index dc7b1d3..2c311b2 100644 --- a/apps/relay/test/helpers.ts +++ b/apps/relay/test/helpers.ts @@ -109,6 +109,30 @@ export function mockTelegramOk(): void { }); } +/** Accept any comail send with an `accepted` response. */ +export function mockComailOk(messageId = 1): void { + routes.push({ + match: (url) => url.hostname === 'smtp.atmos.email', + respond: () => jsonResponse(200, { accepted: [{ recipient: 'x@example.com', messageId }], rejected: [] }), + }); +} + +/** Make any comail send fail with `status`/`code`. */ +export function mockComailError(status = 429, code = 'RATE_LIMITED'): void { + routes.push({ + match: (url) => url.hostname === 'smtp.atmos.email', + respond: () => jsonResponse(status, { error: 'comail error', code }), + }); +} + +/** comail returns 200 but the recipient is rejected (suppressed/bounced). */ +export function mockComailRejected(): void { + routes.push({ + match: (url) => url.hostname === 'smtp.atmos.email', + respond: () => jsonResponse(200, { accepted: [], rejected: [{ recipient: 'x@example.com', reason: 'suppressed' }] }), + }); +} + /** Stub the AppView profile fetch for any actor. */ export function makeBskyProfileMock( profile: { handle?: string; displayName?: string; avatar?: string } = { diff --git a/apps/relay/test/send.test.ts b/apps/relay/test/send.test.ts index a70ce47..4c56ee4 100644 --- a/apps/relay/test/send.test.ts +++ b/apps/relay/test/send.test.ts @@ -198,3 +198,35 @@ it('accepts silently with delivered=0 when the grant is muted', async () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ delivered: 0 }); }); + +it('delivers to a verified email when the route includes email', async () => { + const sender = await makeIdentity('did:plc:sendemail'); + mockPlc(sender); + const recip: Did = 'did:plc:emailrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + // A verified email + a route that includes it. + await q.upsertEmailChannel(env.DB, { + did: recip, + address: 'me@example.com', + verifyCode: '111111', + verifyExpires: Date.now() + 60_000, + createdAt: Date.now() + }); + await q.verifyEmailChannel(env.DB, recip, '111111', Date.now()); + await q.setDefaultRoute(env.DB, recip, 'push+email'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + // Push has no subscriptions, so only the email target counts. + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 1 }); +}); diff --git a/apps/relay/wrangler.toml b/apps/relay/wrangler.toml index 42d9b2e..2b36d12 100644 --- a/apps/relay/wrangler.toml +++ b/apps/relay/wrangler.toml @@ -42,8 +42,14 @@ BOT_USERNAME = "atmo_notify_bot" # e.g. "atmonotifsbot" # browser applicationServerKey — paste the SAME value into apps/web VAPID_PUBLIC_KEY. VAPID_PUBLIC_KEY = "BF4pVUiFeh9wltn6Rj151RHA4WfidcRRv8kXp2aKcRATi_2gUgq0uGX8jVY1EczXqOvRtluqIxRj6Mtf5d1ImRw" VAPID_SUBJECT = "https://atmo.pub" +# Email via comail (https://comail.at). COMAIL_DID = the account DID for the +# X-Atmos-DID header; COMAIL_FROM = an enrolled sender address. +COMAIL_DID = "did:web:relay.atmo.pub" +COMAIL_FROM = "atmo.pub " # Secrets set via `wrangler secret put`: # - TELEGRAM_BOT_TOKEN # - TELEGRAM_WEBHOOK_SECRET # - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`) +# - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`) +# - COMAIL_API_KEY (atmos_… from comail.at) diff --git a/apps/web/src/lib/components/ChannelRoutePicker.svelte b/apps/web/src/lib/components/ChannelRoutePicker.svelte new file mode 100644 index 0000000..4a6a813 --- /dev/null +++ b/apps/web/src/lib/components/ChannelRoutePicker.svelte @@ -0,0 +1,59 @@ + + +
+ {#if inherit} + + {/if} + {#each CHANNELS as c (c.id)} + {@const active = !inheriting && selected.includes(c.id)} + + {/each} +
diff --git a/apps/web/src/lib/components/RouteChip.svelte b/apps/web/src/lib/components/RouteChip.svelte index c6bb41a..ad7ee3c 100644 --- a/apps/web/src/lib/components/RouteChip.svelte +++ b/apps/web/src/lib/components/RouteChip.svelte @@ -1,22 +1,19 @@ diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts index b255421..beda3a6 100644 --- a/apps/web/src/lib/remote/notifs.remote.ts +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -2,6 +2,7 @@ // `load` functions; after a command runs, the client calls `invalidateAll()` to // refresh the page data. import type { Did } from '@atcute/lexicons'; +import { isConcreteRoute } from '@atmo/notifs-lexicons'; import { command, getRequestEvent } from '$app/server'; import { error } from '@sveltejs/kit'; import * as v from 'valibot'; @@ -55,6 +56,22 @@ export const unlinkTelegram = command(async () => { await requireRelay().unlinkChannel({ platform: 'telegram' }); }); +export const linkEmail = command( + v.object({ address: v.pipe(v.string(), v.email()) }), + async ({ address }) => { + await requireRelay().linkEmail(address); + } +); + +export const verifyEmail = command( + v.object({ code: v.pipe(v.string(), v.regex(/^\d{6}$/)) }), + async ({ code }) => requireRelay().verifyEmail(code) +); + +export const unlinkEmail = command(async () => { + await requireRelay().unlinkEmail(); +}); + export const registerPush = command( v.object({ endpoint: v.string(), @@ -85,29 +102,30 @@ export const markNotificationsRead = command( } ); -export const setDefaultRoute = command( - v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off']) }), - async ({ route }) => { - await requireRelay().setDefaultRoute(route); - } +// A route is a `+`-joined channel set or 'off'; app/category add an inherit sentinel. +const concreteRoute = v.pipe(v.string(), v.check(isConcreteRoute, 'Invalid route')); +const appRoute = v.pipe( + v.string(), + v.check((s) => s === 'default' || isConcreteRoute(s), 'Invalid route') ); +const categoryRoute = v.pipe( + v.string(), + v.check((s) => s === 'app' || isConcreteRoute(s), 'Invalid route') +); + +export const setDefaultRoute = command(v.object({ route: concreteRoute }), async ({ route }) => { + await requireRelay().setDefaultRoute(route); +}); export const setRouting = command( - v.object({ - sender: didSchema, - category: v.string(), - route: v.picklist(['app', 'push', 'telegram', 'push+telegram', 'off']) - }), + v.object({ sender: didSchema, category: v.string(), route: categoryRoute }), async ({ sender, category, route }) => { await requireRelay().setRouting(sender as Did, category, route); } ); export const setAppRouting = command( - v.object({ - sender: didSchema, - route: v.picklist(['default', 'push', 'telegram', 'push+telegram', 'off']) - }), + v.object({ sender: didSchema, route: appRoute }), async ({ sender, route }) => { await requireRelay().setAppRouting(sender as Did, route); } diff --git a/apps/web/src/lib/routes.ts b/apps/web/src/lib/routes.ts index 7863629..43fc66f 100644 --- a/apps/web/src/lib/routes.ts +++ b/apps/web/src/lib/routes.ts @@ -1,38 +1,25 @@ -// Route options for the routing UI. Concrete alert routes gate push/telegram; -// everything is in the inbox regardless. Inheritance: a category can be 'app' -// (use the app-wide route); an app can be 'default' (use the account default). -import type { AlertRoute, AppRoute, CategoryRoute } from '@atmo/notifs-lexicons'; +// Routing UI helpers. A route is a channel set ('+'-joined, e.g. 'push+email') or +// 'off'; app-wide and per-category routes add the inherit sentinels 'default'/'app'. +// See @atmo/notifs-lexicons (routeChannels / channelsRoute). +import { type Channel, routeChannels } from '@atmo/notifs-lexicons'; -export const ALERT_ROUTES = [ - 'push', - 'telegram', - 'push+telegram', - 'off' -] as const satisfies readonly AlertRoute[]; +/** Channels offered in the routing picker, in display order. */ +export const CHANNELS: { id: Channel; label: string }[] = [ + { id: 'push', label: 'Push' }, + { id: 'telegram', label: 'Telegram' }, + { id: 'email', label: 'Email' } +]; -/** App-wide selector: inherit account default, or a concrete route. */ -export const APP_ROUTES = [ - 'default', - 'push', - 'telegram', - 'push+telegram', - 'off' -] as const satisfies readonly AppRoute[]; - -/** Per-category selector: inherit the app-wide route, or a concrete route. */ -export const CATEGORY_ROUTES = [ - 'app', - 'push', - 'telegram', - 'push+telegram', - 'off' -] as const satisfies readonly CategoryRoute[]; - -export const ROUTE_LABELS: Record = { - default: 'Account default', - app: 'Like app', +const CHANNEL_LABEL: Record = { push: 'Push', telegram: 'Telegram', - 'push+telegram': 'Push + Telegram', - off: 'Off' + email: 'Email' }; + +/** Human label for a route string (a channel set, 'off', or an inherit sentinel). */ +export function routeLabel(route: string): string { + if (route === 'default') return 'Account default'; + if (route === 'app') return 'Like app'; + const ch = routeChannels(route); + return ch.length > 0 ? ch.map((c) => CHANNEL_LABEL[c]).join(' + ') : 'Off'; +} diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts index ab41e1f..93e80ef 100644 --- a/apps/web/src/lib/server/relay.ts +++ b/apps/web/src/lib/server/relay.ts @@ -53,6 +53,10 @@ export function relayFor(platform: App.Platform | undefined, did: Did | null) { muteGrant: (input: PubAtmoNotifyMuteGrant.$input) => svc.muteGrant(did, input), linkChannel: (input: PubAtmoNotifyLinkChannel.$input) => svc.linkChannel(did, input), unlinkChannel: (input: PubAtmoNotifyUnlinkChannel.$input) => svc.unlinkChannel(did, input), + linkEmail: (address: string) => svc.linkEmail(did, address), + verifyEmail: (code: string) => svc.verifyEmail(did, code), + unlinkEmail: () => svc.unlinkEmail(did), + getEmailChannel: () => svc.getEmailChannel(did), updateSettings: (input: PubAtmoNotifyUpdateSettings.$input) => svc.updateSettings(did, input), registerWebPush: (sub: PushSubscriptionInput) => svc.registerWebPush(did, sub), diff --git a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte index 799ef89..05288ef 100644 --- a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte +++ b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte @@ -2,8 +2,9 @@ import type { AppRoute, Capability, CategoryRoute } from '@atmo/notifs-lexicons'; import { invalidateAll } from '$app/navigation'; import AppMark from '$lib/components/AppMark.svelte'; + import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; import { setAppRouting, setManage, setRouting } from '$lib/remote/notifs.remote'; - import { APP_ROUTES, CATEGORY_ROUTES, ROUTE_LABELS } from '$lib/routes'; + import { routeLabel } from '$lib/routes'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -90,19 +91,15 @@

Account default follows your default route - ({ROUTE_LABELS[data.defaultRoute]}). Everything is in your inbox regardless. + ({routeLabel(data.defaultRoute)}). Everything is in your inbox regardless.

- + onchange={changeApp} + /> @@ -131,16 +128,12 @@
{c.category}
{#if c.description}
{c.description}
{/if} - + onchange={(route) => changeCategory(c.category, route)} + /> {/each} diff --git a/apps/web/src/routes/(app)/settings/+page.server.ts b/apps/web/src/routes/(app)/settings/+page.server.ts index 1f8968a..56a9cbb 100644 --- a/apps/web/src/routes/(app)/settings/+page.server.ts +++ b/apps/web/src/routes/(app)/settings/+page.server.ts @@ -7,11 +7,12 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, platform }) => { const relay = relayFor(platform, locals.did); - const [channels, settings, routing, devices] = await Promise.all([ + const [channels, settings, routing, devices, email] = await Promise.all([ relay.listChannels(), relay.getSettings(), relay.getRouting(), - relay.listDevices() + relay.listDevices(), + relay.getEmailChannel() ]); // Be defensive about relay responses — render fallbacks rather than crash. @@ -20,6 +21,7 @@ export const load: PageServerLoad = async ({ locals, platform }) => { notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false, autoAllow: settings?.autoAllow ?? 'trusted', devices: devices ?? [], - defaultRoute: routing?.defaultRoute ?? 'push' + defaultRoute: routing?.defaultRoute ?? 'push', + email: email ?? null }; }; diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte index 51b0df3..4fd4a58 100644 --- a/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,25 +1,27 @@