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" | "disabled"
    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_issued(data: dict) -> None:
        await NotificationHandlers._send_notification(
            notification_type="prescription_issued",
            data=data,
        )
  1. Register in module:
python
# notifications/__init__.py
def get_event_handlers(self) -> dict:
    return {
        # ... existing handlers
        "prescription.issued": NotificationHandlers.on_prescription_issued,
    }
  1. Add template:
html
<!-- templates/email/es/prescription_issued.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.issued", {
    "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

EMAIL_ENABLED=false is the instance-wide master switch: it disables the global provider and any per-clinic SMTP configuration, and the attempt is recorded as skipped instead of being retried.

Production boots normally with EMAIL_PROVIDER=console (or an unknown provider) while email is enabled (#614): resolving the provider logs an ERROR naming the remedy (resolution is lazy, so this happens on the first email, not at boot), the console provider writes metadata-only lines (masked recipient, no subject/body/names) and reports every send as failed, so the UI stops claiming delivery. Either configure SMTP or set EMAIL_ENABLED=false, which skips the send (recorded as skipped, never retried) instead of failing it. The public demo is not exempt: it runs ENVIRONMENT=production, so it takes this branch too and now reports those sends as failed instead of printing the message and reporting it sent. A demo running ENVIRONMENT=development is unaffected.