Skip to content

Adding Email and SMS Notifications

This guide is for developers who need to add a new email or SMS (text) notification to Hardhat Flow. For what customers see today, see Notifications.


Architecture (read this first)

Channel Provider Code Preferences
Email Mailgun HTTP API apps/notifications/email.py, dispatch.py Per-tenant via CompanySettings.email_notification_preferences
SMS Twilio REST apps/notifications/client.py, tasks.py, messages.py Fixed recipient rules (Settings UI placeholder only)

Hard rules:

  1. Always enqueue with Celery (.delay(...)). Never call Mailgun or Twilio on the request thread.
  2. Call the task from the view after the state change succeeds (same pattern as existing bid/job/invoice hooks). Do not use Django signals.
  3. Wrap enqueue in try/except + log so a broker blip does not fail the HTTP response.
  4. Keep tenant isolation: filter users by company_id; use fetch_users_by_ids() for User lookups from tenant-DB models (no select_related on User FKs).
  5. Missing phone/email or missing provider credentials → skip with a warning, do not raise to the user.
HTTP action → view updates DB → task.delay(ids…) → Celery worker
  → resolve recipients → Mailgun or Twilio

Environment

Variable Channel Notes
MAILGUN_API_KEY Email Blank in local/CI → sends no-op with warning
MAILGUN_DOMAIN Email Verified sending domain (e.g. mg.hardhatflow.com)
DEFAULT_FROM_EMAIL Email Must match the Mailgun domain
FRONTEND_URL Email Used for deep links in templates
PLATFORM_ADMIN_NOTIFY_EMAILS Email Optional comma-separated extras for access-request alerts
TWILIO_ACCOUNT_SID SMS Platform Twilio account
TWILIO_AUTH_TOKEN SMS Platform Twilio auth
Redis / Celery broker Both Required for workers + email daily cap counter

Per-tenant (database, not env):

  • CompanySettings.twilio_phone_number — outbound SMS From number
  • CompanySettings.email_notification_preferences — enabled flag + roles per email event

Celery worker must be running (celery -A config worker -l info or Docker Compose worker).


Add a tenant workflow email

Use this path for events Owners can toggle under Settings → Email Notifications.

1. Register the event

In apps/notifications/preferences.py:

  1. Add an entry to TENANT_NOTIFICATION_EVENTS (label, description, template, subject).
  2. Add default roles in _DEFAULT_EVENT_ROLES.

merge_email_notification_preferences() fills missing events from defaults for existing tenants — no migration required for preference JSON shape.

2. Add templates

Create a pair under apps/notifications/templates/notifications/email/:

  • <template_name>.html
  • <template_name>.txt

template in preferences is the base name (no extension). Context keys must match what the Celery task builds.

3. Add a Celery task in dispatch.py

Follow existing tasks (notify_tenant_bid_submitted, notify_tenant_invoice_created, …):

  1. @shared_task(bind=True, max_retries=3, default_retry_delay=60)
  2. Accept db_alias: str | None = None and wrap body in tenant_db_for_celery_task(db_alias) (from apps.companies.middleware). Without the alias, production workers query the platform DB and fail (Table 'defaultdb.bids_bid' doesn't exist).
  3. Load the entity by id (use all_objects if soft-deleted rows can still notify).
  4. Build a plain context dict (include company_name and a *_url via _frontend_url(...)).
  5. Call _dispatch_tenant_event(company_id, event_key, context, actor_user_id=...).

_dispatch_tenant_event resolves recipients from preferences, excludes the actor, and calls send_transactional_email.delay(...).

4. Hook the view

After the successful state change:

from apps.companies.router import get_current_tenant_db

try:
    notify_tenant_my_event.delay(
        str(entity.id),
        str(request.user.id),
        db_alias=get_current_tenant_db(),
    )
except Exception:
    logger.exception("notify_tenant_my_event failed for %s", entity.id)

5. Frontend types

Extend NotificationEventKey in frontend/types/api.ts so TypeScript matches the API. The settings UI loads event labels from API meta; keeping the union in sync avoids type drift.

6. Tests and docs

  • Unit/integration: apps/notifications/tests/test_dispatch.py, hook coverage in the app’s view tests (assert .delay called).
  • Update Notifications (and Spanish sibling) with the new event.
  • Keep this page accurate if the pattern changes.

Add a one-off / platform email

For invites, access-request alerts, or other non-preference emails:

  • Call send_transactional_email.delay(to=..., subject=..., template_name=..., context=...) directly, or
  • Wrap in a @shared_task in dispatch.py (see send_tenant_user_welcome_email, notify_platform_admins_access_request).

Still Celery-only. Add HTML + TXT templates under the same templates directory.


Add an SMS notification

SMS recipient rules are hardcoded in tasks today (no Owner preference storage yet). The Settings page shows a grayed-out placeholder only.

1. Message builder

In apps/notifications/messages.py, add a function that returns a short plain string:

[Hardhat Flow] <Entity>: <identifier> — <what happened>.

Do not hit the database beyond already-loaded relations.

2. Celery task

In apps/notifications/tasks.py:

  1. Load the bid/job (or other entity).
  2. Load CompanySettings; bail with a warning if twilio_phone_number is blank.
  3. Resolve recipients (Owners via User.objects.filter(company_id=...), assigned PM/Supervisor via fetch_users_by_ids).
  4. Call _send_to_users(recipients, from_number, body) (skips users without phone).

3. Hook the view

notify_my_sms_event.delay(str(entity.id))

(or with an extra arg like notify_priority_set.delay(id, "bid")).

4. Optional UI list

If you want the coming-soon Settings list to show the event, update SmsNotificationPlaceholder / related frontend copy. Real SMS preference storage is not implemented yet.

5. Tests and docs

  • apps/notifications/tests/test_tasks.py, test_messages.py, plus view patches.
  • Update Notifications.

Checklist

  • [ ] Event registered (email: preferences.py; SMS: message + task)
  • [ ] Templates or SMS body added
  • [ ] Celery task with retries; soft-skip on missing config/phone/email
  • [ ] View calls .delay(...) after successful mutation; enqueue failures logged
  • [ ] No cross-tenant User joins from tenant DB (fetch_users_by_ids where needed)
  • [ ] Frontend NotificationEventKey updated for new email events
  • [ ] Tests cover happy path + skip paths
  • [ ] Customer docs updated (features/notifications.md + .es.md)

Path Role
apps/notifications/preferences.py Email event catalog + defaults
apps/notifications/dispatch.py Email Celery tasks + recipient resolution
apps/notifications/email.py Mailgun send + daily cap
apps/notifications/tasks.py SMS Celery tasks
apps/notifications/messages.py SMS body builders
apps/notifications/client.py Twilio wrapper
apps/companies/models.py twilio_phone_number, email_notification_preferences
apps/companies/views.py NotificationSettingsView
frontend/components/settings/EmailNotificationSettings.tsx Email prefs UI
frontend/components/settings/SmsNotificationPlaceholder.tsx SMS prefs placeholder