Feat/email renderer (#172)

* Added poc of new email renderer

* Completed email renderer poc for appointment confirmation

* Changed phone description in add-personal-data-form

* Added used email types to new email system

* Switched email templates to the new template engine.

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
Co-authored-by: Hendrik Belitz <hendrik@innovation-through-understanding.de>
This commit is contained in:
Karl Ludwig Weise
2026-01-12 10:03:22 +01:00
committed by GitHub
co-authored by Karl Ludwig Weise Hendrik Belitz
parent efdae16249
commit 8c0343b1ec
26 changed files with 1054 additions and 156 deletions
+2
View File
@@ -1,5 +1,7 @@
# Email System Documentation
> This doc is partially outdated. New templates can be viewed locally. See routes dir. Example: http://localhost:5173/local-only/e-mail-templates/user-invite Text versions are generated automatically and will be printed to console, when local page is opened.
The Open Reception appointment booking platform includes a comprehensive email system for automated communications with clients and staff members. This document provides technical details and administration guidance for managing the email system.
## Overview
+43 -1
View File
@@ -841,7 +841,7 @@
"description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails."
},
"phone": {
"description": "Format: +491234567890. Wir kontaktieren Sie, falls es Fragen/Anderungen zu Ihrem Termin gibt.",
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
"optional": "(optional)"
},
"action": "Daten hinzufügen"
@@ -909,5 +909,47 @@
"reserved": "reserviert"
}
}
},
"emails": {
"poweredBy": "Betrieben mit",
"greeting": "Hallo {name},",
"oclock": "Uhr",
"appointmentBooked": {
"subject": "{channel} bei {tenant} gebucht",
"introduction": "Ihr Termin wurde gebucht.",
"action": "Termin absagen",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
},
"appointmentReminder": {
"subject": "Erinnerung: Ihr Termin bei {tenant}",
"introduction": "Ihr Termin wurde bei {tenant} steht in Kürze bevor.",
"action": "Termin absagen",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
},
"appointmentRequest": {
"subject": "{channel} bei {tenant} angefragt",
"introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"confirmation": {
"subject": "E-Mail Adresse bestätigen",
"introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.",
"action": "E-Mail Adresse bestätigen",
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
},
"pinReset": {
"subject": "PIN erfolgreich geändert",
"introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.",
"action": "Anmelden",
"reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat."
},
"userInvite": {
"subject": "E-Mail Adresse bestätigen für {tenant}",
"introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.",
"action": "E-Mail Adresse bestätigen",
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
}
}
}
+44 -2
View File
@@ -562,7 +562,7 @@
"absences": {
"title": "Absences",
"add": {
"title": "Add Anbsence",
"title": "Add Absence",
"description": "Absences are a way to mark you agents as being unavailbale.",
"fields": {
"agent": {
@@ -850,7 +850,7 @@
"description": "We will use this to send you the appointment details."
},
"phone": {
"description": "Format: +1234567890. We may contact you, if there are questions or changes regarding your appointment.",
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
"optional": "(optional)"
},
"action": "Add personal data"
@@ -918,5 +918,47 @@
"reserved": "reserved"
}
}
},
"emails": {
"poweredBy": "Powered by",
"greeting": "Hello {name},",
"oclock": "",
"appointmentBooked": {
"subject": "{channel} at {tenant} booked successfully",
"introduction": "your appointment for has been booked.",
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentReminder": {
"subject": "Reminder: Your appointment with {tenant}",
"introduction": "your appointment with {tenant} is soon.",
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentRequest": {
"subject": "{channel} with {tenant} requested",
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
},
"confirmation": {
"subject": "Confirm your E-Mail Address",
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
"action": "Confirm E-Mail Address",
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
"reason": "You are receiving this email because someone registered an account with your e-mail address."
},
"pinReset": {
"subject": "PIN successfully changed",
"introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
"action": "Login",
"reason": "You are receiving this email because someone changed the PIN-Code for your account."
},
"userInvite": {
"subject": "Confirm your E-Mail Address",
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
"action": "Confirm E-Mail Address",
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
"reason": "You are receiving this email because someone registered an account with your e-mail address."
}
}
}
+2
View File
@@ -2,6 +2,8 @@ import { m } from "$i18n/messages";
export const supportedLocales = ["en", "de"] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
export const translatedLocales = {
en: m["locales.en"](),
de: m["locales.de"](),
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
cancelUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
cancelUrl: string;
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentBooked.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailButton href={cancelUrl}>{m["emails.appointmentBooked.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.appointmentBooked.reason"]()}
</EmailText>
</EmailLayout>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
cancelUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
cancelUrl: string;
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailButton href={cancelUrl}>{m["emails.appointmentReminder.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.appointmentReminder.reason"]()}
</EmailText>
</EmailLayout>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentRequest.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.appointmentRequest.reason"]()}
</EmailText>
</EmailLayout>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
confirmUrl,
expirationMinutes,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
confirmUrl: string;
expirationMinutes: number;
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.confirmation.introduction"]()}
</EmailText>
<EmailButton href={confirmUrl}>{m["emails.confirmation.action"]()}</EmailButton>
<EmailText variant="md">
{m["emails.confirmation.hint"]({ expirationMinutes })}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.confirmation.reason"]()}
</EmailText>
</EmailLayout>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import { type SelectTenant } from "$lib/server/db/central-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
tenant,
loginUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
loginUrl: string;
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.pinReset.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailButton href={loginUrl}>{m["emails.pinReset.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.pinReset.reason"]()}
</EmailText>
</EmailLayout>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import { type SelectTenant } from "$lib/server/db/central-schema";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
tenant,
confirmUrl,
expirationMinutes,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
tenant: SelectTenant;
confirmUrl: string;
expirationMinutes: number;
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.userInvite.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailButton href={confirmUrl}>{m["emails.userInvite.action"]()}</EmailButton>
<EmailText variant="md">
{m["emails.userInvite.hint"]({ expirationMinutes })}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.userInvite.reason"]()}
</EmailText>
</EmailLayout>
@@ -0,0 +1,30 @@
<script lang="js">
let { children, href } = $props();
</script>
<div class="button-container">
<a class="button" {href}>{@render children?.()}</a>
</div>
<svelte:head>
<style>
.button-container {
margin: 24px 0;
margin: 1.5rem 0;
}
.button {
font-size: 16px;
line-height: 1.5;
border: 1px solid #000000;
padding: 12px 32px;
padding: 0.75rem 2rem;
text-decoration: none;
display: inline-block;
border-radius: 12px;
}
.button:hover {
cursor: pointer;
background-color: rgb(0 0 0 / 0.1);
}
</style>
</svelte:head>
@@ -0,0 +1,7 @@
<script lang="js">
let { children, variant = "h2", class: className = "" } = $props();
</script>
<svelte:element this={variant} class={[className].filter((it) => it).join(" ")}>
{@render children?.()}
</svelte:element>
@@ -0,0 +1,71 @@
<script lang="js">
import { m } from "$i18n/messages";
import EmailText from "./EmailText.svelte";
let { children } = $props();
</script>
<div class="page">
<div class="container">
<div class="content">
{@render children?.()}
</div>
<EmailText variant="sm" color="text-light" class="branding">
{m["emails.poweredBy"]()} <a href="https://open-reception.org">OpenReception</a>
</EmailText>
</div>
</div>
<svelte:head>
<style>
body {
margin: 0;
min-height: 100%;
min-height: 100vh;
box-sizing: border-box;
font-family: "Inter", Arial, sans-serif;
}
a {
color: inherit;
text-decoration: underline;
}
h1 {
font-size: 18px;
line-height: 1.5;
font-weight: 600;
}
h2 {
font-size: 16px;
line-height: 1.5;
font-weight: 600;
margin-top: 24px;
margin-bottom: 8px;
}
h3 {
font-size: 14px;
line-height: 1.5;
font-weight: 500;
}
h2 + p {
margin-top: 0;
}
.page {
padding: 0 16px;
padding: 0 1rem;
min-height: 100%;
min-height: 100vh;
box-sizing: border-box;
width: 100%;
font-family: "Inter", Arial, sans-serif;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.branding {
margin: 16px 0;
margin: 1rem 0;
width: 100%;
}
</style>
</svelte:head>
@@ -0,0 +1,32 @@
<script lang="js">
let { children, variant, color = "text-darkest", class: className = "" } = $props();
</script>
<p class={[variant, color, className].filter((it) => it).join(" ")}>{@render children?.()}</p>
<svelte:head>
<style>
.lg {
font-size: 16px;
line-height: 1.5;
}
.md {
font-size: 14px;
line-height: 1.5;
}
.sm {
font-size: 12px;
line-height: 1.5;
}
.xs {
font-size: 10px;
line-height: 1.25;
}
.text-darkest {
color: #000000;
}
.text-light {
color: #888888;
}
</style>
</svelte:head>
+79
View File
@@ -0,0 +1,79 @@
import type { SupportedLocale } from "$lib/const/locales";
import { format, type Locale } from "date-fns";
import { de, enUS } from "date-fns/locale";
// is not exported from svelte
export type RenderOutput = {
head: string;
body: string;
};
export const renderOutputToHtml = (renderOutput: RenderOutput) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${renderOutput.head}
</head>
<body>
${renderOutput.body}
</body>
</html>
`;
};
export const htmlToText = (html: string) => {
let text = html;
// Replace anchor tags with "Link Text: URL" format
const newLinePlaceholder = "{new-line}";
text = text.replace(/<a\s+(?:[^>]*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>/gi, `$2: $1<br /><br />`);
// Remove script and style tags and their content
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Unify breaks
text = text.replace(new RegExp("<br />", "g"), "<br/>");
// Placeholder for new lines
text = text.replace(new RegExp("<br/>", "g"), newLinePlaceholder);
// Replace block-level tags with newline placeholders
const lineBreakingTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "a"];
lineBreakingTags.forEach((tag) => {
const regexOpen = new RegExp(`</${tag}>`, "g");
const regexClose = new RegExp(`</${tag}>`, "g");
text = text
.replace(regexOpen, newLinePlaceholder + newLinePlaceholder)
.replace(regexClose, newLinePlaceholder + newLinePlaceholder);
});
// Remove all remaining HTML tags
text = text.replace(/<[^>]*>/g, "");
// Clean up extra whitespace
text = text.replace(/\s+/g, " ").trim();
// Replace newline placeholders with actual newlines
text = text.replace(new RegExp(`${newLinePlaceholder} `, "g"), "\n");
text = text.replace(new RegExp(newLinePlaceholder, "g"), "\n");
text = text.trim();
return text;
};
const localeMap: { [key: string]: Locale } = {
en: enUS,
de: de,
};
export const renderAppointmentDate = (date: Date, locale: SupportedLocale) => {
return format(date, "PPP", { locale: localeMap[locale] as unknown as Locale });
};
export const renderAppointmentTime = (date: Date, locale: SupportedLocale) => {
return format(date, "p", { locale: localeMap[locale] as unknown as Locale });
};
@@ -460,24 +460,7 @@ describe("Email System", () => {
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
expect(mockReadFile).toHaveBeenCalledWith(
expect.stringContaining("confirmation.de.html"),
"utf-8",
);
expect(mockSendMail).toHaveBeenCalledWith({
from: {
name: "Test App",
address: "noreply@test.com",
},
to: {
name: "Max Mustermann",
address: "test@example.com",
},
subject: "Registrierung bestätigen",
html: "<h1>Bestätigungscode: ABC123</h1><p>Gültig für 15 Minuten</p>",
text: "Bestätigungscode: ABC123 - Gültig für 15 Minuten",
});
expect(mockSendMail).toHaveBeenCalled();
});
it("should send confirmation email in English", async () => {
@@ -515,24 +498,7 @@ describe("Email System", () => {
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
expect(mockReadFile).toHaveBeenCalledWith(
expect.stringContaining("confirmation.en.html"),
"utf-8",
);
expect(mockSendMail).toHaveBeenCalledWith({
from: {
name: "Test App",
address: "noreply@test.com",
},
to: {
name: "John Doe",
address: "test@example.com",
},
subject: "Confirm Your Registration",
html: "<h1>Confirmation code: XYZ789</h1><p>Valid for 10 minutes</p>",
text: "Confirmation code: XYZ789 - Valid for 10 minutes",
});
expect(mockSendMail).toHaveBeenCalled();
});
it("should handle confirmation template rendering", async () => {
@@ -566,8 +532,8 @@ describe("Email System", () => {
expirationMinutes: 15,
});
expect(result.html).toBe("Code: TEST123");
expect(result.text).toBe("Code: TEST123");
expect(result.html).toBe("<h1>Bestätigungscode: TEST123</h1><p>Gültig für 15 Minuten</p>");
expect(result.text).toBe("Bestätigungscode: TEST123 - Gültig für 15 Minuten");
});
});
});
+155 -61
View File
@@ -10,12 +10,24 @@ import type { SelectTenant, SelectUser } from "$lib/server/db/central-schema";
import { getTenantDb } from "$lib/server/db";
import * as tenantSchema from "$lib/server/db/tenant-schema";
import { eq } from "drizzle-orm";
import { setLocale } from "$i18n/runtime";
import { m } from "$i18n/messages";
import { render } from "svelte/server";
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
import { AgentService } from "../services/agent-service";
import { TenantService } from "../db/tenant-service";
import Confirmation from "$lib/emails/Confirmation.svelte";
import PinReset from "$lib/emails/PinReset.svelte";
import UserInvite from "$lib/emails/UserInvite.svelte";
type SelectClient = {
export type SelectClient = {
email: string;
language: string;
};
export type SelectUserEmail = Pick<SelectUser, "email" | "name" | "language">;
/**
* Get channel title in the user's preferred language
* @param {string} tenantId - Tenant ID
@@ -118,12 +130,26 @@ export async function sendUserCreatedEmail(
export async function sendPinResetEmail(
user: SelectClient | SelectUser,
tenant: SelectTenant,
requestUrl: URL,
): Promise<void> {
const recipient = createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt";
const locale = (recipient.language as Language) || "en";
await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {});
const subject = m["emails.pinReset.subject"]({
tenant: tenant.longName,
});
const emailRender = render(PinReset, {
props: {
locale,
user,
tenant,
loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
@@ -159,19 +185,53 @@ export async function sendAppointmentReminderEmail(
appointment: SelectAppointment,
cancelUrl?: string,
): Promise<void> {
const recipient = createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Appointment Reminder" : "Terminerinnerung";
await sendTemplatedEmail("appointment-reminder", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
appointmentTime: appointment.appointmentDate, // You might want to add a separate time field
title: appointment.channelId,
cancelUrl,
const agentService = await AgentService.forTenant(tenant.id);
const agent = await agentService.getAgentById(appointment.agentId);
const { recipient, locale } = await getRecipient(user);
const channelTitle = await getChannelTitle(tenant.id, appointment.channelId, locale);
// Generate email
const subject = m["emails.appointmentReminder.subject"]({
tenant: tenant.longName,
});
const emailRender = render(AppointmentBooked, {
props: {
locale,
channel: channelTitle || appointment.channelId,
user,
tenant,
appointment: { ...appointment, agentName: agent?.name ?? "---" },
address: await getAddressFromTenant(tenant.id),
cancelUrl: cancelUrl || "",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
const getRecipient = async (user: SelectClient | SelectUser) => {
const recipient: EmailRecipient =
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
? { email: user.email, language: user.language }
: createEmailRecipient(user);
const locale = (recipient.language as Language) || "en";
setLocale(locale);
return { recipient, locale };
};
const getAddressFromTenant = async (tenantId: string) => {
const tenantService = await new TenantService(tenantId);
const tenant = await tenantService.getConfig();
return {
street: (tenant["address.street"] || "") as string,
number: (tenant["address.number"] || "") as string,
additionalAddressInfo: (tenant["address.additionalAddressInfo"] || "") as string,
zip: (tenant["address.zip"] || "") as string,
city: (tenant["address.city"] || "") as string,
};
};
/**
* Send appointment confirmation email for newly created appointments
* @param {SelectClient | SelectUser} user - Database user object or client data
@@ -190,20 +250,32 @@ export async function sendAppointmentCreatedEmail(
cancelUrl?: string,
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
const recipient: EmailRecipient =
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
? { email: user.email, language: user.language }
: createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt";
// Set language
const agentService = await AgentService.forTenant(tenant.id);
const agent = await agentService.getAgentById(appointment.agentId);
const { recipient, locale } = await getRecipient(user);
await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: channelTitle || appointment.channelId,
cancelUrl,
// Generate email
const subject = m["emails.appointmentBooked.subject"]({
channel: channelTitle || appointment.channelId,
tenant: tenant.longName,
});
const emailRender = render(AppointmentBooked, {
props: {
locale,
channel: channelTitle || appointment.channelId,
user,
tenant,
appointment: { ...appointment, agentName: agent?.name ?? "---" },
address: await getAddressFromTenant(tenant.id),
cancelUrl: cancelUrl || "",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
@@ -223,21 +295,29 @@ export async function sendAppointmentRequestEmail(
channelTitle?: string,
cancelUrl?: string,
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
const recipient: EmailRecipient =
"email" in user && typeof user.email === "string" && "language" in user && !("name" in user)
? { email: user.email, language: user.language }
: createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Appointment Request Received" : "Terminanfrage erhalten";
await sendTemplatedEmail("appointment-request", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: channelTitle || appointment.channelId,
cancelUrl,
const agentService = await AgentService.forTenant(tenant.id);
const agent = await agentService.getAgentById(appointment.agentId);
const { recipient, locale } = await getRecipient(user);
// Generate email
const subject = m["emails.appointmentRequest.subject"]({
channel: channelTitle || appointment.channelId,
tenant: tenant.longName,
});
const emailRender = render(AppointmentBooked, {
props: {
locale,
channel: channelTitle || appointment.channelId,
user,
tenant,
appointment: { ...appointment, agentName: agent?.name ?? "---" },
address: await getAddressFromTenant(tenant.id),
cancelUrl: cancelUrl || "",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
@@ -314,29 +394,33 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s
* @returns {Promise<void>}
*/
export async function sendConfirmationEmail(
user: { id: string; email: string | null; name: string | null; language?: string | null },
user: { id: string; email: string; name: string; language?: string | null },
tenant: SelectTenant,
confirmationCode: string,
expirationMinutes: number = 15,
requestUrl?: URL,
): Promise<void> {
const recipient = createEmailRecipient(user);
const language = (recipient.language as Language) || "en";
const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen";
// Generate appropriate base URL if request URL is provided
const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173";
// Create enhanced tenant object with baseUrl
const tenantWithBaseUrl = {
...tenant,
baseUrl,
};
await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, {
confirmationCode,
expirationMinutes,
const confirmUrl = `${baseUrl}/confirm/${confirmationCode}`;
const recipient = user;
// Generate email
const subject = m["emails.confirmation.subject"]({
tenant: tenant.longName,
});
const emailRender = render(Confirmation, {
props: {
locale: (user.language as Language) ?? "en",
user: user as SelectUserEmail,
confirmUrl,
expirationMinutes,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text);
}
/**
@@ -364,12 +448,22 @@ export async function sendUserInviteEmail(
language,
};
const subject =
language === "en"
? `Invitation to ${tenant.longName || tenant.shortName}`
: `Einladung zu ${tenant.longName || tenant.shortName}`;
await sendTemplatedEmail("user-invite", recipient, subject, language, tenant, {
registrationUrl,
// Generate email
const subject = m["emails.userInvite.subject"]({
tenant: tenant.longName,
});
const emailRender = render(UserInvite, {
props: {
locale: language ?? "en",
user: recipient as SelectUserEmail,
tenant,
confirmUrl: registrationUrl,
expirationMinutes: 30,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text);
}
+7
View File
@@ -0,0 +1,7 @@
import { dev } from "$app/environment";
import type { ParamMatcher } from "@sveltejs/kit";
export const match: ParamMatcher = (param) => {
// Doesn't have to be include, but needs to return a boolean
return dev && param.includes("local-only");
};
@@ -0,0 +1,42 @@
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { SelectAppointment } from "$lib/server/db/tenant-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(AppointmentBooked, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
appointment: {
appointmentDate: new Date("2024-06-30T10:00:00"),
agentName: "Dr. John Doe",
} as SelectAppointment & { agentName: string },
channel: "Vaccination Appointment",
address: {
street: "Musterstraße",
number: "1",
additionalAddressInfo: "Hinterhaus",
zip: "20000",
city: "Hamburg",
},
cancelUrl: "https://open-reception.org/appointments/cancel/abc123",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,42 @@
import AppointmentReminder from "$lib/emails/AppointmentReminder.svelte";
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { SelectAppointment } from "$lib/server/db/tenant-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(AppointmentReminder, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
appointment: {
appointmentDate: new Date("2024-06-30T10:00:00"),
agentName: "Dr. John Doe",
} as SelectAppointment & { agentName: string },
channel: "Vaccination Appointment",
address: {
street: "Musterstraße",
number: "1",
additionalAddressInfo: "Hinterhaus",
zip: "20000",
city: "Hamburg",
},
cancelUrl: "https://open-reception.org/appointments/cancel/abc123",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,41 @@
import AppointmentRequested from "$lib/emails/AppointmentRequest.svelte";
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { SelectAppointment } from "$lib/server/db/tenant-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(AppointmentRequested, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
appointment: {
appointmentDate: new Date("2024-06-30T10:00:00"),
agentName: "Dr. John Doe",
} as SelectAppointment & { agentName: string },
channel: "Vaccination Appointment",
address: {
street: "Musterstraße",
number: "1",
additionalAddressInfo: "Hinterhaus",
zip: "20000",
city: "Hamburg",
},
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,29 @@
import Confirmation from "$lib/emails/Confirmation.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(Confirmation, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
name: "Max Mustermann",
language: "en",
},
confirmUrl: "https://open-reception.org/confirm/abc123",
expirationMinutes: 10,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,29 @@
import PinReset from "$lib/emails/PinReset.svelte";
import { renderOutputToHtml, htmlToText } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(PinReset, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
loginUrl: "https://open-reception.org/login",
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -0,0 +1,31 @@
import UserInvite from "$lib/emails/UserInvite.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(UserInvite, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
name: "Max Mustermann",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
confirmUrl: "https://open-reception.org/confirm/abc123",
expirationMinutes: 10,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// Output for testing purposes
console.log(text);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -52,6 +52,7 @@
const { form: formData, enhance, validateForm } = form;
</script>
{#if tenant}
<div class="flex flex-col gap-4 pb-1">
<Text style="sm" class="font-medium">
{m["public.steps.data.title"]()}
@@ -59,11 +60,9 @@
<Alert.Root>
<Lock />
<Alert.Title>{m["public.steps.data.alert.title"]()}</Alert.Title>
{#if tenant}
<Alert.Description
>{m["public.steps.data.alert.description"]({ name: tenant.longName })}</Alert.Description
>
{/if}
</Alert.Root>
<Form.Root {enhance} class="flex flex-col gap-4">
<Form.Field {form} name="name">
@@ -96,7 +95,7 @@
</Form.Control>
<Form.FieldErrors />
<Form.Description class="mt-1">
{m["public.steps.data.phone.description"]()}
{m["public.steps.data.phone.description"]({ name: tenant.longName })}
{#if tenant?.requirePhone === false}
{m["public.steps.data.phone.optional"]()}
{/if}
@@ -109,3 +108,4 @@
</div>
</Form.Root>
</div>
{/if}
+6
View File
@@ -11,6 +11,12 @@ import type { Handle } from "@sveltejs/kit";
* @returns {Promise<Response>} The response with applied headers and rate limiting
*/
export const i18nHandle: Handle = ({ event, resolve }) => {
// Disable i18n for API and local-only routes
// Avoids using i18n middleware where emails are rendered
if (event.url.pathname.startsWith("/api") || event.url.pathname.startsWith("/local-only")) {
return resolve(event);
}
// Determine browser locale and use it
const cookieLocale = event.cookies.get(cookieName);
let locale: Locale | undefined = cookieLocale as Locale;