diff --git a/docs/email-system.md b/docs/email-system.md index f95ee08..4b6dc53 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -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 diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 1ffe690..3d6bbdb 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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." + } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 2b6e3ee..9372fa1 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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." + } } } diff --git a/src/lib/const/locales.ts b/src/lib/const/locales.ts index dabcb15..99ced3b 100644 --- a/src/lib/const/locales.ts +++ b/src/lib/const/locales.ts @@ -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"](), diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte new file mode 100644 index 0000000..b8290c6 --- /dev/null +++ b/src/lib/emails/AppointmentBooked.svelte @@ -0,0 +1,65 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentBooked.introduction"]()} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ {m["emails.appointmentBooked.action"]()} + + {m["emails.appointmentBooked.reason"]()} + +
diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte new file mode 100644 index 0000000..37101c7 --- /dev/null +++ b/src/lib/emails/AppointmentReminder.svelte @@ -0,0 +1,65 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName })} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ {m["emails.appointmentReminder.action"]()} + + {m["emails.appointmentReminder.reason"]()} + +
diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte new file mode 100644 index 0000000..ab54418 --- /dev/null +++ b/src/lib/emails/AppointmentRequest.svelte @@ -0,0 +1,62 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentRequest.introduction"]()} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ + {m["emails.appointmentRequest.reason"]()} + +
diff --git a/src/lib/emails/Confirmation.svelte b/src/lib/emails/Confirmation.svelte new file mode 100644 index 0000000..8e2a279 --- /dev/null +++ b/src/lib/emails/Confirmation.svelte @@ -0,0 +1,37 @@ + + + + {m["emails.greeting"]({ name: user.name })} + + {m["emails.confirmation.introduction"]()} + + {m["emails.confirmation.action"]()} + + {m["emails.confirmation.hint"]({ expirationMinutes })} + + + {m["emails.confirmation.reason"]()} + + diff --git a/src/lib/emails/PinReset.svelte b/src/lib/emails/PinReset.svelte new file mode 100644 index 0000000..5826e5d --- /dev/null +++ b/src/lib/emails/PinReset.svelte @@ -0,0 +1,35 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.pinReset.introduction"]({ tenant: tenant.longName })} + + {m["emails.pinReset.action"]()} + + {m["emails.pinReset.reason"]()} + + diff --git a/src/lib/emails/UserInvite.svelte b/src/lib/emails/UserInvite.svelte new file mode 100644 index 0000000..bb15168 --- /dev/null +++ b/src/lib/emails/UserInvite.svelte @@ -0,0 +1,40 @@ + + + + {m["emails.greeting"]({ name: user.name })} + + {m["emails.userInvite.introduction"]({ tenant: tenant.longName })} + + {m["emails.userInvite.action"]()} + + {m["emails.userInvite.hint"]({ expirationMinutes })} + + + {m["emails.userInvite.reason"]()} + + diff --git a/src/lib/emails/components/EmailButton.svelte b/src/lib/emails/components/EmailButton.svelte new file mode 100644 index 0000000..066a34a --- /dev/null +++ b/src/lib/emails/components/EmailButton.svelte @@ -0,0 +1,30 @@ + + +
+ {@render children?.()} +
+ + + + diff --git a/src/lib/emails/components/EmailHeadline.svelte b/src/lib/emails/components/EmailHeadline.svelte new file mode 100644 index 0000000..47ee03c --- /dev/null +++ b/src/lib/emails/components/EmailHeadline.svelte @@ -0,0 +1,7 @@ + + + it).join(" ")}> + {@render children?.()} + diff --git a/src/lib/emails/components/EmailLayout.svelte b/src/lib/emails/components/EmailLayout.svelte new file mode 100644 index 0000000..b2ba8b0 --- /dev/null +++ b/src/lib/emails/components/EmailLayout.svelte @@ -0,0 +1,71 @@ + + +
+
+
+ {@render children?.()} +
+ + {m["emails.poweredBy"]()} OpenReception + +
+
+ + + + diff --git a/src/lib/emails/components/EmailText.svelte b/src/lib/emails/components/EmailText.svelte new file mode 100644 index 0000000..c8a396b --- /dev/null +++ b/src/lib/emails/components/EmailText.svelte @@ -0,0 +1,32 @@ + + +

it).join(" ")}>{@render children?.()}

+ + + + diff --git a/src/lib/emails/utils.ts b/src/lib/emails/utils.ts new file mode 100644 index 0000000..ae5ae75 --- /dev/null +++ b/src/lib/emails/utils.ts @@ -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 ` + + + + + + ${renderOutput.head} + + + ${renderOutput.body} + + + `; +}; + +export const htmlToText = (html: string) => { + let text = html; + + // Replace anchor tags with "Link Text: URL" format + const newLinePlaceholder = "{new-line}"; + text = text.replace(/]*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>/gi, `$2: $1

`); + + // Remove script and style tags and their content + text = text.replace(/]*>[\s\S]*?<\/script>/gi, ""); + text = text.replace(/]*>[\s\S]*?<\/style>/gi, ""); + + // Unify breaks + text = text.replace(new RegExp("
", "g"), "
"); + + // Placeholder for new lines + text = text.replace(new RegExp("
", "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(``, "g"); + const regexClose = new RegExp(``, "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 }); +}; diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index e4b2cdb..1403f50 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -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: "

Bestätigungscode: ABC123

Gültig für 15 Minuten

", - 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: "

Confirmation code: XYZ789

Valid for 10 minutes

", - 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("

Bestätigungscode: TEST123

Gültig für 15 Minuten

"); + expect(result.text).toBe("Bestätigungscode: TEST123 - Gültig für 15 Minuten"); }); }); }); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 574e8fa..7baaf34 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -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; + /** * 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 { 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 { - 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 { // 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 { - // 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} */ 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 { - 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); } diff --git a/src/params/dev.ts b/src/params/dev.ts new file mode 100644 index 0000000..12653b8 --- /dev/null +++ b/src/params/dev.ts @@ -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"); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts new file mode 100644 index 0000000..e1f0b6a --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts new file mode 100644 index 0000000..fcb1fbf --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts new file mode 100644 index 0000000..86914e3 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts new file mode 100644 index 0000000..ce1aed6 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts new file mode 100644 index 0000000..327f7f3 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts new file mode 100644 index 0000000..386c12c --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts @@ -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", + }, + }); +}; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte index 50ab004..95120e9 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte @@ -52,60 +52,60 @@ const { form: formData, enhance, validateForm } = form; -
- - {m["public.steps.data.title"]()} - - - - {m["public.steps.data.alert.title"]()} - {#if tenant} +{#if tenant} +
+ + {m["public.steps.data.title"]()} + + + + {m["public.steps.data.alert.title"]()} {m["public.steps.data.alert.description"]({ name: tenant.longName })} - {/if} - - - - - {#snippet children({ props })} - {m["form.name"]()} - - {/snippet} - - - - - - {#snippet children({ props })} - {m["form.email"]()} - - {/snippet} - - - - {m["public.steps.data.email.description"]()} - - - - - {#snippet children({ props })} - {m["form.phone"]()} - - {/snippet} - - - - {m["public.steps.data.phone.description"]()} - {#if tenant?.requirePhone === false} - {m["public.steps.data.phone.optional"]()} - {/if} - - -
- - {m["public.steps.data.action"]()} - -
-
-
+
+ + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {m["public.steps.data.email.description"]()} + + + + + {#snippet children({ props })} + {m["form.phone"]()} + + {/snippet} + + + + {m["public.steps.data.phone.description"]({ name: tenant.longName })} + {#if tenant?.requirePhone === false} + {m["public.steps.data.phone.optional"]()} + {/if} + + +
+ + {m["public.steps.data.action"]()} + +
+
+
+{/if} diff --git a/src/server-hooks/i18nHandle.ts b/src/server-hooks/i18nHandle.ts index dd4d410..600e928 100644 --- a/src/server-hooks/i18nHandle.ts +++ b/src/server-hooks/i18nHandle.ts @@ -11,6 +11,12 @@ import type { Handle } from "@sveltejs/kit"; * @returns {Promise} 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;