diff --git a/Dockerfile b/Dockerfile index f048233..af85d97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ WORKDIR /app COPY --from=builder /app/apps/api/dist ./dist COPY --from=builder /app/apps/api/prisma ./prisma +COPY --from=builder /app/apps/api/templates ./templates COPY --from=builder /app/apps/api/package.json ./ COPY --from=builder /app/pnpm-lock.yaml ./ COPY --from=builder /app/pnpm-workspace.yaml ./ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c4b0f74..b524a5a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -13,6 +13,7 @@ import { JobsModule } from './jobs/jobs.module'; import { PlannedTabsModule } from './planned-tabs/planned-tabs.module'; import { RemindersModule } from './reminders/reminders.module'; import { RealtimeModule } from './realtime/realtime.module'; +import { EmailModule } from './email/email.module'; import { HealthController } from './health.controller'; @Module({ @@ -21,6 +22,7 @@ import { HealthController } from './health.controller'; ScheduleModule.forRoot(), PrismaModule, RealtimeModule, + EmailModule, AuthModule, VenuesModule, DevicesModule, diff --git a/apps/api/src/email/email-templates.service.spec.ts b/apps/api/src/email/email-templates.service.spec.ts new file mode 100644 index 0000000..45694b9 --- /dev/null +++ b/apps/api/src/email/email-templates.service.spec.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { EmailTemplateError, EmailTemplatesService } from './email-templates.service'; + +function withTemplates(files: Record) { + const dir = mkdtempSync(path.join(tmpdir(), 'email-templates-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content); + } + process.env.EMAIL_TEMPLATES_DIR = dir; + const service = new EmailTemplatesService(); + delete process.env.EMAIL_TEMPLATES_DIR; + return { service, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe('EmailTemplatesService', () => { + it('renders the shipped payment reminder with a subject and a text version', async () => { + const service = new EmailTemplatesService(); + + const email = await service.render('payment-reminder', { + customerName: 'Jan', + venueName: 'Café De Kroon', + amount: '€12.50', + itemCount: 3, + }); + + expect(email.subject).toBe('Payment reminder for your tab at Café De Kroon'); + expect(email.html).toContain('€12.50'); + expect(email.text).toContain('Hi Jan,'); + expect(email.html).not.toContain('{{'); + expect(email.text).not.toContain('{{'); + }); + + it('renders the shipped test email', async () => { + const email = await new EmailTemplatesService().render('test-email', { customerName: 'Jan', venueName: 'De Kroon' }); + + expect(email.subject).toBe('Test email from De Kroon'); + }); + + it('escapes values in the HTML but not in the subject or text version', async () => { + const { service, cleanup } = withTemplates({ + 'test-email.html': 'Hi {{customerName}}

Hi {{ customerName }}

', + 'test-email.txt': 'Hi {{customerName}}', + }); + + const email = await service.render('test-email', { customerName: 'Tom & Jerry' }); + cleanup(); + + expect(email.html).toContain('

Hi Tom & <b>Jerry</b>

'); + expect(email.subject).toBe('Hi Tom & Jerry'); + expect(email.text).toBe('Hi Tom & Jerry'); + }); + + it('leaves out the text version when there is no .txt file', async () => { + const { service, cleanup } = withTemplates({ 'test-email.html': 'Hi' }); + + const email = await service.render('test-email', {}); + cleanup(); + + expect(email.text).toBeUndefined(); + }); + + it('refuses to render a placeholder without a value', async () => { + const { service, cleanup } = withTemplates({ 'test-email.html': 'Hi

{{customerNmae}}

' }); + + await expect(service.render('test-email', { customerName: 'Jan' })).rejects.toThrow( + new EmailTemplateError('Email template "test-email" uses unknown placeholders: customerNmae'), + ); + cleanup(); + }); + + it('requires a for the subject', async () => { + const { service, cleanup } = withTemplates({ 'test-email.html': '<p>No title</p>' }); + + await expect(service.render('test-email', {})).rejects.toThrow(EmailTemplateError); + cleanup(); + }); + + it('reports a missing template', async () => { + const { service, cleanup } = withTemplates({}); + + await expect(service.render('payment-reminder', {})).rejects.toThrow(/not found/); + cleanup(); + }); +}); diff --git a/apps/api/src/email/email-templates.service.ts b/apps/api/src/email/email-templates.service.ts new file mode 100644 index 0000000..0451865 --- /dev/null +++ b/apps/api/src/email/email-templates.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { existsSync, promises as fs } from 'fs'; +import * as path from 'path'; + +export interface RenderedEmail { + subject: string; + html: string; + text?: string; +} + +export type TemplateVariables = Record<string, string | number>; + +const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g; +const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i; + +/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */ +export const EMAIL_TEMPLATES = ['payment-reminder', 'test-email'] as const; +export type EmailTemplateName = (typeof EMAIL_TEMPLATES)[number]; + +export class EmailTemplateError extends Error {} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function unescapeHtml(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +/** + * Renders the email templates in `templates/emails` (or `EMAIL_TEMPLATES_DIR`). + * Files are read on every send, so edited templates apply without a restart. + */ +@Injectable() +export class EmailTemplatesService implements OnModuleInit { + private readonly logger = new Logger(EmailTemplatesService.name); + + // `src/email` and `dist/email` both sit two levels below the API root + private readonly directory = + process.env.EMAIL_TEMPLATES_DIR || path.resolve(__dirname, '..', '..', 'templates', 'emails'); + + onModuleInit() { + const missing = EMAIL_TEMPLATES.filter((name) => !existsSync(path.join(this.directory, `${name}.html`))); + if (missing.length > 0) { + this.logger.error(`Missing email templates in ${this.directory}: ${missing.join(', ')}`); + } + } + + async render(name: EmailTemplateName, variables: TemplateVariables): Promise<RenderedEmail> { + const htmlTemplate = await this.read(`${name}.html`); + if (htmlTemplate === null) { + throw new EmailTemplateError(`Email template "${name}.html" not found in ${this.directory}`); + } + const textTemplate = await this.read(`${name}.txt`); + + const html = this.fill(name, htmlTemplate, variables, escapeHtml); + const title = TITLE.exec(html)?.[1]; + if (!title?.trim()) { + throw new EmailTemplateError(`Email template "${name}.html" needs a <title> to use as the subject`); + } + + return { + // The title was escaped as HTML; the subject is plain text + subject: unescapeHtml(title.replace(/\s+/g, ' ').trim()), + html, + text: textTemplate === null ? undefined : this.fill(name, textTemplate, variables, (value) => value), + }; + } + + private fill( + name: string, + template: string, + variables: TemplateVariables, + encode: (value: string) => string, + ): string { + const missing = new Set<string>(); + const result = template.replace(PLACEHOLDER, (_, key: string) => { + if (!(key in variables)) { + missing.add(key); + return ''; + } + return encode(String(variables[key])); + }); + + // Never send an email with a blank where a value should be + if (missing.size > 0) { + throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...missing].join(', ')}`); + } + return result; + } + + private async read(file: string): Promise<string | null> { + try { + return await fs.readFile(path.join(this.directory, file), 'utf8'); + } catch (error: any) { + if (error?.code === 'ENOENT') return null; + throw error; + } + } +} diff --git a/apps/api/src/email/email.module.ts b/apps/api/src/email/email.module.ts new file mode 100644 index 0000000..4a8794b --- /dev/null +++ b/apps/api/src/email/email.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { EmailTemplatesService } from './email-templates.service'; + +@Global() +@Module({ + providers: [EmailTemplatesService], + exports: [EmailTemplatesService], +}) +export class EmailModule {} diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index f768d78..3db91d5 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -1,20 +1,12 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; +import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; // Tabs stay open per customer (closing only settles them), so a customer can be reminded again // once this many days have passed since their last reminder. const REMINDER_COOLDOWN_DAYS = 7; -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - @Injectable() export class RemindersService { private resendApiKey: string; @@ -23,6 +15,7 @@ export class RemindersService { constructor( private prisma: PrismaService, private realtime: RealtimeService, + private emailTemplates: EmailTemplatesService, ) { this.resendApiKey = process.env.RESEND_API_KEY || ''; this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>'; @@ -97,31 +90,22 @@ export class RemindersService { }); } + const venueName = await this.venueName(venueId); let sent = 0; const errors: { customerName: string; error: string }[] = []; for (const candidate of candidates) { try { - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.resendApiKey}`, - }, - body: JSON.stringify({ - from: this.fromEmail, - to: candidate.email, - subject: `Payment reminder for your tab at the bar`, - html: `<p>Hi ${escapeHtml(candidate.customerName)},</p> -<p>This is a friendly reminder that you have an open tab of <strong>€${(candidate.amountInCents / 100).toFixed(2)}</strong> (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).</p> -<p>Please settle your tab at the bar at your earliest convenience.</p> -<p>Thank you!</p>`, - }), + const email = await this.emailTemplates.render('payment-reminder', { + customerName: candidate.customerName, + venueName, + amount: formatEuro(candidate.amountInCents), + itemCount: candidate.itemCount, }); - if (!res.ok) { - const errBody = await res.text(); - errors.push({ customerName: candidate.customerName, error: errBody }); + const error = await this.sendEmail(candidate.email, email); + if (error) { + errors.push({ customerName: candidate.customerName, error }); continue; } @@ -179,32 +163,51 @@ export class RemindersService { throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' }); } + let email: RenderedEmail; + try { + email = await this.emailTemplates.render('test-email', { + customerName: tab.customerName, + venueName: await this.venueName(venueId), + }); + } catch (error) { + // Template mistakes are shown to the admin instead of a generic server error + if (error instanceof EmailTemplateError) { + throw new InternalServerErrorException({ code: 'email_template_error', message: error.message }); + } + throw error; + } + + const error = await this.sendEmail(tab.email, email); + if (error) { + throw new BadRequestException({ + code: 'email_failed', + message: `Failed to send test email: ${error}`, + }); + } + + return { ok: true, email: tab.email, customerName: tab.customerName }; + } + + /** Sends through Resend; returns Resend's error text, or null when sent. */ + private async sendEmail(to: string, email: RenderedEmail): Promise<string | null> { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.resendApiKey}`, }, - body: JSON.stringify({ - from: this.fromEmail, - to: tab.email, - subject: `Test email from KoolTap`, - html: `<p>Hi ${escapeHtml(tab.customerName)},</p> -<p>This is a test email from KoolTap to verify that email reminders are working correctly.</p> -<p>If you received this, the email configuration is working!</p> -<hr> -<p style="color: #888; font-size: 12px;">This is a test — no action needed.</p>`, - }), + body: JSON.stringify({ from: this.fromEmail, to, ...email }), }); - if (!res.ok) { - const errBody = await res.text(); - throw new BadRequestException({ - code: 'email_failed', - message: `Failed to send test email: ${errBody}`, - }); - } - - return { ok: true, email: tab.email, customerName: tab.customerName }; + return res.ok ? null : await res.text(); } + + private async venueName(venueId: string): Promise<string> { + const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true } }); + return venue?.name ?? 'KoolTap'; + } +} + +function formatEuro(cents: number): string { + return `€${(cents / 100).toFixed(2)}`; } \ No newline at end of file diff --git a/apps/api/templates/emails/README.md b/apps/api/templates/emails/README.md new file mode 100644 index 0000000..836e1a3 --- /dev/null +++ b/apps/api/templates/emails/README.md @@ -0,0 +1,37 @@ +# Email templates + +Each email the API sends is a pair of files in this folder: + +| File | Purpose | +|---|---| +| `<name>.html` | The email itself. Its `<title>` is used as the subject line. | +| `<name>.txt` | Optional plain-text version, shown by mail clients that don't render HTML. | + +Templates are read from disk every time an email is sent, so edits apply without restarting the API. +To keep them outside the image, mount a folder and point `EMAIL_TEMPLATES_DIR` at it. + +## Placeholders + +Write `{{name}}` to insert a value. Values are HTML-escaped in the `.html` file (and the subject), +and inserted as-is in the `.txt` file. A placeholder without a value stops the email from being sent, +so a typo never reaches a customer. + +### `payment-reminder` + +Sent by "Send reminders" to customers with an unpaid balance. + +| Placeholder | Example | +|---|---| +| `{{customerName}}` | Jan Peeters | +| `{{venueName}}` | Café De Kroon | +| `{{amount}}` | €12.50 | +| `{{itemCount}}` | 3 | + +### `test-email` + +Sent by the test button next to a customer's email address. + +| Placeholder | Example | +|---|---| +| `{{customerName}}` | Jan Peeters | +| `{{venueName}}` | Café De Kroon | diff --git a/apps/api/templates/emails/payment-reminder.html b/apps/api/templates/emails/payment-reminder.html new file mode 100644 index 0000000..5499508 --- /dev/null +++ b/apps/api/templates/emails/payment-reminder.html @@ -0,0 +1,13 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <title>Payment reminder for your tab at {{venueName}} + + +

Hi {{customerName}},

+

This is a friendly reminder that you have an open tab of {{amount}} at {{venueName}} (items: {{itemCount}}).

+

Please settle your tab at the bar at your earliest convenience.

+

Thank you!

+ + diff --git a/apps/api/templates/emails/payment-reminder.txt b/apps/api/templates/emails/payment-reminder.txt new file mode 100644 index 0000000..9b32062 --- /dev/null +++ b/apps/api/templates/emails/payment-reminder.txt @@ -0,0 +1,7 @@ +Hi {{customerName}}, + +This is a friendly reminder that you have an open tab of {{amount}} at {{venueName}} (items: {{itemCount}}). + +Please settle your tab at the bar at your earliest convenience. + +Thank you! diff --git a/apps/api/templates/emails/test-email.html b/apps/api/templates/emails/test-email.html new file mode 100644 index 0000000..7cd7be3 --- /dev/null +++ b/apps/api/templates/emails/test-email.html @@ -0,0 +1,14 @@ + + + + + Test email from {{venueName}} + + +

Hi {{customerName}},

+

This is a test email from {{venueName}} to verify that email reminders are working correctly.

+

If you received this, the email configuration is working!

+
+

This is a test — no action needed.

+ + diff --git a/apps/api/templates/emails/test-email.txt b/apps/api/templates/emails/test-email.txt new file mode 100644 index 0000000..e006043 --- /dev/null +++ b/apps/api/templates/emails/test-email.txt @@ -0,0 +1,8 @@ +Hi {{customerName}}, + +This is a test email from {{venueName}} to verify that email reminders are working correctly. + +If you received this, the email configuration is working! + +-- +This is a test — no action needed.