mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-23 09:24:53 +02:00
Merge remote-tracking branch 'origin' into 34-api-structure-and-third-party-libs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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._
|
||||
@@ -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
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user