Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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",
Comment thread
exAdmos marked this conversation as resolved.
Outdated
"@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",
Comment thread
exAdmos marked this conversation as resolved.
Outdated
"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",
Comment thread
exAdmos marked this conversation as resolved.
Outdated
"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",
Comment thread
exAdmos marked this conversation as resolved.
Outdated
"@types/supertest": "6.0.2",
"@types/unzipper": "0.10.11",
"@types/uuid": "10.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ export class AnnouncementEmailHandler implements IEventHandler<AnnouncementPubli
announcement.baseLanguage,
);
const content = htmlToPlainText(localizedContent);
const { text, html } = this.buildEmail({
const emailTemplate = this.buildEmail({
Comment thread
exAdmos marked this conversation as resolved.
Outdated
title,
content,
template: announcement.emailTemplate,
link: this.getButtonLink(tenantOrigin, announcement.emailTemplate, announcement.sourceId),
...defaultEmailSettings,
language: emailLanguage,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
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
10 changes: 6 additions & 4 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ export class AuthService {
email: string,
oldTokenHash: string,
createToken: string,
emailTemplate: { text: string; html: string },
emailTemplate: { text: Promise<string> | string; html: Promise<string> | string },
Comment thread
exAdmos marked this conversation as resolved.
Outdated
expiryDate: Date,
reminderCount: number,
) {
Expand All @@ -695,12 +695,14 @@ export class AuthService {
userId,
);

const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
to: email,
subject: getEmailSubject("passwordReminderEmail", defaultEmailSettings.language),
text: emailTemplate.text,
html: emailTemplate.html,
text,
html,
},
{ tenantId },
);
Expand Down Expand Up @@ -876,7 +878,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",
}),
]);
});
});
15 changes: 5 additions & 10 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,7 +65,7 @@ export class EmailService {
filename: "border-circle.png",
content: borderCircleBuffer,
contentType: "image/png",
...(this.usingMailhogAdapter ? {} : { cid: "border-circle" }),
cid: "border-circle",
});
}

Expand All @@ -92,7 +87,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 +101,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)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ const CourseChatMentionEmailEvents = [CourseChatUserMentionedEvent] as const;

@Injectable()
@EventsHandler(...CourseChatMentionEmailEvents)
export class CourseChatMentionEmailHandler
implements IEventHandler<CourseChatMentionEmailEventType>
{
export class CourseChatMentionEmailHandler implements IEventHandler<CourseChatMentionEmailEventType> {
constructor(
private readonly courseChatRepository: CourseChatRepository,
private readonly announcementRepository: AnnouncementsRepository,
Expand Down Expand Up @@ -108,7 +106,7 @@ export class CourseChatMentionEmailHandler
if (!courseContext) return;

const authorName = `${message.userFirstName} ${message.userLastName}`;
const { text, html } = new BaseEmailTemplate({
const emailTemplate = new BaseEmailTemplate({
Comment thread
exAdmos marked this conversation as resolved.
heading: getCourseChatMentionEmailHeading(defaultEmailSettings.language),
paragraphs: getCourseChatMentionEmailParagraphs(defaultEmailSettings.language, {
recipientName: recipient.firstName,
Expand All @@ -120,6 +118,7 @@ export class CourseChatMentionEmailHandler
buttonLink: `${tenantOrigin}/course/${courseId}?tab=Discussion`,
...defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4725,11 +4725,12 @@ export class CourseService {

if (!coursesForLanguage?.length) return;

const { text, html } = new OverdueCoursesEmail({
const emailTemplate = new OverdueCoursesEmail({
Comment thread
exAdmos marked this conversation as resolved.
courses: coursesForLanguage,
coursesLink: this.buildAdminCoursesUrl(tenantHost),
...defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

return this.emailService.sendEmailWithLogo(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ import { TenantDbRunnerService } from "src/storage/db/tenant-db-runner.service";
import type { CourseDueDateReminderRecipient } from "../types/course-due-date-reminder.types";

@EventsHandler(CourseDueDateReminderEmailEvent)
export class CourseDueDateReminderEmailHandler
implements IEventHandler<CourseDueDateReminderEmailEvent>
{
export class CourseDueDateReminderEmailHandler implements IEventHandler<CourseDueDateReminderEmailEvent> {
private readonly logger = new Logger(CourseDueDateReminderEmailHandler.name);

constructor(
Expand Down Expand Up @@ -60,13 +58,14 @@ export class CourseDueDateReminderEmailHandler
private async sendCourseDueDateReminderEmail(recipient: CourseDueDateReminderRecipient) {
const formattedDueDate = format(new Date(recipient.dueDate), "dd.MM.yyyy");

const { text, html } = new CourseDueDateReminderEmail({
const emailTemplate = new CourseDueDateReminderEmail({
Comment thread
exAdmos marked this conversation as resolved.
courseName: recipient.courseName,
courseLink: `${recipient.tenantHost.replace(/\/$/, "")}/course/${recipient.courseId}`,
dueDate: formattedDueDate,
daysBeforeDueDate: recipient.daysBeforeDueDate,
...recipient.defaultEmailSettings,
});
const [text, html] = await Promise.all([emailTemplate.text, emailTemplate.html]);

await this.emailService.sendEmailWithLogo(
{
Expand Down
Loading
Loading