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 72ad3a0..9997996 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -917,6 +917,37 @@ "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 08741a2..ecbfb17 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": { @@ -926,6 +926,37 @@ "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/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/EmailLayout.svelte b/src/lib/emails/components/EmailLayout.svelte index da760e4..b2ba8b0 100644 --- a/src/lib/emails/components/EmailLayout.svelte +++ b/src/lib/emails/components/EmailLayout.svelte @@ -50,10 +50,13 @@ 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; diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index ff3a91d..329e8b6 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -21,6 +21,8 @@ export type SelectClient = { language: string; }; +export type SelectUserEmail = Pick; + /** * Get channel title in the user's preferred language * @param {string} tenantId - Tenant ID 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", + }, + }); +};