From baf319ee57ea89784bc73ba83c524cdd3da839ac Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Tue, 28 Apr 2026 10:15:45 +0100 Subject: [PATCH] feat: add queued transactional email notification system --- package.json | 10 ---- src/lib/email/index.ts | 4 ++ src/lib/email/provider.ts | 95 +++++++++++++++++++++++++++++++++++ src/lib/email/queue.ts | 80 +++++++++++++++++++++++++++++ src/lib/email/templates.ts | 64 +++++++++++++++++++++++ src/lib/email/types.ts | 51 +++++++++++++++++++ src/services/notifications.ts | 86 +++++++++++++++++++++++++++++++ 7 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 src/lib/email/index.ts create mode 100644 src/lib/email/provider.ts create mode 100644 src/lib/email/queue.ts create mode 100644 src/lib/email/templates.ts create mode 100644 src/lib/email/types.ts create mode 100644 src/services/notifications.ts diff --git a/package.json b/package.json index 46972eb6..34e5a443 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", -<<<<<<< HEAD -======= "dompurify": "^3.2.4", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "framer-motion": "^12.23.0", "idb": "^8.0.0", "lucide-react": "^0.462.0", @@ -57,20 +54,13 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", -<<<<<<< HEAD -======= "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "recharts": "^2.15.4", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", "workbox-webpack-plugin": "^7.0.0", -<<<<<<< HEAD - "dompurify": "^3.2.4", -======= ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "zod": "^3.25.75", "zustand": "^5.0.10" }, diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts new file mode 100644 index 00000000..8180a088 --- /dev/null +++ b/src/lib/email/index.ts @@ -0,0 +1,4 @@ +export * from '@/lib/email/types'; +export * from '@/lib/email/provider'; +export * from '@/lib/email/templates'; +export * from '@/lib/email/queue'; diff --git a/src/lib/email/provider.ts b/src/lib/email/provider.ts new file mode 100644 index 00000000..f4698a7f --- /dev/null +++ b/src/lib/email/provider.ts @@ -0,0 +1,95 @@ +import { EmailMessage, EmailProvider, EmailProviderType, EmailSendResult } from '@/lib/email/types'; + +const DEFAULT_FROM_EMAIL = process.env.EMAIL_FROM_ADDRESS ?? 'no-reply@teachlink.com'; +const DEFAULT_FROM_NAME = process.env.EMAIL_FROM_NAME ?? 'TeachLink'; + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} + +function resolveFrom(message: EmailMessage) { + return message.from ?? { email: DEFAULT_FROM_EMAIL, name: DEFAULT_FROM_NAME }; +} + +class SendGridProvider implements EmailProvider { + readonly type: EmailProviderType = 'sendgrid'; + + async send(message: EmailMessage): Promise { + const apiKey = process.env.SENDGRID_API_KEY; + if (!apiKey) { + return { success: false, provider: this.type, error: 'SENDGRID_API_KEY is not configured' }; + } + + try { + const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + personalizations: [ + { + to: asArray(message.to).map((recipient) => ({ + email: recipient.email, + name: recipient.name, + })), + }, + ], + from: resolveFrom(message), + reply_to: message.replyTo, + subject: message.subject, + content: [ + { type: 'text/plain', value: message.text ?? '' }, + { type: 'text/html', value: message.html }, + ], + categories: message.tags, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + provider: this.type, + error: `SendGrid error ${response.status}: ${errorText}`, + }; + } + + return { + success: true, + provider: this.type, + messageId: response.headers.get('x-message-id') ?? undefined, + }; + } catch (error) { + return { + success: false, + provider: this.type, + error: error instanceof Error ? error.message : 'Unknown SendGrid error', + }; + } + } +} + +class SesProvider implements EmailProvider { + readonly type: EmailProviderType = 'ses'; + + async send(_message: EmailMessage): Promise { + return { + success: false, + provider: this.type, + error: + 'SES provider requires AWS SDK integration. Configure EMAIL_PROVIDER=sendgrid or implement SES transport.', + }; + } +} + +export function createEmailProvider(providerType?: string): EmailProvider { + const type = (providerType ?? process.env.EMAIL_PROVIDER ?? 'sendgrid').toLowerCase(); + + if (type === 'ses') { + return new SesProvider(); + } + + return new SendGridProvider(); +} diff --git a/src/lib/email/queue.ts b/src/lib/email/queue.ts new file mode 100644 index 00000000..67e3d5c1 --- /dev/null +++ b/src/lib/email/queue.ts @@ -0,0 +1,80 @@ +import { EmailMessage, EmailProvider, EmailSendResult, QueueJob, QueueOptions } from '@/lib/email/types'; + +const DEFAULT_OPTIONS: QueueOptions = { + maxRetries: 3, + retryDelayMs: 1500, + maxConcurrent: 2, +}; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createJobId(): string { + return `email_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +export class EmailQueue { + private readonly provider: EmailProvider; + private readonly options: QueueOptions; + private readonly queue: QueueJob[] = []; + private processing = 0; + + constructor(provider: EmailProvider, options?: Partial) { + this.provider = provider; + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + } + + enqueue(message: EmailMessage): Promise { + return new Promise((resolve) => { + this.queue.push({ id: createJobId(), message, attempts: 0 }); + this.process(resolve); + }); + } + + private process(resolve: (result: EmailSendResult) => void): void { + while (this.processing < this.options.maxConcurrent && this.queue.length > 0) { + const nextJob = this.queue.shift(); + if (!nextJob) { + return; + } + + this.processing += 1; + void this.runJob(nextJob) + .then((result) => resolve(result)) + .finally(() => { + this.processing -= 1; + this.process(resolve); + }); + } + } + + private async runJob(job: QueueJob): Promise { + let result: EmailSendResult = { + success: false, + provider: this.provider.type, + error: 'No attempt made', + }; + + while (job.attempts < this.options.maxRetries) { + job.attempts += 1; + result = await this.provider.send(job.message); + + if (result.success) { + return result; + } + + if (job.attempts < this.options.maxRetries) { + await delay(this.options.retryDelayMs * job.attempts); + } + } + + return { + ...result, + error: `Queue failed after ${job.attempts} attempts: ${result.error ?? 'Unknown error'}`, + }; + } +} diff --git a/src/lib/email/templates.ts b/src/lib/email/templates.ts new file mode 100644 index 00000000..a5242ec4 --- /dev/null +++ b/src/lib/email/templates.ts @@ -0,0 +1,64 @@ +import { EmailTemplate, EmailTemplatePayload } from '@/lib/email/types'; + +export type TransactionalTemplateId = + | 'welcome' + | 'password-reset' + | 'security-alert' + | 'course-enrollment'; + +const TEMPLATE_SUBJECTS: Record = { + welcome: 'Welcome to TeachLink', + 'password-reset': 'Reset your TeachLink password', + 'security-alert': 'New sign-in detected', + 'course-enrollment': 'You are enrolled successfully', +}; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); +} + +function render(template: string, payload: EmailTemplatePayload): string { + return template.replace(/{{\s*([\w.-]+)\s*}}/g, (_match, key: string) => { + const value = payload[key]; + return escapeHtml(value == null ? '' : String(value)); + }); +} + +const HTML_TEMPLATES: Record = { + welcome: + '

Welcome, {{name}}

Your TeachLink account is ready. Start learning today.

', + 'password-reset': + '

Password reset request

Use this link to reset your password:

Reset Password

This link expires in {{expiresInMinutes}} minutes.

', + 'security-alert': + '

Security alert

We noticed a sign-in from {{device}} on {{timestamp}}.

If this was not you, secure your account immediately.

', + 'course-enrollment': + '

Enrollment confirmed

You are now enrolled in {{courseName}}.

Start here: Open course

', +}; + +const TEXT_TEMPLATES: Record = { + welcome: 'Welcome, {{name}}. Your TeachLink account is ready.', + 'password-reset': + 'Reset your TeachLink password using this link: {{resetUrl}}. Expires in {{expiresInMinutes}} minutes.', + 'security-alert': + 'Security alert: sign-in from {{device}} on {{timestamp}}. If not you, secure your account.', + 'course-enrollment': + 'Enrollment confirmed for {{courseName}}. Start here: {{courseUrl}}', +}; + +export class EmailTemplateManager { + getTemplate(id: TransactionalTemplateId, payload: EmailTemplatePayload): EmailTemplate { + return { + id, + subject: render(TEMPLATE_SUBJECTS[id], payload), + html: render(HTML_TEMPLATES[id], payload), + text: render(TEXT_TEMPLATES[id], payload), + }; + } +} + +export const emailTemplateManager = new EmailTemplateManager(); diff --git a/src/lib/email/types.ts b/src/lib/email/types.ts new file mode 100644 index 00000000..f0d67f6d --- /dev/null +++ b/src/lib/email/types.ts @@ -0,0 +1,51 @@ +export type EmailProviderType = 'sendgrid' | 'ses'; + +export interface EmailAddress { + email: string; + name?: string; +} + +export interface EmailMessage { + to: EmailAddress | EmailAddress[]; + subject: string; + html: string; + text?: string; + from?: EmailAddress; + replyTo?: EmailAddress; + tags?: string[]; +} + +export interface EmailSendResult { + success: boolean; + provider: EmailProviderType; + messageId?: string; + error?: string; +} + +export interface EmailProvider { + readonly type: EmailProviderType; + send(message: EmailMessage): Promise; +} + +export interface EmailTemplatePayload { + [key: string]: string | number | boolean | null | undefined; +} + +export interface EmailTemplate { + id: string; + subject: string; + html: string; + text: string; +} + +export interface QueueOptions { + maxRetries: number; + retryDelayMs: number; + maxConcurrent: number; +} + +export interface QueueJob { + id: string; + message: EmailMessage; + attempts: number; +} diff --git a/src/services/notifications.ts b/src/services/notifications.ts new file mode 100644 index 00000000..f230b8c9 --- /dev/null +++ b/src/services/notifications.ts @@ -0,0 +1,86 @@ +import { + createEmailProvider, + EmailQueue, + EmailSendResult, + emailTemplateManager, + TransactionalTemplateId, +} from '@/lib/email'; + +interface BaseNotificationInput { + email: string; + name: string; +} + +interface PasswordResetInput extends BaseNotificationInput { + resetUrl: string; + expiresInMinutes: number; +} + +interface SecurityAlertInput extends BaseNotificationInput { + device: string; + timestamp: string; +} + +interface CourseEnrollmentInput extends BaseNotificationInput { + courseName: string; + courseUrl: string; +} + +export type NotificationEvent = + | { type: 'welcome'; data: BaseNotificationInput } + | { type: 'password-reset'; data: PasswordResetInput } + | { type: 'security-alert'; data: SecurityAlertInput } + | { type: 'course-enrollment'; data: CourseEnrollmentInput }; + +export class NotificationService { + private readonly queue: EmailQueue; + + constructor() { + const provider = createEmailProvider(); + this.queue = new EmailQueue(provider, { + maxRetries: Number(process.env.EMAIL_MAX_RETRIES ?? 3), + retryDelayMs: Number(process.env.EMAIL_RETRY_DELAY_MS ?? 1500), + maxConcurrent: Number(process.env.EMAIL_MAX_CONCURRENT ?? 2), + }); + } + + async sendEvent(event: NotificationEvent): Promise { + const template = this.buildTemplate(event.type, event.data); + + return this.queue.enqueue({ + to: { + email: event.data.email, + name: event.data.name, + }, + subject: template.subject, + html: template.html, + text: template.text, + tags: ['transactional', event.type], + }); + } + + sendWelcomeEmail(data: BaseNotificationInput): Promise { + return this.sendEvent({ type: 'welcome', data }); + } + + sendPasswordResetEmail(data: PasswordResetInput): Promise { + return this.sendEvent({ type: 'password-reset', data }); + } + + sendSecurityAlertEmail(data: SecurityAlertInput): Promise { + return this.sendEvent({ type: 'security-alert', data }); + } + + sendCourseEnrollmentEmail(data: CourseEnrollmentInput): Promise { + return this.sendEvent({ type: 'course-enrollment', data }); + } + + private buildTemplate( + templateId: TransactionalTemplateId, + payload: Record, + ) { + return emailTemplateManager.getTemplate(templateId, payload); + } +} + +export const notificationService = new NotificationService();