feat: add template for email

This commit is contained in:
2026-10-08 12:59:01 +02:00
parent b336580040
commit 6f237a937a
11 changed files with 337 additions and 47 deletions
+1
View File
@@ -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 ./
+2
View File
@@ -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,
@@ -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<string, string>) {
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('<strong>€12.50</strong>');
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': '<title>Hi {{customerName}}</title><p>Hi {{ customerName }}</p>',
'test-email.txt': 'Hi {{customerName}}',
});
const email = await service.render('test-email', { customerName: 'Tom & <b>Jerry</b>' });
cleanup();
expect(email.html).toContain('<p>Hi Tom &amp; &lt;b&gt;Jerry&lt;/b&gt;</p>');
expect(email.subject).toBe('Hi Tom & <b>Jerry</b>');
expect(email.text).toBe('Hi Tom & <b>Jerry</b>');
});
it('leaves out the text version when there is no .txt file', async () => {
const { service, cleanup } = withTemplates({ 'test-email.html': '<title>Hi</title>' });
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': '<title>Hi</title><p>{{customerNmae}}</p>' });
await expect(service.render('test-email', { customerName: 'Jan' })).rejects.toThrow(
new EmailTemplateError('Email template "test-email" uses unknown placeholders: customerNmae'),
);
cleanup();
});
it('requires a <title> 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();
});
});
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function unescapeHtml(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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;
}
}
}
+9
View File
@@ -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 {}
+50 -47
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@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>&euro;${(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)}`;
}
+37
View File
@@ -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 |
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Payment reminder for your tab at {{venueName}}</title>
</head>
<body>
<p>Hi {{customerName}},</p>
<p>This is a friendly reminder that you have an open tab of <strong>{{amount}}</strong> at {{venueName}} (items: {{itemCount}}).</p>
<p>Please settle your tab at the bar at your earliest convenience.</p>
<p>Thank you!</p>
</body>
</html>
@@ -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!
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Test email from {{venueName}}</title>
</head>
<body>
<p>Hi {{customerName}},</p>
<p>This is a test email from {{venueName}} 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>
</html>
+8
View File
@@ -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.