---
title: "Notifications"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Notifications}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE)
```
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**](https://certe-medical-epidemiology.github.io/EpiSODIC/articles/environment-variables.html)). Add a `notifications` key at the top level. The shipped `r EpiSODIC:::doc_system_file("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](https://ntfy.sh/) 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:
```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:
```r
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:
```yaml
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](https://support.google.com/accounts/answer/185833) (requires 2FA on the Google account) and use that instead of the account password.
```yaml
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):
```yaml
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:
```yaml
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).
1. In the [Azure portal](https://portal.azure.com/), go to **Azure
Active Directory > App registrations > New registration**.
2. Name it (e.g. "EpiSODIC Notifications"), set **Supported account
types** to "Accounts in this organizational directory only".
3. Under **API permissions**, add **Microsoft Graph > Application
permissions > Mail.Send**. Click **Grant admin consent**.
4. Under **Certificates & secrets**, create a new client secret and copy
it immediately.
```yaml
microsoft365:
enabled: true
tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_secret: "your-client-secret-value"
from: "episodic@example.org" # must be a mailbox the app is authorised to send from
to:
- "team-lead@example.org"
```
### 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:
```r
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`:
```yaml
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.
```yaml
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
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.api.powerplatform.com:443/powerautomate/automations/direct/cu//workflows//triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=...`).
```yaml
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](https://api.slack.com/messaging/webhooks).
### Setting up the webhook
1. Go to [api.slack.com/apps](https://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.
```yaml
slack:
enabled: true
webhook_url: "https://hooks.slack.com/services///"
```
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` |
```yaml
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.
## Dashboard link
If your EpiSODIC dashboard is reachable at a URL, set `dashboard_url` and every notification will include a direct link:
```yaml
notifications:
enabled: true
dashboard_url: "https://episodic.example.org"
# ...channels...
```
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.
```yaml
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///"
```
## Testing your setup
After configuring notifications, always run the test function interactively before relying on cron:
```r
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 `