Skip to content

Commit db4267b

Browse files
committed
Release v0.2.18
1 parent 9268af8 commit db4267b

93 files changed

Lines changed: 4172 additions & 215 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎RELEASE.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"version": "0.2.17",
2+
"version": "0.2.18",
33
"license": "FSL-1.1-ALv2",
44
"baselineSha256": "7847f65a9b20b366a60d52d19ca3cd6bdd1bb09b8cb506659a270f27a21d8877",
55
"e2b": {
66
"recipeSha256": "761809ee2efa2b6c750451ace29283cbb8e55b6ce3b5b14c2bb4509df71df801",
77
"profileSha256": "414dbecee2d47de36016ea05c736e2edff3a0deb298babf923887ea184a01c10"
88
},
99
"dxd": {
10-
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.17/dxd-linux-x64",
10+
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.18/dxd-linux-x64",
1111
"sha256": "d5ee6505590f8c4c261f7934254a99a9b6e73866582ee525e5d6a4edc5cb2367"
1212
},
1313
"toolchain": {

‎apps/core/migration-manifest.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
"filename": "0008_release_v0.2.15.sql",
5353
"sha256": "7b8480185abe0bbea9dfe7207412a84cd5e395500e93a77f90bc9214a4ad9cbd",
5454
"classification": "non-destructive"
55+
},
56+
{
57+
"filename": "0009_release_v0.2.18.sql",
58+
"sha256": "f312cfe275d0d89ecb446f93ee768f132a4b62e9c81aee89e533c19f0dc6268e",
59+
"classification": "non-destructive"
5560
}
5661
],
5762
"durableObjects": [
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- A person's notification inbox. A row records that something happened that
2+
-- concerns them (a topic such as `mention`) in a Thread; delivery channels
3+
-- such as email are recorded separately in notification_delivery. The
4+
-- source key names the event, so a retried message notifies once.
5+
CREATE TABLE notification (
6+
id TEXT PRIMARY KEY CHECK (id GLOB 'ntf_*' AND length(id) = 36),
7+
recipient_user_id TEXT NOT NULL,
8+
topic TEXT NOT NULL CHECK (length(topic) BETWEEN 1 AND 64),
9+
source_key TEXT NOT NULL CHECK (length(source_key) BETWEEN 1 AND 256),
10+
thread_id TEXT NOT NULL CHECK (length(thread_id) BETWEEN 1 AND 128),
11+
message_id TEXT CHECK (message_id IS NULL OR length(message_id) <= 64),
12+
actor_user_id TEXT,
13+
excerpt TEXT NOT NULL CHECK (length(excerpt) <= 512),
14+
created_at TEXT NOT NULL,
15+
read_at TEXT,
16+
UNIQUE (recipient_user_id, source_key)
17+
);
18+
19+
CREATE INDEX notification_recipient_idx
20+
ON notification (recipient_user_id, created_at DESC);
21+
22+
CREATE INDEX notification_unread_idx
23+
ON notification (recipient_user_id)
24+
WHERE read_at IS NULL;
25+
26+
-- A person's choice for one topic on one channel. Without a row the topic's
27+
-- default for that channel applies.
28+
CREATE TABLE notification_preference (
29+
user_id TEXT NOT NULL,
30+
topic TEXT NOT NULL CHECK (length(topic) BETWEEN 1 AND 64),
31+
channel TEXT NOT NULL CHECK (length(channel) BETWEEN 1 AND 64),
32+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
33+
updated_at TEXT NOT NULL,
34+
PRIMARY KEY (user_id, topic, channel)
35+
);
36+
37+
-- The outcome of delivering one notification on one channel. `error` is a
38+
-- bounded code, never provider text or message content.
39+
CREATE TABLE notification_delivery (
40+
notification_id TEXT NOT NULL REFERENCES notification (id) ON DELETE CASCADE,
41+
channel TEXT NOT NULL CHECK (length(channel) BETWEEN 1 AND 64),
42+
status TEXT NOT NULL CHECK (status IN ('sent', 'failed')),
43+
error TEXT CHECK (error IS NULL OR length(error) <= 64),
44+
attempted_at TEXT NOT NULL,
45+
PRIMARY KEY (notification_id, channel)
46+
);

‎apps/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/core",
33
"private": true,
4-
"version": "0.2.17",
4+
"version": "0.2.18",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/core/src/app.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { requestId } from "./http/request-id.js";
1515
import { requestLogging } from "./http/request-logging.js";
1616
import type { AppEnv } from "./http/types.js";
1717
import { attachmentLogger } from "./logging.js";
18+
import {
19+
notificationRoutes,
20+
notificationUnsubscribeRoutes,
21+
} from "./notifications/routes.js";
1822
import { installFlueObservation } from "./observability/flue.js";
1923
import { projectRoutes } from "./projects/routes.js";
2024
import { readinessHandler } from "./readiness/handler.js";
@@ -61,6 +65,7 @@ app.route("/api/auth", authRoutes);
6165
app.route("/api/workload-identity", workloadIdentityRoutes);
6266
app.route("/api/triggers", pluginTriggerIngressRoutes);
6367
app.route("/api/invites", publicInviteRoutes);
68+
app.route("/api/notifications/unsubscribe", notificationUnsubscribeRoutes);
6469
app.route("/api/source/bitbucket/git", bitbucketGitGatewayRoutes);
6570
app.route("/v1/integrations/github", githubWebhookRoutes);
6671
app.route("/v1", threadDaemonRoutes);
@@ -69,6 +74,7 @@ app.use("/v1/*", authorizeApiTokenScope);
6974
app.route("/v1/attachments", attachmentRoutes);
7075
app.route("/v1/dictation", dictationRoutes);
7176
app.route("/v1/invites", inviteAcceptRoutes);
77+
app.route("/v1/notifications", notificationRoutes);
7278
app.route("/v1/integrations/bitbucket", bitbucketControlPlaneRoutes);
7379
app.route("/v1/integrations/github", githubControlPlaneRoutes);
7480
app.route("/v1/projects", projectRoutes);

‎apps/core/src/auth/sign-in-configuration.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
SignInAdmission,
44
SignInProviderId,
55
} from "@dx/domain";
6-
import { Effect, Predicate, Schema } from "effect";
6+
import { Effect, Schema } from "effect";
7+
import { deploymentEmailSender, type EmailSender } from "../email/sender.js";
78
import type { Bindings } from "../http/types.js";
89
import type { AuthenticationRequirements } from "./requirements.js";
910

@@ -30,8 +31,11 @@ export interface SignInConfiguration {
3031
readonly providers: ReadonlyArray<SignInProviderId>;
3132
readonly admission: SignInAdmission;
3233
readonly allowedEmailDomains: ReadonlyArray<string>;
33-
/** Cloudflare Email Sending; required by magic link and by password verification. */
34-
readonly email?: { readonly binding: SendEmail; readonly from: string };
34+
/**
35+
* The deployment's sender; required by magic link and by password
36+
* verification. Notification email uses the same sender.
37+
*/
38+
readonly email?: EmailSender;
3539
/** Password sign-ups confirm their address before the first session. */
3640
readonly emailVerification: boolean;
3741
readonly google?: {
@@ -154,13 +158,7 @@ export const loadSignInConfiguration = Effect.fn("loadSignInConfiguration")(
154158
allowedEmailDomains.push(normalized);
155159
}
156160

157-
const from = nonEmpty(bindings.DX_AUTH_EMAIL_FROM);
158-
const email =
159-
from !== undefined &&
160-
Predicate.isObject(bindings.EMAIL) &&
161-
Predicate.isFunction(bindings.EMAIL.send)
162-
? { binding: bindings.EMAIL, from }
163-
: undefined;
161+
const email = deploymentEmailSender(bindings);
164162
const development = developmentEnvironment(requirements.environment);
165163
if (providers.includes("magic-link") && email === undefined)
166164
return yield* fail("email_sender_required");

‎apps/core/src/email/sender.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Predicate } from "effect";
2+
import type { Bindings } from "../http/types.js";
3+
4+
/**
5+
* The deployment's Cloudflare Email Sending sender: one address on the
6+
* operator's zone (`DX_AUTH_EMAIL_FROM`) and the `EMAIL` binding allowed to
7+
* send from it. Sign-in email (magic links, verification) and notification
8+
* email share it; whether magic-link sign-in is offered does not matter.
9+
*/
10+
export interface EmailSender {
11+
readonly binding: SendEmail;
12+
readonly from: string;
13+
}
14+
15+
/** The configured sender, or undefined when the deployment sends no email. */
16+
export const deploymentEmailSender = (
17+
bindings: Pick<Bindings, "DX_AUTH_EMAIL_FROM" | "EMAIL">,
18+
): EmailSender | undefined => {
19+
const from = bindings.DX_AUTH_EMAIL_FROM?.trim();
20+
return from !== undefined &&
21+
from.length > 0 &&
22+
Predicate.isObject(bindings.EMAIL) &&
23+
Predicate.isFunction(bindings.EMAIL.send)
24+
? { binding: bindings.EMAIL, from }
25+
: undefined;
26+
};
27+
28+
/** Every dx email shows the lowercase product name as its sender. */
29+
export const senderAddress = (sender: EmailSender) => ({
30+
email: sender.from,
31+
name: "dx",
32+
});

‎apps/core/src/logging.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const workloadIdentityLogger = getLogger([
7676
export const threadChangesLogger = getLogger(["dx", "thread-changes"]);
7777
export const realtimeLogger = getLogger(["dx", "realtime"]);
7878
export const threadSharingLogger = getLogger(["dx", "threads", "sharing"]);
79+
export const notificationLogger = getLogger(["dx", "notifications"]);
7980
export const agentLifecycleLogger = getLogger(["dx", "agent", "lifecycle"]);
8081
export const agentModelLogger = getLogger(["dx", "agent", "model"]);
8182
export const agentToolLogger = getLogger(["dx", "agent", "tool"]);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { NotificationChannel as NotificationChannelId } from "@dx/api";
2+
import type { UserId } from "@dx/domain";
3+
import type { NotificationContent } from "../topics.js";
4+
5+
/** Everything a channel needs to deliver one notification to one person. */
6+
export interface ChannelDelivery {
7+
readonly recipient: {
8+
readonly userId: UserId;
9+
readonly name: string;
10+
readonly email: string;
11+
};
12+
readonly content: NotificationContent;
13+
/** Copy that names the topic: "Unsubscribe from mention emails". */
14+
readonly topicNoun: string;
15+
readonly links: {
16+
/** Opens the Thread at the message. */
17+
readonly open: string;
18+
/** Turns this topic off on this channel, without signing in. */
19+
readonly unsubscribe: string;
20+
};
21+
}
22+
23+
/**
24+
* One way to reach a person outside the app: email today; push, WhatsApp,
25+
* or Telegram later. Adding a channel means writing one of these, listing it
26+
* in `channels/index.ts`, and adding its id to `NotificationChannelSchema`.
27+
* The publisher owns inbox rows, preferences, deduplication, delivery
28+
* records, and realtime updates; a channel only delivers.
29+
*/
30+
export interface NotificationChannel {
31+
readonly id: NotificationChannelId;
32+
/** Settings row title for a topic: "Mention notification emails". */
33+
readonly subscriptionTitle: (topicNoun: string) => string;
34+
/** Settings row description: "Receive an email when …". */
35+
readonly subscriptionDescription: (trigger: string) => string;
36+
/** False when this deployment lacks what the channel needs. */
37+
readonly available: boolean;
38+
/** Delivers or throws a `ChannelDeliveryError`. */
39+
readonly deliver: (delivery: ChannelDelivery) => Promise<void>;
40+
}
41+
42+
/** A failed delivery, with a bounded code safe to store. */
43+
export class ChannelDeliveryError extends Error {
44+
constructor(
45+
readonly code: string,
46+
options?: ErrorOptions,
47+
) {
48+
super(`Notification delivery failed: ${code}.`, options);
49+
}
50+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { type EmailSender, senderAddress } from "../../email/sender.js";
2+
import {
3+
type ChannelDelivery,
4+
ChannelDeliveryError,
5+
type NotificationChannel,
6+
} from "./channel.js";
7+
8+
const escapeHtml = (value: string) =>
9+
value
10+
.replaceAll("&", "&amp;")
11+
.replaceAll("<", "&lt;")
12+
.replaceAll(">", "&gt;")
13+
.replaceAll('"', "&quot;");
14+
15+
const initial = (name: string | undefined) =>
16+
(name?.trim().at(0) ?? "?").toUpperCase();
17+
18+
const capitalized = (value: string) =>
19+
`${value.at(0)?.toUpperCase() ?? ""}${value.slice(1)}`;
20+
21+
/** A notification email: who did what, the message in a card, one link. */
22+
export const notificationEmail = ({
23+
content,
24+
topicNoun,
25+
links,
26+
}: ChannelDelivery) => {
27+
const open = escapeHtml(links.open);
28+
const unsubscribe = escapeHtml(links.unsubscribe);
29+
const unsubscribeLabel = `Unsubscribe from ${topicNoun} emails`;
30+
const text = [
31+
content.headline,
32+
"",
33+
content.title,
34+
content.body,
35+
"",
36+
`Open thread: ${links.open}`,
37+
"",
38+
`You received this email because ${content.reason}.`,
39+
`${unsubscribeLabel}: ${links.unsubscribe}`,
40+
].join("\n");
41+
const html = `<!doctype html>
42+
<html lang="en">
43+
<body style="margin:0;padding:0;background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#14171a;">
44+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
45+
<tr><td align="center">
46+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
47+
<tr><td style="font-size:26px;font-weight:700;letter-spacing:-0.03em;padding-bottom:28px;">dx</td></tr>
48+
<tr><td style="font-size:15px;line-height:22px;color:#55595e;padding-bottom:16px;">${escapeHtml(content.headline)}</td></tr>
49+
<tr><td style="padding-bottom:24px;">
50+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;border:1px solid #e7e5e4;border-radius:8px;">
51+
<tr>
52+
<td width="40" valign="top" style="padding:20px 0 20px 20px;">
53+
<div style="width:36px;height:36px;border-radius:18px;background:#1f4e8c;color:#ffffff;font-size:16px;font-weight:600;line-height:36px;text-align:center;">${escapeHtml(initial(content.actorName))}</div>
54+
</td>
55+
<td valign="top" style="padding:20px 20px 20px 14px;">
56+
<div style="font-size:15px;font-weight:600;line-height:22px;padding-bottom:4px;">${escapeHtml(content.title)}</div>
57+
<div style="font-size:15px;line-height:22px;color:#14171a;white-space:pre-wrap;word-break:break-word;">${escapeHtml(content.body)}</div>
58+
</td>
59+
</tr>
60+
</table>
61+
</td></tr>
62+
<tr><td style="padding-bottom:28px;font-size:15px;">
63+
<a href="${open}" style="color:#5b3fb1;text-decoration:underline;">Open thread &rarr;</a>
64+
</td></tr>
65+
<tr><td style="border-top:1px solid #e7e5e4;padding-top:20px;font-size:13px;line-height:20px;color:#6b7076;">
66+
You received this email because ${escapeHtml(content.reason)}.<br />
67+
<a href="${unsubscribe}" style="color:#6b7076;text-decoration:underline;">${escapeHtml(unsubscribeLabel)}</a>
68+
</td></tr>
69+
</table>
70+
</td></tr>
71+
</table>
72+
</body>
73+
</html>`;
74+
return { subject: content.subject, text, html };
75+
};
76+
77+
/**
78+
* Email through the deployment's Cloudflare Email Sending sender. Available
79+
* whenever a sender is configured, whatever sign-in methods are offered.
80+
*/
81+
export const emailChannel = (
82+
sender: EmailSender | undefined,
83+
): NotificationChannel => ({
84+
id: "email",
85+
subscriptionTitle: (topicNoun) =>
86+
`${capitalized(topicNoun)} notification emails`,
87+
subscriptionDescription: (trigger) => `Receive an email when ${trigger}.`,
88+
available: sender !== undefined,
89+
deliver: async (delivery) => {
90+
if (sender === undefined) throw new ChannelDeliveryError("unavailable");
91+
try {
92+
await sender.binding.send({
93+
from: senderAddress(sender),
94+
to: delivery.recipient.email,
95+
...notificationEmail(delivery),
96+
headers: {
97+
"List-Unsubscribe": `<${delivery.links.unsubscribe}>`,
98+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
99+
},
100+
});
101+
} catch (cause) {
102+
throw new ChannelDeliveryError("email_send_failed", { cause });
103+
}
104+
},
105+
});

0 commit comments

Comments
 (0)