Skip to content

Commit 0877f69

Browse files
committed
chore: setup resend webhooks to get deliveredAt, linkClickedAt
1 parent ccf174f commit 0877f69

File tree

10 files changed

+176
-8
lines changed

10 files changed

+176
-8
lines changed

.env.example

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ GITLAB_APP_SECRET=
2222
GITLAB_ARGOS_AUTH_SECRET=
2323
# Resend
2424
RESEND_API_KEY=
25+
RESEND_WEBHOOK_SECRET=
2526
# Discord
2627
DISCORD_WEBHOOK_URL=
2728
# GitHub SSO Stripe product id

apps/backend/db/migrations/20250111204217_user-notifications.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ export const up = async (knex) => {
3131
table.bigInteger("workflowId").index().notNullable();
3232
table.foreign("workflowId").references("notification_workflows.id");
3333
table.string("channel").notNullable();
34+
table.dateTime("sentAt");
3435
table.dateTime("deliveredAt");
35-
table.dateTime("seenAt");
36+
table.dateTime("linkClickedAt");
37+
table.string("externalId").index();
3638
});
3739
};
3840

apps/backend/db/structure.sql

+10-1
Original file line numberDiff line numberDiff line change
@@ -822,8 +822,10 @@ CREATE TABLE public.notification_messages (
822822
"userId" bigint NOT NULL,
823823
"workflowId" bigint NOT NULL,
824824
channel character varying(255) NOT NULL,
825+
"sentAt" timestamp with time zone,
825826
"deliveredAt" timestamp with time zone,
826-
"seenAt" timestamp with time zone
827+
"linkClickedAt" timestamp with time zone,
828+
"externalId" character varying(255)
827829
);
828830

829831

@@ -2233,6 +2235,13 @@ CREATE INDEX github_synchronizations_githubinstallationid_index ON public.github
22332235
CREATE INDEX github_synchronizations_jobstatus_index ON public.github_synchronizations USING btree ("jobStatus");
22342236

22352237

2238+
--
2239+
-- Name: notification_messages_externalid_index; Type: INDEX; Schema: public; Owner: postgres
2240+
--
2241+
2242+
CREATE INDEX notification_messages_externalid_index ON public.notification_messages USING btree ("externalId");
2243+
2244+
22362245
--
22372246
-- Name: notification_messages_userid_index; Type: INDEX; Schema: public; Owner: postgres
22382247
--

apps/backend/src/config/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ const config = convict({
9292
default: "",
9393
env: "RESEND_API_KEY",
9494
},
95+
webhookSecret: {
96+
doc: "Resend webhook secret",
97+
format: String,
98+
default: "development",
99+
env: "RESEND_WEBHOOK_SECRET",
100+
},
95101
},
96102
s3: {
97103
screenshotsBucket: {

apps/backend/src/database/models/NotificationMessage.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,33 @@ export class NotificationMessage extends Model {
2323
userId: { type: "string" },
2424
workflowId: { type: "string" },
2525
channel: { type: "string", enum: channels as unknown as string[] },
26+
sentAt: { type: ["string", "null"] },
2627
deliveredAt: { type: ["string", "null"] },
27-
seenAt: { type: ["string", "null"] },
28+
linkClickedAt: { type: ["string", "null"] },
29+
externalId: { type: ["string", "null"] },
2830
},
2931
});
3032

3133
jobStatus!: JobStatus;
3234
userId!: string;
3335
workflowId!: string;
3436
channel!: NotificationChannel;
37+
/**
38+
* Message has been processed and sent.
39+
*/
40+
sentAt!: string | null;
41+
/**
42+
* Message has been delivered, example email delivered by our email provider.
43+
*/
3544
deliveredAt!: string | null;
36-
seenAt!: string | null;
45+
/**
46+
* User clicked on a link in the message.
47+
*/
48+
linkClickedAt!: string | null;
49+
/**
50+
* External ID from the provider, example Resend ID.
51+
*/
52+
externalId!: string | null;
3753

3854
static override get relationMappings(): RelationMappings {
3955
return {

apps/backend/src/email/send.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { render } from "@react-email/render";
12
import { Resend } from "resend";
23

34
import config from "@/config/index.js";
@@ -33,10 +34,11 @@ export async function sendEmail(options: {
3334
if (production) {
3435
if (!resend) {
3536
logger.error("Resend API key is missing");
36-
return;
37+
return null;
3738
}
3839
} else if (!resend) {
39-
return;
40+
return null;
4041
}
41-
await resend.emails.send({ ...options, from: defaultFrom });
42+
const text = await render(options.react, { plainText: true });
43+
return resend.emails.send({ ...options, text, from: defaultFrom });
4244
}

apps/backend/src/notification/message-job.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,19 @@ async function processMessage(message: NotificationMessage) {
3737
},
3838
};
3939
const email = handler.email({ ...(data as any), ctx });
40-
await sendEmail({
40+
41+
const result = await sendEmail({
4142
to,
4243
subject: email.subject,
4344
react: email.body,
4445
});
46+
47+
const externalId = (await result?.data?.id) ?? null;
48+
49+
// Mark the message as sent and store the external ID.
50+
await message
51+
.$query()
52+
.patch({ sentAt: new Date().toISOString(), externalId });
4553
}
4654

4755
/**

apps/backend/src/web/api/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Application, Router } from "express";
22

33
import { apiMiddleware as githubApiMiddleware } from "../middlewares/github.js";
4+
import { apiMiddleware as resendApiMiddleware } from "../middlewares/resend.js";
45
import { subdomain } from "../util.js";
56
import auth from "./auth.js";
67
import builds from "./builds.js";
@@ -13,6 +14,7 @@ export const installApiRouter = (app: Application) => {
1314

1415
router.use(status);
1516
router.use(githubApiMiddleware);
17+
router.use(resendApiMiddleware);
1618
router.use("/v2", v2);
1719
router.use(builds);
1820
router.use(auth);
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import crypto from "node:crypto";
2+
import { assertNever } from "@argos/util/assertNever";
3+
import { invariant } from "@argos/util/invariant";
4+
import express, { RequestHandler, Router } from "express";
5+
import { z } from "zod";
6+
7+
import config from "@/config/index.js";
8+
import { NotificationMessage } from "@/database/models";
9+
10+
import { asyncHandler } from "../util";
11+
12+
const router = Router();
13+
14+
const verifyWebhookSignature: RequestHandler = (req, res, next) => {
15+
const secret = config.get("resend.webhookSecret");
16+
17+
if (!secret) {
18+
res.status(400).send('Missing "resend.webhookSecret"');
19+
return;
20+
}
21+
22+
const svixId = req.headers["svix-id"];
23+
const svixTimestamp = req.headers["svix-timestamp"];
24+
const svixSignatureHeader = req.headers["svix-signature"];
25+
26+
if (
27+
typeof svixId !== "string" ||
28+
typeof svixTimestamp !== "string" ||
29+
typeof svixSignatureHeader !== "string"
30+
) {
31+
res.status(400).send("Missing headers");
32+
return;
33+
}
34+
35+
const svixSignatures = svixSignatureHeader.split(" ").map((s) => {
36+
const [, value] = s.split(",");
37+
if (!value) {
38+
return null;
39+
}
40+
return value;
41+
});
42+
43+
if (svixSignatures.some((s) => s === null)) {
44+
res.status(400).send("Invalid signature header");
45+
return;
46+
}
47+
48+
const secretParts = secret.split("_");
49+
invariant(secretParts[1], 'Secret must be in the format "whsec_<base64>"');
50+
const secretBytes = Buffer.from(secretParts[1], "base64");
51+
const message = `${svixId}.${svixTimestamp}.${req.body}`;
52+
53+
const expectedSignature = crypto
54+
.createHmac("sha256", secretBytes)
55+
.update(message)
56+
.digest("base64");
57+
58+
if (!svixSignatures.includes(expectedSignature)) {
59+
res.status(401).send("Invalid signature");
60+
return;
61+
}
62+
63+
next();
64+
};
65+
66+
const EventSchema = z.object({
67+
type: z.enum(["email.delivered", "email.clicked"]),
68+
data: z.object({
69+
email_id: z.string(),
70+
}),
71+
});
72+
73+
router.post(
74+
"/resend/event-handler",
75+
express.text({ type: "*/*" }),
76+
verifyWebhookSignature,
77+
asyncHandler(async (req, res) => {
78+
const body = JSON.parse(req.body);
79+
const parsed = EventSchema.safeParse(body);
80+
if (!parsed.success) {
81+
res.status(400).send("Invalid payload");
82+
return;
83+
}
84+
const event = parsed.data;
85+
86+
const message = await NotificationMessage.query()
87+
.where("channel", "email")
88+
.where("externalId", event.data.email_id)
89+
.first();
90+
91+
if (!message) {
92+
res.status(200).send("Message not found");
93+
return;
94+
}
95+
96+
switch (event.type) {
97+
case "email.delivered": {
98+
if (!message.deliveredAt) {
99+
await message
100+
.$query()
101+
.patch({ deliveredAt: new Date().toISOString() });
102+
}
103+
break;
104+
}
105+
case "email.clicked": {
106+
if (!message.linkClickedAt) {
107+
await message
108+
.$query()
109+
.patch({ linkClickedAt: new Date().toISOString() });
110+
}
111+
break;
112+
}
113+
default:
114+
assertNever(event.type);
115+
}
116+
117+
res.status(200).send("Message updated");
118+
}),
119+
);
120+
121+
export const apiMiddleware: Router = router;

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"e2e:setup": "NODE_ENV=test pnpm run --filter @argos/backend db:truncate && NODE_ENV=test pnpm run --filter @argos/backend db:seed",
99
"e2e:start": "NODE_ENV=test playwright test",
1010
"setup": "turbo run setup",
11+
"resend-webhook-proxy": "NODE_TLS_REJECT_UNAUTHORIZED=0 smee --url https://smee.io/H7XOiU60kSYJJTZs --target https://api.argos-ci.dev:4001/resend/event-handler",
1112
"github-webhook-proxy": "NODE_TLS_REJECT_UNAUTHORIZED=0 smee --url https://smee.io/SmH89Dx2HZ89wK7T --target https://api.argos-ci.dev:4001/github/event-handler",
1213
"github-light-webhook-proxy": "NODE_TLS_REJECT_UNAUTHORIZED=0 smee --url https://smee.io/T3JFnf2lr3Zq6 --target https://api.argos-ci.dev:4001/github-light/event-handler",
1314
"slack-webhook-proxy": "ngrok http --host-header=rewrite --domain=foal-great-publicly.ngrok-free.app https://app.argos-ci.dev:4001",

0 commit comments

Comments
 (0)