Email & Notifications Module
Documentation for the email and notifications system in DentalPin.
Overview
The notifications module provides a complete email notification system with:
- Per-clinic SMTP configuration - Each clinic can configure its own email server
- Event-driven notifications - Automatic emails triggered by system events
- Manual notifications - Staff can send emails on demand
- Customizable templates - Per-clinic template customization
- Patient preferences - Opt-in/opt-out per notification type
- Email logging - Full audit trail of all sent emails
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Other Modules │
│ (clinical, budget, odontogram, etc.) │
└─────────────────────────┬───────────────────────────────────────┘
│ Events / Direct calls
▼
┌─────────────────────────────────────────────────────────────────┐
│ NotificationService │
│ - Check preferences (clinic + patient) │
│ - Load templates │
│ - Send via EmailService │
│ - Log results │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ EmailService │
│ - Provider selection (global or per-clinic) │
│ - Template rendering (Jinja2) │
│ - Email sending │
└─────────────────────────┬───────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ SMTP │ │ Console │ │ Disabled │
│ Provider │ │ Provider │ │ │
└──────────┘ └──────────┘ └──────────┘Email Providers
SMTP Provider
Production email sending via SMTP server.
# Configuration via ClinicSmtpSettings model
host: str # e.g., "smtp.gmail.com"
port: int # e.g., 587
username: str # SMTP username
password: str # Encrypted with Fernet
use_tls: bool # STARTTLS (port 587)
use_ssl: bool # SSL/TLS (port 465)
from_email: str # Sender email
from_name: str # Sender display nameConsole Provider
Development mode - prints emails to console instead of sending.
Disabled
Email completely disabled for the clinic.
Per-Clinic SMTP Configuration
Each clinic can configure its own SMTP server from Settings > Notifications.
Database Model
class ClinicSmtpSettings(Base):
clinic_id: UUID # One per clinic (unique)
provider: str # "smtp" | "console" | "disabled"
host: str
port: int
username: str
password_encrypted: str # Fernet encrypted
use_tls: bool
use_ssl: bool
from_email: str
from_name: str
is_verified: bool # True after successful test
last_verified_at: datetimeAPI Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/notifications/smtp-settings | Get current SMTP config |
| PUT | /api/v1/notifications/smtp-settings | Update SMTP config |
| POST | /api/v1/notifications/smtp-settings/test | Test connection |
Password Encryption
SMTP passwords are encrypted using Fernet symmetric encryption:
from app.core.email.encryption import encrypt_password, decrypt_password
# Encrypt before storing
encrypted = encrypt_password("my_smtp_password")
# Decrypt when needed
password = decrypt_password(encrypted)The encryption key is derived from SECRET_KEY using PBKDF2.
Notification Types
| Type | Event | Description |
|---|---|---|
appointment_confirmation | Appointment created | Confirms scheduled appointment |
appointment_cancelled | Appointment cancelled | Notifies cancellation |
appointment_reminder | Scheduled (cron) | Reminder X hours before |
budget_sent | Budget sent to patient | Quote details |
budget_accepted | Budget accepted | Acceptance confirmation |
welcome | Patient created | Welcome new patient |
Integration Guide
Option 1: Event-Driven (Recommended)
Publish events and let the notifications module handle them automatically.
from app.core.events import event_bus
# In your module's service
async def create_appointment(db, clinic_id, data):
appointment = Appointment(**data)
db.add(appointment)
await db.commit()
# Publish event - notifications module will handle it
event_bus.publish("appointment.scheduled", {
"clinic_id": str(clinic_id),
"appointment_id": str(appointment.id),
"patient_id": str(appointment.patient_id),
"patient_email": patient.email,
"patient_name": f"{patient.first_name} {patient.last_name}",
"start_time": appointment.start_time.isoformat(),
"professional_name": professional.first_name,
})
return appointmentAvailable Events:
| Event | Payload |
|---|---|
appointment.scheduled | clinic_id, appointment_id, patient_id, patient_email, patient_name, start_time, professional_name |
appointment.cancelled | clinic_id, appointment_id, patient_id, patient_email, patient_name, reason |
patient.created | clinic_id, patient_id, patient_email, patient_name |
budget.sent | clinic_id, budget_id, patient_id, patient_email, patient_name, budget_number, total |
budget.accepted | clinic_id, budget_id, patient_id, patient_email, patient_name |
Option 2: Direct NotificationService Call
For more control, call the NotificationService directly.
from app.modules.notifications.service import NotificationService
async def send_custom_notification(db, clinic_id, patient):
result = await NotificationService.send_notification(
db=db,
clinic_id=clinic_id,
notification_type="welcome",
to_email=patient.email,
context={
"patient_name": f"{patient.first_name} {patient.last_name}",
"clinic_name": "Mi Clínica Dental",
"clinic_phone": "+34 123 456 789",
},
patient_id=patient.id,
triggered_by_event="patient.created",
force_send=False, # Respect preferences
)
if result.is_success:
print(f"Email sent: {result.message_id}")
else:
print(f"Email failed: {result.error_message}")Option 3: Low-Level EmailService
For non-notification emails (reports, exports, etc.):
from app.core.email import email_service
from app.core.email.providers.base import EmailMessage
async def send_report_email(db, clinic_id, to_email, report_html):
message = EmailMessage(
to_email=to_email,
subject="Monthly Report - DentalPin",
body_html=report_html,
body_text="Please view this email in an HTML-capable client.",
)
# Uses clinic-specific SMTP if configured
result = await email_service.send(
message,
db=db,
clinic_id=clinic_id,
)
return resultEmail Templates
Template Location
backend/templates/email/
├── es/ # Spanish templates
│ ├── appointment_confirmation.html
│ ├── appointment_cancelled.html
│ ├── appointment_reminder.html
│ ├── budget_sent.html
│ ├── budget_accepted.html
│ └── welcome.html
├── en/ # English templates
│ └── ...
└── default/ # Fallback templates
└── ...Template Variables
Templates use Jinja2 syntax:
<!DOCTYPE html>
<html>
<body>
<h1>Hola {{ patient_name }},</h1>
<p>Tu cita está confirmada para el {{ start_time }}.</p>
<p>Profesional: {{ professional_name }}</p>
<hr>
<p>{{ clinic_name }} - {{ clinic_phone }}</p>
</body>
</html>Database Template Override
Clinics can customize templates via the database:
# Create custom template for a clinic
template = EmailTemplate(
clinic_id=clinic_id,
template_key="appointment_confirmation",
locale="es",
subject="Cita confirmada en {{ clinic_name }}",
body_html="<h1>Custom template HTML...</h1>",
)Priority: Clinic template > System template > File template
Clinic Settings
Notification Type Settings
Each clinic can configure per-notification-type behavior:
class ClinicNotificationSettings:
settings: dict = {
"appointment_confirmation": {
"enabled": True, # Is this notification type active?
"auto_send": True, # Send automatically on event?
},
"appointment_reminder": {
"enabled": True,
"auto_send": True,
"hours_before": 24, # When to send reminder
},
# ... other types
}API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/notifications/settings | Get clinic settings |
| PUT | /api/v1/notifications/settings | Update settings |
Patient Preferences
Patients can opt-out of specific notification types:
class NotificationPreference:
patient_id: UUID
email_enabled: bool = True # Master toggle
preferences: dict = {
"appointment_reminder": True,
"budget_sent": False, # Opted out
}
preferred_locale: str = "es"Checking Preferences
should_send, reason = await NotificationService.should_send_notification(
db=db,
clinic_id=clinic_id,
notification_type="appointment_reminder",
patient_id=patient_id,
)
if not should_send:
print(f"Skipping: {reason}")
# Possible reasons:
# - "disabled_at_clinic_level"
# - "manual_send_required"
# - "patient_opted_out"
# - "patient_opted_out_of_appointment_reminder"Email Logging
All email attempts are logged:
class EmailLog:
clinic_id: UUID
recipient_email: str
patient_id: UUID | None
template_key: str
subject: str
status: str # "pending" | "sent" | "failed" | "skipped"
provider: str # "smtp" | "console"
provider_message_id: str
error_message: str | None
triggered_by_event: str
triggered_by_user_id: UUID | None
created_at: datetime
sent_at: datetime | NoneQuerying Logs
logs, total = await NotificationService.list_logs(
db=db,
clinic_id=clinic_id,
page=1,
page_size=20,
status="failed", # Filter by status
patient_id=patient_id, # Filter by patient
)Manual Sending
For notifications with auto_send: False, users can trigger manual sends:
# API Endpoint
POST /api/v1/notifications/send
{
"notification_type": "budget_sent",
"budget_id": "uuid-here",
"patient_id": "uuid-here"
}The NotificationService will:
- Load the related entities (budget, patient)
- Build the template context
- Send the email
- Log the result
Testing
Test Email Endpoint
POST /api/v1/notifications/test
{
"to_email": "test@example.com"
}Sends a simple test email to verify the configuration.
SMTP Test Endpoint
POST /api/v1/notifications/smtp-settings/test
{
"host": "smtp.gmail.com",
"port": 587,
"username": "user@gmail.com",
"password": "app-password",
"use_tls": true,
"use_ssl": false,
"from_email": "clinic@example.com",
"to_email": "test@example.com"
}Tests SMTP connection with specific settings before saving.
Permissions
| Permission | Description |
|---|---|
notifications.settings.read | View clinic notification settings |
notifications.settings.write | Modify SMTP and notification settings |
notifications.templates.read | View email templates |
notifications.templates.write | Edit email templates |
notifications.preferences.read | View patient preferences |
notifications.preferences.write | Modify patient preferences |
notifications.logs.read | View email logs |
notifications.send | Send manual notifications |
Common Integration Patterns
Pattern 1: Appointment Module Integration
# In clinical/service.py
async def create_appointment(db, clinic_id, ctx, data):
appointment = await AppointmentService.create(db, clinic_id, data)
# Get patient for context
patient = await PatientService.get(db, clinic_id, data.patient_id)
# Publish event - notifications module handles the rest
event_bus.publish("appointment.scheduled", {
"clinic_id": str(clinic_id),
"appointment_id": str(appointment.id),
"patient_id": str(patient.id),
"patient_email": patient.email,
"patient_name": f"{patient.first_name} {patient.last_name}",
"start_time": appointment.start_time.isoformat(),
})
return appointmentPattern 2: Budget Module Integration
# In budget/service.py
async def send_budget(db, clinic_id, budget_id):
budget = await BudgetService.get(db, clinic_id, budget_id)
patient = await PatientService.get(db, clinic_id, budget.patient_id)
# Update status
budget.status = "sent"
await db.commit()
# Trigger notification
event_bus.publish("budget.sent", {
"clinic_id": str(clinic_id),
"budget_id": str(budget.id),
"patient_id": str(patient.id),
"patient_email": patient.email,
"patient_name": f"{patient.first_name} {patient.last_name}",
"budget_number": budget.budget_number,
"total": str(budget.total),
"currency": budget.currency,
})Pattern 3: Custom Module with New Notification Type
- Add event handler in notifications module:
# notifications/handlers.py
class NotificationHandlers:
@staticmethod
async def on_prescription_created(data: dict) -> None:
await NotificationHandlers._send_notification(
notification_type="prescription_created",
data=data,
)- Register in module:
# notifications/__init__.py
def get_event_handlers(self) -> dict:
return {
# ... existing handlers
"prescription.created": NotificationHandlers.on_prescription_created,
}- Add template:
<!-- templates/email/es/prescription_created.html -->
<h1>Nueva receta</h1>
<p>Hola {{ patient_name }},</p>
<p>Tu receta está lista para recoger.</p>- Publish from your module:
event_bus.publish("prescription.created", {
"clinic_id": str(clinic_id),
"patient_id": str(patient.id),
"patient_email": patient.email,
"patient_name": patient_name,
"prescription_details": "...",
})Troubleshooting
Email not sending
- Check SMTP settings are configured and verified
- Check clinic notification settings (
enabled: true) - Check patient hasn't opted out
- Check email logs for errors
Template not found
- Verify template file exists in
templates/email/{locale}/ - Check template_key matches filename
- Fallback order: clinic DB > system DB > file
SMTP connection failed
- Verify host and port
- Check TLS/SSL settings match port (587=TLS, 465=SSL)
- For Gmail: use App Password, not account password
- Check firewall allows outbound SMTP
Environment Variables (Global Fallback)
When no clinic SMTP is configured, falls back to:
EMAIL_ENABLED=true
EMAIL_PROVIDER=smtp # smtp | console
EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=user@example.com
EMAIL_SMTP_PASSWORD=password
EMAIL_SMTP_TLS=true
EMAIL_FROM_ADDRESS=noreply@example.com
EMAIL_FROM_NAME=DentalPin