[READ-ONLY] Mirror of https://github.com/flo-bit/atproto-notify.
0

Configure Feed

Select the types of activity you want to include in your feed.

email

+753 -126
+6 -4
apps/homepage/static/llms.txt
··· 147 147 must have granted your app "manage its own settings" in the dashboard; reads are 148 148 open). On a relay you run yourself, you can allow your own DID by default. 149 149 150 + A **route** is a channel set, encoded as a `+`-joined string of `push`/`telegram`/`email` 151 + (e.g. `"push+email"`), `"off"` for none, plus the inherit sentinels `"default"` 152 + (app-wide → account default) and `"app"` (category → app-wide). 150 153 - `setRouting` — body `{ userToken, route?, categories? }` 151 - - `route`: `"push" | "telegram" | "push+telegram" | "off" | "default"` (app-wide; 152 - `"default"` = inherit the user's account default) 153 - - `categories`: `[{ "id": "<category>", "route": "push"|…|"off"|"app" }]` 154 - (`"app"` = inherit the app-wide route) 154 + - `route`: app-wide route — a `+`-joined channel set, `"off"`, or `"default"`. Omit to leave unchanged. 155 + - `categories`: `[{ "id": "<category>", "route": "<channel set>" | "off" | "app" }]` 155 156 - → `{ "ok": true }` 156 157 - `getRouting` — body `{ userToken }` → 157 158 `{ "route": "<app-wide>", "defaultRoute": "<account default>", 158 159 "categories": [{ "id", "description?", "route" }] }` 160 + (each route is a `+`-joined channel set / `off` / sentinel as above) 159 161 - `listNotifications` — body `{ userToken, limit?(1–100, default 50), cursor? }` → 160 162 `{ "notifications": [{ "id","title","body","uri?","category?", 161 163 "createdAt"(ISO datetime),"read"(bool),"delivered?"(int) }], "cursor?" }`
+10
apps/relay/migrations/0009_email.sql
··· 1 + -- Email delivery channel: one address per user, verified by a short code emailed 2 + -- via comail before the relay will deliver to it. See delivery/email.ts. 3 + CREATE TABLE email_channels ( 4 + recipient_did TEXT PRIMARY KEY, 5 + address TEXT NOT NULL, 6 + verified INTEGER NOT NULL DEFAULT 0, 7 + verify_code TEXT, 8 + verify_expires INTEGER, 9 + created_at INTEGER NOT NULL 10 + );
+72
apps/relay/src/db/queries.ts
··· 27 27 expires_at: number; 28 28 } 29 29 30 + // --- email channel (one verified address per user) ------------------------- 31 + 32 + export interface EmailChannelRow { 33 + recipient_did: Did; 34 + address: string; 35 + verified: number; 36 + verify_code: string | null; 37 + verify_expires: number | null; 38 + created_at: number; 39 + } 40 + 41 + export function getEmailChannel(db: D1Database, did: Did): Promise<EmailChannelRow | null> { 42 + return db 43 + .prepare('SELECT * FROM email_channels WHERE recipient_did = ?') 44 + .bind(did) 45 + .first<EmailChannelRow>(); 46 + } 47 + 48 + /** Set (or replace) a user's pending email + verification code (resets verified). */ 49 + export async function upsertEmailChannel( 50 + db: D1Database, 51 + input: { did: Did; address: string; verifyCode: string; verifyExpires: number; createdAt: number }, 52 + ): Promise<void> { 53 + await db 54 + .prepare( 55 + `INSERT INTO email_channels (recipient_did, address, verified, verify_code, verify_expires, created_at) 56 + VALUES (?, ?, 0, ?, ?, ?) 57 + ON CONFLICT(recipient_did) DO UPDATE SET 58 + address = excluded.address, 59 + verified = 0, 60 + verify_code = excluded.verify_code, 61 + verify_expires = excluded.verify_expires, 62 + created_at = excluded.created_at`, 63 + ) 64 + .bind(input.did, input.address, input.verifyCode, input.verifyExpires, input.createdAt) 65 + .run(); 66 + } 67 + 68 + /** Mark verified iff the code matches and hasn't expired. Returns true on success. */ 69 + export async function verifyEmailChannel( 70 + db: D1Database, 71 + did: Did, 72 + code: string, 73 + nowMs: number, 74 + ): Promise<boolean> { 75 + const result = await db 76 + .prepare( 77 + `UPDATE email_channels SET verified = 1, verify_code = NULL, verify_expires = NULL 78 + WHERE recipient_did = ? AND verified = 0 AND verify_code = ? AND verify_expires > ?`, 79 + ) 80 + .bind(did, code, nowMs) 81 + .run(); 82 + return changed(result); 83 + } 84 + 85 + export async function deleteEmailChannel(db: D1Database, did: Did): Promise<boolean> { 86 + const result = await db 87 + .prepare('DELETE FROM email_channels WHERE recipient_did = ?') 88 + .bind(did) 89 + .run(); 90 + return changed(result); 91 + } 92 + 93 + /** The user's verified email address, or null. Used by delivery. */ 94 + export async function getVerifiedEmail(db: D1Database, did: Did): Promise<string | null> { 95 + const row = await db 96 + .prepare('SELECT address FROM email_channels WHERE recipient_did = ? AND verified = 1') 97 + .bind(did) 98 + .first<{ address: string }>(); 99 + return row?.address ?? null; 100 + } 101 + 30 102 export interface SenderRow { 31 103 did: Did; 32 104 handle: string | null;
+21
apps/relay/src/delivery/dispatcher.ts
··· 5 5 import type { DispatchJob, Env } from '../env'; 6 6 import { callbackAppFor } from '../lib/apps'; 7 7 8 + import { EmailError, sendEmail } from './email'; 8 9 import { 9 10 escapeMd, 10 11 type InlineKeyboardMarkup, ··· 62 63 console.error(`dispatch: dropping dead push subscription (${err.statusCode})`); 63 64 return true; 64 65 } 66 + if ( 67 + channel.platform === 'email' && 68 + err instanceof EmailError && 69 + err.statusCode >= 400 && 70 + err.statusCode !== 429 71 + ) { 72 + // Permanent (bad/rejected address) — stop retrying. The channel is kept so the 73 + // user can fix it; 429 (rate limit) and 5xx fall through to retry. 74 + console.error(`dispatch: dropping email to ${channel.address} (${err.statusCode} ${err.code})`); 75 + return true; 76 + } 65 77 return false; 66 78 } 67 79 ··· 127 139 body: job.body, 128 140 uri: job.uri, 129 141 senderDid: job.senderDid, 142 + }); 143 + return; 144 + } 145 + 146 + if (job.channel.platform === 'email') { 147 + await sendEmail(env, { 148 + to: job.channel.address, 149 + subject: job.title, 150 + text: job.uri !== undefined ? `${job.body}\n\n${job.uri}` : job.body, 130 151 }); 131 152 return; 132 153 }
+73
apps/relay/src/delivery/email.ts
··· 1 + // Email delivery via comail (https://comail.at). Plain REST send API: 2 + // POST https://smtp.atmos.email/v1/send 3 + // headers: Authorization: Bearer atmos_…, X-Atmos-DID: <account did> 4 + // body: { from, to, subject, text, html?, replyTo?, category? } 5 + // See https://comail.at/docs/send-api. 6 + import type { Env } from '../env'; 7 + 8 + const COMAIL_SEND_API = 'https://smtp.atmos.email/v1/send'; 9 + 10 + /** Thrown when comail rejects the send (non-2xx, or the recipient is rejected). */ 11 + export class EmailError extends Error { 12 + readonly statusCode: number; 13 + readonly code: string; 14 + constructor(statusCode: number, code: string, detail: string) { 15 + super(`email send failed: ${statusCode} ${code}${detail ? ` ${detail}` : ''}`); 16 + this.name = 'EmailError'; 17 + this.statusCode = statusCode; 18 + this.code = code; 19 + } 20 + } 21 + 22 + export interface EmailMessage { 23 + /** A single plain email address (the relay sends per-channel). */ 24 + to: string; 25 + subject: string; 26 + text: string; 27 + html?: string; 28 + replyTo?: string; 29 + /** comail category hint: 'login-link'|'password-reset'|'mfa-otp'|'verification'|'bulk'|'broadcast'. */ 30 + category?: string; 31 + } 32 + 33 + interface ComailResponse { 34 + accepted?: { recipient: string; messageId: number }[]; 35 + rejected?: { recipient: string; reason?: string }[]; 36 + error?: string; 37 + code?: string; 38 + } 39 + 40 + /** 41 + * Send one email via comail. Resolves with the comail message id, or throws 42 + * {@link EmailError} on a non-2xx response or a rejected recipient. 43 + */ 44 + export async function sendEmail(env: Env, msg: EmailMessage): Promise<{ messageId: number }> { 45 + const res = await fetch(COMAIL_SEND_API, { 46 + method: 'POST', 47 + headers: { 48 + authorization: `Bearer ${env.COMAIL_API_KEY}`, 49 + 'x-atmos-did': env.COMAIL_DID, 50 + 'content-type': 'application/json', 51 + }, 52 + body: JSON.stringify({ 53 + from: env.COMAIL_FROM, 54 + to: msg.to, 55 + subject: msg.subject, 56 + text: msg.text, 57 + ...(msg.html !== undefined && { html: msg.html }), 58 + ...(msg.replyTo !== undefined && { replyTo: msg.replyTo }), 59 + ...(msg.category !== undefined && { category: msg.category }), 60 + }), 61 + }); 62 + 63 + const data = (await res.json().catch(() => ({}))) as ComailResponse; 64 + if (!res.ok) { 65 + throw new EmailError(res.status, data.code ?? 'UNKNOWN', data.error ?? ''); 66 + } 67 + const accepted = data.accepted?.[0]; 68 + if (accepted === undefined) { 69 + // 2xx but the address was rejected (suppressed/bounced) — treat as a failure. 70 + throw new EmailError(res.status, 'REJECTED', data.rejected?.[0]?.reason ?? 'recipient rejected'); 71 + } 72 + return { messageId: accepted.messageId }; 73 + }
+15 -1
apps/relay/src/env.ts
··· 15 15 auth: string; 16 16 } 17 17 18 + /** An email delivery channel (a verified address). */ 19 + export interface EmailChannel { 20 + platform: 'email'; 21 + address: string; 22 + } 23 + 18 24 /** Where a notification can be delivered. */ 19 - export type DeliveryChannel = TelegramChannel | WebPushChannel; 25 + export type DeliveryChannel = TelegramChannel | WebPushChannel | EmailChannel; 20 26 21 27 /** 22 28 * Work item placed on `DISPATCH_QUEUE` and handled by the `queue` consumer. ··· 86 92 VAPID_SUBJECT: string; 87 93 /** VAPID private signing key as JWK JSON (secret). */ 88 94 VAPID_PRIVATE_JWK: string; 95 + 96 + // Email delivery via comail (https://comail.at) — POST https://smtp.atmos.email/v1/send. 97 + /** comail API key `atmos_…` (secret). */ 98 + COMAIL_API_KEY: string; 99 + /** Account DID for the `X-Atmos-DID` header (var). */ 100 + COMAIL_DID: string; 101 + /** Enrolled sender address for the `from` field, e.g. "atmo.pub <notify@atmo.pub>" (var). */ 102 + COMAIL_FROM: string; 89 103 } 90 104 91 105 /**
+13
apps/relay/src/rpc/entrypoint.ts
··· 8 8 Capability, 9 9 CategoryRoute, 10 10 DeviceView, 11 + EmailChannelView, 11 12 ListNotificationsResult, 12 13 MarkReadInput, 13 14 NotifsRpc, ··· 72 73 } 73 74 getSettings(did: Did): Promise<PubAtmoNotifyGetSettings.$output> { 74 75 return ops.getSettings(this.env, did); 76 + } 77 + linkEmail(did: Did, address: string) { 78 + return ops.linkEmail(this.env, did, address); 79 + } 80 + verifyEmail(did: Did, code: string) { 81 + return ops.verifyEmail(this.env, did, code); 82 + } 83 + unlinkEmail(did: Did) { 84 + return ops.unlinkEmail(this.env, did); 85 + } 86 + getEmailChannel(did: Did): Promise<EmailChannelView | null> { 87 + return ops.getEmailChannel(this.env, did); 75 88 } 76 89 registerWebPush(did: Did, sub: PushSubscriptionInput) { 77 90 return ops.registerWebPush(this.env, did, sub);
+56
apps/relay/src/rpc/ops.ts
··· 12 12 Capability, 13 13 CategoryRoute, 14 14 DeviceView, 15 + EmailChannelView, 15 16 ListNotificationsResult, 16 17 MarkReadInput, 17 18 NotificationView, ··· 32 33 } from '@atmo/notifs-lexicons'; 33 34 34 35 import { verifyAppLoginToken } from '../auth/appLogin'; 36 + import { sendEmail } from '../delivery/email'; 35 37 import * as q from '../db/queries'; 36 38 import type { Env } from '../env'; 37 39 import { appCatalog, callbackAppFor } from '../lib/apps'; 40 + import { invalidRequest } from '../lib/errors'; 38 41 import { newLinkToken } from '../lib/ids'; 39 42 import { addMinutes, now, toIsoDatetime } from '../lib/time'; 40 43 ··· 432 435 ): Promise<{ ok: boolean }> { 433 436 await q.setGrantManage(env.DB, did, sender, manage); 434 437 return { ok: true }; 438 + } 439 + 440 + // --- email channel --------------------------------------------------------- 441 + 442 + const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; 443 + const VERIFY_TTL_MS = 15 * 60 * 1000; 444 + 445 + function genVerifyCode(): string { 446 + const n = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0; 447 + return (n % 1_000_000).toString().padStart(6, '0'); 448 + } 449 + 450 + /** 451 + * Set the user's email and email them a verification code (via comail). Stored 452 + * unverified until `verifyEmail` succeeds. Throws if comail rejects, so nothing 453 + * is stored for an undeliverable address. 454 + */ 455 + export async function linkEmail(env: Env, did: Did, address: string): Promise<{ ok: boolean }> { 456 + const addr = address.trim().toLowerCase(); 457 + if (!EMAIL_RE.test(addr)) throw invalidRequest('Invalid email address'); 458 + 459 + const code = genVerifyCode(); 460 + await sendEmail(env, { 461 + to: addr, 462 + subject: 'Verify your email for atmo.pub', 463 + 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.`, 464 + category: 'verification', 465 + }); 466 + 467 + const t = now(); 468 + await q.upsertEmailChannel(env.DB, { 469 + did, 470 + address: addr, 471 + verifyCode: code, 472 + verifyExpires: t + VERIFY_TTL_MS, 473 + createdAt: t, 474 + }); 475 + return { ok: true }; 476 + } 477 + 478 + export async function verifyEmail(env: Env, did: Did, code: string): Promise<{ verified: boolean }> { 479 + const verified = await q.verifyEmailChannel(env.DB, did, code.trim(), now()); 480 + return { verified }; 481 + } 482 + 483 + export async function unlinkEmail(env: Env, did: Did): Promise<{ ok: boolean }> { 484 + await q.deleteEmailChannel(env.DB, did); 485 + return { ok: true }; 486 + } 487 + 488 + export async function getEmailChannel(env: Env, did: Did): Promise<EmailChannelView | null> { 489 + const row = await q.getEmailChannel(env.DB, did); 490 + return row === null ? null : { address: row.address, verified: row.verified === 1 }; 435 491 } 436 492 437 493 /**
+4
apps/relay/src/xrpc/manage.ts
··· 37 37 listChannels: (env, did) => ops.listChannels(env, did), 38 38 getSettings: (env, did) => ops.getSettings(env, did), 39 39 listDevices: (env, did) => ops.listDevices(env, did), 40 + getEmailChannel: (env, did) => ops.getEmailChannel(env, did), 40 41 getRouting: (env, did) => ops.getRouting(env, did), 41 42 listNotifications: (env, did, p) => 42 43 ops.listNotifications(env, did, (p as { cursor?: string } | undefined)?.cursor), ··· 48 49 linkChannel: (env, did, p) => ops.linkChannel(env, did, p as PubAtmoNotifyLinkChannel.$input), 49 50 unlinkChannel: (env, did, p) => 50 51 ops.unlinkChannel(env, did, p as PubAtmoNotifyUnlinkChannel.$input), 52 + linkEmail: (env, did, p) => ops.linkEmail(env, did, (p as { address: string }).address), 53 + verifyEmail: (env, did, p) => ops.verifyEmail(env, did, (p as { code: string }).code), 54 + unlinkEmail: (env, did) => ops.unlinkEmail(env, did), 51 55 updateSettings: (env, did, p) => 52 56 ops.updateSettings(env, did, p as PubAtmoNotifyUpdateSettings.$input), 53 57 registerWebPush: (env, did, p) => ops.registerWebPush(env, did, p as PushSubscriptionInput),
+25 -5
apps/relay/src/xrpc/send.ts
··· 94 94 if (route === undefined) { 95 95 route = (await q.getUser(app.env.DB, recipient))?.default_route ?? 'push'; 96 96 } 97 - const usePush = route === 'push' || route === 'push+telegram'; 98 - const useTelegram = route === 'telegram' || route === 'push+telegram'; 97 + // A route is a `+`-joined channel set ('off' = none); see MANAGEMENT-AUTH / 98 + // routing. Parse it so adding channels (email) needs no new combos. 99 + const channels = route === 'off' ? new Set<string>() : new Set(route.split('+')); 99 100 100 - const telegramChannels = useTelegram 101 + const telegramChannels = channels.has('telegram') 101 102 ? (await q.listChannelsForDid(app.env.DB, recipient)).filter((c) => c.platform === 'telegram') 102 103 : []; 103 - const pushSubs = usePush ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) : []; 104 - const deliveredCount = telegramChannels.length + pushSubs.length; 104 + const pushSubs = channels.has('push') 105 + ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) 106 + : []; 107 + const emailAddress = channels.has('email') 108 + ? await q.getVerifiedEmail(app.env.DB, recipient) 109 + : null; 110 + const deliveredCount = telegramChannels.length + pushSubs.length + (emailAddress ? 1 : 0); 105 111 106 112 // No targets → accept but deliver to nobody. 107 113 if (deliveredCount === 0) { ··· 136 142 senderDid, 137 143 }, 138 144 })), 145 + ...(emailAddress !== null 146 + ? [ 147 + { 148 + body: { 149 + kind: 'notification' as const, 150 + channel: { platform: 'email' as const, address: emailAddress }, 151 + title: input.title, 152 + body: input.body, 153 + uri: input.uri, 154 + senderDid, 155 + }, 156 + }, 157 + ] 158 + : []), 139 159 ]; 140 160 await app.env.DISPATCH_QUEUE.sendBatch(jobs); 141 161
+12 -1
apps/relay/src/xrpc/setRouting.ts
··· 1 - import { PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; 1 + import { isConcreteRoute, PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 4 import { verifyManagementCall } from '../auth/management'; 5 5 import * as q from '../db/queries'; 6 6 import type { AppContext } from '../env'; 7 + import { invalidRequest } from '../lib/errors'; 7 8 8 9 const LXM = 'pub.atmo.notify.setRouting'; 9 10 ··· 26 27 write: true, 27 28 lxm: LXM, 28 29 }); 30 + 31 + // The lexicon route fields are free strings; validate the channel-set format. 32 + if (input.route !== undefined && input.route !== 'default' && !isConcreteRoute(input.route)) { 33 + throw invalidRequest('Invalid route'); 34 + } 35 + for (const c of input.categories ?? []) { 36 + if (c.route !== 'app' && !isConcreteRoute(c.route)) { 37 + throw invalidRequest('Invalid category route'); 38 + } 39 + } 29 40 30 41 if (input.route !== undefined) { 31 42 if (input.route === 'default') {
+57
apps/relay/test/email-channel.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { env } from 'cloudflare:test'; 3 + import { beforeEach, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import * as ops from '../src/rpc/ops'; 7 + 8 + import { installFetchMock, mockComailOk } from './helpers'; 9 + 10 + beforeEach(() => { 11 + installFetchMock(); 12 + mockComailOk(); 13 + }); 14 + 15 + it('linkEmail stores an unverified, normalized address', async () => { 16 + const did: Did = 'did:plc:email-link'; 17 + await ops.linkEmail(env, did, ' Me@Example.com '); 18 + expect(await ops.getEmailChannel(env, did)).toEqual({ address: 'me@example.com', verified: false }); 19 + }); 20 + 21 + it('rejects an invalid address', async () => { 22 + const did: Did = 'did:plc:email-bad'; 23 + await expect(ops.linkEmail(env, did, 'not-an-email')).rejects.toThrow(); 24 + expect(await ops.getEmailChannel(env, did)).toBeNull(); 25 + }); 26 + 27 + it('verifyEmail: right code verifies, wrong code does not', async () => { 28 + const did: Did = 'did:plc:email-verify'; 29 + await ops.linkEmail(env, did, 'a@b.com'); 30 + const code = (await q.getEmailChannel(env.DB, did))?.verify_code; 31 + if (!code) throw new Error('expected a verify code'); 32 + 33 + expect((await ops.verifyEmail(env, did, '000000')).verified).toBe(false); 34 + expect((await ops.verifyEmail(env, did, code)).verified).toBe(true); 35 + expect((await ops.getEmailChannel(env, did))?.verified).toBe(true); 36 + // Delivery query now sees it. 37 + expect(await q.getVerifiedEmail(env.DB, did)).toBe('a@b.com'); 38 + }); 39 + 40 + it('verifyEmail fails once expired', async () => { 41 + const did: Did = 'did:plc:email-expired'; 42 + await q.upsertEmailChannel(env.DB, { 43 + did, 44 + address: 'c@d.com', 45 + verifyCode: '123456', 46 + verifyExpires: Date.now() - 1000, // already expired 47 + createdAt: Date.now() - 2000, 48 + }); 49 + expect((await ops.verifyEmail(env, did, '123456')).verified).toBe(false); 50 + }); 51 + 52 + it('unlinkEmail removes it', async () => { 53 + const did: Did = 'did:plc:email-unlink'; 54 + await ops.linkEmail(env, did, 'x@y.com'); 55 + await ops.unlinkEmail(env, did); 56 + expect(await ops.getEmailChannel(env, did)).toBeNull(); 57 + });
+30
apps/relay/test/email.test.ts
··· 1 + import { env } from 'cloudflare:test'; 2 + import { beforeEach, expect, it } from 'vitest'; 3 + 4 + import { EmailError, sendEmail } from '../src/delivery/email'; 5 + 6 + import { installFetchMock, mockComailError, mockComailOk, mockComailRejected } from './helpers'; 7 + 8 + // Reset routes before each test — all comail mocks match the same host, so the 9 + // first-registered would otherwise win across tests. 10 + beforeEach(() => { 11 + installFetchMock(); 12 + }); 13 + 14 + const msg = { to: 'user@example.com', subject: 'Hello', text: 'Sent via comail' }; 15 + 16 + it('sends an email and returns the comail message id', async () => { 17 + mockComailOk(448); 18 + const { messageId } = await sendEmail(env, msg); 19 + expect(messageId).toBe(448); 20 + }); 21 + 22 + it('throws EmailError on a comail error response', async () => { 23 + mockComailError(429, 'RATE_LIMITED'); 24 + await expect(sendEmail(env, msg)).rejects.toBeInstanceOf(EmailError); 25 + }); 26 + 27 + it('throws EmailError when the recipient is rejected (2xx, empty accepted)', async () => { 28 + mockComailRejected(); 29 + await expect(sendEmail(env, msg)).rejects.toBeInstanceOf(EmailError); 30 + });
+24
apps/relay/test/helpers.ts
··· 109 109 }); 110 110 } 111 111 112 + /** Accept any comail send with an `accepted` response. */ 113 + export function mockComailOk(messageId = 1): void { 114 + routes.push({ 115 + match: (url) => url.hostname === 'smtp.atmos.email', 116 + respond: () => jsonResponse(200, { accepted: [{ recipient: 'x@example.com', messageId }], rejected: [] }), 117 + }); 118 + } 119 + 120 + /** Make any comail send fail with `status`/`code`. */ 121 + export function mockComailError(status = 429, code = 'RATE_LIMITED'): void { 122 + routes.push({ 123 + match: (url) => url.hostname === 'smtp.atmos.email', 124 + respond: () => jsonResponse(status, { error: 'comail error', code }), 125 + }); 126 + } 127 + 128 + /** comail returns 200 but the recipient is rejected (suppressed/bounced). */ 129 + export function mockComailRejected(): void { 130 + routes.push({ 131 + match: (url) => url.hostname === 'smtp.atmos.email', 132 + respond: () => jsonResponse(200, { accepted: [], rejected: [{ recipient: 'x@example.com', reason: 'suppressed' }] }), 133 + }); 134 + } 135 + 112 136 /** Stub the AppView profile fetch for any actor. */ 113 137 export function makeBskyProfileMock( 114 138 profile: { handle?: string; displayName?: string; avatar?: string } = {
+32
apps/relay/test/send.test.ts
··· 198 198 expect(res.status).toBe(200); 199 199 expect(await res.json()).toMatchObject({ delivered: 0 }); 200 200 }); 201 + 202 + it('delivers to a verified email when the route includes email', async () => { 203 + const sender = await makeIdentity('did:plc:sendemail'); 204 + mockPlc(sender); 205 + const recip: Did = 'did:plc:emailrecipient'; 206 + await q.ensureUser(env.DB, recip, Date.now()); 207 + await q.upsertGrant(env.DB, { 208 + recipientDid: recip, 209 + senderDid: sender.did, 210 + grantedAt: Date.now(), 211 + title: null, 212 + description: null, 213 + iconUrl: null 214 + }); 215 + // A verified email + a route that includes it. 216 + await q.upsertEmailChannel(env.DB, { 217 + did: recip, 218 + address: 'me@example.com', 219 + verifyCode: '111111', 220 + verifyExpires: Date.now() + 60_000, 221 + createdAt: Date.now() 222 + }); 223 + await q.verifyEmailChannel(env.DB, recip, '111111', Date.now()); 224 + await q.setDefaultRoute(env.DB, recip, 'push+email'); 225 + const jwt = await makeJwt(sender, { lxm: SEND }); 226 + 227 + // Push has no subscriptions, so only the email target counts. 228 + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); 229 + 230 + expect(res.status).toBe(200); 231 + expect(await res.json()).toMatchObject({ delivered: 1 }); 232 + });
+6
apps/relay/wrangler.toml
··· 42 42 # browser applicationServerKey — paste the SAME value into apps/web VAPID_PUBLIC_KEY. 43 43 VAPID_PUBLIC_KEY = "BF4pVUiFeh9wltn6Rj151RHA4WfidcRRv8kXp2aKcRATi_2gUgq0uGX8jVY1EczXqOvRtluqIxRj6Mtf5d1ImRw" 44 44 VAPID_SUBJECT = "https://atmo.pub" 45 + # Email via comail (https://comail.at). COMAIL_DID = the account DID for the 46 + # X-Atmos-DID header; COMAIL_FROM = an enrolled sender address. 47 + COMAIL_DID = "did:web:relay.atmo.pub" 48 + COMAIL_FROM = "atmo.pub <notify@atmo.pub>" 45 49 46 50 # Secrets set via `wrangler secret put`: 47 51 # - TELEGRAM_BOT_TOKEN 48 52 # - TELEGRAM_WEBHOOK_SECRET 49 53 # - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`) 54 + # - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`) 55 + # - COMAIL_API_KEY (atmos_… from comail.at)
+59
apps/web/src/lib/components/ChannelRoutePicker.svelte
··· 1 + <script lang="ts"> 2 + import { channelsRoute, routeChannels, type Channel } from '@atmo/notifs-lexicons'; 3 + import { CHANNELS } from '$lib/routes'; 4 + 5 + let { 6 + value, 7 + inherit, 8 + disabled = false, 9 + onchange 10 + }: { 11 + /** Current route string (a channel set, 'off', or an inherit sentinel). */ 12 + value: string; 13 + /** Optional "inherit" choice (account default for apps, app-wide for categories). */ 14 + inherit?: { token: 'default' | 'app'; label: string }; 15 + disabled?: boolean; 16 + onchange: (route: string) => void; 17 + } = $props(); 18 + 19 + const inheriting = $derived(inherit !== undefined && value === inherit.token); 20 + const selected = $derived(routeChannels(value)); 21 + 22 + function toggle(id: Channel) { 23 + const next = selected.includes(id) 24 + ? selected.filter((c) => c !== id) 25 + : [...selected, id]; 26 + onchange(channelsRoute(next)); 27 + } 28 + 29 + const base = 30 + 'rounded-md border px-2.5 py-1 text-sm font-medium transition-colors disabled:opacity-50'; 31 + const on = 'border-accent bg-accent-soft text-accent'; 32 + const off = 'border-line text-muted hover:bg-surface-2 hover:text-fg'; 33 + </script> 34 + 35 + <div class="flex flex-wrap gap-1.5"> 36 + {#if inherit} 37 + <button 38 + type="button" 39 + {disabled} 40 + onclick={() => onchange(inherit.token)} 41 + aria-pressed={inheriting} 42 + class="{base} {inheriting ? on : off}" 43 + > 44 + {inherit.label} 45 + </button> 46 + {/if} 47 + {#each CHANNELS as c (c.id)} 48 + {@const active = !inheriting && selected.includes(c.id)} 49 + <button 50 + type="button" 51 + {disabled} 52 + onclick={() => toggle(c.id)} 53 + aria-pressed={active} 54 + class="{base} {active ? on : off}" 55 + > 56 + {c.label} 57 + </button> 58 + {/each} 59 + </div>
+6 -9
apps/web/src/lib/components/RouteChip.svelte
··· 1 1 <script lang="ts"> 2 - import { ROUTE_LABELS } from '$lib/routes'; 2 + import { routeLabel } from '$lib/routes'; 3 3 4 4 let { route, size = 'sm' }: { route: string; size?: 'sm' | 'md' } = $props(); 5 5 6 - // Hue per route; null = neutral (uses theme tokens). Colors are translucent so 7 - // they read on both light and dark. 6 + // Hue per single channel; sets/sentinels render neutral. Colors are translucent 7 + // so they read on both light and dark. 8 8 const HUE: Record<string, number | null> = { 9 9 push: 145, 10 10 telegram: 220, 11 - 'push+telegram': 185, 12 - inbox: 250, 13 - off: null, 14 - default: null, 15 - app: null 11 + email: 25, 12 + inbox: 250 16 13 }; 17 14 18 15 const hue = $derived(HUE[route] ?? null); 19 - const label = $derived(ROUTE_LABELS[route] ?? route); 16 + const label = $derived(routeLabel(route)); 20 17 const fs = $derived(size === 'md' ? 'text-xs' : 'text-[11px]'); 21 18 </script> 22 19
+32 -14
apps/web/src/lib/remote/notifs.remote.ts
··· 2 2 // `load` functions; after a command runs, the client calls `invalidateAll()` to 3 3 // refresh the page data. 4 4 import type { Did } from '@atcute/lexicons'; 5 + import { isConcreteRoute } from '@atmo/notifs-lexicons'; 5 6 import { command, getRequestEvent } from '$app/server'; 6 7 import { error } from '@sveltejs/kit'; 7 8 import * as v from 'valibot'; ··· 55 56 await requireRelay().unlinkChannel({ platform: 'telegram' }); 56 57 }); 57 58 59 + export const linkEmail = command( 60 + v.object({ address: v.pipe(v.string(), v.email()) }), 61 + async ({ address }) => { 62 + await requireRelay().linkEmail(address); 63 + } 64 + ); 65 + 66 + export const verifyEmail = command( 67 + v.object({ code: v.pipe(v.string(), v.regex(/^\d{6}$/)) }), 68 + async ({ code }) => requireRelay().verifyEmail(code) 69 + ); 70 + 71 + export const unlinkEmail = command(async () => { 72 + await requireRelay().unlinkEmail(); 73 + }); 74 + 58 75 export const registerPush = command( 59 76 v.object({ 60 77 endpoint: v.string(), ··· 85 102 } 86 103 ); 87 104 88 - export const setDefaultRoute = command( 89 - v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off']) }), 90 - async ({ route }) => { 91 - await requireRelay().setDefaultRoute(route); 92 - } 105 + // A route is a `+`-joined channel set or 'off'; app/category add an inherit sentinel. 106 + const concreteRoute = v.pipe(v.string(), v.check(isConcreteRoute, 'Invalid route')); 107 + const appRoute = v.pipe( 108 + v.string(), 109 + v.check((s) => s === 'default' || isConcreteRoute(s), 'Invalid route') 93 110 ); 111 + const categoryRoute = v.pipe( 112 + v.string(), 113 + v.check((s) => s === 'app' || isConcreteRoute(s), 'Invalid route') 114 + ); 115 + 116 + export const setDefaultRoute = command(v.object({ route: concreteRoute }), async ({ route }) => { 117 + await requireRelay().setDefaultRoute(route); 118 + }); 94 119 95 120 export const setRouting = command( 96 - v.object({ 97 - sender: didSchema, 98 - category: v.string(), 99 - route: v.picklist(['app', 'push', 'telegram', 'push+telegram', 'off']) 100 - }), 121 + v.object({ sender: didSchema, category: v.string(), route: categoryRoute }), 101 122 async ({ sender, category, route }) => { 102 123 await requireRelay().setRouting(sender as Did, category, route); 103 124 } 104 125 ); 105 126 106 127 export const setAppRouting = command( 107 - v.object({ 108 - sender: didSchema, 109 - route: v.picklist(['default', 'push', 'telegram', 'push+telegram', 'off']) 110 - }), 128 + v.object({ sender: didSchema, route: appRoute }), 111 129 async ({ sender, route }) => { 112 130 await requireRelay().setAppRouting(sender as Did, route); 113 131 }
+20 -33
apps/web/src/lib/routes.ts
··· 1 - // Route options for the routing UI. Concrete alert routes gate push/telegram; 2 - // everything is in the inbox regardless. Inheritance: a category can be 'app' 3 - // (use the app-wide route); an app can be 'default' (use the account default). 4 - import type { AlertRoute, AppRoute, CategoryRoute } from '@atmo/notifs-lexicons'; 1 + // Routing UI helpers. A route is a channel set ('+'-joined, e.g. 'push+email') or 2 + // 'off'; app-wide and per-category routes add the inherit sentinels 'default'/'app'. 3 + // See @atmo/notifs-lexicons (routeChannels / channelsRoute). 4 + import { type Channel, routeChannels } from '@atmo/notifs-lexicons'; 5 5 6 - export const ALERT_ROUTES = [ 7 - 'push', 8 - 'telegram', 9 - 'push+telegram', 10 - 'off' 11 - ] as const satisfies readonly AlertRoute[]; 6 + /** Channels offered in the routing picker, in display order. */ 7 + export const CHANNELS: { id: Channel; label: string }[] = [ 8 + { id: 'push', label: 'Push' }, 9 + { id: 'telegram', label: 'Telegram' }, 10 + { id: 'email', label: 'Email' } 11 + ]; 12 12 13 - /** App-wide selector: inherit account default, or a concrete route. */ 14 - export const APP_ROUTES = [ 15 - 'default', 16 - 'push', 17 - 'telegram', 18 - 'push+telegram', 19 - 'off' 20 - ] as const satisfies readonly AppRoute[]; 21 - 22 - /** Per-category selector: inherit the app-wide route, or a concrete route. */ 23 - export const CATEGORY_ROUTES = [ 24 - 'app', 25 - 'push', 26 - 'telegram', 27 - 'push+telegram', 28 - 'off' 29 - ] as const satisfies readonly CategoryRoute[]; 30 - 31 - export const ROUTE_LABELS: Record<string, string> = { 32 - default: 'Account default', 33 - app: 'Like app', 13 + const CHANNEL_LABEL: Record<Channel, string> = { 34 14 push: 'Push', 35 15 telegram: 'Telegram', 36 - 'push+telegram': 'Push + Telegram', 37 - off: 'Off' 16 + email: 'Email' 38 17 }; 18 + 19 + /** Human label for a route string (a channel set, 'off', or an inherit sentinel). */ 20 + export function routeLabel(route: string): string { 21 + if (route === 'default') return 'Account default'; 22 + if (route === 'app') return 'Like app'; 23 + const ch = routeChannels(route); 24 + return ch.length > 0 ? ch.map((c) => CHANNEL_LABEL[c]).join(' + ') : 'Off'; 25 + }
+4
apps/web/src/lib/server/relay.ts
··· 53 53 muteGrant: (input: PubAtmoNotifyMuteGrant.$input) => svc.muteGrant(did, input), 54 54 linkChannel: (input: PubAtmoNotifyLinkChannel.$input) => svc.linkChannel(did, input), 55 55 unlinkChannel: (input: PubAtmoNotifyUnlinkChannel.$input) => svc.unlinkChannel(did, input), 56 + linkEmail: (address: string) => svc.linkEmail(did, address), 57 + verifyEmail: (code: string) => svc.verifyEmail(did, code), 58 + unlinkEmail: () => svc.unlinkEmail(did), 59 + getEmailChannel: () => svc.getEmailChannel(did), 56 60 updateSettings: (input: PubAtmoNotifyUpdateSettings.$input) => 57 61 svc.updateSettings(did, input), 58 62 registerWebPush: (sub: PushSubscriptionInput) => svc.registerWebPush(did, sub),
+11 -18
apps/web/src/routes/(app)/apps/[sender]/+page.svelte
··· 2 2 import type { AppRoute, Capability, CategoryRoute } from '@atmo/notifs-lexicons'; 3 3 import { invalidateAll } from '$app/navigation'; 4 4 import AppMark from '$lib/components/AppMark.svelte'; 5 + import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; 5 6 import { setAppRouting, setManage, setRouting } from '$lib/remote/notifs.remote'; 6 - import { APP_ROUTES, CATEGORY_ROUTES, ROUTE_LABELS } from '$lib/routes'; 7 + import { routeLabel } from '$lib/routes'; 7 8 import type { PageData } from './$types'; 8 9 9 10 let { data }: { data: PageData } = $props(); ··· 90 91 <p class="mt-1 text-xs text-muted"> 91 92 <span class="font-medium text-fg">Account default</span> follows your 92 93 <a href="/settings?tab=routing" class="text-accent hover:underline">default route</a> 93 - ({ROUTE_LABELS[data.defaultRoute]}). Everything is in your inbox regardless. 94 + ({routeLabel(data.defaultRoute)}). Everything is in your inbox regardless. 94 95 </p> 95 96 </div> 96 - <select 97 - class={selectClass} 97 + <ChannelRoutePicker 98 98 value={data.app.route} 99 + inherit={{ token: 'default', label: 'Account default' }} 99 100 disabled={busy['__app']} 100 - onchange={(e) => changeApp(e.currentTarget.value as AppRoute)} 101 - > 102 - {#each APP_ROUTES as r (r)} 103 - <option value={r}>{ROUTE_LABELS[r]}</option> 104 - {/each} 105 - </select> 101 + onchange={changeApp} 102 + /> 106 103 </div> 107 104 </div> 108 105 </section> ··· 131 128 <div class="text-sm font-semibold text-fg">{c.category}</div> 132 129 {#if c.description}<div class="text-xs text-muted">{c.description}</div>{/if} 133 130 </div> 134 - <select 135 - class={selectClass} 131 + <ChannelRoutePicker 136 132 value={c.route} 133 + inherit={{ token: 'app', label: 'Like app' }} 137 134 disabled={busy[c.category]} 138 - onchange={(e) => changeCategory(c.category, e.currentTarget.value as CategoryRoute)} 139 - > 140 - {#each CATEGORY_ROUTES as r (r)} 141 - <option value={r}>{ROUTE_LABELS[r]}</option> 142 - {/each} 143 - </select> 135 + onchange={(route) => changeCategory(c.category, route)} 136 + /> 144 137 </li> 145 138 {/each} 146 139 </ul>
+5 -3
apps/web/src/routes/(app)/settings/+page.server.ts
··· 7 7 export const load: PageServerLoad = async ({ locals, platform }) => { 8 8 const relay = relayFor(platform, locals.did); 9 9 10 - const [channels, settings, routing, devices] = await Promise.all([ 10 + const [channels, settings, routing, devices, email] = await Promise.all([ 11 11 relay.listChannels(), 12 12 relay.getSettings(), 13 13 relay.getRouting(), 14 - relay.listDevices() 14 + relay.listDevices(), 15 + relay.getEmailChannel() 15 16 ]); 16 17 17 18 // Be defensive about relay responses — render fallbacks rather than crash. ··· 20 21 notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false, 21 22 autoAllow: settings?.autoAllow ?? 'trusted', 22 23 devices: devices ?? [], 23 - defaultRoute: routing?.defaultRoute ?? 'push' 24 + defaultRoute: routing?.defaultRoute ?? 'push', 25 + email: email ?? null 24 26 }; 25 27 };
+103 -23
apps/web/src/routes/(app)/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 - import type { AlertRoute } from '@atmo/notifs-lexicons'; 3 2 import { onMount } from 'svelte'; 4 3 import { page } from '$app/state'; 5 4 import { invalidateAll } from '$app/navigation'; 5 + import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; 6 6 import Icon from '$lib/components/Icon.svelte'; 7 7 import IOSToggle from '$lib/components/IOSToggle.svelte'; 8 8 import RelativeTime from '$lib/components/RelativeTime.svelte'; 9 9 import RouteChip from '$lib/components/RouteChip.svelte'; 10 10 import { currentSubscription, pushSupported, subscribe, unsubscribe } from '$lib/push'; 11 11 import { 12 + linkEmail, 12 13 linkTelegram, 13 14 registerPush, 14 15 renameDevice, 15 16 setAutoAllow, 16 17 setDefaultRoute, 17 18 setNotifyPending, 19 + unlinkEmail, 18 20 unlinkTelegram, 19 - unregisterPush 21 + unregisterPush, 22 + verifyEmail 20 23 } from '$lib/remote/notifs.remote'; 21 24 import { DOCS_URL } from '$lib/config'; 22 - import { ALERT_ROUTES, ROUTE_LABELS } from '$lib/routes'; 23 25 import type { PageData } from './$types'; 24 26 25 27 let { data }: { data: PageData } = $props(); ··· 80 82 } finally { 81 83 busy[key] = false; 82 84 } 85 + } 86 + 87 + // Email channel. 88 + let emailInput = $state(''); 89 + let codeInput = $state(''); 90 + 91 + function submitEmail() { 92 + const address = emailInput.trim(); 93 + if (address) run('email', () => linkEmail({ address })); 94 + } 95 + 96 + function submitCode() { 97 + const code = codeInput.trim(); 98 + if (!/^\d{6}$/.test(code)) return; 99 + run('email-verify', async () => { 100 + const { verified } = await verifyEmail({ code }); 101 + if (!verified) throw new Error('That code is invalid or expired.'); 102 + codeInput = ''; 103 + }); 83 104 } 84 105 85 106 async function connectTelegram() { ··· 370 391 {/if} 371 392 </div> 372 393 </section> 394 + 395 + <!-- Email --> 396 + <section class="mt-6 max-w-2xl"> 397 + <h2 class={sectionLabel}>Email</h2> 398 + <div class={card}> 399 + {#if !data.email} 400 + <p class="mb-2 text-xs text-muted">Get notifications by email.</p> 401 + <div class="flex gap-2"> 402 + <input 403 + type="email" 404 + bind:value={emailInput} 405 + placeholder="you@example.com" 406 + class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent" 407 + /> 408 + <button 409 + class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 410 + disabled={busy['email']} 411 + onclick={submitEmail} 412 + > 413 + {busy['email'] ? 'Sending…' : 'Send code'} 414 + </button> 415 + </div> 416 + {:else if !data.email.verified} 417 + <div class="flex items-center justify-between gap-3"> 418 + <div class="min-w-0"> 419 + <div class="truncate text-sm font-semibold text-fg">{data.email.address}</div> 420 + <div class="text-xs text-warn">Pending — enter the 6-digit code we emailed you.</div> 421 + </div> 422 + <button 423 + class="shrink-0 rounded-md border border-line px-3 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10 disabled:opacity-50" 424 + disabled={busy['email-unlink']} 425 + onclick={() => run('email-unlink', () => unlinkEmail())} 426 + > 427 + Remove 428 + </button> 429 + </div> 430 + <div class="mt-3 flex gap-2"> 431 + <input 432 + inputmode="numeric" 433 + maxlength="6" 434 + bind:value={codeInput} 435 + placeholder="123456" 436 + class="w-28 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm tracking-widest text-fg placeholder:text-muted-2 focus:border-accent" 437 + /> 438 + <button 439 + class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 440 + disabled={busy['email-verify']} 441 + onclick={submitCode} 442 + > 443 + {busy['email-verify'] ? 'Verifying…' : 'Verify'} 444 + </button> 445 + </div> 446 + {:else} 447 + <div class="flex items-center justify-between gap-3"> 448 + <div class="flex min-w-0 items-center gap-2"> 449 + <Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" /> 450 + <span class="truncate text-sm font-semibold text-fg">{data.email.address}</span> 451 + </div> 452 + <button 453 + class="shrink-0 rounded-md border border-line px-3 py-1.5 text-sm font-medium text-danger transition-colors hover:bg-danger/10 disabled:opacity-50" 454 + disabled={busy['email-unlink']} 455 + onclick={() => run('email-unlink', () => unlinkEmail())} 456 + > 457 + Remove 458 + </button> 459 + </div> 460 + {/if} 461 + </div> 462 + </section> 373 463 {:else if tab === 'routing'} 374 464 <!-- Default routing --> 375 465 <section class="mt-6 max-w-2xl"> 376 466 <h2 class={sectionLabel}>Default routing</h2> 377 467 <div class={card}> 378 - <div class="flex items-center justify-between gap-4"> 379 - <div class="min-w-0"> 380 - <div class="text-sm font-medium text-fg">Where notifications go by default</div> 381 - <p class="mt-1 text-xs text-muted"> 382 - Apps set to “Account default” use this; everything always lands in your inbox. Override 383 - per-app (and per-category) from 384 - <a href="/apps" class="text-accent hover:underline">Apps</a>. 385 - </p> 386 - </div> 387 - <select 388 - class="shrink-0 rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg disabled:opacity-50" 468 + <div class="text-sm font-medium text-fg">Where notifications go by default</div> 469 + <p class="mt-1 text-xs text-muted"> 470 + Pick which channels fire by default — everything always lands in your inbox regardless. 471 + Apps set to “Account default” use this; override per-app (and per-category) from 472 + <a href="/apps" class="text-accent hover:underline">Apps</a>. 473 + </p> 474 + <div class="mt-3"> 475 + <ChannelRoutePicker 389 476 value={data.defaultRoute} 390 477 disabled={busy['defaultRoute']} 391 - onchange={(e) => 392 - run('defaultRoute', () => 393 - setDefaultRoute({ route: e.currentTarget.value as AlertRoute }) 394 - )} 395 - > 396 - {#each ALERT_ROUTES as r (r)} 397 - <option value={r}>{ROUTE_LABELS[r]}</option> 398 - {/each} 399 - </select> 478 + onchange={(route) => run('defaultRoute', () => setDefaultRoute({ route }))} 479 + /> 400 480 </div> 401 481 </div> 402 482 </section>
+3 -6
packages/lexicons/lexicons/pub/atmo/notify/getRouting.json
··· 26 26 "properties": { 27 27 "route": { 28 28 "type": "string", 29 - "enum": ["push", "telegram", "push+telegram", "off", "default"], 30 - "description": "The app-wide route. 'default' means it inherits the account default." 29 + "description": "App-wide route: a '+'-joined channel set (push|telegram|email), 'off', or 'default' to inherit the account default." 31 30 }, 32 31 "defaultRoute": { 33 32 "type": "string", 34 - "enum": ["push", "telegram", "push+telegram", "off"], 35 - "description": "The user's account-default route, so the app can label what 'default'/'app' resolves to." 33 + "description": "The user's account-default route: a '+'-joined channel set (push|telegram|email) or 'off'." 36 34 }, 37 35 "categories": { 38 36 "type": "array", ··· 55 53 }, 56 54 "route": { 57 55 "type": "string", 58 - "enum": ["push", "telegram", "push+telegram", "off", "app"], 59 - "description": "Route for this category. 'app' means it inherits the app-wide route." 56 + "description": "Route for this category: a '+'-joined channel set (push|telegram|email), 'off', or 'app' to inherit the app-wide route." 60 57 } 61 58 } 62 59 }
+2 -4
packages/lexicons/lexicons/pub/atmo/notify/setRouting.json
··· 17 17 }, 18 18 "route": { 19 19 "type": "string", 20 - "enum": ["push", "telegram", "push+telegram", "off", "default"], 21 - "description": "App-wide route for all of this app's notifications. 'default' clears the override so it inherits the account default. Omit to leave the app-wide route unchanged." 20 + "description": "App-wide route: a '+'-joined channel set of push|telegram|email (e.g. 'push+email'), 'off' for none, or 'default' to inherit the account default. Omit to leave unchanged." 22 21 }, 23 22 "categories": { 24 23 "type": "array", ··· 50 49 }, 51 50 "route": { 52 51 "type": "string", 53 - "enum": ["push", "telegram", "push+telegram", "off", "app"], 54 - "description": "Route for this category. 'app' clears the override so it inherits the app-wide route." 52 + "description": "Route for this category: a '+'-joined channel set of push|telegram|email, 'off' for none, or 'app' to inherit the app-wide route." 55 53 } 56 54 } 57 55 }
+49 -4
packages/lexicons/src/rpc.ts
··· 43 43 createdAt: string; 44 44 } 45 45 46 + /** A user's email delivery channel (binding-only). */ 47 + export interface EmailChannelView { 48 + address: string; 49 + /** false while a verification code is outstanding. */ 50 + verified: boolean; 51 + } 52 + 46 53 /** A notification as shown in the inbox (binding-only; no public lexicon). */ 47 54 export interface NotificationView { 48 55 id: string; ··· 68 75 all?: boolean; 69 76 } 70 77 71 - /** Concrete alert routes (binding-only). Everything is in the inbox regardless; these gate alerts. */ 72 - export type AlertRoute = 'push' | 'telegram' | 'push+telegram' | 'off'; 78 + /** The alert channels a route can fire. Everything is in the inbox regardless. */ 79 + export const CHANNELS = ['push', 'telegram', 'email'] as const; 80 + export type Channel = (typeof CHANNELS)[number]; 81 + 82 + // A route is a concrete channel SET, encoded as a `+`-joined string in canonical 83 + // CHANNELS order ('off' = none) — e.g. 'push', 'push+email', 'off'. App-wide and 84 + // per-category routes add the inherit sentinels 'default' / 'app'. Stored as a 85 + // string; existing values ('push'/'telegram'/'push+telegram'/'off') are valid sets. 86 + /** A concrete route: a `+`-joined channel set, or 'off'. */ 87 + export type AlertRoute = string; 73 88 /** App-wide route: a concrete route, or 'default' (inherit the account default). */ 74 - export type AppRoute = AlertRoute | 'default'; 89 + export type AppRoute = string; 75 90 /** Per-category route: a concrete route, or 'app' (inherit the app-wide route). */ 76 - export type CategoryRoute = AlertRoute | 'app'; 91 + export type CategoryRoute = string; 92 + 93 + /** Parse a route string into its channels ('off'/'default'/'app'/'' → []). */ 94 + export function routeChannels(route: string): Channel[] { 95 + if (route === 'off' || route === 'default' || route === 'app' || route === '') return []; 96 + const set = new Set(route.split('+')); 97 + return CHANNELS.filter((c) => set.has(c)); 98 + } 99 + 100 + /** Encode channels as a canonical route string ('off' when empty). */ 101 + export function channelsRoute(channels: readonly Channel[]): string { 102 + const ordered = CHANNELS.filter((c) => channels.includes(c)); 103 + return ordered.length > 0 ? ordered.join('+') : 'off'; 104 + } 105 + 106 + /** True if `route` is a valid concrete route (a channel set or 'off'). */ 107 + export function isConcreteRoute(route: string): boolean { 108 + if (route === 'off') return true; 109 + const parts = route.split('+'); 110 + return ( 111 + parts.length > 0 && 112 + new Set(parts).size === parts.length && 113 + parts.every((p) => (CHANNELS as readonly string[]).includes(p)) 114 + ); 115 + } 77 116 78 117 /** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */ 79 118 export type Capability = 'none' | 'self' | 'full'; ··· 132 171 listPending(did: Did): Promise<PubAtmoNotifyListPending.$output>; 133 172 listChannels(did: Did): Promise<PubAtmoNotifyListChannels.$output>; 134 173 getSettings(did: Did): Promise<PubAtmoNotifyGetSettings.$output>; 174 + 175 + // Email channel (binding-only). `linkEmail` emails a verification code via comail. 176 + linkEmail(did: Did, address: string): Promise<{ ok: boolean }>; 177 + verifyEmail(did: Did, code: string): Promise<{ verified: boolean }>; 178 + unlinkEmail(did: Did): Promise<{ ok: boolean }>; 179 + getEmailChannel(did: Did): Promise<EmailChannelView | null>; 135 180 136 181 // Web push (binding-only; no public lexicon). 137 182 registerWebPush(did: Did, sub: PushSubscriptionInput): Promise<{ registered: boolean }>;
+3 -1
todo.md
··· 1 1 - xrpc endpoints for apps to enable notifications for a app from atmo.pub 2 2 - xrpc endpoints for an app that 3 3 4 - - make it easily reusable as a custom thing for just one app 4 + - make it easily reusable as a custom thing for just one app 5 + 6 + - send to just one channel instead of all of a category