Skip to content

Commit 1042520

Browse files
rajat1saxenaRajat
andauthored
Better notification template (#801)
* Better notification template * Updated the PRD --------- Co-authored-by: Rajat <hi@rajatsaxena.dev>
1 parent b0422ff commit 1042520

8 files changed

Lines changed: 545 additions & 13 deletions

File tree

apps/queue/__mocks__/@courselit/email-editor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ export type {
55
EmailBlock,
66
EmailMeta,
77
EmailStyle,
8-
BlockComponent,
98
} from "../../../../packages/email-editor/src/types/email-editor";
10-
export type { BlockRegistry } from "../../../../packages/email-editor/src/types/block-registry";
9+
export type {
10+
BlockComponent,
11+
BlockRegistry,
12+
} from "../../../../packages/email-editor/src/types/block-registry";
1113
export { renderEmailToHtml } from "../../../../packages/email-editor/src/lib/email-renderer";
1214
export { defaultEmail } from "../../../../packages/email-editor/src/lib/default-email";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Notification Email Template Overhaul
2+
3+
## Summary
4+
5+
Replace the hand-written notification email HTML in `@courselit/queue` with a standardized `@courselit/email-editor` template. The email will show the actor name with the actor avatar when a safe avatar URL exists, make “View notification” the clear primary button, move unsubscribe into small grey footer text, and conditionally include a CourseLit branding badge.
6+
7+
## Key Changes
8+
9+
- Render notification emails with `renderEmailToHtml` from `@courselit/email-editor` instead of raw inline HTML.
10+
- Template structure:
11+
- Actor avatar, when available, followed by `actorName`.
12+
- Notification message.
13+
- Centered `View notification` button.
14+
- Footer separator/spacer.
15+
- Small centered grey unsubscribe footer.
16+
- Conditional `Powered by CourseLit` badge below the footer.
17+
- Avatar behavior:
18+
- Use `payload.actor.avatar.file`, then `payload.actor.avatar.thumbnail`.
19+
- Render the avatar only when the URL is safe for email markup.
20+
- If no safe avatar URL exists, skip the avatar image entirely and render only the actor name.
21+
- Branding behavior:
22+
- Match existing system-email templates.
23+
- Show the badge when `!payload.domain.settings?.hideCourseLitBranding`.
24+
- Hide the badge when `payload.domain.settings?.hideCourseLitBranding` is true.
25+
- Badge links to `https://courselit.app` and reads `Powered by CourseLit`.
26+
- Preserve current delivery behavior and headers, including `List-Unsubscribe`.
27+
28+
## Interfaces
29+
30+
- No GraphQL or REST API changes.
31+
- Add an internal notification email template helper accepting:
32+
- `actorName`
33+
- `actorAvatarUrl`
34+
- `message`
35+
- `notificationUrl`
36+
- `unsubscribeUrl`
37+
- `hideCourseLitBranding`
38+
- Use standard email-editor `image` and `text` blocks for actor avatar/name rendering.
39+
- Do not generate initials image fallbacks; some email clients render `data:` images as broken images.
40+
41+
## Tests
42+
43+
- Verify the rendered email includes:
44+
- Actor name and avatar URL when available.
45+
- No avatar image when avatar is missing.
46+
- No avatar image when the avatar URL uses an unsafe scheme.
47+
- `View notification` before unsubscribe.
48+
- Unsubscribe only in the footer area.
49+
- `Powered by CourseLit` when branding is not hidden.
50+
- No `Powered by CourseLit` when `hideCourseLitBranding` is true.
51+
- Existing `List-Unsubscribe` headers unchanged.
52+
- Run:
53+
- `pnpm --filter @courselit/queue check-types`
54+
- `pnpm test`
55+
- Before commit: `pnpm lint` and `pnpm prettier`
56+
57+
## Assumptions
58+
59+
- The CourseLit badge should use the same visibility rule as `download-link.ts`, `course-enroll.ts`, and `magic-code-email.ts`.
60+
- The CTA remains a button.
61+
- This applies only to notification emails.

apps/queue/docs/wip/DRIP_HARDENING_GAPS_AND_ROADMAP.md renamed to apps/queue/docs/wip/drip-hardening-gaps-and-roadmap.md

File renamed without changes.

apps/queue/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const config = {
77
"@courselit/common-models":
88
"<rootDir>/../../packages/common-models/src",
99
"@courselit/orm-models": "<rootDir>/../../packages/orm-models/src",
10-
"@courselit/email-editor":
10+
"^@courselit/email-editor$":
1111
"<rootDir>/__mocks__/@courselit/email-editor.ts",
1212
nanoid: "<rootDir>/__mocks__/nanoid.ts",
1313
"@sindresorhus/slugify": "<rootDir>/__mocks__/slugify.ts",
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import { Constants } from "@courselit/common-models";
6+
import { getNotificationMessageAndHref } from "@courselit/common-logic";
7+
import { addMailJob } from "../../../../domain/handler";
8+
import { EmailChannel } from "../email";
9+
10+
jest.mock("@courselit/common-logic", () => ({
11+
getNotificationMessageAndHref: jest.fn(),
12+
}));
13+
14+
jest.mock("../../../../domain/handler", () => ({
15+
addMailJob: jest.fn(),
16+
}));
17+
18+
const mockedGetNotificationMessageAndHref =
19+
getNotificationMessageAndHref as jest.Mock;
20+
const mockedAddMailJob = addMailJob as jest.Mock;
21+
22+
function makePayload(overrides: Partial<any> = {}): any {
23+
return {
24+
domain: {
25+
_id: "domain-id",
26+
name: "school",
27+
settings: {
28+
title: "School",
29+
},
30+
},
31+
actorUserId: "actor-id",
32+
actor: {
33+
userId: "actor-id",
34+
name: "Test Instructor",
35+
email: "instructor@example.com",
36+
avatar: {
37+
file: "https://cdn.example.com/avatar.png",
38+
thumbnail: "https://cdn.example.com/avatar-thumb.png",
39+
},
40+
},
41+
recipient: {
42+
userId: "recipient-id",
43+
email: "student@example.com",
44+
unsubscribeToken: "unsubscribe-token",
45+
subscribedToUpdates: true,
46+
},
47+
activityType: Constants.ActivityType.ENROLLED,
48+
entityId: "entity-id",
49+
entityTargetId: "target-id",
50+
metadata: {},
51+
...overrides,
52+
};
53+
}
54+
55+
describe("EmailChannel", () => {
56+
beforeEach(() => {
57+
jest.clearAllMocks();
58+
process.env.PROTOCOL = "https";
59+
process.env.DOMAIN = "courselit.test";
60+
process.env.MULTITENANT = "true";
61+
process.env.EMAIL_FROM = "hello@courselit.test";
62+
63+
mockedGetNotificationMessageAndHref.mockResolvedValue({
64+
message:
65+
"Test Instructor granted your request to join Test Course community",
66+
href: "https://school.courselit.test/community/post",
67+
});
68+
});
69+
70+
it("renders a notification email with actor avatar, CTA, footer unsubscribe, branding, and unsubscribe headers", async () => {
71+
await new EmailChannel().send(makePayload());
72+
73+
expect(mockedAddMailJob).toHaveBeenCalledTimes(1);
74+
const mail = mockedAddMailJob.mock.calls[0][0];
75+
76+
expect(mail.subject).toBe(
77+
"Test Instructor granted your request to join Test Course community",
78+
);
79+
expect(mail.body).toContain("Test Instructor");
80+
expect(mail.body).toContain("https://cdn.example.com/avatar.png");
81+
expect(mail.body).toContain("padding:24px 24px 10px 24px");
82+
expect(mail.body).toContain(
83+
"Test Instructor granted your request to join Test Course community",
84+
);
85+
expect(mail.body).toContain("View notification");
86+
expect(mail.body).toContain(
87+
"https://school.courselit.test/community/post",
88+
);
89+
expect(mail.body).toContain("Unsubscribe from email notifications");
90+
expect(mail.body).toContain(
91+
"https://school.courselit.test/api/unsubscribe/unsubscribe-token",
92+
);
93+
expect(mail.body).toContain("Powered by");
94+
expect(mail.body).toContain("CourseLit");
95+
expect(mail.body.indexOf("View notification")).toBeLessThan(
96+
mail.body.indexOf("Unsubscribe from email notifications"),
97+
);
98+
expect(mail.body).toContain("background-color:#000000");
99+
expect(mail.body).not.toContain("background-color:#07077b");
100+
expect(mail.body).toContain("padding:12px 24px 56px 24px");
101+
expect(mail.body).toContain("padding:32px 24px 16px 24px");
102+
expect(mail.body).toMatch(/font-size:\s*12px/);
103+
expect(mail.headers).toEqual({
104+
"List-Unsubscribe":
105+
"<https://school.courselit.test/api/unsubscribe/unsubscribe-token>",
106+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
107+
});
108+
});
109+
110+
it("omits the actor avatar image when avatar is missing", async () => {
111+
await new EmailChannel().send(
112+
makePayload({
113+
actor: {
114+
userId: "actor-id",
115+
name: "Test Instructor",
116+
email: "instructor@example.com",
117+
avatar: {},
118+
},
119+
}),
120+
);
121+
122+
const mail = mockedAddMailJob.mock.calls[0][0];
123+
expect(mail.body).toContain("Test Instructor");
124+
expect(mail.body).not.toContain('alt="TI"');
125+
expect(mail.body).not.toContain("data:image/svg+xml");
126+
expect(mail.body).not.toContain("https://cdn.example.com/avatar.png");
127+
});
128+
129+
it("renders dynamic notification text as text instead of Markdown links", async () => {
130+
mockedGetNotificationMessageAndHref.mockResolvedValue({
131+
message:
132+
"Test Instructor replied to [a post](https://evil.example)",
133+
href: "https://school.courselit.test/community/post",
134+
});
135+
136+
await new EmailChannel().send(
137+
makePayload({
138+
actor: {
139+
userId: "actor-id",
140+
name: "[Test Teacher](https://evil.example)",
141+
email: "instructor@example.com",
142+
avatar: {},
143+
},
144+
}),
145+
);
146+
147+
const mail = mockedAddMailJob.mock.calls[0][0];
148+
expect(mail.body).toContain(
149+
"&#91;&#84;&#101;&#115;&#116;&#32;&#84;&#101;&#97;&#99;&#104;&#101;&#114;&#93;&#40;&#104;&#116;&#116;&#112;&#115;&#58;&#47;&#47;&#101;&#118;&#105;&#108;&#46;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#41;",
150+
);
151+
expect(mail.body).toContain(
152+
"&#84;&#101;&#115;&#116;&#32;&#73;&#110;&#115;&#116;&#114;&#117;&#99;&#116;&#111;&#114;&#32;&#114;&#101;&#112;&#108;&#105;&#101;&#100;&#32;&#116;&#111;&#32;&#91;&#97;&#32;&#112;&#111;&#115;&#116;&#93;&#40;",
153+
);
154+
expect(mail.body).not.toContain('href="https://evil.example"');
155+
});
156+
157+
it("omits the actor avatar image when actor avatar URL uses an unsafe scheme", async () => {
158+
await new EmailChannel().send(
159+
makePayload({
160+
actor: {
161+
userId: "actor-id",
162+
name: "<Test",
163+
email: "instructor@example.com",
164+
avatar: {
165+
file: "javascript:alert(1)",
166+
},
167+
},
168+
}),
169+
);
170+
171+
const mail = mockedAddMailJob.mock.calls[0][0];
172+
expect(mail.body).toContain("&#60;&#84;&#101;&#115;&#116;");
173+
expect(mail.body).not.toContain('alt="?"');
174+
expect(mail.body).not.toContain("data:image/svg+xml");
175+
expect(mail.body).not.toContain("javascript:alert(1)");
176+
});
177+
178+
it("hides CourseLit branding when domain branding is hidden", async () => {
179+
await new EmailChannel().send(
180+
makePayload({
181+
domain: {
182+
_id: "domain-id",
183+
name: "school",
184+
settings: {
185+
title: "School",
186+
hideCourseLitBranding: true,
187+
},
188+
},
189+
}),
190+
);
191+
192+
const mail = mockedAddMailJob.mock.calls[0][0];
193+
expect(mail.body).not.toContain("Powered by");
194+
expect(mail.body).not.toContain("CourseLit");
195+
});
196+
197+
it("does not send when the recipient is unsubscribed from updates", async () => {
198+
await new EmailChannel().send(
199+
makePayload({
200+
recipient: {
201+
userId: "recipient-id",
202+
email: "student@example.com",
203+
unsubscribeToken: "unsubscribe-token",
204+
subscribedToUpdates: false,
205+
},
206+
}),
207+
);
208+
209+
expect(mockedAddMailJob).not.toHaveBeenCalled();
210+
});
211+
212+
it("does not send when the recipient cannot receive unsubscribe-managed email", async () => {
213+
await new EmailChannel().send(
214+
makePayload({
215+
recipient: {
216+
userId: "recipient-id",
217+
email: "",
218+
unsubscribeToken: "unsubscribe-token",
219+
subscribedToUpdates: true,
220+
},
221+
}),
222+
);
223+
224+
await new EmailChannel().send(
225+
makePayload({
226+
recipient: {
227+
userId: "recipient-id",
228+
email: "student@example.com",
229+
unsubscribeToken: "",
230+
subscribedToUpdates: true,
231+
},
232+
}),
233+
);
234+
235+
expect(mockedAddMailJob).not.toHaveBeenCalled();
236+
});
237+
238+
it("does not send when notification details do not include a message and href", async () => {
239+
mockedGetNotificationMessageAndHref.mockResolvedValue({
240+
message: "",
241+
href: "",
242+
});
243+
244+
await new EmailChannel().send(makePayload());
245+
246+
expect(mockedAddMailJob).not.toHaveBeenCalled();
247+
});
248+
});

apps/queue/src/notifications/services/channels/email.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { getNotificationMessageAndHref } from "@courselit/common-logic";
2+
import { renderEmailToHtml } from "@courselit/email-editor";
23
import { getEmailFrom } from "@courselit/utils";
34
import { addMailJob } from "../../../domain/handler";
45
import { getSiteUrl } from "../../../utils/get-site-url";
56
import { getUnsubLink } from "../../../utils/get-unsub-link";
67
import { ChannelPayload, NotificationChannel } from "./types";
78
import { getDomainId } from "../../../observability/posthog";
9+
import { buildNotificationEmailTemplate } from "./notification-email-template";
10+
11+
function getActorAvatarUrl(actor: ChannelPayload["actor"]) {
12+
return actor?.avatar?.file || actor?.avatar?.thumbnail || undefined;
13+
}
814

915
export class EmailChannel implements NotificationChannel {
1016
async send(payload: ChannelPayload): Promise<void> {
@@ -40,6 +46,17 @@ export class EmailChannel implements NotificationChannel {
4046
payload.domain,
4147
payload.recipient.unsubscribeToken,
4248
);
49+
const body = await renderEmailToHtml({
50+
email: buildNotificationEmailTemplate({
51+
actorName,
52+
actorAvatarUrl: getActorAvatarUrl(payload.actor),
53+
message: notificationDetails.message,
54+
notificationUrl: notificationDetails.href,
55+
unsubscribeUrl,
56+
hideCourseLitBranding:
57+
payload.domain.settings?.hideCourseLitBranding,
58+
}),
59+
});
4360

4461
await addMailJob({
4562
to: [payload.recipient.email],
@@ -49,14 +66,7 @@ export class EmailChannel implements NotificationChannel {
4966
}),
5067
domainId: getDomainId(payload.domain?._id),
5168
subject: notificationDetails.message,
52-
body: `
53-
<p>${notificationDetails.message}</p>
54-
<p><a href="${notificationDetails.href}">View notification</a></p>
55-
<hr />
56-
<p>
57-
<a href="${unsubscribeUrl}">Unsubscribe from email notifications</a>
58-
</p>
59-
`,
69+
body,
6070
headers: {
6171
"List-Unsubscribe": `<${unsubscribeUrl}>`,
6272
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",

0 commit comments

Comments
 (0)