--- 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 `