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.
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:
config_snapshot stored on each run.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.
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")).
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.
Sends email through the Microsoft Graph API. Two authentication flows are supported, depending on your Azure AD (Entra ID) setup.
This is the recommended approach for a cron job. It requires an Azure AD app registration with the Mail.Send application permission (admin-consented).
microsoft365:
enabled: true
tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_secret: "your-client-secret-value"
from: "[email protected]" # must be a mailbox the app is authorised to send from
to:
- "[email protected]"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().
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"))).
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.
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 notifications use an Incoming Webhook.
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")).
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.
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.
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>"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.
The notification includes, for each new cluster (up to 10, with a “+N more” summary for the rest):
The subject line reads:
EpiSODIC: 3 new cluster(s) detected on 2025-09-01
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.
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.
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:
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:
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.mail_style, since a
<style> block does not survive Outlook.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.
“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.
EPISODIC_CONFIG.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.