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