Merge remote-tracking branch 'origin' into 34-api-structure-and-third-party-libs

This commit is contained in:
Hendrik Belitz
2025-07-08 09:10:09 +02:00
23 changed files with 2451 additions and 4 deletions
+9
View File
@@ -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
+21
View File
@@ -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:
+425
View File
@@ -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
<p>Hello {{recipient.name}},</p>
<p>Your appointment is on {{appointmentDate}}.</p>
```
#### Conditional Blocks
```html
{{#if tenant.logo}}
<img src="data:image/png;base64,{{tenant.logo}}" alt="{{tenant.longName}}" />
{{/if}} {{#if cancelUrl}}
<a href="{{cancelUrl}}" class="button">Cancel Appointment</a>
{{/if}}
```
#### Loops (for arrays)
```html
{{#each items}}
<li>{{item.name}}: {{item.value}}</li>
{{/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}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px;"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
```
**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._
+200
View File
@@ -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
+21
View File
@@ -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",
+2
View File
@@ -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"
},
+171 -4
View File
@@ -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<typeof tenant>;
/** Client record type for database queries */
export type SelectClient = InferSelectModel<typeof client>;
/** Staff record type for database queries */
export type SelectStaff = InferSelectModel<typeof staff>;
/** Channel record type for database queries */
export type SelectChannel = InferSelectModel<typeof channel>;
/** Appointment record type for database queries */
export type SelectAppointment = InferSelectModel<typeof appointment>;
@@ -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 = "<h1>Test HTML</h1>";
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: "<h1>Test HTML</h1>",
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 = "<h1>Hello {{recipient.name}}</h1>";
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("<h1>Hello Test User</h1>");
expect(result.text).toBe("Hello Test User");
expect(result.subject).toBe("Test Subject");
});
it("should handle conditional blocks", async () => {
const mockHtml = "{{#if showButton}}<button>Click</button>{{/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("<button>Click</button>");
expect(result.text).toBe("Button available");
});
it("should handle missing conditionals", async () => {
const mockHtml = "{{#if showButton}}<button>Click</button>{{/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 = "<h1>Welcome {{recipient.name}}</h1>";
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("<h1>Welcome John Doe</h1>");
});
});
describe("Email Service", () => {
it("should send user created email in German", async () => {
const mockHtml = "<h1>Willkommen {{recipient.name}}</h1>";
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: "<h1>Willkommen Max Mustermann</h1>",
text: "Willkommen Max Mustermann"
});
});
it("should send user created email in English", async () => {
const mockHtml = "<h1>Welcome {{recipient.name}}</h1>";
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: "<h1>Welcome John Doe</h1>",
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 = "<h1>Test</h1>";
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");
});
});
});
+192
View File
@@ -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<string, unknown>} templateData - Additional template variables
* @throws {Error} When template rendering or email sending fails
* @returns {Promise<void>}
*/
export async function sendTemplatedEmail(
templateType: EmailTemplateType,
recipient: EmailRecipient,
subject: string,
language: Language = "de",
tenant: SelectTenant,
templateData: Record<string, unknown> = {}
): Promise<void> {
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<void>}
*/
export async function sendUserCreatedEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant,
loginUrl: string
): Promise<void> {
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<void>}
*/
export async function sendPinResetEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant
): Promise<void> {
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<void>}
*/
export async function sendKeyResetEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant
): Promise<void> {
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<void>}
*/
export async function sendAppointmentReminderEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string
): Promise<void> {
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<void>}
*/
export async function sendAppointmentCreatedEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string
): Promise<void> {
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<void>}
*/
export async function sendAppointmentUpdatedEmail(
user: SelectClient | SelectStaff,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string
): Promise<void> {
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
});
}
+128
View File
@@ -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<void>}
*/
export async function sendEmail(
recipient: EmailRecipient,
subject: string,
htmlContent: string,
textContent: string
): Promise<void> {
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<boolean>} True if connection successful, false otherwise
*/
export async function testEmailConnection(): Promise<boolean> {
try {
const transporter = createTransporter();
await transporter.verify();
return true;
} catch (error) {
console.error("SMTP connection test failed:", error);
return false;
}
}
+181
View File
@@ -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<RenderedTemplate>} Rendered template with HTML, text, and subject
* @throws {Error} When template files are not found
*/
async renderTemplate(
templateType: EmailTemplateType,
data: TemplateData
): Promise<RenderedTemplate> {
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<string>} Template file content
* @throws {Error} When template file is not found
* @private
*/
private async loadTemplate(
templateType: EmailTemplateType,
fileType: "html" | "txt",
language: Language = "de"
): Promise<string> {
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<string, unknown>)[key]
: undefined;
}, obj);
}
}
/** Global template engine instance */
export const templateEngine = new EmailTemplateEngine();
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Terminerinnerung</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
h2 {
color: #374151;
font-size: 20px;
margin-bottom: 15px;
}
.button {
display: inline-block;
background-color: {{tenant.primaryColor}};
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 5px;
}
.button:hover {
background-color: #1d4ed8;
}
.button-cancel {
background-color: #dc2626;
}
.button-cancel:hover {
background-color: #b91c1c;
}
.appointment-details {
background-color: #f8fafc;
padding: 20px;
border-radius: 6px;
border-left: 4px solid {{tenant.primaryColor}};
margin: 20px 0;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Terminerinnerung</h1>
<p>Hallo {{recipient.name}},</p>
<p>Dies ist eine Erinnerung an Ihren bevorstehenden Termin:</p>
<div class="appointment-details">
<h2>Termindetails</h2>
<p><strong>Datum:</strong> {{appointmentDate}}</p>
<p><strong>Uhrzeit:</strong> {{appointmentTime}}</p>
{{#if location}}
<p><strong>Ort:</strong> {{location}}</p>
{{/if}} {{#if description}}
<p><strong>Beschreibung:</strong> {{description}}</p>
{{/if}}
</div>
<div style="text-align: center">
{{#if cancelUrl}}
<a href="{{cancelUrl}}" class="button button-cancel">Termin absagen</a>
{{/if}}
</div>
<div class="footer">
<p>Wir freuen uns auf Ihren Besuch!</p>
</div>
</div>
</body>
</html>
@@ -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
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Appointment Reminder</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
h2 {
color: #374151;
font-size: 20px;
margin-bottom: 15px;
}
.button {
display: inline-block;
background-color: {{tenant.primaryColor}};
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 5px;
}
.button:hover {
background-color: #1d4ed8;
}
.button-cancel {
background-color: #dc2626;
}
.button-cancel:hover {
background-color: #b91c1c;
}
.appointment-details {
background-color: #f8fafc;
padding: 20px;
border-radius: 6px;
border-left: 4px solid {{tenant.primaryColor}};
margin: 20px 0;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Appointment Reminder</h1>
<p>Hello {{recipient.name}},</p>
<p>This is a reminder about your upcoming appointment:</p>
<div class="appointment-details">
<h2>Appointment Details</h2>
<p><strong>Date:</strong> {{appointmentDate}}</p>
<p><strong>Time:</strong> {{appointmentTime}}</p>
{{#if location}}
<p><strong>Location:</strong> {{location}}</p>
{{/if}} {{#if description}}
<p><strong>Description:</strong> {{description}}</p>
{{/if}}
</div>
<div style="text-align: center">
{{#if cancelUrl}}
<a href="{{cancelUrl}}" class="button button-cancel">Cancel Appointment</a>
{{/if}}
</div>
<div class="footer">
<p>We look forward to seeing you!</p>
</div>
</div>
</body>
</html>
@@ -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
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PIN zurückgesetzt</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
.success {
background-color: {{tenant.secondaryColor}};
color: white;
padding: 15px;
border-radius: 6px;
text-align: center;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>PIN zurückgesetzt</h1>
<p>Hallo {{recipient.name}},</p>
<div class="success">Ihre PIN wurde erfolgreich zurückgesetzt.</div>
<p>Sie können sich nun mit Ihrer neuen PIN anmelden.</p>
<div class="footer">
<p>
Falls Sie diese Zurücksetzung nicht angefordert haben, wenden Sie sich bitte an den
Support.
</p>
</div>
</div>
</body>
</html>
@@ -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
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PIN Reset Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
.success {
background-color: {{tenant.secondaryColor}};
color: white;
padding: 15px;
border-radius: 6px;
text-align: center;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>PIN Reset Successful</h1>
<p>Hello {{recipient.name}},</p>
<div class="success">Your PIN has been successfully reset.</div>
<p>You can now sign in with your new PIN.</p>
<div class="footer">
<p>If you did not request this reset, please contact support.</p>
</div>
</div>
</body>
</html>
@@ -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
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Willkommen bei Open Reception</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.button {
display: inline-block;
background-color: {{tenant.primaryColor}};
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 0;
}
.button:hover {
background-color: #1d4ed8;
}
.code {
background-color: #f3f4f6;
padding: 10px 15px;
border-radius: 4px;
font-family: monospace;
font-size: 18px;
font-weight: bold;
text-align: center;
margin: 15px 0;
border: 1px solid #d1d5db;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Willkommen!</h1>
<p>Hallo {{recipient.name}},</p>
<p>Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt anmelden:</p>
<p><a href="{{loginUrl}}" class="button">Jetzt anmelden</a></p>
<div class="footer">
<p>Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.</p>
</div>
</div>
</body>
</html>
@@ -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
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Open Reception</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: {{tenant.backgroundColor}};
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: {{tenant.primaryColor}};
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.button {
display: inline-block;
background-color: {{tenant.primaryColor}};
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 0;
}
.button:hover {
background-color: #1d4ed8;
}
.code {
background-color: #f3f4f6;
padding: 10px 15px;
border-radius: 4px;
font-family: monospace;
font-size: 18px;
font-weight: bold;
text-align: center;
margin: 15px 0;
border: 1px solid #d1d5db;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
{{#if tenant.logo}}
<img
src="data:image/png;base64,{{tenant.logo}}"
alt="{{tenant.longName}}"
style="max-height: 60px; margin-bottom: 10px"
/>
{{/if}}
<div class="logo">{{tenant.longName}}</div>
</div>
<h1>Welcome!</h1>
<p>Hello {{recipient.name}},</p>
<p>Your account has been successfully created. You can now sign in:</p>
<p><a href="{{loginUrl}}" class="button">Sign In Now</a></p>
<div class="footer">
<p>If you did not request this email, you can safely ignore it.</p>
</div>
</div>
</body>
</html>
@@ -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