diff --git a/.env.example b/.env.example index 02ce975..267286e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,15 @@ # Development Environment Configuration # Copy this file to .env and fill in your values +# SMTP Configuration for email sending +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_SECURE="false" +SMTP_USER="your-email@example.com" +SMTP_PASS="your-password" +SMTP_FROM_NAME="Open Reception" +SMTP_FROM_EMAIL="noreply@example.com" + # Database Configuration POSTGRES_DB=appointment_booking POSTGRES_USER=postgres diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0cba9ec..64bb8ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -50,6 +50,13 @@ services: user: "1001:1001" environment: NODE_ENV: production + SMTP_HOST: /run/secrets/smtp_host + SMTP_PORT: /run/secrets/smtp_port + SMTP_SECURE: /run/secrets/smtp_secure + SMTP_USER: /run/secrets/smtp_user + SMTP_PASS: /run/secrets/smtp_pass + SMTP_FROM_NAME: /run/secrets/smtp_from_name + SMTP_FROM_EMAIL: /run/secrets/smtp_from_email secrets: - postgres_db - postgres_user @@ -112,6 +119,20 @@ secrets: file: ./secrets/postgres_user.txt postgres_password: file: ./secrets/postgres_password.txt + smtp_port: + file: ./secrets/smtp_port.txt + smtp_host: + file: ./secrets/smtp_host.txt + smtp_secure: + file: ./secrets/smtp_secure.txt + smtp_user: + file: ./secrets/smtp_user.txt + smtp_pass: + file: ./secrets/smtp_pass.txt + smtp_from_name: + file: ./secrets/smtp_from_name.txt + smtp_from_email: + file: ./secrets/smtp_from_email.txt volumes: postgres_data: diff --git a/docs/email-system.md b/docs/email-system.md new file mode 100644 index 0000000..2dcd958 --- /dev/null +++ b/docs/email-system.md @@ -0,0 +1,425 @@ +# Email System Documentation + +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 + +The email system is built on several key components: + +- **Template Engine**: Handlebars-like syntax for dynamic content +- **Multi-tenant Branding**: Customizable colors, logos, and styling per organization +- **Multi-language Support**: German (DE) and English (EN) templates +- **End-to-End Encryption**: Privacy-focused user data handling +- **SMTP Integration**: Flexible email delivery configuration + +## System Architecture + +### Core Components + +1. **Mailer (`src/lib/server/email/mailer.ts`)** + + - SMTP transport configuration + - Email sending functionality + - Connection testing utilities + +2. **Template Engine (`src/lib/server/email/template-engine.ts`)** + + - Template loading and rendering + - Variable substitution + - Conditional logic and loops + - Language fallback support + +3. **Email Service (`src/lib/server/email/email-service.ts`)** + + - High-level email functions + - Database integration + - Template selection logic + +4. **Templates (`src/lib/server/email/templates/`)** + - HTML and plain text templates + - Multi-language template files + - Tenant branding integration + +## Email Template Management + +### Template Structure + +Email templates are stored in `src/lib/server/email/templates/` with the following naming convention: + +``` +{template-type}.{language}.{format} +``` + +**Examples:** + +- `user-created.de.html` - German HTML template for user creation +- `user-created.en.txt` - English plain text template for user creation +- `appointment-reminder.de.html` - German appointment reminder + +### Available Template Types + +| Template Type | Purpose | Required Variables | +| ---------------------- | --------------------------------- | -------------------------- | +| `user-created` | Welcome email for new users | `loginUrl` | +| `pin-reset` | PIN reset notification | None | +| `key-reset` | Encryption key reset notification | None | +| `appointment-reminder` | Appointment reminders | `appointment`, `cancelUrl` | +| `appointment-created` | New appointment confirmation | `appointment`, `cancelUrl` | +| `appointment-updated` | Appointment change notification | `appointment`, `cancelUrl` | + +### Template Syntax + +Templates use a Handlebars-like syntax for dynamic content: + +#### Variable Substitution + +```html +

Hello {{recipient.name}},

+

Your appointment is on {{appointmentDate}}.

+``` + +#### Conditional Blocks + +```html +{{#if tenant.logo}} +{{tenant.longName}} +{{/if}} {{#if cancelUrl}} +Cancel Appointment +{{/if}} +``` + +#### Loops (for arrays) + +```html +{{#each items}} +
  • {{item.name}}: {{item.value}}
  • +{{/each}} +``` + +### Available Template Variables + +All templates have access to the following standard variables: + +#### Recipient Information + +- `{{recipient.email}}` - Email address +- `{{recipient.name}}` - Display name (staff only, clients for privacy) +- `{{recipient.language}}` - Preferred language (de/en) + +#### Tenant Branding + +- `{{tenant.longName}}` - Organization name +- `{{tenant.logo}}` - Base64-encoded logo image +- `{{tenant.primaryColor}}` - Primary brand color (hex) +- `{{tenant.secondaryColor}}` - Secondary brand color (hex) +- `{{tenant.backgroundColor}}` - Background color (hex) + +#### Template-Specific Variables + +Additional variables depend on the template type (see table above). + +## Multi-Language Support + +### Adding New Languages + +1. **Create Template Files** + + ```bash + # Example for French (fr) + cp user-created.de.html user-created.fr.html + cp user-created.de.txt user-created.fr.txt + ``` + +2. **Update Language Type** + + ```typescript + // In src/lib/server/email/template-engine.ts + export type Language = "de" | "en" | "fr"; + ``` + +3. **Translate Content** + - Update all text content to the target language + - Maintain all template variables (`{{...}}`) + - Test with different tenant branding + +### Language Fallback + +The system automatically falls back to German (DE) if a template doesn't exist in the requested language: + +``` +Request: user-created.fr.html +Not found → Fallback to: user-created.de.html +``` + +## Tenant Branding Configuration + +### Brand Colors + +Tenants can customize three color values that are automatically applied to email templates: + +```typescript +interface TenantBranding { + primaryColor: string; // Main brand color (buttons, links, logos) + secondaryColor: string; // Accent color (success messages, highlights) + backgroundColor: string; // Email background color +} +``` + +**Example Usage in Templates:** + +```css +.logo { + color: {{tenant.primaryColor}}; +} + +.button { + background-color: {{tenant.primaryColor}}; +} + +.success { + background-color: {{tenant.secondaryColor}}; +} + +body { + background-color: {{tenant.backgroundColor}}; +} +``` + +### Logo Integration + +Tenant logos are stored as binary data in the database and automatically converted to base64 for email embedding: + +```html +{{#if tenant.logo}} +{{tenant.longName}} +{{/if}} + +``` + +**Supported Logo Formats:** + +- PNG (recommended) +- JPEG +- GIF +- WEBP + +**Recommendations:** + +- Maximum size: 200x100 pixels +- Transparent backgrounds for PNG +- Optimize file size for email delivery + +## SMTP Configuration + +### Environment Variables + +Configure email delivery through environment variables: + +```bash +# Required SMTP settings +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=username@example.com +SMTP_PASS=password + +# Optional sender information +SMTP_FROM_NAME="Your Organization" +SMTP_FROM_EMAIL=noreply@example.com +``` + +### Security Considerations + +- Use TLS/SSL for SMTP connections (`SMTP_SECURE=true` for port 465) +- Store credentials securely (environment variables, secrets management) +- Consider using app-specific passwords for G-Mail/Outlook +- Implement proper SPF, DKIM, and D-MARC records + +## Privacy and Data Protection + +### End-to-End Encryption + +The email system is designed with privacy in mind: + +- **Client Emails**: Optional field, may be empty for privacy +- **Staff Emails**: Required for administrative notifications +- **No Personal Data**: Templates avoid exposing sensitive information +- **Minimal Logging**: Only delivery status is logged, not content + +### Data Handling + +```typescript +// Client email creation (privacy-focused) +const clientRecipient = { + email: user.email || "", // May be empty + name: undefined, // Never stored for clients + language: user.language || "de" +}; + +// Staff email creation +const staffRecipient = { + email: user.email, // Always required + name: user.name || undefined, // Optional display name + language: user.language || "de" +}; +``` + +## Testing and Development + +### Running Tests + +```bash +# Run email system tests +npm run test src/lib/server/email/__tests__/email-system.test.ts + +# Run all tests +npm run test +``` + +### Email Testing in Development + +The system automatically uses test mode in development: + +- No actual emails are sent +- SMTP configuration is mocked +- Template rendering is fully tested +- Console logging shows email content + +### Template Development Workflow + +1. **Create/Edit Templates** + + ```bash + # Edit existing template + vim src/lib/server/email/templates/user-created.de.html + ``` + +2. **Test Template Rendering** + + ```typescript + // Add test case in email-system.test.ts + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com", name: "Test User" }, + subject: "Test Subject", + language: "de", + tenant: mockTenant + }); + ``` + +3. **Validate Output** + - Check HTML rendering + - Verify variable substitution + - Test conditional logic + - Validate across languages + +## Troubleshooting + +### Common Issues + +**Template Not Found** + +``` +Error: Template file not found: template-name.de.html +``` + +- Verify file exists in `src/lib/server/email/templates/` +- Check file naming convention +- Ensure file permissions are correct + +**SMTP Connection Failed** + +``` +Error: SMTP configuration is incomplete +``` + +- Verify all required environment variables +- Test SMTP credentials manually +- Check network connectivity and firewall rules + +**Variable Not Substituted** + +``` +Email shows: Hello {{recipient.name}} +``` + +- Verify variable name spelling +- Check data structure matches template expectations +- Ensure tenant object is properly passed + +### Debug Mode + +Enable detailed logging for email debugging: + +```typescript +// In template-engine.ts, add console.log statements +console.log("Template data:", JSON.stringify(data, null, 2)); +console.log("Rendered HTML:", renderedHtml); +``` + +## Security Best Practices + +1. **Template Security** + + - Sanitize any user-generated content + - Avoid exposing sensitive data in templates + - Use HTTPS for all links in emails + +2. **SMTP Security** + + - Use encrypted connections (TLS/SSL) + - Rotate SMTP credentials regularly + - Monitor for unauthorized access + +3. **Data Protection** + - Respect user privacy preferences + - Implement proper data retention policies + - Log minimal information for debugging + +## Future Enhancements + +### Extension Points + +The email system is designed for extensibility: + +- **New Template Types**: Add to `EmailTemplateType` enum +- **Custom Variables**: Extend `TemplateData` interface +- **Advanced Logic**: Enhance template engine syntax +- **External Services**: Integration with email service providers + +## Support and Maintenance + +### Regular Maintenance Tasks + +1. **Monitor Delivery Rates** + + - Check SMTP logs for delivery failures + - Monitor bounce rates and spam complaints + - Update DNS records as needed + +2. **Template Updates** + + - Review templates for brand consistency + - Update content for policy changes + - Test across email clients + +3. **Security Updates** + - Keep dependencies updated + - Review SMTP security settings + - Audit access logs + +### Getting Help + +For technical support or questions about the email system: + +1. Check the test suite for usage examples +2. Review JSDoc comments in source code +3. Consult the main project documentation +4. Submit issues via the project repository + +--- + +_This documentation is maintained as part of the Open Reception project. For the latest updates, refer to the project repository._ diff --git a/docs/entity-relationship-diagram.md b/docs/entity-relationship-diagram.md new file mode 100644 index 0000000..2af0afb --- /dev/null +++ b/docs/entity-relationship-diagram.md @@ -0,0 +1,200 @@ +# Entity-Relationship Diagram + +## Appointment Booking Platform Data Structure + +Please note that this is currently the **unencrypted data model**. Appointments, Notes, Attachments and Answers of course have to be encrypted before being stored in the database. + +```mermaid +erDiagram + ADMINS { + uuid id PK + string email + string password + } + + TENANT { + uuid id PK + string short_name "Used as subdomain" + string long_name + text description "Optional" + blob logo "PNG, JPEG, GIF, or WEBP" + string brand_color + string default_language + int max_channels + int max_team_members + int auto_delete_days "Days after appointment to auto-delete" + boolean require_email "Requires clients to enter their e-mail address" + boolean require_phone "Requires clients to enter a phone number" + boolean url "Requires clients to enter a phone number" + } + + CLIENT { + uuid id PK + string hash_key "Generated from email" + string public_key + string private_key_share + string email "Optional" + string language + } + + STAFF { + uuid id PK + uuid role_id "Role of staff member (e.g. 'tenant-admin', 'staff')" + string hash_key "Generated from login name" + string public_key + string name "Optional" + string email + string language + } + + TEAM { + uuid id PK + string name + string description "Optional" + blob image "PNG, JPEG, GIF, or WEBP" + } + + CHANNEL { + uuid id PK + string name + string description "Optional" + boolean public "A channel may be only bookable with Code or internally" + boolean require_confirmation "Must appointments be explicitly confirmed" + any slot_creation "am Wochentag XY von bis, länge; davon mehrere" + } + + SLOT { + uuid id PK + uuid channel_id FK + datetime start + int length + uuid appointment_id FK "Optional, set when in use" + } + + APPOINTMENT { + uuid id PK + uuid client_id FK + uuid channel_id FK + uuid team_id FK + uuid slot_id FK + date appointment_date + date expiry_date + string title + string description + enum status "NEW, CONFIRMED, HELD, REJECTED, NO_SHOW" + } + + NOTE { + uuid id PK + uuid appointment_id FK + string title + text content + timestamp created_at + } + + ATTACHMENT { + uuid id PK + uuid appointment_id FK + string title + blob file_data + string mime_type + timestamp created_at + } + + QUESTIONNAIRE { + uuid id PK + uuid channel_id FK + string title + string description "Optional" + boolean is_active + } + + QUESTION { + uuid id PK + string text + enum type "FREETEXT, SINGLE_CHOICE, MULTIPLE_CHOICE" + json options "For choice questions" + boolean is_required + } + + QUESTIONNAIRE_QUESTION { + uuid questionnaire_id FK + uuid question_id FK + int order_index + } + + QUESTIONNAIRE_ANSWER { + uuid id PK + uuid appointment_id FK + uuid questionnaire_id FK + uuid question_id FK + text answer + timestamp created_at + } + + CLIENT ||--o{ APPOINTMENT : "has" + CHANNEL ||--o{ APPOINTMENT : "assigned_to" + CHANNEL ||--o{ QUESTIONNAIRE : "has" + CHANNEL }o--o{ TEAM : "associated" + APPOINTMENT ||--o{ NOTE : "contains" + APPOINTMENT ||--o{ ATTACHMENT : "contains" + QUESTIONNAIRE ||--o{ QUESTIONNAIRE_QUESTION : "contains" + QUESTION ||--o{ QUESTIONNAIRE_QUESTION : "used_in" + APPOINTMENT ||--o{ QUESTIONNAIRE_ANSWER : "answered" + QUESTIONNAIRE ||--o{ QUESTIONNAIRE_ANSWER : "answered_for" + QUESTION ||--o{ QUESTIONNAIRE_ANSWER : "answer_to" +``` + +## Database Architecture Notes + +- **Multi-tenancy**: Each tenant operates with a separate database instance +- **No explicit tenant references**: Since each tenant has its own database, foreign key relationships don't need to reference the tenant +- **Encryption**: The system uses end-to-end encryption with public/private key pairs for clients and staff +- **Hash-based identification**: Both clients and staff are identified by hash keys derived from their credentials +- **Flexible appointments**: Appointments support multiple notes and attachments for comprehensive record keeping + +## Entity Descriptions + +### TENANT + +Central configuration entity (exists in a separate management database, not in tenant-specific databases) + +### CLIENT + +End-to-end encrypted client records with optional email for notifications + +### STAFF + +Practice staff members with minimal required information for privacy + +### CHANNEL + +Represents bookable resources such as rooms, machines, or personnel that appointments can be scheduled for + +### APPOINTMENT + +Core booking entity with flexible status management and expiry handling, now linked to specific channels + +### NOTE + +Text-based annotations attached to appointments + +### ATTACHMENT + +File attachments (documents, images, etc.) associated with appointments + +### QUESTIONNAIRE + +Collection of questions associated with a specific channel, can be activated/deactivated + +### QUESTION + +Individual questions that can be reused across multiple questionnaires, supporting different answer types + +### QUESTIONNAIRE_QUESTION + +Junction table linking questionnaires to questions with ordering information + +### QUESTIONNAIRE_ANSWER + +Stores answers provided by clients for specific appointments and questionnaires diff --git a/package-lock.json b/package-lock.json index 1dc714b..3777f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv-expand": "^12.0.2", "drizzle-orm": "^0.44.2", "mode-watcher": "^1.0.8", + "nodemailer": "^7.0.3", "postgres": "^3.4.7", "winston": "^3.17.0" }, @@ -31,6 +32,7 @@ "@testing-library/svelte": "^5.2.8", "@types/dotenv": "^6.1.1", "@types/node": "^24", + "@types/nodemailer": "^6.4.17", "clsx": "^2.1.1", "drizzle-kit": "^0.31.1", "eslint": "^9.29.0", @@ -2427,6 +2429,16 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4982,6 +4994,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", diff --git a/package.json b/package.json index 30c334e..b142f38 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tailwindcss/vite": "^4.1.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.8", + "@types/nodemailer": "^6.4.17", "@types/dotenv": "^6.1.1", "@types/node": "^24", "clsx": "^2.1.1", @@ -73,6 +74,7 @@ "dotenv-expand": "^12.0.2", "drizzle-orm": "^0.44.2", "mode-watcher": "^1.0.8", + "nodemailer": "^7.0.3", "postgres": "^3.4.7", "winston": "^3.17.0" }, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index bfe8980..362cc9e 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,6 +1,173 @@ -import { pgTable, serial, integer } from "drizzle-orm/pg-core"; +import type { InferSelectModel } from "drizzle-orm"; +import { + pgTable, + uuid, + text, + boolean, + integer, + customType, + date, + pgEnum +} from "drizzle-orm/pg-core"; -export const user = pgTable("user", { - id: serial("id").primaryKey(), - age: integer("age") +/** + * Custom PostgreSQL bytea type for binary data storage + * Used for storing encrypted data, images, and other binary content + */ +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return "bytea"; + } }); + +/** + * Database enums + */ + +/** Channel type enumeration - defines what kind of resource a channel represents */ +export const channelTypeEnum = pgEnum("channel_type", ["ROOM", "MACHINE", "PERSONNEL"]); + +/** Appointment status enumeration - tracks the lifecycle of appointments */ +export const appointmentStatusEnum = pgEnum("appointment_status", [ + "NEW", + "CONFIRMED", + "HELD", + "REJECTED", + "NO_SHOW" +]); + +/** + * Tenant table - represents a single organization/company using the system + * Each tenant gets their own isolated data and subdomain + * @table tenant + */ +export const tenant = pgTable("tenant", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Short name used as subdomain (e.g., 'acme' for acme.example.com) */ + shortName: text("short_name").notNull().unique(), + /** Full organization name displayed to users */ + longName: text("long_name").notNull(), + /** Optional description of the organization */ + description: text("description"), + /** Organization logo as binary data (PNG, JPEG, GIF, or WEBP) */ + logo: bytea("logo"), + /** Background color for email templates and UI theming */ + backgroundColor: text("background_color"), + /** Primary brand color for buttons, links, and highlights */ + primaryColor: text("primary_color"), + /** Secondary brand color for accents and success messages */ + secondaryColor: text("secondary_color"), + /** Default language for the tenant (de/en) */ + defaultLanguage: text("default_language").default("de"), + /** Days after appointment completion before auto-deletion */ + autoDeleteDays: integer("auto_delete_days").default(365), + /** Whether appointments require explicit confirmation */ + requireConfirmation: boolean("require_confirmation").default(false) +}); + +/** + * Client table - represents end users who book appointments + * Uses end-to-end encryption for privacy protection + * @table client + */ +export const client = pgTable("client", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Hash of client email for identification without storing plaintext */ + hashKey: text("hash_key").notNull().unique(), + /** Client's public key for end-to-end encryption */ + publicKey: text("public_key").notNull(), + /** Server-side share of client's private key for recovery */ + privateKeyShare: text("private_key_share").notNull(), + /** Client email address (optional for privacy) */ + email: text("email"), + /** Preferred language for communications (de/en) */ + language: text("language") +}); + +/** + * Staff table - represents employees/staff members who manage appointments + * Staff members have administrative access and can view/manage appointments + * @table staff + */ +export const staff = pgTable("staff", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Hash of staff login name for identification */ + hashKey: text("hash_key").notNull().unique(), + /** Staff member's public key for end-to-end encryption */ + publicKey: text("public_key").notNull(), + /** Staff member's display name */ + name: text("name"), + /** Job title or position within the organization */ + position: text("position"), + /** Staff email address (required for notifications) */ + email: text("email").notNull(), + /** Preferred language for communications (de/en) */ + language: text("language") +}); + +/** + * Channel table - represents bookable resources (rooms, machines, personnel) + * Channels define what can be booked and when + * @table channel + */ +export const channel = pgTable("channel", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Type of channel (ROOM, MACHINE, or PERSONNEL) */ + type: channelTypeEnum("type").notNull(), + /** Display name of the channel */ + name: text("name").notNull(), + /** Optional description of the channel and its capabilities */ + description: text("description") +}); + +/** + * Appointment table - represents scheduled appointments between clients and channels + * Contains encrypted appointment data for privacy protection + * @table appointment + */ +export const appointment = pgTable("appointment", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Foreign key to client who booked the appointment */ + clientId: uuid("client_id") + .notNull() + .references(() => client.id), + /** Foreign key to channel/resource being booked */ + channelId: uuid("channel_id") + .notNull() + .references(() => channel.id), + /** Date and time of the appointment */ + appointmentDate: date("appointment_date").notNull(), + /** When appointment data expires and can be auto-deleted */ + expiryDate: date("expiry_date").notNull(), + /** Appointment title/subject */ + title: text("title").notNull(), + /** Optional detailed description of the appointment */ + description: text("description"), + /** Current status of the appointment */ + status: appointmentStatusEnum("status").notNull().default("NEW") +}); + +/** + * TypeScript type exports for use in application code + * These types represent the shape of data when queried from the database + */ + +/** Tenant record type for database queries */ +export type SelectTenant = InferSelectModel; + +/** Client record type for database queries */ +export type SelectClient = InferSelectModel; + +/** Staff record type for database queries */ +export type SelectStaff = InferSelectModel; + +/** Channel record type for database queries */ +export type SelectChannel = InferSelectModel; + +/** Appointment record type for database queries */ +export type SelectAppointment = InferSelectModel; diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts new file mode 100644 index 0000000..3a2a17a --- /dev/null +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -0,0 +1,396 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock environment variables +vi.mock("$env/dynamic/private", () => ({ + env: { + SMTP_HOST: "smtp.test.com", + SMTP_PORT: "587", + SMTP_SECURE: "false", + SMTP_USER: "test@example.com", + SMTP_PASS: "test-password", + SMTP_FROM_NAME: "Test App", + SMTP_FROM_EMAIL: "noreply@test.com" + } +})); + +// Mock nodemailer +const mockSendMail = vi.fn(); +const mockVerify = vi.fn(); +const mockTransporter = { + sendMail: mockSendMail, + verify: mockVerify +}; + +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => mockTransporter) + } +})); + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + readFile: vi.fn() +})); + +// Import modules after mocking +import { sendEmail, testEmailConnection } from "../mailer"; +import { EmailTemplateEngine } from "../template-engine"; +import { sendUserCreatedEmail, sendTemplatedEmail } from "../email-service"; +import { readFile } from "fs/promises"; +import nodemailer from "nodemailer"; + +const mockReadFile = vi.mocked(readFile); +const mockCreateTransporter = vi.mocked(nodemailer.createTransport); + +describe("Email System", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + mockVerify.mockResolvedValue(true); + }); + + describe("Mailer", () => { + it("should send email successfully", async () => { + const recipient = { email: "test@example.com", name: "Test User" }; + const subject = "Test Subject"; + const htmlContent = "

    Test HTML

    "; + const textContent = "Test Text"; + + await sendEmail(recipient, subject, htmlContent, textContent); + + expect(mockCreateTransporter).toHaveBeenCalledWith({ + host: "smtp.test.com", + port: 587, + secure: false, + auth: { + user: "test@example.com", + pass: "test-password" + } + }); + + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com" + }, + to: { + name: "Test User", + address: "test@example.com" + }, + subject: "Test Subject", + html: "

    Test HTML

    ", + text: "Test Text" + }); + }); + + it("should test email connection", async () => { + const result = await testEmailConnection(); + expect(result).toBe(true); + expect(mockVerify).toHaveBeenCalled(); + }); + }); + + describe("Template Engine", () => { + let templateEngine: EmailTemplateEngine; + + beforeEach(() => { + templateEngine = new EmailTemplateEngine("test/templates"); + }); + + it("should render template with variables", async () => { + const mockHtml = "

    Hello {{recipient.name}}

    "; + const mockText = "Hello {{recipient.name}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com", name: "Test User" }, + subject: "Test Subject", + language: "de", + tenant: mockTenant + }); + + expect(result.html).toBe("

    Hello Test User

    "); + expect(result.text).toBe("Hello Test User"); + expect(result.subject).toBe("Test Subject"); + }); + + it("should handle conditional blocks", async () => { + const mockHtml = "{{#if showButton}}{{/if}}"; + const mockText = "{{#if showButton}}Button available{{/if}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com" }, + subject: "Test", + language: "de", + tenant: mockTenant, + showButton: true + }); + + expect(result.html).toBe(""); + expect(result.text).toBe("Button available"); + }); + + it("should handle missing conditionals", async () => { + const mockHtml = "{{#if showButton}}{{/if}}"; + const mockText = "{{#if showButton}}Button available{{/if}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com" }, + subject: "Test", + language: "de", + tenant: mockTenant, + showButton: false + }); + + expect(result.html).toBe(""); + expect(result.text).toBe(""); + }); + + it("should handle English language", async () => { + const mockHtml = "

    Welcome {{recipient.name}}

    "; + const mockText = "Welcome {{recipient.name}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com", name: "John Doe" }, + subject: "Welcome", + language: "en", + tenant: mockTenant + }); + + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.en.html"), + "utf-8" + ); + expect(result.html).toBe("

    Welcome John Doe

    "); + }); + }); + + describe("Email Service", () => { + it("should send user created email in German", async () => { + const mockHtml = "

    Willkommen {{recipient.name}}

    "; + const mockText = "Willkommen {{recipient.name}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + // Create a mock staff user with German language + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "Max Mustermann", + position: "Arzt", + email: "test@example.com", + language: "de" + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + const loginUrl = "https://app.example.com/login"; + + await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); + + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.de.html"), + "utf-8" + ); + + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com" + }, + to: { + name: "Max Mustermann", + address: "test@example.com" + }, + subject: "Willkommen bei Open Reception", + html: "

    Willkommen Max Mustermann

    ", + text: "Willkommen Max Mustermann" + }); + }); + + it("should send user created email in English", async () => { + const mockHtml = "

    Welcome {{recipient.name}}

    "; + const mockText = "Welcome {{recipient.name}}"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + // Create a mock staff user with English language + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "John Doe", + position: "Doctor", + email: "test@example.com", + language: "en" + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + const loginUrl = "https://app.example.com/login"; + + await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); + + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.en.html"), + "utf-8" + ); + + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com" + }, + to: { + name: "John Doe", + address: "test@example.com" + }, + subject: "Welcome to Open Reception", + html: "

    Welcome John Doe

    ", + text: "Welcome John Doe" + }); + }); + + it("should handle template rendering errors", async () => { + mockReadFile.mockRejectedValue(new Error("Template not found")); + + const recipient = { email: "test@example.com" }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + await expect( + sendTemplatedEmail("user-created", recipient, "Test", "de", mockTenant, {}) + ).rejects.toThrow("Template not found"); + }); + + it("should handle SMTP errors", async () => { + const mockHtml = "

    Test

    "; + const mockText = "Test"; + + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + + mockSendMail.mockRejectedValueOnce(new Error("SMTP Error")); + + const clientUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + privateKeyShare: "test-share", + email: "test@example.com", + language: "de" + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + backgroundColor: "#f0f0f0", + primaryColor: "#007bff", + secondaryColor: "#28a745", + defaultLanguage: "de", + autoDeleteDays: 365, + requireConfirmation: false + }; + + await expect( + sendUserCreatedEmail(clientUser, mockTenant, "https://example.com/login") + ).rejects.toThrow("Failed to send email to test@example.com"); + }); + }); +}); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts new file mode 100644 index 0000000..76eef50 --- /dev/null +++ b/src/lib/server/email/email-service.ts @@ -0,0 +1,192 @@ +import { sendEmail, type EmailRecipient, createEmailRecipient } from "./mailer"; +import { + templateEngine, + type EmailTemplateType, + type TemplateData, + type Language +} from "./template-engine"; +import type { + SelectClient, + SelectStaff, + SelectAppointment, + SelectTenant +} from "$lib/server/db/schema"; + +/** + * Send a templated email using the template engine + * @param {EmailTemplateType} templateType - Type of email template to use + * @param {EmailRecipient} recipient - Email recipient information + * @param {string} subject - Email subject line + * @param {Language} language - Template language (defaults to 'de') + * @param {SelectTenant} tenant - Tenant information for branding + * @param {Record} templateData - Additional template variables + * @throws {Error} When template rendering or email sending fails + * @returns {Promise} + */ +export async function sendTemplatedEmail( + templateType: EmailTemplateType, + recipient: EmailRecipient, + subject: string, + language: Language = "de", + tenant: SelectTenant, + templateData: Record = {} +): Promise { + const data: TemplateData = { + recipient, + subject, + language, + tenant, + ...templateData + }; + + try { + const rendered = await templateEngine.renderTemplate(templateType, data); + await sendEmail(recipient, rendered.subject, rendered.html, rendered.text); + } catch (error) { + console.error(`Failed to send templated email (${templateType}):`, error); + throw error; + } +} + +/** + * Send welcome email to newly created user + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @param {string} loginUrl - URL for user to login + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendUserCreatedEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant, + loginUrl: string +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + const subject = language === "en" ? "Welcome to Open Reception" : "Willkommen bei Open Reception"; + + await sendTemplatedEmail("user-created", recipient, subject, language, tenant, { + loginUrl + }); +} + +/** + * Send informational email about PIN reset (no reset code included) + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendPinResetEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt"; + + await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {}); +} + +/** + * Send informational email about key reset + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendKeyResetEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + const subject = language === "en" ? "Key Reset Information" : "Schlüssel zurückgesetzt"; + + await sendTemplatedEmail("key-reset", recipient, subject, language, tenant, {}); +} + +/** + * Send appointment reminder email + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Appointment details + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentReminderEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + 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.title, + description: appointment.description, + cancelUrl + }); +} + +/** + * Send appointment confirmation email for newly created appointments + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Appointment details + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentCreatedEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt"; + + await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: appointment.title, + description: appointment.description, + cancelUrl + }); +} + +/** + * Send appointment update notification email + * @param {SelectClient | SelectStaff} user - Database user object + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Updated appointment details + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentUpdatedEmail( + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string +): Promise { + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "de"; + const subject = language === "en" ? "Appointment Updated" : "Termin aktualisiert"; + + await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: appointment.title, + description: appointment.description, + cancelUrl + }); +} diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts new file mode 100644 index 0000000..ca7fedd --- /dev/null +++ b/src/lib/server/email/mailer.ts @@ -0,0 +1,128 @@ +import nodemailer from "nodemailer"; +import { env } from "$env/dynamic/private"; +import type { SelectClient, SelectStaff } from "$lib/server/db/schema"; +import type Mail from "nodemailer/lib/mailer"; + +/** + * Email recipient interface + * @interface EmailRecipient + * @property {string} email - Recipient's email address + * @property {string} [name] - Optional recipient name + * @property {string} [language] - Optional language preference (de/en) + */ +export interface EmailRecipient { + email: string; + name?: string; + language?: string; +} + +/** + * Create an EmailRecipient from database user objects + * @param {SelectClient | SelectStaff} user - Database user object (client or staff) + * @returns {EmailRecipient} Email recipient object + * @throws {Error} When user has no email address + */ +export function createEmailRecipient(user: SelectClient | SelectStaff): EmailRecipient { + if ("name" in user) { + return { + email: user.email, + name: user.name || undefined, + language: user.language || "de" + }; + } else { + return { + email: user.email || "", // Client email is optional + name: undefined, // Clients don't have names for privacy + language: user.language || "de" + }; + } +} + +/** + * Create SMTP transporter with environment configuration + * @returns {nodemailer.Transporter} Configured SMTP transporter + * @throws {Error} When SMTP configuration is incomplete + * @private + */ +function createTransporter() { + if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASS) { + throw new Error("SMTP configuration is incomplete. Please check your environment variables."); + } + + return nodemailer.createTransport({ + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT), + secure: env.SMTP_SECURE === "true", // true for 465, false for other ports + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASS + } + }); +} + +/** + * Send an email with HTML and plain text content + * @param {EmailRecipient} recipient - Email recipient information + * @param {string} subject - Email subject line + * @param {string} htmlContent - HTML email content + * @param {string} textContent - Plain text email content + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendEmail( + recipient: EmailRecipient, + subject: string, + htmlContent: string, + textContent: string +): Promise { + const transporter = createTransporter(); + + if (!recipient.email) { + // Recipient has not stored an email address, so no mail will be send + // TODO: Log this event + return; + } + + const from_address = env.SMTP_FROM_EMAIL || env.SMTP_USER; + + if (!from_address) { + throw new Error("From address is required"); + } + + const mailOptions: Mail.Options = { + from: { + name: env.SMTP_FROM_NAME || "Open Reception", + address: from_address + }, + to: { + name: recipient.name || "", + address: recipient.email + }, + subject, + html: htmlContent, + text: textContent + }; + + try { + await transporter.sendMail(mailOptions); + console.log(`Email sent successfully to ${recipient.email}`); + } catch (error) { + console.error("Failed to send email:", error); + throw new Error(`Failed to send email to ${recipient.email}`); + } +} + +/** + * Test SMTP connection + * @returns {Promise} True if connection successful, false otherwise + */ +export async function testEmailConnection(): Promise { + try { + const transporter = createTransporter(); + await transporter.verify(); + return true; + } catch (error) { + console.error("SMTP connection test failed:", error); + return false; + } +} diff --git a/src/lib/server/email/template-engine.ts b/src/lib/server/email/template-engine.ts new file mode 100644 index 0000000..692e009 --- /dev/null +++ b/src/lib/server/email/template-engine.ts @@ -0,0 +1,181 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import type { EmailRecipient } from "./mailer"; +import type { SelectTenant } from "$lib/server/db/schema"; + +/** Supported template languages */ +export type Language = "de" | "en"; + +/** + * Available email template types + * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-updated'} EmailTemplateType + */ +export type EmailTemplateType = + | "user-created" + | "pin-reset" // Info that PIN was reset (no code) + | "key-reset" + | "appointment-reminder" + | "appointment-created" + | "appointment-updated"; + +/** + * Template data interface containing all variables available in templates + * @interface TemplateData + * @property {EmailRecipient} recipient - Email recipient information + * @property {string} subject - Email subject line + * @property {Language} language - Template language (de/en) + * @property {SelectTenant} tenant - Tenant information for branding + * @property {unknown} [key] - Additional template variables + */ +export interface TemplateData { + recipient: EmailRecipient; + subject: string; + language: Language; + tenant: SelectTenant; + [key: string]: unknown; +} + +/** + * Template rendering result containing rendered HTML, text, and subject + * @interface RenderedTemplate + * @property {string} html - Rendered HTML content + * @property {string} text - Rendered plain text content + * @property {string} subject - Rendered subject line + */ +export interface RenderedTemplate { + html: string; + text: string; + subject: string; +} + +/** + * Email template engine with Handlebars-like syntax support + * Supports variable substitution, conditionals, loops, and multilingual templates + */ +export class EmailTemplateEngine { + private templatePath: string; + + /** + * Create a new template engine instance + * @param {string} templatePath - Path to template directory + */ + constructor(templatePath: string = "src/lib/server/email/templates") { + this.templatePath = templatePath; + } + + /** + * Render a template with the provided data + * @param {EmailTemplateType} templateType - Type of template to render + * @param {TemplateData} data - Template data and variables + * @returns {Promise} Rendered template with HTML, text, and subject + * @throws {Error} When template files are not found + */ + async renderTemplate( + templateType: EmailTemplateType, + data: TemplateData + ): Promise { + const language = data.language || "de"; + const [htmlContent, textContent] = await Promise.all([ + this.loadTemplate(templateType, "html", language), + this.loadTemplate(templateType, "txt", language) + ]); + + const renderedHtml = this.replaceVariables(htmlContent, data); + const renderedText = this.replaceVariables(textContent, data); + const renderedSubject = this.replaceVariables(data.subject, data); + + return { + html: renderedHtml, + text: renderedText, + subject: renderedSubject + }; + } + + /** + * Load template file with language fallback support + * @param {EmailTemplateType} templateType - Template type + * @param {'html' | 'txt'} fileType - File type to load + * @param {Language} language - Language preference (defaults to 'de') + * @returns {Promise} Template file content + * @throws {Error} When template file is not found + * @private + */ + private async loadTemplate( + templateType: EmailTemplateType, + fileType: "html" | "txt", + language: Language = "de" + ): Promise { + const fileName = `${templateType}.${language}.${fileType}`; + const filePath = join(process.cwd(), this.templatePath, fileName); + + try { + return await readFile(filePath, "utf-8"); + } catch (error) { + // Fallback to German if language-specific template doesn't exist + if (language !== "de") { + console.warn(`Template ${fileName} not found, falling back to German`); + return this.loadTemplate(templateType, fileType, "de"); + } + throw new Error(`Template file not found: ${fileName}. Error: ${error}`); + } + } + + /** + * Replace variables in template content using Handlebars-like syntax + * Supports: {{variable}}, {{#if condition}}...{{/if}}, {{#each items}}...{{/each}} + * @param {string} content - Template content with variables + * @param {TemplateData} data - Data for variable substitution + * @returns {string} Content with variables replaced + * @private + */ + private replaceVariables(content: string, data: TemplateData): string { + let result = content; + + // Replace simple variables like {{variable}} + result = result.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, path) => { + const value = this.getNestedValue(data, path); + return value !== undefined ? String(value) : match; + }); + + // Replace conditional blocks like {{#if condition}}...{{/if}} + result = result.replace( + /\{\{#if\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (match, path, block) => { + const value = this.getNestedValue(data, path); + return value ? block : ""; + } + ); + + // Replace loops like {{#each items}}...{{/each}} + result = result.replace( + /\{\{#each\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/each\}\}/g, + (match, path, block) => { + const items = this.getNestedValue(data, path); + if (Array.isArray(items)) { + return items.map((item) => this.replaceVariables(block, { ...data, item })).join(""); + } + return ""; + } + ); + + return result; + } + + /** + * Get nested value from object using dot notation (e.g., 'tenant.primaryColor') + * @param {TemplateData} obj - Object to search in + * @param {string} path - Dot-separated path to value + * @returns {unknown} Value at path or undefined if not found + * @private + */ + private getNestedValue(obj: TemplateData, path: string): unknown { + return path.split(".").reduce((current: unknown, key: string) => { + return current && typeof current === "object" && key in current + ? (current as Record)[key] + : undefined; + }, obj); + } +} + +/** Global template engine instance */ +export const templateEngine = new EmailTemplateEngine(); diff --git a/src/lib/server/email/templates/appointment-reminder.de.html b/src/lib/server/email/templates/appointment-reminder.de.html new file mode 100644 index 0000000..8605990 --- /dev/null +++ b/src/lib/server/email/templates/appointment-reminder.de.html @@ -0,0 +1,121 @@ + + + + + + Terminerinnerung + + + + + + diff --git a/src/lib/server/email/templates/appointment-reminder.de.txt b/src/lib/server/email/templates/appointment-reminder.de.txt new file mode 100644 index 0000000..e96de45 --- /dev/null +++ b/src/lib/server/email/templates/appointment-reminder.de.txt @@ -0,0 +1,25 @@ +Terminerinnerung - Open Reception + +Hallo {{recipient.name}}, + +Dies ist eine Erinnerung an Ihren bevorstehenden Termin: + +TERMINDETAILS +============= +Datum: {{appointmentDate}} +Uhrzeit: {{appointmentTime}} +{{#if location}} +Ort: {{location}} +{{/if}} +{{#if description}} +Beschreibung: {{description}} +{{/if}} + +{{#if cancelUrl}} +Termin absagen: {{cancelUrl}} +{{/if}} + +Wir freuen uns auf Ihren Besuch! + +-- +Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/appointment-reminder.en.html b/src/lib/server/email/templates/appointment-reminder.en.html new file mode 100644 index 0000000..012a9cb --- /dev/null +++ b/src/lib/server/email/templates/appointment-reminder.en.html @@ -0,0 +1,121 @@ + + + + + + Appointment Reminder + + + + + + diff --git a/src/lib/server/email/templates/appointment-reminder.en.txt b/src/lib/server/email/templates/appointment-reminder.en.txt new file mode 100644 index 0000000..ba61121 --- /dev/null +++ b/src/lib/server/email/templates/appointment-reminder.en.txt @@ -0,0 +1,25 @@ +Appointment Reminder - Open Reception + +Hello {{recipient.name}}, + +This is a reminder about your upcoming appointment: + +APPOINTMENT DETAILS +=================== +Date: {{appointmentDate}} +Time: {{appointmentTime}} +{{#if location}} +Location: {{location}} +{{/if}} +{{#if description}} +Description: {{description}} +{{/if}} + +{{#if cancelUrl}} +Cancel appointment: {{cancelUrl}} +{{/if}} + +We look forward to seeing you! + +-- +Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/pin-reset.de.html b/src/lib/server/email/templates/pin-reset.de.html new file mode 100644 index 0000000..28fa9bd --- /dev/null +++ b/src/lib/server/email/templates/pin-reset.de.html @@ -0,0 +1,86 @@ + + + + + + PIN zurückgesetzt + + + + + + diff --git a/src/lib/server/email/templates/pin-reset.de.txt b/src/lib/server/email/templates/pin-reset.de.txt new file mode 100644 index 0000000..2a6830a --- /dev/null +++ b/src/lib/server/email/templates/pin-reset.de.txt @@ -0,0 +1,12 @@ +PIN zurückgesetzt - Open Reception + +Hallo {{recipient.name}}, + +Ihre PIN wurde erfolgreich zurückgesetzt. + +Sie können sich nun mit Ihrer neuen PIN anmelden. + +Falls Sie diese Zurücksetzung nicht angefordert haben, wenden Sie sich bitte an den Support. + +-- +Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/pin-reset.en.html b/src/lib/server/email/templates/pin-reset.en.html new file mode 100644 index 0000000..3eabb3b --- /dev/null +++ b/src/lib/server/email/templates/pin-reset.en.html @@ -0,0 +1,83 @@ + + + + + + PIN Reset Successful + + + + + + diff --git a/src/lib/server/email/templates/pin-reset.en.txt b/src/lib/server/email/templates/pin-reset.en.txt new file mode 100644 index 0000000..350b658 --- /dev/null +++ b/src/lib/server/email/templates/pin-reset.en.txt @@ -0,0 +1,12 @@ +PIN Reset Successful - Open Reception + +Hello {{recipient.name}}, + +Your PIN has been successfully reset. + +You can now sign in with your new PIN. + +If you did not request this reset, please contact support. + +-- +Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/user-created.de.html b/src/lib/server/email/templates/user-created.de.html new file mode 100644 index 0000000..1fe8bd5 --- /dev/null +++ b/src/lib/server/email/templates/user-created.de.html @@ -0,0 +1,99 @@ + + + + + + Willkommen bei Open Reception + + + + + + diff --git a/src/lib/server/email/templates/user-created.de.txt b/src/lib/server/email/templates/user-created.de.txt new file mode 100644 index 0000000..f9ce507 --- /dev/null +++ b/src/lib/server/email/templates/user-created.de.txt @@ -0,0 +1,11 @@ +Willkommen bei Open Reception! + +Hallo {{recipient.name}}, + +Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt anmelden: +{{loginUrl}} + +Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. + +-- +Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/user-created.en.html b/src/lib/server/email/templates/user-created.en.html new file mode 100644 index 0000000..42ebc06 --- /dev/null +++ b/src/lib/server/email/templates/user-created.en.html @@ -0,0 +1,99 @@ + + + + + + Welcome to Open Reception + + + + + + diff --git a/src/lib/server/email/templates/user-created.en.txt b/src/lib/server/email/templates/user-created.en.txt new file mode 100644 index 0000000..e02650b --- /dev/null +++ b/src/lib/server/email/templates/user-created.en.txt @@ -0,0 +1,11 @@ +Welcome to Open Reception! + +Hello {{recipient.name}}, + +Your account has been successfully created. You can now sign in: +{{loginUrl}} + +If you did not request this email, you can safely ignore it. + +-- +Open Reception Team \ No newline at end of file