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_snapshotstored 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:
Subscribe to the topic my-episodic-alerts in the ntfy
app on your phone or desktop, and you will see the test message
arrive.
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: "episodic.alerts@gmail.com"
password: "xxxx xxxx xxxx xxxx" # Gmail App Password
from: "episodic.alerts@gmail.com"
to:
- "team-lead@example.org"
- "oncall@example.org"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: "episodic@example.org"
to:
- "epi-team@example.org"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: "episodic@example.org"
to:
- "epi-team@example.org"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 A: App-only (client credentials) – recommended for unattended cron
This is the recommended approach for a cron job. It requires an Azure AD app registration with the Mail.Send application permission (admin-consented).
- In the Azure portal, go to Azure Active Directory > App registrations > New registration.
- Name it (e.g. “EpiSODIC Notifications”), set Supported account types to “Accounts in this organizational directory only”.
- Under API permissions, add Microsoft Graph > Application permissions > Mail.Send. Click Grant admin consent.
- Under Certificates & secrets, create a new client secret and copy it immediately.
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: "episodic@example.org"
to:
- "team-lead@example.org"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: "team-alerts@example.org" # 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:
- "team-lead@example.org"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
- In Teams, go to the channel where you want notifications.
- Click … (more options) > Workflows.
- Search for “webhook” and select the “Send webhook alerts to a channel” template.
- Choose the team and channel, then click Save.
- 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
- Go to api.slack.com/apps and create a new app (or use an existing one).
- Under Incoming Webhooks, activate them and click Add New Webhook to Workspace.
- 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
|
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.
Dashboard link
If your EpiSODIC dashboard is reachable at a URL, set
dashboard_url and every notification will include a direct
link:
This produces a clickable link in email bodies, a button in the Teams Adaptive Card, and a hyperlink in Slack messages.
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: "episodic.alerts@gmail.com"
password: "xxxx xxxx xxxx xxxx"
from: "episodic.alerts@gmail.com"
to:
- "team-lead@example.org"
- "oncall@example.org"
sendmail:
enabled: false
binary: "/usr/sbin/sendmail"
from: "episodic@example.org"
to:
- "epi-team@example.org"
microsoft365:
enabled: false
tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_secret: "your-client-secret-value"
from: "episodic@example.org"
to:
- "team-lead@example.org"
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:
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 | |
|---|---|
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),langandpackage_version; -
palette, the instance palette, andmail_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 astyleattribute; -
default_html, the built-in body as a whole HTML document, anddefault_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: trueandembed-resources: truein itsformat: htmloptions, 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_notificationsfor the API-level documentation. -
?episodic_notify_testfor the test function. -
?episodic_setup_microsoft365for the Microsoft 365 delegated auth setup helper.