Notifications

EpiSODIC can notify your team whenever episodic_run_cron() detects new clusters or when a detection run fails. Notifications are entirely optional: if you do not configure them, the system continues to work exactly as before, and you review clusters through the dashboard alone.

Supported channels:

Channel Transport Needs R package
ntfy HTTP POST to a self-hosted or public ntfy server httr2
SMTP Any SMTP server (Gmail, Outlook, institutional relay, etc.) curl
sendmail The sendmail binary on a Linux/Unix server (none)
Microsoft 365 Microsoft Graph API (application or delegated auth) Microsoft365R, AzureGraph, AzureAuth
Teams Power Automate Workflow webhook (Adaptive Card) httr2
Slack Incoming Webhook httr2

You can enable as many channels in parallel as you like. Each channel is independent: if one fails, the others still send, and a notification failure never rolls back a successful detection run.

Where to configure

Notification settings go in your instance YAML configuration file, the same file that EPISODIC_CONFIG points at (see Environment variables). Add a notifications key at the top level. The shipped inst/config/episodic_default_config.yaml contains commented-out examples for every channel; copy the ones you need into your instance file and fill in the values.

Notification configuration is deliberately excluded from the detection config_hash. This means:

  • Changing notification settings never alters detection reproducibility.
  • Secrets (passwords, webhook URLs, client secrets) never reach the config_snapshot stored on each run.

Minimal example: ntfy

ntfy is the simplest channel to set up. If you run your own ntfy server, or use the public instance at ntfy.sh, this is all you need in your instance YAML:

notifications:
  enabled: true
  triggers:
    new_clusters: true
    run_failure: true
  channels:
    ntfy:
      enabled: true
      server: "https://ntfy.sh"
      topic: "my-episodic-alerts"

That is it. Install httr2 (install.packages("httr2")) and test:

episodic_notify_test()

Subscribe to the topic my-episodic-alerts in the ntfy app on your phone or desktop, and you will see the test message arrive.

ntfy with authentication

If your ntfy server requires a login:

    ntfy:
      enabled: true
      server: "https://ntfy.example.org"
      topic: "episodic-alerts"
      priority: 4            # ntfy priority 1 (min) to 5 (max); default 4 (high)
      auth:
        username: "episodic"
        password: "s3cr3t"

SMTP (Gmail, institutional relay, any SMTP server)

Works with any SMTP server that supports STARTTLS. For Gmail, create an App Password (requires 2FA on the Google account) and use that instead of the account password.

    smtp:
      enabled: true
      host: "smtp.gmail.com"
      port: 587
      tls: true
      username: "[email protected]"
      password: "xxxx xxxx xxxx xxxx"   # Gmail App Password
      from: "[email protected]"
      to:
        - "[email protected]"
        - "[email protected]"

For an institutional relay that does not require authentication (common on internal networks):

    smtp:
      enabled: true
      host: "mail-relay.internal.example.org"
      port: 25
      tls: false
      from: "[email protected]"
      to:
        - "[email protected]"

Requires the curl package (install.packages("curl")).

sendmail (Linux/Unix)

If the server running EpiSODIC has sendmail (or a compatible MTA such as Postfix or msmtp) installed and configured, you can pipe messages directly without needing an external SMTP server:

    sendmail:
      enabled: true
      binary: "/usr/sbin/sendmail"    # default; change if your MTA is elsewhere
      from: "[email protected]"
      to:
        - "[email protected]"

No additional R packages required. The binary must be executable by the user running the cron job.

Microsoft 365

Sends email through the Microsoft Graph API. Two authentication flows are supported, depending on your Azure AD (Entra ID) setup.

Option B: Delegated (cached interactive token)

If you cannot get admin consent for application-level permissions, or if your organisation restricts app-only mail sending, you can use a delegated token instead. This requires a one-time interactive login on the server:

episodic_setup_microsoft365(
  tenant_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"  # optional; uses the Microsoft365R default if NULL
)

This opens a browser window (or prints a device-code URL for headless servers). Sign in with the account that will send notifications. The token is cached in ~/.local/share/AzureR/ and reused by subsequent cron runs.

In your instance YAML, omit client_secret:

    microsoft365:
      enabled: true
      tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
      client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
      from: "[email protected]"
      to:
        - "[email protected]"

Token lifetime: Delegated refresh tokens typically expire after 90 days of inactivity. If the cron runs at least once within that window, the token refreshes itself automatically. If the token does expire, re-run episodic_setup_microsoft365().

Option C: Reuse an already-cached login (no app registration needed)

If staff at your organisation already sign in to Microsoft 365 through a shared login on this server, for example by running Microsoft365R::get_business_outlook() interactively for other purposes, or by running episodic_setup_microsoft365() once as in Option B, you do not need to configure client_id or client_secret at all. Omit client_id and episodic_notify_microsoft365() will look up whichever cached Azure AD token it finds for the configured tenant_id in AzureAuth::AzureR_dir() (by default ~/.local/share/AzureR/, overridable with the R_AZURE_DATA_DIR environment variable) and reuse it, without ever starting a new interactive login. tenant_id can be the short tenant name (e.g. "contoso"), the full tenant domain ("contoso.onmicrosoft.com"), or the tenant GUID; it is matched against each cached login’s tenant.

from is optional here. Leave it out and mail is sent from the mailbox of whoever is signed in on the cached token. Set it to send from a different mailbox instead (a shared or departmental mailbox, for example): episodic_notify_microsoft365() then opens that mailbox with Microsoft365R::get_business_outlook(shared_mbox_email = from), which requires the signed-in account to hold Send As or Send on Behalf permission on it. Whether the cached token’s account actually has that permission is between your organisation and Azure AD; if it does not, sending fails with a permissions error from the Graph API, not from EpiSODIC.

    microsoft365:
      enabled: true
      tenant_id: "contoso"
      from: "[email protected]"  # optional: a shared mailbox the
                                        # signed-in account may send as
      # client_id and client_secret are not needed: an already cached
      # Azure AD token for this tenant will be reused
      to:
        - "[email protected]"

If no cached token is found for the tenant, sending fails loudly with an error naming the tenant and the directory searched, rather than starting a new interactive login (which would hang an unattended cron run).

Requires Microsoft365R, AzureGraph, and AzureAuth (install.packages(c("Microsoft365R", "AzureGraph", "AzureAuth"))).

Microsoft Teams

Teams notifications use a Power Automate Workflow webhook. The legacy Office 365 Connector webhooks are deprecated by Microsoft and should not be used for new setups.

Setting up the webhook

  1. In Teams, go to the channel where you want notifications.
  2. Click … (more options) > Workflows.
  3. Search for “webhook” and select the “Send webhook alerts to a channel” template.
  4. Choose the team and channel, then click Save.
  5. Click Copy webhook link. When done, copy the webhook URL (it looks like https://default<environment-id>.<region>.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/<n>/workflows/<workflow-id>/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=...).
    teams:
      enabled: true
      webhook_url: "https://default32d178cfed3488384266e0286e48b.2c.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/29/workflows/dc666faf7e22497f9854bce58e0e09d8/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=..."

The notification is sent as an Adaptive Card, which renders as a structured card in the Teams channel with pathogen, location, case counts, and a link to the dashboard.

Requires httr2 (install.packages("httr2")).

Slack

Slack notifications use an Incoming Webhook.

Setting up the webhook

  1. Go to api.slack.com/apps and create a new app (or use an existing one).
  2. Under Incoming Webhooks, activate them and click Add New Webhook to Workspace.
  3. Choose the channel and copy the webhook URL.
    slack:
      enabled: true
      webhook_url: "https://hooks.slack.com/services/<workspace-id>/<bot-id>/<token>"

The notification is formatted as Slack mrkdwn, with pathogen names in bold, case counts, and (if configured) a link to the dashboard.

Requires httr2 (install.packages("httr2")).

Triggers

Two triggers are available. Both are independently toggleable:

Trigger Fires when Default
new_clusters The cron run created at least one new cluster Must be explicitly set to true
run_failure The cron run’s status is "failed", including a run that stopped before it began: case data that failed its checks, or a database it would not open (see “Upgrading EpiSODIC” in vignette("deployment")) Must be explicitly set to true
notifications:
  enabled: true
  triggers:
    new_clusters: true
    run_failure: true

If both triggers fire on the same run (new clusters were detected, but something else in the run also failed), two separate notifications are sent.

Full reference example

Below is a complete notifications block with every channel shown. Copy only what you need.

notifications:
  enabled: true
  dashboard_url: "https://episodic.example.org"
  triggers:
    new_clusters: true
    run_failure: true
  channels:
    ntfy:
      enabled: true
      server: "https://ntfy.example.org"
      topic: "episodic-alerts"
      priority: 4
      auth:
        username: "episodic"
        password: "s3cr3t"
    smtp:
      enabled: false
      host: "smtp.gmail.com"
      port: 587
      tls: true
      username: "[email protected]"
      password: "xxxx xxxx xxxx xxxx"
      from: "[email protected]"
      to:
        - "[email protected]"
        - "[email protected]"
    sendmail:
      enabled: false
      binary: "/usr/sbin/sendmail"
      from: "[email protected]"
      to:
        - "[email protected]"
    microsoft365:
      enabled: false
      tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
      client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
      client_secret: "your-client-secret-value"
      from: "[email protected]"
      to:
        - "[email protected]"
    teams:
      enabled: false
      webhook_url: "https://default32d178cfed3488384266e0286e48b.2c.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/29/workflows/dc666faf7e22497f9854bce58e0e09d8/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=..."
    slack:
      enabled: false
      webhook_url: "https://hooks.slack.com/services/<workspace-id>/<bot-id>/<token>"

Testing your setup

After configuring notifications, always run the test function interactively before relying on cron:

episodic_notify_test()

This validates the configuration (checking for missing required fields and uninstalled packages), then sends a test message through every enabled channel. It reports success or failure per channel with the exact error message, so you can fix problems before the next real run.

What a notification looks like

New clusters

The notification includes, for each new cluster (up to 10, with a “+N more” summary for the rest):

  • Pathogen name
  • Location: institution, ward, and/or region, depending on the stream level
  • Cases: observed count, expected count, and their ratio
  • Priority score: the composite score that ranks the cluster
  • Period: first and last day of the detection window

The subject line reads: EpiSODIC: 3 new cluster(s) detected on 2025-09-01

Run failure

The notification includes the date, the hostname of the server that ran the detection, and the error message. This lets you triage whether the failure is a data problem, a configuration problem, or a server issue before you check any logs.

Colours and font

Every email is styled from the instance palette, the same colours and font the dashboard uses (episodic_palette(), overridden per instance with EPISODIC_STYLE). The styles are written inline on each element, since Outlook drops a <style> block and Gmail restricts one. A mail client does not load web fonts, so the email shows the first font in the font stack that the reader has installed, which makes the stack’s fallbacks what most readers see.

Your own email layout

Two emails can be given a layout of your own, each with its own environment variable pointing at a Quarto (.qmd) template:

Variable Email
EPISODIC_MAIL_TEMPLATE_NEW_CLUSTERS The alert sent when a run detects new clusters
EPISODIC_MAIL_TEMPLATE_REPORT The email a scheduled outbreak report is attached to

A template changes only the HTML body sent through the email channels (smtp, sendmail, microsoft365). The subject line, the ntfy, Teams and Slack messages, and the run-failure email are unchanged: a message saying a run failed should not itself depend on a render succeeding. Rendering needs the Quarto CLI, the same as outbreak reports do.

Only .qmd is accepted. Quarto would render an .Rmd as well, but it reads format: where an R Markdown author wrote output: and drops those options without a word; renaming the file and rewriting output: as format: is all it takes.

Two starting points ship with the package. Copy one and adapt it:

system.file("mail", "episodic_default_mail_new_clusters.qmd", package = "EpiSODIC")
system.file("mail", "episodic_default_mail_report.qmd", package = "EpiSODIC")

Then check it against example data before pointing the variable at it:

episodic_mail_template_preview("new_clusters", template_path = "~/templates/new_clusters.qmd")

A template reads its data with readRDS(params$data_path). Both kinds receive:

  • kind ("new_clusters" or "report"), title (the subject line), lang and package_version;
  • palette, the instance palette, and mail_style, the inline styles built from it (body, heading, paragraph, table, head_row, th, td, link, muted), each a CSS declaration string to put in a style attribute;
  • default_html, the built-in body as a whole HTML document, and default_content, the inside of its <body>, so a template can place EpiSODIC’s own message inside a letterhead and signature without rebuilding it.

The new-clusters template also receives n_new, run_date, dashboard_url (NULL when none is configured) and clusters, a data frame with one row per new cluster, ordered as every cluster table on screen is: cluster_id, ref (O-12), url (the cluster’s dossier, NA without a dashboard_url), pathogen, level, location, first_day, last_day, period, duration_days, n_cases, case_days, expected, ratio and priority_score. A value that was not measured is NA, never 0.

The report template also receives cluster_id, ref, pathogen, level, location, first_day, last_day, period, n_cases, final (whether this send ends the schedule), interval_days, attachment_name, and diff: what changed since the previous report, in the shape described in Scheduled reports, or NULL when there is nothing to compare against.

An email is not a web page, and a template has to be written for an inbox:

  • Keep minimal: true and embed-resources: true in its format: html options, as the shipped ones do, so Quarto adds no Bootstrap or JavaScript that no inbox would run.
  • Style inline, from mail_style, since a <style> block does not survive Outlook.
  • Use text and tables only. Gmail and Outlook block images embedded in the HTML, so a chart would reach most readers as a broken image.

A template that cannot be used never costs an email. When the file is missing, is not a .qmd, Quarto is not installed, or the render fails, the email goes out with the built-in body and the run log carries a line saying why. The same problems are shown beside each variable on the dashboard’s Info screen and reported by episodic_notify_test(), so they can be fixed before the next alert.

Troubleshooting

“Notifications are not enabled in the configuration” when calling episodic_notify_test(): Check that your instance YAML has notifications: enabled: true and that EPISODIC_CONFIG points at that file.

Channel sends succeed in the test but do not fire on cron runs: Check the triggers section. Both new_clusters and run_failure must be explicitly set to true; they do not default to on.

SMTP connection times out: Ensure the server can reach the SMTP host and port. Firewalls, VPNs, and cloud-provider restrictions on outbound port 25/587 are common causes.

Microsoft 365 “Insufficient privileges”: For app-only auth, the Mail.Send application permission must have admin consent. For delegated auth, the signed-in user must have a mailbox and the Mail.Send delegated permission on the app registration.

Teams webhook returns 202 but no card appears: Power Automate Workflow webhooks can silently drop malformed payloads. Check that the webhook URL is correct and that the workflow is turned on in Power Automate.

Slack webhook returns invalid_payload: Ensure httr2 is installed and up to date. The payload is JSON with a single text field; the webhook URL must be an Incoming Webhook, not a Slack API endpoint.

See also

  • Scheduled reports for emailing a recurring outbreak report to colleagues without a dashboard account, through these same channels.
  • Deployment for how to stand up an instance and configure EPISODIC_CONFIG.
  • Environment variables for the full reference table of EPISODIC_* variables.
  • ?episodic_notifications for the API-level documentation.
  • ?episodic_notify_test for the test function.
  • ?episodic_setup_microsoft365 for the Microsoft 365 delegated auth setup helper.