Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@langfuse/core": "4.2.0",
"@langfuse/otel": "4.2.0",
"@langfuse/tracing": "4.2.0",
"@maily-to/render": "0.2.3",
"@microsoft/microsoft-graph-client": "3.0.7",
"@nestjs/common": "10.4.17",
"@nestjs/config": "3.2.3",
Expand Down Expand Up @@ -113,6 +114,7 @@
"load-esm": "1.0.3",
"lodash": "4.17.21",
"mammoth": "1.10.0",
"marked": "18.0.5",
"mime-types": "3.0.2",
"multer": "2.0.2",
"nanoid": "3.3.7",
Expand All @@ -133,6 +135,7 @@
"redis": "4.7.0",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1",
"sanitize-html": "2.17.5",
"sharp": "0.34.5",
"slugify": "1.6.6",
"socket.io": "4.8.1",
Expand Down Expand Up @@ -166,6 +169,7 @@
"@types/passport-jwt": "4.0.1",
"@types/passport-local": "1.0.38",
"@types/passport-microsoft": "2.1.0",
"@types/sanitize-html": "2.16.1",
"@types/supertest": "6.0.2",
"@types/unzipper": "0.10.11",
"@types/uuid": "10.0.0",
Expand Down
19 changes: 17 additions & 2 deletions apps/api/src/announcements/handlers/announcement-email.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class AnnouncementEmailHandler implements IEventHandler<AnnouncementPubli
announcement.baseLanguage,
);
const content = htmlToPlainText(localizedContent);
const { text, html } = this.buildEmail({
const { text, html } = await this.buildEmail({
title,
content,
template: announcement.emailTemplate,
Expand Down Expand Up @@ -117,7 +117,22 @@ export class AnnouncementEmailHandler implements IEventHandler<AnnouncementPubli
return Object.keys(value).find(isSupportedLanguage) ?? baseLanguage;
}

private buildEmail(input: {
private async buildEmail(input: {
title: string;
content: string;
template: AnnouncementEmailTemplate;
link: string;
primaryColor: string;
companyName: string;
language: SupportedLanguages;
}) {
const emailTemplate = this.buildEmailTemplate(input);
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

return { text, html };
}

private buildEmailTemplate(input: {
title: string;
content: string;
template: AnnouncementEmailTemplate;
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { PermissionsGuard } from "./common/guards/permissions.guard";
import { StagingGuard } from "./common/guards/staging.guard";
import { CourseChatModule } from "./course-chat/course-chat.module";
import { CourseModule } from "./courses/course.module";
import { PublicCourseThumbnailModule } from "./courses/public-course-thumbnail.module";
import { EmailNotificationTemplatesModule } from "./email-notification-templates/email-templates.module";
import { EventsModule } from "./events/events.module";
import { FileModule } from "./file/files.module";
import { GlobalSearchModule } from "./global-search/global-search.module";
Expand All @@ -63,6 +65,7 @@ import { LumaModule } from "./luma/luma.module";
import { NewsModule } from "./news/news.module";
import { OutboxModule } from "./outbox/outbox.module";
import { PermissionsModule } from "./permissions/permissions.module";
import { PublicEmailTemplateImageModule } from "./public-email-template-image/public-email-template-image.module";
import { QuestionsModule } from "./questions/question.module";
import { AppThrottlerGuard } from "./rate-limit/app-throttler.guard";
import { RedisThrottlerStorage } from "./rate-limit/redis-throttler.storage";
Expand Down Expand Up @@ -168,6 +171,9 @@ import type { RedisClient } from "src/redis";
ScormModule,
CertificatesModule,
AnnouncementsModule,
EmailNotificationTemplatesModule,
PublicCourseThumbnailModule,
PublicEmailTemplateImageModule,
IngestionModule,
IntegrationModule,
LearningTimeModule,
Expand Down
14 changes: 7 additions & 7 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,8 +661,9 @@ export class AuthService {
}),
...defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

return { createToken, emailTemplate };
return { createToken, emailContent: { text, html } };
}

private async sendEmailAndUpdateDatabase(
Expand All @@ -671,7 +672,7 @@ export class AuthService {
email: string,
oldTokenHash: string,
createToken: string,
emailTemplate: { text: string; html: string },
emailContent: { text: string; html: string },
expiryDate: Date,
reminderCount: number,
) {
Expand All @@ -695,8 +696,7 @@ export class AuthService {
{
to: email,
subject: getEmailSubject("passwordReminderEmail", defaultEmailSettings.language),
text: emailTemplate.text,
html: emailTemplate.html,
...emailContent,
},
{ tenantId },
);
Expand All @@ -718,15 +718,15 @@ export class AuthService {

expiryTokens.map(async ({ userId, email, oldTokenHash, reminderCount }) => {
const user = await this.userService.getUserById(userId);
const { createToken, emailTemplate } = await this.generateNewTokenAndEmail(userId);
const { createToken, emailContent } = await this.generateNewTokenAndEmail(userId);

await this.sendEmailAndUpdateDatabase(
user.tenantId,
userId,
email,
oldTokenHash,
createToken,
emailTemplate,
emailContent,
expiryDate,
reminderCount + 1,
);
Expand Down Expand Up @@ -872,7 +872,7 @@ export class AuthService {
...defaultEmailSettings,
});

const { html, text } = magicLinkEmail;
const [text, html] = await Promise.all([magicLinkEmail.text, magicLinkEmail.html]);
Comment thread
exAdmos marked this conversation as resolved.

await this.emailService.sendEmailWithLogo(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import type { CertificateEmailRecipient } from "src/events/certificate/certifica
import type { CertificateExpirationWarningEmailRecipient } from "src/events/certificate/certificate-expiration-warning-email.event";

type CertificateEmailEventType =
| CertificateExpirationWarningEmailEvent
| CertificateArchivedEmailEvent;
CertificateExpirationWarningEmailEvent | CertificateArchivedEmailEvent;

const CertificateEmailEvents = [
CertificateExpirationWarningEmailEvent,
Expand Down Expand Up @@ -56,12 +55,13 @@ export class CertificateEmailHandler implements IEventHandler<CertificateEmailEv

const { courseName, courseLink, expiresAt } = certificate;

const { text, html } = new CertificateExpirationWarningEmail({
const emailTemplate = new CertificateExpirationWarningEmail({
Comment thread
exAdmos marked this conversation as resolved.
courseName,
courseLink,
expiresAt,
...defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
Expand Down Expand Up @@ -91,12 +91,13 @@ export class CertificateEmailHandler implements IEventHandler<CertificateEmailEv

const { courseName, courseLink } = certificate;

const { text, html } = new CertificateExpiredEmail({
const emailTemplate = new CertificateExpiredEmail({
Comment thread
exAdmos marked this conversation as resolved.
courseName,
courseLink,
reason,
...defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
Expand Down
97 changes: 97 additions & 0 deletions apps/api/src/common/emails/__tests__/emails.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { TENANT_LOGO_CID, TENANT_LOGO_CID_SRC } from "@repo/shared";

import { EmailService } from "../emails.service";

const TENANT_ID = "22222222-2222-2222-2222-222222222222";
const BORDER_CIRCLE_CID = "border-circle";

const makeService = (adapter: "mailhog" | "smtp" | "ses" = "mailhog") => {
const emailAdapter = {
sendMail: jest.fn(),
};
const settingsService = {
getPlatformLogoBuffer: jest.fn().mockResolvedValue(Buffer.from("logo")),
getEmailBorderCircleBuffer: jest.fn().mockResolvedValue(Buffer.from("border")),
};
const tenantRunner = {
runWithTenant: jest.fn((_tenantId: string, fn: () => Promise<unknown>) => fn()),
};
const configService = {
get: jest.fn((key: string) => {
if (key === "email.SMTP_EMAIL_FROM") return "noreply@example.com";
if (key === "email.EMAIL_ADAPTER") return adapter;
return undefined;
}),
};

const service = new EmailService(
{} as never,
emailAdapter as never,
settingsService as never,
tenantRunner as never,
configService as never,
);

return { service, emailAdapter, tenantRunner };
};

describe("EmailService", () => {
it("adds inline content ids for Mailhog mailbox rendering", async () => {
const { service, emailAdapter, tenantRunner } = makeService();

await service.sendEmailWithLogo(
{
to: "learner@example.com",
subject: "Subject",
html: `<img src="${TENANT_LOGO_CID_SRC}" alt="logo" />`,
},
{ tenantId: TENANT_ID },
);

expect(tenantRunner.runWithTenant).toHaveBeenCalledWith(TENANT_ID, expect.any(Function));
expect(emailAdapter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: "noreply@example.com",
attachments: [
expect.objectContaining({
filename: "logo.png",
cid: TENANT_LOGO_CID,
contentType: "image/png",
}),
expect.objectContaining({
filename: "border-circle.png",
cid: BORDER_CIRCLE_CID,
contentType: "image/png",
}),
],
}),
);
});

it.each(["smtp", "ses"] as const)("adds inline content ids for %s", async (adapter) => {
const { service, emailAdapter } = makeService(adapter);

await service.sendEmailWithLogo(
{
to: "learner@example.com",
subject: "Subject",
html: `<img src="${TENANT_LOGO_CID_SRC}" alt="logo" />`,
},
{ tenantId: TENANT_ID },
);

const payload = emailAdapter.sendMail.mock.calls[0][0];
expect(payload.attachments).toEqual([
expect.objectContaining({
filename: "logo.png",
cid: TENANT_LOGO_CID,
contentType: "image/png",
}),
expect.objectContaining({
filename: "border-circle.png",
cid: BORDER_CIRCLE_CID,
contentType: "image/png",
}),
]);
});
});
32 changes: 20 additions & 12 deletions apps/api/src/common/emails/emails.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SUPPORTED_LANGUAGES } from "@repo/shared";
import { DEFAULT_TENANT_PRIMARY_COLOR, SUPPORTED_LANGUAGES, TENANT_LOGO_CID } from "@repo/shared";
import { sql } from "drizzle-orm";

import { DatabasePg } from "src/common";
Expand All @@ -19,7 +19,6 @@ import type { DefaultEmailSettings } from "src/events/types";

@Injectable()
export class EmailService {
private readonly usingMailhogAdapter: boolean;
private readonly fromEmail: string;

constructor(
Expand All @@ -29,10 +28,6 @@ export class EmailService {
private readonly tenantRunner: TenantDbRunnerService,
private configService: ConfigService,
) {
this.usingMailhogAdapter =
this.configService.get<EmailConfigSchema["EMAIL_ADAPTER"]>("email.EMAIL_ADAPTER") ===
"mailhog";

this.fromEmail = this.configService.get<EmailConfigSchema["SMTP_EMAIL_FROM"]>(
"email.SMTP_EMAIL_FROM",
) as string;
Expand Down Expand Up @@ -61,7 +56,7 @@ export class EmailService {
filename: "logo.png",
content: logoBuffer,
contentType: "image/png",
...(this.usingMailhogAdapter ? {} : { cid: "logo" }),
Comment thread
exAdmos marked this conversation as resolved.
cid: TENANT_LOGO_CID,
});
}

Expand All @@ -70,15 +65,28 @@ export class EmailService {
filename: "border-circle.png",
content: borderCircleBuffer,
contentType: "image/png",
...(this.usingMailhogAdapter ? {} : { cid: "border-circle" }),
cid: "border-circle",
});
}

const payload = {
...(email as Email),
const baseEmail = {
to: email.to,
subject: email.subject,
from: this.fromEmail,
attachments: attachments.length > 0 ? attachments : undefined,
};

let payload: Email;
if (email.text !== undefined && email.html !== undefined) {
payload = { ...baseEmail, text: email.text, html: email.html };
} else if (email.text !== undefined) {
payload = { ...baseEmail, text: email.text };
} else if (email.html !== undefined) {
payload = { ...baseEmail, html: email.html };
} else {
throw new Error("Email content is missing");
}

await this.emailAdapter.sendMail(payload);
}

Expand All @@ -92,7 +100,7 @@ export class EmailService {
const companyName = globalSettings.companyInformation?.companyName || "Mentingo.com";

return {
primaryColor: globalSettings.primaryColor || "#4796FD",
primaryColor: globalSettings.primaryColor || DEFAULT_TENANT_PRIMARY_COLOR,
companyName,
language:
language ?? (userId ? await this.getFinalLanguage(userId) : SUPPORTED_LANGUAGES.EN),
Expand All @@ -106,7 +114,7 @@ export class EmailService {
'language',
${userSettingsColumn}->>'language',
'primaryColor',
COALESCE(NULLIF(${globalSettingsColumn}->>'primaryColor', ''), '#4796FD'),
COALESCE(NULLIF(${globalSettingsColumn}->>'primaryColor', ''), ${DEFAULT_TENANT_PRIMARY_COLOR}),
'companyName',
COALESCE(NULLIF(${globalSettingsColumn} #>> '{companyInformation,companyName}', ''), 'Mentingo.com')
)
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/common/utils/postgresErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const PG_UNIQUE_VIOLATION = "23505";

type PostgresErrorLike = {
code?: string;
constraint_name?: string;
constraint?: string;
};

export function isPostgresUniqueViolation(err: unknown, constraintName: string): boolean {
if (!err || typeof err !== "object") return false;

const { code, constraint_name, constraint } = err as PostgresErrorLike;

return (
code === PG_UNIQUE_VIOLATION &&
(constraint_name === constraintName || constraint === constraintName)
);
}
Loading
Loading