feat: add template for email
This commit is contained in:
@@ -30,6 +30,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/apps/api/dist ./dist
|
COPY --from=builder /app/apps/api/dist ./dist
|
||||||
COPY --from=builder /app/apps/api/prisma ./prisma
|
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/apps/api/package.json ./
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { JobsModule } from './jobs/jobs.module';
|
|||||||
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
||||||
import { RemindersModule } from './reminders/reminders.module';
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
|
import { EmailModule } from './email/email.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -21,6 +22,7 @@ import { HealthController } from './health.controller';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
|
EmailModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
VenuesModule,
|
VenuesModule,
|
||||||
DevicesModule,
|
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 & <b>Jerry</b></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, '&')
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RealtimeService } from '../realtime/realtime.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
|
// 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.
|
// once this many days have passed since their last reminder.
|
||||||
const REMINDER_COOLDOWN_DAYS = 7;
|
const REMINDER_COOLDOWN_DAYS = 7;
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RemindersService {
|
export class RemindersService {
|
||||||
private resendApiKey: string;
|
private resendApiKey: string;
|
||||||
@@ -23,6 +15,7 @@ export class RemindersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private realtime: RealtimeService,
|
private realtime: RealtimeService,
|
||||||
|
private emailTemplates: EmailTemplatesService,
|
||||||
) {
|
) {
|
||||||
this.resendApiKey = process.env.RESEND_API_KEY || '';
|
this.resendApiKey = process.env.RESEND_API_KEY || '';
|
||||||
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
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;
|
let sent = 0;
|
||||||
const errors: { customerName: string; error: string }[] = [];
|
const errors: { customerName: string; error: string }[] = [];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api.resend.com/emails', {
|
const email = await this.emailTemplates.render('payment-reminder', {
|
||||||
method: 'POST',
|
customerName: candidate.customerName,
|
||||||
headers: {
|
venueName,
|
||||||
'Content-Type': 'application/json',
|
amount: formatEuro(candidate.amountInCents),
|
||||||
Authorization: `Bearer ${this.resendApiKey}`,
|
itemCount: candidate.itemCount,
|
||||||
},
|
|
||||||
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>`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
const error = await this.sendEmail(candidate.email, email);
|
||||||
const errBody = await res.text();
|
if (error) {
|
||||||
errors.push({ customerName: candidate.customerName, error: errBody });
|
errors.push({ customerName: candidate.customerName, error });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,32 +163,51 @@ export class RemindersService {
|
|||||||
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
|
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', {
|
const res = await fetch('https://api.resend.com/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${this.resendApiKey}`,
|
Authorization: `Bearer ${this.resendApiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ from: this.fromEmail, to, ...email }),
|
||||||
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>`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
return res.ok ? null : await res.text();
|
||||||
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 };
|
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)}`;
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user