Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/queue/__mocks__/@courselit/email-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ export type {
EmailBlock,
EmailMeta,
EmailStyle,
BlockComponent,
} from "../../../../packages/email-editor/src/types/email-editor";
export type { BlockRegistry } from "../../../../packages/email-editor/src/types/block-registry";
export type {
BlockComponent,
BlockRegistry,
} from "../../../../packages/email-editor/src/types/block-registry";
export { renderEmailToHtml } from "../../../../packages/email-editor/src/lib/email-renderer";
export { defaultEmail } from "../../../../packages/email-editor/src/lib/default-email";
61 changes: 61 additions & 0 deletions apps/queue/docs/notification-email-template-overhaul.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Notification Email Template Overhaul

## Summary

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.

## Key Changes

- Render notification emails with `renderEmailToHtml` from `@courselit/email-editor` instead of raw inline HTML.
- Template structure:
- Actor avatar, when available, followed by `actorName`.
- Notification message.
- Centered `View notification` button.
- Footer separator/spacer.
- Small centered grey unsubscribe footer.
- Conditional `Powered by CourseLit` badge below the footer.
- Avatar behavior:
- Use `payload.actor.avatar.file`, then `payload.actor.avatar.thumbnail`.
- Render the avatar only when the URL is safe for email markup.
- If no safe avatar URL exists, skip the avatar image entirely and render only the actor name.
- Branding behavior:
- Match existing system-email templates.
- Show the badge when `!payload.domain.settings?.hideCourseLitBranding`.
- Hide the badge when `payload.domain.settings?.hideCourseLitBranding` is true.
- Badge links to `https://courselit.app` and reads `Powered by CourseLit`.
- Preserve current delivery behavior and headers, including `List-Unsubscribe`.

## Interfaces

- No GraphQL or REST API changes.
- Add an internal notification email template helper accepting:
- `actorName`
- `actorAvatarUrl`
- `message`
- `notificationUrl`
- `unsubscribeUrl`
- `hideCourseLitBranding`
- Use standard email-editor `image` and `text` blocks for actor avatar/name rendering.
- Do not generate initials image fallbacks; some email clients render `data:` images as broken images.

## Tests

- Verify the rendered email includes:
- Actor name and avatar URL when available.
- No avatar image when avatar is missing.
- No avatar image when the avatar URL uses an unsafe scheme.
- `View notification` before unsubscribe.
- Unsubscribe only in the footer area.
- `Powered by CourseLit` when branding is not hidden.
- No `Powered by CourseLit` when `hideCourseLitBranding` is true.
- Existing `List-Unsubscribe` headers unchanged.
- Run:
- `pnpm --filter @courselit/queue check-types`
- `pnpm test`
- Before commit: `pnpm lint` and `pnpm prettier`

## Assumptions

- The CourseLit badge should use the same visibility rule as `download-link.ts`, `course-enroll.ts`, and `magic-code-email.ts`.
- The CTA remains a button.
- This applies only to notification emails.
2 changes: 1 addition & 1 deletion apps/queue/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const config = {
"@courselit/common-models":
"<rootDir>/../../packages/common-models/src",
"@courselit/orm-models": "<rootDir>/../../packages/orm-models/src",
"@courselit/email-editor":
"^@courselit/email-editor$":
"<rootDir>/__mocks__/@courselit/email-editor.ts",
nanoid: "<rootDir>/__mocks__/nanoid.ts",
"@sindresorhus/slugify": "<rootDir>/__mocks__/slugify.ts",
Expand Down
248 changes: 248 additions & 0 deletions apps/queue/src/notifications/services/channels/__tests__/email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* @jest-environment node
*/

import { Constants } from "@courselit/common-models";
import { getNotificationMessageAndHref } from "@courselit/common-logic";
import { addMailJob } from "../../../../domain/handler";
import { EmailChannel } from "../email";

jest.mock("@courselit/common-logic", () => ({
getNotificationMessageAndHref: jest.fn(),
}));

jest.mock("../../../../domain/handler", () => ({
addMailJob: jest.fn(),
}));

const mockedGetNotificationMessageAndHref =
getNotificationMessageAndHref as jest.Mock;
const mockedAddMailJob = addMailJob as jest.Mock;

function makePayload(overrides: Partial<any> = {}): any {
return {
domain: {
_id: "domain-id",
name: "school",
settings: {
title: "School",
},
},
actorUserId: "actor-id",
actor: {
userId: "actor-id",
name: "Test Instructor",
email: "instructor@example.com",
avatar: {
file: "https://cdn.example.com/avatar.png",
thumbnail: "https://cdn.example.com/avatar-thumb.png",
},
},
recipient: {
userId: "recipient-id",
email: "student@example.com",
unsubscribeToken: "unsubscribe-token",
subscribedToUpdates: true,
},
activityType: Constants.ActivityType.ENROLLED,
entityId: "entity-id",
entityTargetId: "target-id",
metadata: {},
...overrides,
};
}

describe("EmailChannel", () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.PROTOCOL = "https";
process.env.DOMAIN = "courselit.test";
process.env.MULTITENANT = "true";
process.env.EMAIL_FROM = "hello@courselit.test";

mockedGetNotificationMessageAndHref.mockResolvedValue({
message:
"Test Instructor granted your request to join Test Course community",
href: "https://school.courselit.test/community/post",
});
});

it("renders a notification email with actor avatar, CTA, footer unsubscribe, branding, and unsubscribe headers", async () => {
await new EmailChannel().send(makePayload());

expect(mockedAddMailJob).toHaveBeenCalledTimes(1);
const mail = mockedAddMailJob.mock.calls[0][0];

expect(mail.subject).toBe(
"Test Instructor granted your request to join Test Course community",
);
expect(mail.body).toContain("Test Instructor");
expect(mail.body).toContain("https://cdn.example.com/avatar.png");
expect(mail.body).toContain("padding:24px 24px 10px 24px");
expect(mail.body).toContain(
"Test Instructor granted your request to join Test Course community",
);
expect(mail.body).toContain("View notification");
expect(mail.body).toContain(
"https://school.courselit.test/community/post",
);
expect(mail.body).toContain("Unsubscribe from email notifications");
expect(mail.body).toContain(
"https://school.courselit.test/api/unsubscribe/unsubscribe-token",
);
expect(mail.body).toContain("Powered by");
expect(mail.body).toContain("CourseLit");
expect(mail.body.indexOf("View notification")).toBeLessThan(
mail.body.indexOf("Unsubscribe from email notifications"),
);
expect(mail.body).toContain("background-color:#000000");
expect(mail.body).not.toContain("background-color:#07077b");
expect(mail.body).toContain("padding:12px 24px 56px 24px");
expect(mail.body).toContain("padding:32px 24px 16px 24px");
expect(mail.body).toMatch(/font-size:\s*12px/);
expect(mail.headers).toEqual({
"List-Unsubscribe":
"<https://school.courselit.test/api/unsubscribe/unsubscribe-token>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
});
});

it("omits the actor avatar image when avatar is missing", async () => {
await new EmailChannel().send(
makePayload({
actor: {
userId: "actor-id",
name: "Test Instructor",
email: "instructor@example.com",
avatar: {},
},
}),
);

const mail = mockedAddMailJob.mock.calls[0][0];
expect(mail.body).toContain("Test Instructor");
expect(mail.body).not.toContain('alt="TI"');
expect(mail.body).not.toContain("data:image/svg+xml");
expect(mail.body).not.toContain("https://cdn.example.com/avatar.png");
});

it("renders dynamic notification text as text instead of Markdown links", async () => {
mockedGetNotificationMessageAndHref.mockResolvedValue({
message:
"Test Instructor replied to [a post](https://evil.example)",
href: "https://school.courselit.test/community/post",
});

await new EmailChannel().send(
makePayload({
actor: {
userId: "actor-id",
name: "[Test Teacher](https://evil.example)",
email: "instructor@example.com",
avatar: {},
},
}),
);

const mail = mockedAddMailJob.mock.calls[0][0];
expect(mail.body).toContain(
"&#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;",
);
expect(mail.body).toContain(
"&#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;",
);
expect(mail.body).not.toContain('href="https://evil.example"');
});

it("omits the actor avatar image when actor avatar URL uses an unsafe scheme", async () => {
await new EmailChannel().send(
makePayload({
actor: {
userId: "actor-id",
name: "<Test",
email: "instructor@example.com",
avatar: {
file: "javascript:alert(1)",
},
},
}),
);

const mail = mockedAddMailJob.mock.calls[0][0];
expect(mail.body).toContain("&#60;&#84;&#101;&#115;&#116;");
expect(mail.body).not.toContain('alt="?"');
expect(mail.body).not.toContain("data:image/svg+xml");
expect(mail.body).not.toContain("javascript:alert(1)");
});

it("hides CourseLit branding when domain branding is hidden", async () => {
await new EmailChannel().send(
makePayload({
domain: {
_id: "domain-id",
name: "school",
settings: {
title: "School",
hideCourseLitBranding: true,
},
},
}),
);

const mail = mockedAddMailJob.mock.calls[0][0];
expect(mail.body).not.toContain("Powered by");
expect(mail.body).not.toContain("CourseLit");
});

it("does not send when the recipient is unsubscribed from updates", async () => {
await new EmailChannel().send(
makePayload({
recipient: {
userId: "recipient-id",
email: "student@example.com",
unsubscribeToken: "unsubscribe-token",
subscribedToUpdates: false,
},
}),
);

expect(mockedAddMailJob).not.toHaveBeenCalled();
});

it("does not send when the recipient cannot receive unsubscribe-managed email", async () => {
await new EmailChannel().send(
makePayload({
recipient: {
userId: "recipient-id",
email: "",
unsubscribeToken: "unsubscribe-token",
subscribedToUpdates: true,
},
}),
);

await new EmailChannel().send(
makePayload({
recipient: {
userId: "recipient-id",
email: "student@example.com",
unsubscribeToken: "",
subscribedToUpdates: true,
},
}),
);

expect(mockedAddMailJob).not.toHaveBeenCalled();
});

it("does not send when notification details do not include a message and href", async () => {
mockedGetNotificationMessageAndHref.mockResolvedValue({
message: "",
href: "",
});

await new EmailChannel().send(makePayload());

expect(mockedAddMailJob).not.toHaveBeenCalled();
});
});
26 changes: 18 additions & 8 deletions apps/queue/src/notifications/services/channels/email.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { getNotificationMessageAndHref } from "@courselit/common-logic";
import { renderEmailToHtml } from "@courselit/email-editor";
import { getEmailFrom } from "@courselit/utils";
import { addMailJob } from "../../../domain/handler";
import { getSiteUrl } from "../../../utils/get-site-url";
import { getUnsubLink } from "../../../utils/get-unsub-link";
import { ChannelPayload, NotificationChannel } from "./types";
import { getDomainId } from "../../../observability/posthog";
import { buildNotificationEmailTemplate } from "./notification-email-template";

function getActorAvatarUrl(actor: ChannelPayload["actor"]) {
return actor?.avatar?.file || actor?.avatar?.thumbnail || undefined;
}

export class EmailChannel implements NotificationChannel {
async send(payload: ChannelPayload): Promise<void> {
Expand Down Expand Up @@ -40,6 +46,17 @@ export class EmailChannel implements NotificationChannel {
payload.domain,
payload.recipient.unsubscribeToken,
);
const body = await renderEmailToHtml({
email: buildNotificationEmailTemplate({
actorName,
actorAvatarUrl: getActorAvatarUrl(payload.actor),
message: notificationDetails.message,
notificationUrl: notificationDetails.href,
unsubscribeUrl,
hideCourseLitBranding:
payload.domain.settings?.hideCourseLitBranding,
}),
});

await addMailJob({
to: [payload.recipient.email],
Expand All @@ -49,14 +66,7 @@ export class EmailChannel implements NotificationChannel {
}),
domainId: getDomainId(payload.domain?._id),
subject: notificationDetails.message,
body: `
<p>${notificationDetails.message}</p>
<p><a href="${notificationDetails.href}">View notification</a></p>
<hr />
<p>
<a href="${unsubscribeUrl}">Unsubscribe from email notifications</a>
</p>
`,
body,
headers: {
"List-Unsubscribe": `<${unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
Expand Down
Loading
Loading