Skip to content

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.

python
# 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 name

Console 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

python
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: datetime

API Endpoints

MethodEndpointDescription
GET/api/v1/notifications/smtp-settingsGet current SMTP config
PUT/api/v1/notifications/smtp-settingsUpdate SMTP config
POST/api/v1/notifications/smtp-settings/testTest connection

Password Encryption

SMTP passwords are encrypted using Fernet symmetric encryption:

python
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

TypeEventDescription
appointment_confirmationAppointment createdConfirms scheduled appointment
appointment_cancelledAppointment cancelledNotifies cancellation
appointment_reminderScheduled (cron)Reminder X hours before
budget_sentBudget sent to patientQuote details
budget_acceptedBudget acceptedAcceptance confirmation
welcomePatient createdWelcome new patient

Integration Guide

Publish events and let the notifications module handle them automatically.

python
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 appointment

Available Events:

EventPayload
appointment.scheduledclinic_id, appointment_id, patient_id, patient_email, patient_name, start_time, professional_name
appointment.cancelledclinic_id, appointment_id, patient_id, patient_email, patient_name, reason
patient.createdclinic_id, patient_id, patient_email, patient_name
budget.sentclinic_id, budget_id, patient_id, patient_email, patient_name, budget_number, total
budget.acceptedclinic_id, budget_id, patient_id, patient_email, patient_name

Option 2: Direct NotificationService Call

For more control, call the NotificationService directly.

python
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.):

python
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 result

Email 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:

html
<!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:

python
# 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:

python
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

MethodEndpointDescription
GET/api/v1/notifications/settingsGet clinic settings
PUT/api/v1/notifications/settingsUpdate settings

Patient Preferences

Patients can opt-out of specific notification types:

python
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

python
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:

python
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 | None

Querying Logs

python
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:

python
# API Endpoint
POST /api/v1/notifications/send
{
    "notification_type": "budget_sent",
    "budget_id": "uuid-here",
    "patient_id": "uuid-here"
}

The NotificationService will:

  1. Load the related entities (budget, patient)
  2. Build the template context
  3. Send the email
  4. Log the result

Testing

Test Email Endpoint

python
POST /api/v1/notifications/test
{
    "to_email": "test@example.com"
}

Sends a simple test email to verify the configuration.

SMTP Test Endpoint

python
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

PermissionDescription
notifications.settings.readView clinic notification settings
notifications.settings.writeModify SMTP and notification settings
notifications.templates.readView email templates
notifications.templates.writeEdit email templates
notifications.preferences.readView patient preferences
notifications.preferences.writeModify patient preferences
notifications.logs.readView email logs
notifications.sendSend manual notifications

Common Integration Patterns

Pattern 1: Appointment Module Integration

python
# 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 appointment

Pattern 2: Budget Module Integration

python
# 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

  1. Add event handler in notifications module:
python
# notifications/handlers.py
class NotificationHandlers:
    @staticmethod
    async def on_prescription_created(data: dict) -> None:
        await NotificationHandlers._send_notification(
            notification_type="prescription_created",
            data=data,
        )
  1. Register in module:
python
# notifications/__init__.py
def get_event_handlers(self) -> dict:
    return {
        # ... existing handlers
        "prescription.created": NotificationHandlers.on_prescription_created,
    }
  1. Add template:
html
<!-- templates/email/es/prescription_created.html -->
<h1>Nueva receta</h1>
<p>Hola {{ patient_name }},</p>
<p>Tu receta está lista para recoger.</p>
  1. Publish from your module:
python
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

  1. Check SMTP settings are configured and verified
  2. Check clinic notification settings (enabled: true)
  3. Check patient hasn't opted out
  4. Check email logs for errors

Template not found

  1. Verify template file exists in templates/email/{locale}/
  2. Check template_key matches filename
  3. Fallback order: clinic DB > system DB > file

SMTP connection failed

  1. Verify host and port
  2. Check TLS/SSL settings match port (587=TLS, 465=SSL)
  3. For Gmail: use App Password, not account password
  4. Check firewall allows outbound SMTP

Environment Variables (Global Fallback)

When no clinic SMTP is configured, falls back to:

bash
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