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
10 changes: 0 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions src/lib/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from '@/lib/email/types';
export * from '@/lib/email/provider';
export * from '@/lib/email/templates';
export * from '@/lib/email/queue';
95 changes: 95 additions & 0 deletions src/lib/email/provider.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<EmailSendResult> {
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<EmailSendResult> {
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();
}
80 changes: 80 additions & 0 deletions src/lib/email/queue.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<QueueOptions>) {
this.provider = provider;
this.options = {
...DEFAULT_OPTIONS,
...options,
};
}

enqueue(message: EmailMessage): Promise<EmailSendResult> {
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<EmailSendResult> {
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'}`,
};
}
}
64 changes: 64 additions & 0 deletions src/lib/email/templates.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionalTemplateId, string> = {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}

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<TransactionalTemplateId, string> = {
welcome:
'<h2>Welcome, {{name}}</h2><p>Your TeachLink account is ready. Start learning today.</p>',
'password-reset':
'<h2>Password reset request</h2><p>Use this link to reset your password:</p><p><a href="{{resetUrl}}">Reset Password</a></p><p>This link expires in {{expiresInMinutes}} minutes.</p>',
'security-alert':
'<h2>Security alert</h2><p>We noticed a sign-in from {{device}} on {{timestamp}}.</p><p>If this was not you, secure your account immediately.</p>',
'course-enrollment':
'<h2>Enrollment confirmed</h2><p>You are now enrolled in <strong>{{courseName}}</strong>.</p><p>Start here: <a href="{{courseUrl}}">Open course</a></p>',
};

const TEXT_TEMPLATES: Record<TransactionalTemplateId, string> = {
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();
51 changes: 51 additions & 0 deletions src/lib/email/types.ts
Original file line number Diff line number Diff line change
@@ -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<EmailSendResult>;
}

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;
}
Loading
Loading