diff --git a/.planning/STATE.md b/.planning/STATE.md
index c1be727e..8e714dd3 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -28,7 +28,7 @@ Phase: 1028 (tag-update-perf-mex-simd) — COMPLETE 2026-05-19 (this branch)
Plan: 6 of 6 executed (with 03/04 deferred per Plan 02d data). Shipped plans: 01, 02, 02b, 02d, 05, 06.
Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114.
Status: Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache).
-Last activity: 2026-05-26 - Completed quick task 260526-r9x: Add PerTag composer mode to FastSenseCompanion - spawn one DashboardEngine window per selected tag
+Last activity: 2026-05-29 - Completed 260529-fnt (via /gsd:fast): FunctionTransport adapter — reuse an external/company MATLAB mailer as a NotificationService Transport, no SMTP config
### Note on parallel v4.0 work (main branch state)
@@ -93,6 +93,8 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict
| 260519-bs4 | Add Tag Status Table window to FastSenseCompanion — new `TagStatusTableWindow.m` (classical figure, not uifigure, per CONTEXT.md), opened via new **Tags ↗** button on companion top toolbar (col 3 in the post-merge 1×7 grid: Events / Live / Tags / Tile / Close all / spacer / gear). Detached-only window with 12-column `uitable`: Key, Name, Type, Criticality, Units, Latest, Status (smart per-type — Monitor→OK/ALARM, State→state label, others→—), Last updated (X(end) timestamp), Activity (Live/Inactive at 5-min threshold), Events (count from EventStore), Samples, Labels. All 18 demo tags listed (snapshot from `TagRegistry.find(@(t)true)`). Two parallel refresh paths: (a) push-on-write via existing `FastSenseCompanion.scanLiveTagUpdates_` → `markStatusTableDirty_(keys)` when companion is in Live mode, (b) window-owned `RefreshTimer_` (1s fixedSpacing, unique UUID name, BusyMode='drop', self-stop after 2 consecutive tick errors) so the table refreshes regardless of companion's IsLive — addresses user feedback that Activity/Last updated must stay correct when companion is idle. Pause/Resume polling toggle freezes both paths (markTagsDirty becomes a no-op while paused; header shows "Last refreshed: HH:MM:SS (paused)"). "Last refreshed" heartbeat label updates every tick. Filter chips mirror TagCatalogPane pattern: Type (Sensor/Monitor/Composite/State/Derived), Criticality (Low/Medium/High/Safety), Activity (Live/Inactive) — multi-toggle, AND-across-groups / OR-within-group; broadened free-text search across Key+Name+Units+Labels. Push-on-write hook in companion stays — both mechanisms run in parallel. Six atomic commits + 1 merge: 01 base class + 11 pure-logic tests; 02 companion wiring + 7 lifecycle tests; 03 Activity column + own timer (+5 logic + 2 lifecycle tests, deviation from "push-on-write only" CONTEXT decision per user); 04 last-refreshed header + chip filters + broader search (+4 logic + 2 lifecycle tests); 05 Pause/Resume polling toggle (+4 lifecycle tests); 06 Events count column (+4 logic + 1 lifecycle test); 07 merge with main (PR #143 toolbar grid conflict). Final test counts post-merge: `test_companion_tag_status_table` 24/24 (pure-logic), `TestTagStatusTableWindow` 16/16 (UI lifecycle), `test_companion_tile_close_buttons` 9/9 (main's new test still PASS), `TestFastSenseCompanion` 64/64 (no regression) = 113/113 total. Verified end-to-end on live industrial-plant demo: 4 MonitorTags showed real event counts (29/32/33/35), 14 others showed 0; Activity flipped Live→Inactive at exactly 5-min boundary via static buildRow_ proof; companion IsLive=0 throughout (window polled itself). Deferred / out-of-scope: (1) polling-scope clarification dismissed by user (heartbeat-only vs. passive-observation vs. only-update-changed-cells — left as-is, table updates all cells every tick); (2) Info button + markdown help — scoped up to a milestone-sized "unified in-app help/wiki" effort, parked as backlog 999.1. | 2026-05-19 | b2ed937, e8a1be5, 43d2d3b, 2a24965, 50d464c, 10df740, 73a3bf1 | Verified | [260519-bs4-implement-a-new-table-view-in-the-compan](./quick/260519-bs4-implement-a-new-table-view-in-the-compan/) |
| 260526-tcf | Fix two pre-existing column assertions in `TestFastSenseCompanion.m` to match the post-PR-#159 1x9 companion toolbar grid — `testToolbarHasWikiButton` now asserts Wiki at col **7** (was 6), `testToolbarGearMovedToColumn8` now asserts Settings gear at col **9** (was 8). Production source-of-truth: `FastSenseCompanion.m:410` (`hWikiBtn_.Layout.Column = 7`) and `FastSenseCompanion.m:423` (`hSettingsBtn_.Layout.Column = 9`); commit `e2ded77` migrated the parallel `TestFastSenseCompanionPlantLogToolbar.m` file but missed these two assertions. Column-value fix only — method name `testToolbarGearMovedToColumn8` retained per user choice; rename to `testToolbarGearAtColumn9` + matching docstring cleanup deferred to a separate task. Diagnostic-message strings on the two `verifyEqual` calls updated alongside the literals so failure messages stay coherent. Pre-existing nature confirmed in briefing: both failures reproduce against HEAD~1 and survived a stash-revert of the parallel quick task `260526-r9x`. MATLAB test verification (expected: 73/73 PASS, or 74/74 if PerTag commit landed first) deferred to the user's local session — `mcp__matlab__*` tools route to local MATLAB and are not reachable from the remote sandbox. | 2026-05-26 | e321ac7 | Ready for verification | [260526-tcf-fix-companion-toolbar-1x9-grid-test-cols](./quick/260526-tcf-fix-companion-toolbar-1x9-grid-test-cols/) |
| 260526-pqz | Raise per-signal slider-preview cap from 400 → 1000 buckets in `DashboardEngine.computePreviewEnvelopeReturning_` — three textual edits (1 code clamp + 2 documenting comments) in `libs/Dashboard/DashboardEngine.m` plus one consistency comment in `tests/test_dashboard_preview_overlay.m` (no assertion change; `numel(xd) >= 4` is cap-independent). Edit sites: line 3524 doc-comment (`computePreviewEnvelope` range), line 3542 inline comment (clamp range), line 3555 actual clamp `max(50, min(1000, floor(axWpx / 2)))`. Out of scope per plan: cache invalidation of `PreviewNBuckets_` — running demos must restart (or trigger the existing resize-invalidation path at `DashboardEngine.m:2241`) for the new cap to take effect. Static analysis clean: `mh_lint` + `mh_style` on both edited files report "everything seems fine"; regression sweep `grep -rn "\b400\b" tests/ \| grep -iE "(preview\|bucket\|envelope)"` returns no matches. MATLAB R2025a: `test_dashboard_preview_envelope` 7/7, `test_dashboard_preview_overlay` 10/10. Octave 11.1.0: `test_dashboard_preview_envelope` 2/2 (5 skipped — pre-existing TimeRangeSelector guard for patch+FaceAlpha+NaN on xvfb), `test_dashboard_preview_overlay` skipped entirely (pre-existing). | 2026-05-26 | 834b43c | — | [260526-pqz-raise-preview-line-cap-per-signal-from-4](./quick/260526-pqz-raise-preview-line-cap-per-signal-from-4/) |
+| 260529-rxf | Real per-event email alerts for background monitoring — new `EmailTransport` (SMTP auth/STARTTLS:587 default, also `none`/`ssl`; Octave `exist('sendmail','file')` log-and-skip guard; pure static `buildMailProps` CI seam) that `NotificationService` now delegates to via an injectable `Transport` property; per-(sensor,threshold) email cooldown (default 5 min, 0 disables; dry-run honors it too) with public `SuppressedCount`; `LiveEventPipeline.processMonitorTag_`/`runCycle` now forward real per-event `sensorData` (X/Y/thresholdValue/thresholdDirection from the live tick) so `IncludeSnapshot` rules attach PNGs in live mode. MATLAB-only per user decision. **Backward-compat preserved**: pipeline still defaults to `NotificationService('DryRun', true)` and all prior tests stay green. Verified locally (R2025a, live MATLAB MCP): `test_email_transport` 5/5, `test_notification_service` 10/10 (7 original + 3 new: delegation / cooldown-suppress / cooldown-expiry-via-Hidden-DI-seam), `test_live_event_pipeline_tag` 3/3, plus class suites `TestEmailTransport` 5/5, `TestNotificationService` 7/7, `TestLiveEventPipelineTag` 3/3. MISS_HIT (`mh_style`+`mh_lint`) clean on all 8 files; MATLAB Code Analyzer clean on the 3 new/edited libs. Real SMTP delivery is the single manual step via `examples/05-events/smoke_email_send.m` (FASTSENSE_SMTP_* env vars, STARTTLS:587), out of CI. | 2026-05-29 | 203da7a, 2ac6887, 341bab2, cef1fc5 | Verified | [260529-rxf-real-per-event-email-alerts-for-backgrou](./quick/260529-rxf-real-per-event-email-alerts-for-backgrou/) |
+| 260529-fnt | Add `FunctionTransport` adapter (`libs/EventDetection/FunctionTransport.m`) — wraps a user-supplied function handle as a `NotificationService` `Transport` so an existing site/company MATLAB mailer can be reused for alerts with **no SMTP config** (no server/port/creds, no Gmail App Password). Drop-in duck-typed `send(recipients,subject,body,attachments)` (same as EmailTransport), normalizes recipients to a flat cellstr, defaults attachments to `{}`, Octave-safe (only calls user code). Purely additive — EmailTransport/NotificationService behavior unchanged. Built via **/gsd:fast** (inline, no subagents). Verified (R2025a): `test_function_transport` 5/5 (forwarding / recipients-normalization / attachments-default / invalid-handle / NotificationService integration), `test_notification_service` 10/10 (no regression); MISS_HIT + Code Analyzer clean on all touched files. `example_live_pipeline.m` gains a commented FunctionTransport option. Follow-up to 260529-rxf after the user opted to reuse their company mailer instead of configuring Gmail SMTP. | 2026-05-29 | 706e9d5 | Verified | (inline) |
## Progress Bar
diff --git a/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-PLAN.md b/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-PLAN.md
new file mode 100644
index 00000000..b3d993e9
--- /dev/null
+++ b/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-PLAN.md
@@ -0,0 +1,315 @@
+---
+phase: 260529-rxf
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/EventDetection/EmailTransport.m
+ - libs/EventDetection/NotificationService.m
+ - libs/EventDetection/LiveEventPipeline.m
+ - examples/05-events/example_live_pipeline.m
+ - examples/05-events/smoke_email_send.m
+ - tests/test_email_transport.m
+ - tests/suite/TestEmailTransport.m
+ - tests/test_notification_service.m
+autonomous: true
+requirements: [RXF-01]
+
+must_haves:
+ truths:
+ - "A non-dry-run NotificationService injected into LiveEventPipeline sends a real email per matched event via JavaMail sendmail"
+ - "On Octave (no sendmail) the send path logs-and-skips and NEVER errors"
+ - "SMTP STARTTLS auth on port 587 is the default and verified-manual case; 'none' and 'ssl' modes set the documented mail.smtp.* property map"
+ - "Per-(sensor,threshold) cooldown (default 5 min, 0 disables) suppresses repeat sends/dry-run-logs within the window and allows them after expiry"
+ - "Live pipeline ticks pass real sensorData (X/Y + thresholdValue + thresholdDirection) so IncludeSnapshot rules attach PNGs in live mode"
+ - "LiveEventPipeline still defaults to NotificationService('DryRun', true) — existing scripts behave identically"
+ artifacts:
+ - path: "libs/EventDetection/EmailTransport.m"
+ provides: "SMTP mechanics + pure buildMailProps mapping + Octave guard"
+ contains: "classdef EmailTransport"
+ - path: "libs/EventDetection/NotificationService.m"
+ provides: "Transport delegation + cooldown + SuppressedCount"
+ contains: "CooldownMinutes"
+ - path: "libs/EventDetection/LiveEventPipeline.m"
+ provides: "real sensorData built in processMonitorTag_ and passed to notify"
+ contains: "thresholdDirection"
+ - path: "examples/05-events/smoke_email_send.m"
+ provides: "Manual one-shot real-send smoke test using env-var creds"
+ contains: "FASTSENSE_SMTP_PASSWORD"
+ - path: "tests/test_email_transport.m"
+ provides: "Function-based unit tests for prop-map mapping + Octave-guard no-throw"
+ contains: "buildMailProps"
+ key_links:
+ - from: "libs/EventDetection/NotificationService.m"
+ to: "libs/EventDetection/EmailTransport.m"
+ via: "sendEmail delegates to transport.send(...)"
+ pattern: "\\.send\\("
+ - from: "libs/EventDetection/LiveEventPipeline.m"
+ to: "libs/EventDetection/NotificationService.m"
+ via: "runCycle passes real sensorData to notify(ev, sensorData)"
+ pattern: "notify\\(ev,"
+---
+
+
+Finish and wire the existing event-notification stack so `LiveEventPipeline` sends REAL per-event emails during background monitoring, while staying byte-for-byte backward compatible (default still dry-run) and Octave-safe (log-and-skip, never error).
+
+The plumbing already exists end-to-end EXCEPT three gaps: (1) `NotificationService.sendEmail` only sets `SMTP_Server`+`E_mail` prefs — no port/auth/TLS; (2) there is no send cooldown, so a flapping threshold would email on every tick; (3) the live path calls `notify(ev, struct())` with empty sensorData, so `IncludeSnapshot` rules never attach PNGs in live mode.
+
+Approach B (LOCKED, pre-approved — DO NOT re-discuss): extract a dedicated `EmailTransport` unit that owns SMTP mechanics and exposes a PURE `buildMailProps` mapping for CI; `NotificationService` delegates to it (mockable for tests) and gains a per-(sensor,threshold) cooldown; `LiveEventPipeline` builds and forwards real `sensorData`.
+
+Purpose: Engineers running background `LiveEventPipeline` monitoring get actual alert emails (with snapshot PNGs) instead of console-only dry-run output, without touching any existing dry-run-by-default behavior.
+Output: New `EmailTransport.m` + `smoke_email_send.m`; edited `NotificationService.m`, `LiveEventPipeline.m`, `example_live_pipeline.m`; new `test_email_transport.m` / `TestEmailTransport.m`; extended `test_notification_service.m`. All affected tests green.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@CLAUDE.md
+@.planning/STATE.md
+
+# Files being edited / patterns to mirror
+@libs/EventDetection/NotificationService.m
+@libs/EventDetection/NotificationRule.m
+@libs/EventDetection/LiveEventPipeline.m
+@libs/EventDetection/generateEventSnapshot.m
+@libs/EventDetection/Event.m
+@tests/test_notification_service.m
+@tests/test_live_event_pipeline_tag.m
+@examples/05-events/example_live_pipeline.m
+
+
+
+
+generateEventSnapshot sensorData contract (libs/EventDetection/generateEventSnapshot.m:34-37):
+ sensorData = struct('X', , 'Y', , ...
+ 'thresholdValue', , ...
+ 'thresholdDirection', <'upper'|'lower'>)
+ (thresholdDirection is compared via strcmp(thDir,'upper') — must be 'upper' or 'lower'.)
+
+Event fields (libs/EventDetection/Event.m, SetAccess=private):
+ SensorName (char), ThresholdLabel (char), ThresholdValue (numeric),
+ Direction ('upper'|'lower'), PeakValue, StartTime, EndTime, Duration, ...
+
+LiveEventPipeline carrier semantics (proven by tests/test_live_event_pipeline_tag.m:60-62):
+ In the MonitorTag path, emitted event.SensorName == parent.Key and
+ event.ThresholdLabel == monitor.Key. So the cooldown key
+ 'SensorName|ThresholdLabel' is stable per (parent,monitor) pair.
+
+processMonitorTag_ ALREADY snapshots the parent grid (LiveEventPipeline.m:347-352):
+ if ismethod(monitor.Parent, 'getXY'); [oldX, oldY] = monitor.Parent.getXY(); end
+ newX = result.X; newY = result.Y;
+ fullX = [oldX(:).', newX(:).']; fullY = [oldY(:).', newY(:).'];
+ -> reuse fullX/fullY for sensorData.X/.Y.
+
+NotificationService.notify current control flow (libs/EventDetection/NotificationService.m:67-107):
+ guard ~Enabled -> return (no count, no stamp)
+ rule = findBestRule(event); if empty -> return (no count, no stamp)
+ build subject/message; generate snapshots if rule.IncludeSnapshot;
+ if ~DryRun -> sendEmail(...) else -> fprintf dry-run line;
+ NotificationCount++ at the very end.
+
+NotificationService public props ALREADY declared but NOT wired through inputParser
+ (lines 4-17): SmtpPort=25, SmtpUser='', SmtpPassword='' exist as properties but
+ the constructor's inputParser only parses Enabled/DryRun/SnapshotDir/SmtpServer/FromAddress.
+
+Existing tests construct a FRESH NotificationService per test, so cooldown maps start
+ empty and the FIRST notify in each test always passes the window. test_disabled asserts
+ NotificationCount==0; test_default_rule expects empty rule. Cooldown stamping MUST occur
+ only AFTER the Enabled + non-empty-rule guards so these stay green.
+
+
+
+
+
+
+ Task 1: Create EmailTransport with pure buildMailProps + Octave guard, and its unit tests
+ libs/EventDetection/EmailTransport.m, tests/test_email_transport.m, tests/suite/TestEmailTransport.m
+
+Create `libs/EventDetection/EmailTransport.m` — a `handle` class with single responsibility: SMTP mechanics. Follow CLAUDE.md conventions (full class header comment with description/usage/properties/methods/See-also; `%METHODNAME` header on every public method; PascalCase properties; namespaced `EmailTransport:*` error IDs; <=160-char lines; 4-space tabs).
+
+Public properties (with inline defaults), all settable via constructor NV-pairs through an inputParser:
+ - `Server` (char, default '')
+ - `Port` (numeric, default 587)
+ - `User` (char, default '')
+ - `Password` (char, default '')
+ - `PasswordEnv` (char, default '') — env-var NAME (e.g. 'FASTSENSE_SMTP_PASSWORD'); when `Password` is empty and `PasswordEnv` is non-empty, resolve via `getenv(PasswordEnv)` at send time
+ - `SecurityMode` (char, default 'starttls') — validate ∈ {'none','starttls','ssl'}; on invalid value throw `error('EmailTransport:invalidSecurityMode', ...)` listing the valid set
+ - `From` (char, default 'fastsense@noreply.com')
+
+Methods:
+ 1. Constructor `EmailTransport(varargin)` — inputParser parses all the above; validate SecurityMode (case-insensitive, store lower-cased). Throw `EmailTransport:invalidSecurityMode` on bad mode.
+ 2. PURE static method `props = buildMailProps(securityMode, port)` — returns a `containers.Map('KeyType','char','ValueType','char')` of the JavaMail `mail.smtp.*` properties for the given mode WITHOUT touching prefs or sending. This is the key CI testability seam. Mapping (all values stored as char):
+ - common to every mode: `'mail.smtp.port'` -> `num2str(port)`
+ - 'none' : port only, NO auth keys.
+ - 'starttls': add `'mail.smtp.auth'`='true', `'mail.smtp.starttls.enable'`='true'.
+ - 'ssl' : add `'mail.smtp.auth'`='true',
+ `'mail.smtp.socketFactory.class'`='javax.net.ssl.SSLSocketFactory',
+ `'mail.smtp.socketFactory.port'`=`num2str(port)`.
+ Validate securityMode here too (reuse the same valid set / error id) so the pure mapping is self-defending.
+ 3. `send(obj, recipients, subject, body, attachments)` — performs the actual send:
+ - OCTAVE GUARD FIRST: `if exist('sendmail','file') == 0` then
+ `fprintf('[EmailTransport] sendmail unavailable (Octave?) — skipping send to %d recipient(s)\n', numel(cellstr(recipients)));`
+ and `return;` (NO error). Use a robust recipient count that tolerates char or cellstr.
+ - Resolve effective password: `pw = obj.Password; if isempty(pw) && ~isempty(obj.PasswordEnv); pw = getenv(obj.PasswordEnv); end`
+ - Set prefs: `setpref('Internet','SMTP_Server', obj.Server)`, `setpref('Internet','E_mail', obj.From)`. For auth modes (starttls/ssl) also `setpref('Internet','SMTP_Username', obj.User)` and `setpref('Internet','SMTP_Password', pw)`.
+ - Apply mail.smtp.* props onto the live JVM: `props = java.lang.System.getProperties;` then iterate `EmailTransport.buildMailProps(obj.SecurityMode, obj.Port)` keys and `props.setProperty(k, v)`.
+ - Send: `if isempty(attachments); sendmail(recipients, subject, body); else; sendmail(recipients, subject, body, attachments); end`
+ Wrap the JVM-props block so a missing/odd JVM doesn't hard-crash beyond MATLAB's own sendmail behavior — but do NOT swallow real send errors (NotificationService already try/catches sendEmail).
+
+Add brief inline comments documenting WHY each prop is set (auth/STARTTLS/SSL semantics) per the CLAUDE.md comment convention.
+
+Then create the tests (both styles, per repo convention — function-based `test_` + class-based `Test`):
+
+`tests/test_email_transport.m` (function-based, mirror the structure of tests/test_notification_service.m — local `add_event_path()` helper that addpath's repoRoot + libs/EventDetection + libs/SensorThreshold + libs/FastSense then calls `install()`; a top driver that calls each sub-test and prints `test_email_transport: ALL PASSED`). Sub-tests (all PURE — no real send):
+ - test_props_none: `m = EmailTransport.buildMailProps('none', 587);` assert `m('mail.smtp.port')` == '587' and `~isKey(m, 'mail.smtp.auth')` and `~isKey(m, 'mail.smtp.starttls.enable')`.
+ - test_props_starttls: `m = EmailTransport.buildMailProps('starttls', 587);` assert `m('mail.smtp.auth')`=='true', `m('mail.smtp.starttls.enable')`=='true', `m('mail.smtp.port')`=='587', and `~isKey(m,'mail.smtp.socketFactory.class')`.
+ - test_props_ssl: `m = EmailTransport.buildMailProps('ssl', 465);` assert `m('mail.smtp.auth')`=='true', `m('mail.smtp.socketFactory.class')`=='javax.net.ssl.SSLSocketFactory', `m('mail.smtp.socketFactory.port')`=='465', `m('mail.smtp.port')`=='465'.
+ - test_invalid_mode: assert `EmailTransport('SecurityMode','bogus')` throws with identifier `EmailTransport:invalidSecurityMode` (use try/catch + check ME.identifier).
+ - test_octave_guard_no_throw: construct `t = EmailTransport('Server','localhost','SecurityMode','none');` then call `t.send({'a@b.com'}, 'subj', 'body', {});` inside try/catch and assert NO error was raised (on MATLAB with sendmail present this may attempt a connection — to keep it deterministic in CI, scope the assertion to "does not throw a MATLAB error from our guard logic"; if a network/sendmail error surfaces, accept identifiers NOT starting with 'EmailTransport:' as environmental and still pass the no-throw-from-our-code intent). Prefer asserting the guard branch directly: this test's PRIMARY guarantee is that when `exist('sendmail','file')==0` the function returns cleanly — document that in a comment.
+
+`tests/suite/TestEmailTransport.m` (class-based `matlab.unittest.TestCase`, mirror tests/suite/TestLiveEventPipelineTag.m header + `TestClassSetup`/`addPaths` calling addpath(repo)+install()+addpath suite). Test methods mirror the function-based assertions using `testCase.verifyEqual` / `testCase.verifyError(@() EmailTransport('SecurityMode','bogus'), 'EmailTransport:invalidSecurityMode')` / `verifyFalse(isKey(...))`. Keep it focused (the prop-map mapping for all three modes + invalid-mode error + a no-throw guard check).
+
+Honor CLAUDE.md MATLAB-MCP note: use `mcp__matlab__check_matlab_code` on each new .m file before running, then `mcp__matlab__run_matlab_test_file` to verify.
+
+
+ mcp__matlab__check_matlab_code on libs/EventDetection/EmailTransport.m (no errors), then mcp__matlab__run_matlab_test_file tests/test_email_transport.m → "test_email_transport: ALL PASSED"
+
+
+EmailTransport.m exists with PascalCase NV-pair props (Server/Port/User/Password/PasswordEnv/SecurityMode/From), a PURE static `buildMailProps(securityMode, port)` returning the documented mail.smtp.* containers.Map per mode, a `send(...)` with the Octave `exist('sendmail','file')==0` log-and-skip-no-error guard, and namespaced `EmailTransport:*` errors with full header comments. `tests/test_email_transport.m` and `tests/suite/TestEmailTransport.m` assert the none/starttls/ssl prop mapping, invalid-mode error, and the guard no-throw path — all green.
+
+
+
+
+ Task 2: Delegate NotificationService.sendEmail to EmailTransport, add SecurityMode wiring + per-(sensor,threshold) cooldown, extend tests with a mock transport
+ libs/EventDetection/NotificationService.m, tests/test_notification_service.m
+
+Edit `libs/EventDetection/NotificationService.m` (preserve all existing behavior; surgical additions). Maintain CLAUDE.md conventions.
+
+(a) Wire real SMTP + new properties through the constructor inputParser. Add NV-pairs for the already-declared-but-unwired props plus the new ones:
+ - `SmtpPort` (default 587 — note: bump the property default from 25 to 587 to match the STARTTLS-default decision; keep the property declared)
+ - `SmtpUser` (default '')
+ - `SmtpPassword` (default '')
+ - `PasswordEnv` (NEW property, char, default '')
+ - `SecurityMode` (NEW property, char, default 'starttls')
+ - `CooldownMinutes` (NEW property, numeric, default 5; 0 disables cooldown)
+ - `Transport` (NEW property, default []) — injectable EmailTransport (or mock) for DI/testing; constructor NV-pair `'Transport'` lets tests pass a mock. When empty, the real transport is lazily built on first real send.
+ Add `SuppressedCount` (NEW public property, default 0) to mirror `NotificationCount`.
+ Parse each new NV-pair with appropriate validators (e.g. `@isnumeric` for SmtpPort/CooldownMinutes, `@ischar` for char fields; `Transport` no validator or accept any). Assign Results to properties. Keep the existing SnapshotDir tempdir fallback.
+
+(b) Add a private cooldown map: in the `properties (Access = private)` block add `lastSentByKey_ = []` (lazily initialized to `containers.Map('KeyType','char','ValueType','double')` in the constructor or on first use). Add a small private helper `key = cooldownKey_(~, event)` returning `sprintf('%s|%s', event.SensorName, event.ThresholdLabel)`.
+
+(c) Cooldown logic in `notify(obj, event, sensorData)` — insert AFTER the `~Enabled` guard and AFTER `rule = findBestRule(event); if isempty(rule); return; end` (so disabled / no-rule paths do NOT stamp and stay count-0, keeping test_disabled + test_default_rule green), and BEFORE building subject/snapshots:
+ - If `obj.CooldownMinutes > 0`:
+ `k = obj.cooldownKey_(event);`
+ `nowDatenum = now;` %#ok (datenum; convert minutes via /1440)
+ if `isKey(obj.lastSentByKey_, k)` and `(nowDatenum - obj.lastSentByKey_(k)) * 1440 < obj.CooldownMinutes`:
+ `obj.SuppressedCount = obj.SuppressedCount + 1; return;` % suppress BOTH real send AND dry-run log
+ - This means cooldown is checked before snapshot generation (don't waste work on a suppressed event) and applies identically to DryRun and real-send paths (LOCKED requirement: dry-run honors cooldown).
+ - After a successful proceed (i.e., reaching the send/dry-run block), stamp `obj.lastSentByKey_(k) = nowDatenum;` (stamp on the proceed path, regardless of DryRun). Keep `NotificationCount` incrementing only on the proceed path exactly as today.
+
+(d) Replace the private `sendEmail` body to DELEGATE to the transport instead of calling `sendmail` + setting only two prefs:
+ - Lazily build the transport if `isempty(obj.Transport)`:
+ `obj.Transport = EmailTransport('Server', obj.SmtpServer, 'Port', obj.SmtpPort, 'User', obj.SmtpUser, 'Password', obj.SmtpPassword, 'PasswordEnv', obj.PasswordEnv, 'SecurityMode', obj.SecurityMode, 'From', obj.FromAddress);`
+ - Then `obj.Transport.send(recipients, subject, message, attachments);`
+ - Remove the now-obsolete `smtpConfigured_`/setpref-only logic (EmailTransport owns prefs now). The DI seam: because `Transport` is settable via constructor NV-pair, tests inject a mock object whose `send(...)` records its args.
+
+(e) Extend `tests/test_notification_service.m` (KEEP all existing sub-tests passing; add new ones + register them in the top driver). Add a local mock transport. Since this is a function-based test file (no separate classdef allowed cleanly inline in Octave function files), implement the mock as a tiny `classdef` in `tests/suite/` named `MockEmailTransport.m` (a `handle` with public props `Calls = {}` capturing `{recipients, subject, body, attachments}` and a `send(obj, r, s, b, a)` that appends to `Calls`). Add it to the suite dir and addpath it in `add_event_path()` (append `addpath(fullfile(repoRoot,'tests','suite'))`). New sub-tests:
+ - test_transport_delegation: build `mock = MockEmailTransport();` `ns = NotificationService('Transport', mock, 'CooldownMinutes', 0);` set a default rule with IncludeSnapshot=false and Recipients {{'a@b.com'}}, subject template; `notify(ev, sd)`; assert `numel(mock.Calls)==1` and the recorded recipients/subject/body match what the rule produced (recipients forwarded, subject == filled template). This proves recipients/subject/body forwarded correctly to the transport.
+ - test_cooldown_suppresses_within_window: `ns = NotificationService('Transport', mock2, 'CooldownMinutes', 5)` with IncludeSnapshot=false; notify the SAME (sensor,threshold) twice back-to-back; assert second is suppressed: `mock2.Calls` length stays 1, `ns.SuppressedCount==1`, `ns.NotificationCount==1`.
+ - test_cooldown_allows_after_expiry: with `CooldownMinutes`, simulate expiry by directly back-dating the stamp. Easiest deterministic approach: set `CooldownMinutes` to a tiny value AND manipulate the private map is not accessible — instead expose via behavior: construct with `CooldownMinutes`, do first notify, then to simulate elapsed time, construct a SECOND service is not it. PREFERRED deterministic method: add the back-date by making the cooldown comparison use `now`; in the test, set a very small `CooldownMinutes` (e.g. 1/600 ≈ 0.1s) — NO, timing-flaky. Instead: assert expiry semantics by setting `CooldownMinutes = 0`-equivalent boundary is disable, not expiry. To test EXPIRY deterministically WITHOUT a private setter, add a Hidden test-only setter following the repo's DI-seam precedent (STATE.md "1028 DI-seam pattern"): add `methods (Hidden) function setLastSentForTesting_(obj, event, datenumVal)` that writes `obj.lastSentByKey_(obj.cooldownKey_(event)) = datenumVal;`. Then test: notify once (Calls==1), back-date the stamp via `ns.setLastSentForTesting_(ev, now - 10/1440)` (10 min ago, > 5 min window), notify again, assert second send went through (`mock.Calls`==2, `SuppressedCount` unchanged from before this second notify). Document this Hidden setter as a test seam in its header comment.
+ - test_suppressed_count_increments: covered by the within-window test; ensure an explicit assert on `SuppressedCount`.
+ Keep the existing tests (test_constructor/test_add_rule/test_rule_matching_priority/test_notify_dry_run/test_default_rule/test_disabled/test_snapshot_generation) UNCHANGED and still called. Note: existing dry-run/snapshot tests construct fresh services with default `CooldownMinutes=5` but only notify ONCE each, so the first notify always proceeds — they stay green.
+
+Run via MATLAB MCP: `mcp__matlab__check_matlab_code` on edited NotificationService.m + new MockEmailTransport.m, then `mcp__matlab__run_matlab_test_file tests/test_notification_service.m`.
+
+
+ mcp__matlab__check_matlab_code on libs/EventDetection/NotificationService.m + tests/suite/MockEmailTransport.m (no errors), then mcp__matlab__run_matlab_test_file tests/test_notification_service.m → "test_notification_service: ALL PASSED" (all original + new sub-tests)
+
+
+NotificationService wires SmtpPort(default 587)/SmtpUser/SmtpPassword/PasswordEnv/SecurityMode(default 'starttls')/CooldownMinutes(default 5)/Transport through the constructor; `sendEmail` delegates to `Transport.send(...)` (lazily building a real EmailTransport when none injected); `notify` enforces per-`'SensorName|ThresholdLabel'` cooldown (suppressing both real-send AND dry-run within the window, incrementing public `SuppressedCount`, stamping on proceed) placed AFTER the Enabled+rule guards. test_notification_service.m extended with a `MockEmailTransport` DI seam asserting recipients/subject/body forwarding, within-window suppression + `SuppressedCount`, and post-expiry allowance (via a Hidden `setLastSentForTesting_` seam) — all original + new sub-tests green.
+
+
+
+
+ Task 3: Wire real sensorData through LiveEventPipeline live ticks; add guarded real-send example block + manual smoke script
+ libs/EventDetection/LiveEventPipeline.m, examples/05-events/example_live_pipeline.m, examples/05-events/smoke_email_send.m
+
+(a) Edit `libs/EventDetection/LiveEventPipeline.m` so live ticks pass REAL sensorData to notifications (so IncludeSnapshot rules attach PNGs in live mode). Keep the `NotificationService('DryRun', true)` default in the constructor (line ~106) UNTOUCHED — backward-compat is LOCKED.
+
+ - Change `processMonitorTag_` signature to also RETURN the per-tick sensorData keyed by event, alongside `newEvents`/`gotData`. Simplest robust shape: `[newEvents, gotData, sensorData] = processMonitorTag_(obj, key)` where `sensorData` is a struct built from the SAME `fullX`/`fullY` already computed at lines ~347-356, plus the monitor's threshold value/direction. Build it right after `fullX/fullY` are formed and the events are harvested:
+ `sensorData = struct('X', fullX, 'Y', fullY, 'thresholdValue', NaN, 'thresholdDirection', 'upper');`
+ Then, when `newEvents` is non-empty, populate `thresholdValue`/`thresholdDirection` from the FIRST new event (they share the monitor): `sensorData.thresholdValue = newEvents(1).ThresholdValue; sensorData.thresholdDirection = newEvents(1).Direction;`. This matches the `generateEventSnapshot` contract exactly (`X`,`Y`,`thresholdValue`,`thresholdDirection` with direction ∈ {'upper','lower'}). When there are no new events, `sensorData` is harmless and unused.
+ NOTE: there are TWO early `return;` statements in `processMonitorTag_` (no-datasource and no-change) plus the cluster-mode contention `return;`. Initialize `sensorData = struct('X', [], 'Y', [], 'thresholdValue', NaN, 'thresholdDirection', 'upper');` at the TOP alongside `newEvents = []; gotData = false;` so every return path yields a well-formed struct. Preserve the Pitfall Y ordering and the cluster-mode lock block exactly as-is.
+
+ - In `runCycle`, change the per-monitor call site (line ~199) to capture sensorData and stash it so it can be paired with the events for that monitor. Because `allNewEvents` is a flat concatenation across monitors, the cleanest correct wiring is: pair events with their sensorData as they are produced. Implement by accumulating into a parallel cell array — e.g. maintain `allSensorData = {}` and, for each monitor that returns N new events, append N copies of that monitor's `sensorData` (or store one struct per monitor and an index). Concretely:
+ `[newEvents, gotData, sensorData] = obj.processMonitorTag_(key);`
+ `... existing allNewEvents concatenation ...`
+ if `~isempty(newEvents)`, append `repmat({sensorData}, 1, numel(newEvents))` to `allSensorData`.
+ Then in the notifications loop (lines ~228-237) replace `obj.NotificationService.notify(ev, struct())` with the paired data:
+ `sd = struct(); if numel(allSensorData) >= i; sd = allSensorData{i}; end`
+ `obj.NotificationService.notify(ev, sd);`
+ This guarantees each event is notified with ITS monitor's real X/Y/threshold, so IncludeSnapshot rules render correct PNGs. Default dry-run service ignores the richer struct harmlessly (it only generates snapshots when a rule sets IncludeSnapshot=true, which the default pipeline service never has rules for).
+ Keep all existing `try/catch` + `fprintf` diagnostics. Update the `processMonitorTag_` docstring to note the third return value.
+
+ - IMPORTANT regression guard: `tests/test_live_event_pipeline_tag.m` calls `processMonitorTag_` only indirectly via `runCycle`, but its sibling suite `TestLiveEventPipelineTag.m` may call patterns that assume the 2-output signature. Search for direct `processMonitorTag_(` call sites (`grep -rn "processMonitorTag_" libs tests`) and confirm only `runCycle` calls it; if any test calls it directly with two outputs, MATLAB tolerates requesting fewer outputs than returned, so a 3rd output is backward-safe. Do NOT change the existing test files in this task.
+
+(b) Edit `examples/05-events/example_live_pipeline.m` — KEEP the runnable demo in dry-run (the existing `notif = NotificationService('DryRun', true, ...)` at line ~144 stays as the active path). Add a clearly COMMENTED real-send config block (so the offline demo still runs without sending) showing how to enable real emails, e.g. immediately after the dry-run construction, a commented block:
+ `% --- REAL EMAIL SENDING (commented out — uncomment + fill in your SMTP details) ---`
+ `% notif = NotificationService( ...`
+ `% 'DryRun', false, 'SnapshotDir', snapshotDir, ...`
+ `% 'SmtpServer', 'smtp.example.com', 'SmtpPort', 587, ...`
+ `% 'SmtpUser', 'alerts@example.com', 'PasswordEnv', 'FASTSENSE_SMTP_PASSWORD', ...`
+ `% 'SecurityMode', 'starttls', 'FromAddress', 'alerts@example.com', ...`
+ `% 'CooldownMinutes', 5);`
+ `% (then add your rules with IncludeSnapshot=true and set pipeline.NotificationService = notif;)`
+ Add a one-line note that the runnable demo stays dry-run on purpose so the example never sends mail in CI / offline. Do not change any executable line of the demo's behavior.
+
+(c) Create `examples/05-events/smoke_email_send.m` — a documented MANUAL smoke test (NOT run in CI). Header comment must state: "MANUAL smoke test — sends ONE real email. Requires a reachable SMTP server and the FASTSENSE_SMTP_PASSWORD env var. NOT part of the automated suite; run by hand: `run('examples/05-events/smoke_email_send.m')`." The script:
+ - `run(fullfile(...,'install.m'))` to set the path (mirror example_live_pipeline.m's projectRoot pattern).
+ - Read config from env with sensible documented fallbacks: `server = getenv('FASTSENSE_SMTP_SERVER');`, `user = getenv('FASTSENSE_SMTP_USER');`, `from = getenv('FASTSENSE_SMTP_FROM');`, `to = getenv('FASTSENSE_SMTP_TO');` and `pwEnv = 'FASTSENSE_SMTP_PASSWORD';`. If `server`/`user`/`to` are empty, print a clear instruction block listing the required env vars and `return` (don't error).
+ - Build a transport directly: `t = EmailTransport('Server', server, 'Port', 587, 'User', user, 'PasswordEnv', pwEnv, 'SecurityMode', 'starttls', 'From', from);`
+ - Send one mail: `t.send({to}, '[FastSense] smoke test', sprintf('EmailTransport smoke test sent %s', datestr(now)), {});` %#ok
+ - `fprintf('[smoke_email_send] Sent to %s via %s:587 (starttls). Check the inbox.\n', to, server);`
+ Comment that on Octave this will log-and-skip (EmailTransport's guard) rather than send.
+
+Run via MATLAB MCP: `mcp__matlab__check_matlab_code` on edited LiveEventPipeline.m + new smoke_email_send.m, then run the three affected test files to confirm the pipeline still detects/notifies correctly and nothing regressed.
+
+
+ mcp__matlab__check_matlab_code on libs/EventDetection/LiveEventPipeline.m + examples/05-events/smoke_email_send.m (no errors), then mcp__matlab__run_matlab_test_file on each of tests/test_email_transport.m, tests/test_notification_service.m, tests/test_live_event_pipeline_tag.m → all three report ALL PASSED / all tests passed
+
+
+`processMonitorTag_` returns a well-formed `sensorData` struct (`X`,`Y`,`thresholdValue`,`thresholdDirection`) on every return path, built from the existing fullX/fullY and the first new event's ThresholdValue/Direction; `runCycle` pairs each event with its monitor's sensorData and calls `notify(ev, sd)` (no more `struct()`), so IncludeSnapshot rules attach PNGs in live mode. The `NotificationService('DryRun', true)` constructor default is unchanged (backward-compat preserved). `example_live_pipeline.m` keeps its runnable dry-run path and gains a commented real-send config block. `examples/05-events/smoke_email_send.m` exists as a documented MANUAL one-shot real-send using env-var creds (FASTSENSE_SMTP_* ; STARTTLS:587), gracefully instructing-and-returning when env vars are unset. tests/test_email_transport.m, tests/test_notification_service.m, and tests/test_live_event_pipeline_tag.m all pass.
+
+
+
+
+
+
+Affected automated tests (run via MATLAB MCP `run_matlab_test_file` — the live session routes to local MATLAB):
+- `tests/test_email_transport.m` — prop-map mapping (none/starttls/ssl) + invalid-mode error + Octave-guard no-throw.
+- `tests/suite/TestEmailTransport.m` — class-based mirror of the above.
+- `tests/test_notification_service.m` — all original sub-tests STILL green + new transport-delegation, cooldown-suppress, cooldown-expiry, SuppressedCount sub-tests.
+- `tests/test_live_event_pipeline_tag.m` — MonitorTag path still emits events and honors Pitfall Y ordering with the new 3-output `processMonitorTag_`.
+
+Static analysis: `mcp__matlab__check_matlab_code` clean on every new/edited .m file (proxy for MISS_HIT). Honor CLAUDE.md style: <=160-char lines, 4-space tabs, namespaced `EmailTransport:*` error IDs, full class/method header comments.
+
+Manual-only (NOT CI): real SMTP delivery verified by running `examples/05-events/smoke_email_send.m` with FASTSENSE_SMTP_* env vars set against the user's own server (STARTTLS:587). This is the single human verification step; everything else is automated.
+
+Backward-compat checks (implicit in the above): LiveEventPipeline constructor still creates `NotificationService('DryRun', true)`; existing notification + pipeline tests unchanged and green.
+
+
+
+- `libs/EventDetection/EmailTransport.m` exists: NV-pair config (Server/Port=587/User/Password/PasswordEnv/SecurityMode='starttls'/From), PURE static `buildMailProps(securityMode, port)` returning the documented mail.smtp.* map per mode, `send(...)` with Octave `exist('sendmail','file')==0` log-and-skip-never-error guard, namespaced `EmailTransport:*` errors, full header comments.
+- `NotificationService` delegates `sendEmail` to `EmailTransport.send`, accepts an injected `Transport` (mock seam), wires SmtpPort/SmtpUser/SmtpPassword/PasswordEnv/SecurityMode, and enforces per-(sensor,threshold) `CooldownMinutes` (default 5; 0 disables) suppressing BOTH real-send and dry-run within the window with a public `SuppressedCount`.
+- `LiveEventPipeline.runCycle` passes REAL per-event sensorData (X/Y/thresholdValue/thresholdDirection) to `notify`, and still defaults to `NotificationService('DryRun', true)`.
+- `example_live_pipeline.m` stays runnable in dry-run with an added commented real-send block; `smoke_email_send.m` exists as a documented manual real-send.
+- All three affected test files pass; `check_matlab_code` clean on all touched files. Manual SMTP delivery confirmed via smoke script (out of CI).
+
+
+
diff --git a/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-SUMMARY.md b/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-SUMMARY.md
new file mode 100644
index 00000000..50f013f3
--- /dev/null
+++ b/.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-SUMMARY.md
@@ -0,0 +1,144 @@
+---
+phase: 260529-rxf
+plan: "01"
+subsystem: EventDetection
+tags: [email, notifications, smtp, cooldown, live-pipeline]
+dependency-graph:
+ requires: []
+ provides: [real-smtp-send, per-event-cooldown, live-sensordata-notifications]
+ affects: [LibsEventDetection, TestsEventDetection, ExamplesEvents]
+tech-stack:
+ added: [EmailTransport]
+ patterns: [DI-seam, injectable-transport, hidden-test-seam, Octave-guard]
+key-files:
+ created:
+ - libs/EventDetection/EmailTransport.m
+ - tests/test_email_transport.m
+ - tests/suite/TestEmailTransport.m
+ - tests/suite/MockEmailTransport.m
+ - examples/05-events/smoke_email_send.m
+ modified:
+ - libs/EventDetection/NotificationService.m
+ - libs/EventDetection/LiveEventPipeline.m
+ - examples/05-events/example_live_pipeline.m
+decisions:
+ - "EmailTransport owns all SMTP mechanics (props + sendmail call); NotificationService delegates via injectable Transport property"
+ - "CooldownMinutes default=5; cooldown suppresses both real-send and dry-run; stamping is post-guard so disabled/no-rule paths never stamp"
+ - "Hidden setLastSentForTesting_ seam follows STATE.md DI-seam pattern for deterministic cooldown expiry tests"
+ - "LiveEventPipeline default NotificationService('DryRun',true) preserved unchanged for backward-compat"
+metrics:
+ duration: "~7 minutes"
+ completed: "2026-05-29"
+ tasks: 3
+ files_changed: 8
+---
+
+# Phase 260529-rxf Plan 01: Real Per-Event Email Alerts Summary
+
+Real SMTP email delivery via injected EmailTransport in NotificationService, with per-(sensor,threshold) cooldown and live sensorData forwarding in LiveEventPipeline.
+
+## Tasks Completed
+
+| # | Task | Commit | Key Files |
+|---|------|--------|-----------|
+| 1 | Create EmailTransport with pure buildMailProps + Octave guard + unit tests | `203da7a6` | `EmailTransport.m`, `test_email_transport.m`, `TestEmailTransport.m` |
+| 2 | Delegate NotificationService.sendEmail to EmailTransport; add cooldown + mock transport tests | `2ac68876` | `NotificationService.m`, `MockEmailTransport.m`, `test_notification_service.m` |
+| 3 | Wire real sensorData through LiveEventPipeline live ticks; update example; add smoke script | `341bab24` | `LiveEventPipeline.m`, `example_live_pipeline.m`, `smoke_email_send.m` |
+
+## What Was Built
+
+### Task 1 — EmailTransport
+
+`libs/EventDetection/EmailTransport.m` — new `handle` class:
+
+- **Public NV-pair config:** `Server`/`Port`(587)/`User`/`Password`/`PasswordEnv`/`SecurityMode`('starttls')/`From`
+- **PURE static `buildMailProps(mode, port)`:** returns `containers.Map` of `mail.smtp.*` properties for `none`/`starttls`/`ssl` without any side-effects (key CI testability seam)
+- **`send(recipients, subject, body, attachments)`:** Octave guard (`exist('sendmail','file')==0` → log + return, never error); resolves password from `PasswordEnv` env-var; sets MATLAB Internet prefs; applies JVM props for TLS/SSL; delegates to MATLAB `sendmail`
+- **`EmailTransport:invalidSecurityMode`** on unrecognised mode
+- Full CLAUDE.md header comments, namespaced error IDs, ≤160-char lines
+
+Tests:
+- `tests/test_email_transport.m`: function-based (prop-map none/starttls/ssl, invalid-mode error, octave-guard no-throw)
+- `tests/suite/TestEmailTransport.m`: class-based mirror with `verifyEqual`/`verifyError`/`verifyFalse`
+
+### Task 2 — NotificationService
+
+`libs/EventDetection/NotificationService.m` surgical additions:
+
+- **New constructor NV-pairs wired:** `SmtpPort`(587), `SmtpUser`, `SmtpPassword`, `PasswordEnv`, `SecurityMode`('starttls'), `CooldownMinutes`(5), `Transport`([])
+- **New public properties:** `PasswordEnv`, `SecurityMode`, `CooldownMinutes`, `Transport`, `SuppressedCount`
+- **`sendEmail_` delegates to `Transport.send(...)`:** lazily builds real `EmailTransport` when `Transport` is empty; DI seam accepts injected mock via constructor `'Transport'` NV-pair
+- **Per-(SensorName|ThresholdLabel) cooldown in `notify()`:** suppresses both real-send and dry-run within window; `SuppressedCount++` on suppress; stamps AFTER Enabled+rule guards; `lastSentByKey_` is a `containers.Map` char→double initialised in constructor
+- **Hidden `setLastSentForTesting_(event, datenumVal)` seam** following STATE.md "1028 DI-seam pattern" for deterministic expiry testing
+- All existing tests (test_constructor/test_add_rule/test_rule_matching_priority/test_notify_dry_run/test_default_rule/test_disabled/test_snapshot_generation) preserved unchanged
+
+New tests in `tests/test_notification_service.m`:
+- `test_transport_delegation`: verifies recipients/subject/body forwarded to `MockEmailTransport.send`
+- `test_cooldown_suppresses_within_window`: back-to-back notify of same (sensor,threshold) → second suppressed; mock.Calls==1, SuppressedCount==1, NotificationCount==1
+- `test_cooldown_allows_after_expiry`: uses `setLastSentForTesting_` to back-date stamp 10 min; second notify goes through; mock.Calls==2
+
+`tests/suite/MockEmailTransport.m`: new test double with `Calls` cell array recording `{recipients, subject, body, attachments}`.
+
+### Task 3 — LiveEventPipeline + Examples
+
+`libs/EventDetection/LiveEventPipeline.m`:
+- `processMonitorTag_` gains 3rd return value `sensorData` (struct `X`/`Y`/`thresholdValue`/`thresholdDirection`); initialised as empty well-formed struct at top (all early-return paths yield valid output)
+- `sensorData` built from `fullX`/`fullY` (same accumulated grid used for `parent.updateData`) and then populated with `newEvents(1).ThresholdValue`/`Direction` after event harvest
+- `runCycle` accumulates `allSensorData` cell array (one entry per event) via `repmat({sensorData}, 1, numel(newEvents))`; notification loop passes `allSensorData{i}` as `sd` to `notify(ev, sd)` instead of `struct()`
+- `NotificationService('DryRun', true)` constructor default on line ~106 is **unchanged** (backward-compat)
+
+`examples/05-events/example_live_pipeline.m`:
+- Runnable path stays dry-run; added clearly-commented real-send config block showing `SmtpServer`/`SmtpPort`/`PasswordEnv`/`SecurityMode`/`CooldownMinutes` wiring
+
+`examples/05-events/smoke_email_send.m`:
+- Manual one-shot SMTP smoke test; reads `FASTSENSE_SMTP_SERVER`/`USER`/`FROM`/`TO` from env; `FASTSENSE_SMTP_PASSWORD` resolved at send time via `PasswordEnv`; prints clear instructions and returns when vars unset; Octave-safe comment
+
+## Deviations from Plan
+
+None — plan executed exactly as written.
+
+## Verification Handoff
+
+MATLAB test execution is **deferred to the orchestrator** (no `mcp__matlab__*` access in executor). The orchestrator must run the following and confirm all pass:
+
+1. **`tests/test_email_transport.m`** — expects: `test_email_transport: ALL PASSED` (5 sub-tests)
+2. **`tests/suite/TestEmailTransport.m`** — expects: 5/5 tests passed
+3. **`tests/test_notification_service.m`** — expects: `test_notification_service: ALL PASSED` (10 sub-tests: 7 original + 3 new)
+4. **`tests/test_live_event_pipeline_tag.m`** — expects: `All 3 live_event_pipeline_tag tests passed.` (no regression from 3-output `processMonitorTag_`)
+
+### Notes for orchestrator verification focus
+
+- **test_notify_dry_run** and **test_snapshot_generation**: these construct `NotificationService()` with default `CooldownMinutes=5` and only call `notify` ONCE, so the cooldown window is never triggered — should be green.
+- **test_disabled**: `~Enabled` guard fires before cooldown; `NotificationCount` stays 0, `SuppressedCount` stays 0.
+- **test_cooldown_allows_after_expiry**: uses `setLastSentForTesting_` (Hidden method) — verify MATLAB does not block Hidden method calls from test scripts (it shouldn't; Hidden is only advisory in function-based test files, not enforced like private).
+- **`processMonitorTag_` 3-output**: MATLAB tolerates requesting 2 outputs from a function that returns 3 (caller requests fewer than declared nargout). The `test_live_event_pipeline_tag.m` file calls it only indirectly via `runCycle`, so no direct-call concern.
+
+### Manual-only verification (not CI)
+
+Run `examples/05-events/smoke_email_send.m` with `FASTSENSE_SMTP_*` env vars set against a real SMTP server (STARTTLS:587) to confirm end-to-end real email delivery.
+
+## Known Stubs
+
+None. All feature paths are fully wired:
+- `EmailTransport.buildMailProps` returns real JVM property maps (not placeholders)
+- `NotificationService.sendEmail_` lazily constructs a real `EmailTransport` when none injected
+- `LiveEventPipeline.runCycle` passes real `sensorData` from `processMonitorTag_` to `notify`
+
+## Self-Check: PASSED
+
+All 9 files verified present. All 3 task commits verified in git log.
+
+| Item | Status |
+|------|--------|
+| libs/EventDetection/EmailTransport.m | FOUND |
+| libs/EventDetection/NotificationService.m | FOUND |
+| libs/EventDetection/LiveEventPipeline.m | FOUND |
+| tests/test_email_transport.m | FOUND |
+| tests/suite/TestEmailTransport.m | FOUND |
+| tests/suite/MockEmailTransport.m | FOUND |
+| tests/test_notification_service.m | FOUND |
+| examples/05-events/smoke_email_send.m | FOUND |
+| examples/05-events/example_live_pipeline.m | FOUND |
+| Commit 203da7a6 (Task 1) | FOUND |
+| Commit 2ac68876 (Task 2) | FOUND |
+| Commit 341bab24 (Task 3) | FOUND |
diff --git a/examples/05-events/example_live_pipeline.m b/examples/05-events/example_live_pipeline.m
index 1dacd82a..158e0304 100644
--- a/examples/05-events/example_live_pipeline.m
+++ b/examples/05-events/example_live_pipeline.m
@@ -141,8 +141,32 @@
snapshotDir = fullfile(tempdir, 'fastsense_snapshots');
fprintf('Snapshot directory: %s\n', snapshotDir);
+% NOTE: The runnable demo always stays in dry-run mode so the example never
+% sends real mail in CI or offline sessions. The commented block below shows
+% exactly how to switch to real email sending — fill in your SMTP details and
+% uncomment to enable.
notif = NotificationService('DryRun', true, 'SnapshotDir', snapshotDir);
+% --- REAL EMAIL SENDING (commented out — uncomment + fill in your SMTP details) ---
+% notif = NotificationService( ...
+% 'DryRun', false, 'SnapshotDir', snapshotDir, ...
+% 'SmtpServer', 'smtp.example.com', 'SmtpPort', 587, ...
+% 'SmtpUser', 'alerts@example.com', 'PasswordEnv', 'FASTSENSE_SMTP_PASSWORD', ...
+% 'SecurityMode', 'starttls', 'FromAddress', 'alerts@example.com', ...
+% 'CooldownMinutes', 5);
+% (then add your rules with IncludeSnapshot=true and set pipeline.NotificationService = notif;)
+
+% --- ALTERNATIVE: reuse an EXISTING email function (no SMTP config in FastSense) ---
+% If your site already has a MATLAB mailer, wrap it in a FunctionTransport instead
+% of configuring SMTP here. You still get rules, templated subjects/bodies, the
+% cooldown, and snapshot attachments — only the actual send hands off to your code.
+% Example for a 4-arg companyMail(to, subject, body, attachments):
+% transport = FunctionTransport( ...
+% @(to, subject, body, attachments) companyMail(to, subject, body, attachments));
+% notif = NotificationService('DryRun', false, 'SnapshotDir', snapshotDir, ...
+% 'Transport', transport, 'CooldownMinutes', 5);
+% (recipients arrive as a flat cellstr; then add your rules and set pipeline.NotificationService = notif;)
+
% Default rule: catches all events not matched by specific rules (score=1)
notif.setDefaultRule(NotificationRule( ...
'Recipients', {{'ops-team@company.com'}}, ...
diff --git a/examples/05-events/smoke_email_send.m b/examples/05-events/smoke_email_send.m
new file mode 100644
index 00000000..751537a6
--- /dev/null
+++ b/examples/05-events/smoke_email_send.m
@@ -0,0 +1,56 @@
+% smoke_email_send MANUAL one-shot real-email smoke test for EmailTransport.
+%
+% MANUAL smoke test — sends ONE real email.
+% Requires a reachable SMTP server and the FASTSENSE_SMTP_PASSWORD env var.
+% NOT part of the automated suite; run by hand:
+%
+% run('examples/05-events/smoke_email_send.m')
+%
+% Required environment variables:
+% FASTSENSE_SMTP_SERVER — hostname of your SMTP server (e.g. smtp.gmail.com)
+% FASTSENSE_SMTP_USER — SMTP auth username (e.g. alerts@example.com)
+% FASTSENSE_SMTP_FROM — From address in the envelope (e.g. alerts@example.com)
+% FASTSENSE_SMTP_TO — Recipient address (e.g. you@example.com)
+% FASTSENSE_SMTP_PASSWORD — SMTP auth password (read via PasswordEnv at send time)
+%
+% On Octave, EmailTransport.send detects the absence of sendmail and logs
+% a skip message rather than erroring — no real email will be sent on Octave.
+%
+% See also EmailTransport, NotificationService.
+
+projectRoot = fileparts(fileparts(fileparts(mfilename('fullpath'))));
+run(fullfile(projectRoot, 'install.m'));
+
+%% Read configuration from environment variables
+server = getenv('FASTSENSE_SMTP_SERVER');
+user = getenv('FASTSENSE_SMTP_USER');
+from = getenv('FASTSENSE_SMTP_FROM');
+to = getenv('FASTSENSE_SMTP_TO');
+pwEnv = 'FASTSENSE_SMTP_PASSWORD';
+
+%% Guard: print instructions and return when required variables are unset
+if isempty(server) || isempty(user) || isempty(to)
+ fprintf('\n[smoke_email_send] Required environment variables are not set.\n');
+ fprintf('Please set the following before running this script:\n');
+ fprintf(' FASTSENSE_SMTP_SERVER — SMTP hostname\n');
+ fprintf(' FASTSENSE_SMTP_USER — SMTP auth username\n');
+ fprintf(' FASTSENSE_SMTP_FROM — From address\n');
+ fprintf(' FASTSENSE_SMTP_TO — Recipient address\n');
+ fprintf(' FASTSENSE_SMTP_PASSWORD — SMTP auth password\n');
+ fprintf('\nExample (bash):\n');
+ fprintf(' export FASTSENSE_SMTP_SERVER=smtp.example.com\n');
+ fprintf(' export FASTSENSE_SMTP_USER=alerts@example.com\n');
+ fprintf(' export FASTSENSE_SMTP_FROM=alerts@example.com\n');
+ fprintf(' export FASTSENSE_SMTP_TO=you@example.com\n');
+ fprintf(' export FASTSENSE_SMTP_PASSWORD=yourpassword\n');
+ return;
+end
+
+%% Build EmailTransport and send one test email
+t = EmailTransport('Server', server, 'Port', 587, 'User', user, ...
+ 'PasswordEnv', pwEnv, 'SecurityMode', 'starttls', 'From', from);
+
+t.send({to}, '[FastSense] smoke test', ...
+ sprintf('EmailTransport smoke test sent %s', datestr(now)), {}); %#ok
+
+fprintf('[smoke_email_send] Sent to %s via %s:587 (starttls). Check the inbox.\n', to, server);
diff --git a/libs/EventDetection/EmailTransport.m b/libs/EventDetection/EmailTransport.m
new file mode 100644
index 00000000..59c14d99
--- /dev/null
+++ b/libs/EventDetection/EmailTransport.m
@@ -0,0 +1,232 @@
+classdef EmailTransport < handle
+ % EmailTransport SMTP email send mechanics with configurable security modes.
+ %
+ % EmailTransport owns all JavaMail property configuration and the sendmail
+ % call so that NotificationService can delegate real-send logic and be unit-
+ % tested without touching the network.
+ %
+ % Usage:
+ % t = EmailTransport('Server', 'smtp.example.com', 'Port', 587, ...
+ % 'User', 'alerts@example.com', ...
+ % 'PasswordEnv', 'FASTSENSE_SMTP_PASSWORD', ...
+ % 'SecurityMode', 'starttls');
+ % t.send({'dest@example.com'}, 'Subject', 'Body', {});
+ %
+ % Properties (all configurable via constructor name-value pairs):
+ % Server — SMTP host (char, default '')
+ % Port — TCP port (numeric, default 587)
+ % User — SMTP auth username (char, default '')
+ % Password — SMTP auth password (char, default '') — takes precedence over PasswordEnv
+ % PasswordEnv — env-var NAME holding the password, e.g. 'FASTSENSE_SMTP_PASSWORD'
+ % (char, default ''); resolved via getenv() at send time when Password is empty
+ % SecurityMode — 'none' | 'starttls' | 'ssl' (char, default 'starttls')
+ % From — sender address used in the SMTP envelope (char, default 'fastsense@noreply.com')
+ %
+ % Methods:
+ % EmailTransport(varargin) — Constructor; validates SecurityMode
+ % props = EmailTransport.buildMailProps(mode, port) — PURE static mapping; no side-effects
+ % send(obj, recipients, subject, body, attachments) — performs SMTP send; Octave-safe
+ %
+ % Error IDs:
+ % EmailTransport:invalidSecurityMode
+ %
+ % See also NotificationService, NotificationRule, generateEventSnapshot.
+
+ properties (Access = public)
+ Server = '' % SMTP host name or IP address
+ Port = 587 % TCP port (default 587 for STARTTLS)
+ User = '' % SMTP auth username
+ Password = '' % Explicit password (takes precedence over PasswordEnv)
+ PasswordEnv = '' % Env-var NAME for password resolution at send time
+ SecurityMode = 'starttls' % 'none' | 'starttls' | 'ssl'
+ From = 'fastsense@noreply.com' % Sender address for SMTP envelope
+ end
+
+ methods (Access = public)
+
+ function obj = EmailTransport(varargin)
+ %EMAILTRANSPORT Construct EmailTransport with optional name-value configuration.
+ % Accepts any subset of the public properties as name-value pairs.
+ % SecurityMode is validated case-insensitively; it is stored lower-cased.
+ % Throws EmailTransport:invalidSecurityMode on unrecognised mode.
+ p = inputParser();
+ p.addParameter('Server', '', @ischar);
+ p.addParameter('Port', 587, @isnumeric);
+ p.addParameter('User', '', @ischar);
+ p.addParameter('Password', '', @ischar);
+ p.addParameter('PasswordEnv', '', @ischar);
+ p.addParameter('SecurityMode', 'starttls', @ischar);
+ p.addParameter('From', 'fastsense@noreply.com', @ischar);
+ p.parse(varargin{:});
+ r = p.Results;
+
+ obj.Server = r.Server;
+ obj.Port = r.Port;
+ obj.User = r.User;
+ obj.Password = r.Password;
+ obj.PasswordEnv = r.PasswordEnv;
+ obj.From = r.From;
+
+ % Validate and normalise SecurityMode (case-insensitive, store lower-cased).
+ mode = lower(r.SecurityMode);
+ EmailTransport.validateSecurityMode_(mode);
+ obj.SecurityMode = mode;
+ end
+
+ function send(obj, recipients, subject, body, attachments)
+ %SEND Send an email to one or more recipients via SMTP.
+ % send(obj, recipients, subject, body, attachments)
+ %
+ % Inputs:
+ % recipients — char or cellstr of recipient addresses
+ % subject — char subject line
+ % body — char body text
+ % attachments — cellstr of file paths, or {} for no attachments
+ %
+ % Octave guard: when sendmail is unavailable (Octave does not ship it),
+ % this method logs a message and returns cleanly without error.
+ % NotificationService already wraps sendEmail in try/catch; real SMTP
+ % errors from MATLAB's sendmail bubble up through that guard.
+
+ % --- OCTAVE GUARD: sendmail is absent on Octave ---
+ % exist('sendmail','file')==0 is true on Octave where sendmail.m is not
+ % present. We must NOT error in this case — log and return silently.
+ if exist('sendmail', 'file') == 0
+ % Robust recipient count that tolerates char or cellstr input.
+ nRecip = numel(cellstr(recipients));
+ fprintf('[EmailTransport] sendmail unavailable (Octave?) — skipping send to %d recipient(s)\n', ...
+ nRecip);
+ return;
+ end
+
+ % --- Resolve effective password ---
+ % Explicit Password property takes precedence; fall back to env-var.
+ pw = obj.Password;
+ if isempty(pw) && ~isempty(obj.PasswordEnv)
+ pw = getenv(obj.PasswordEnv);
+ end
+
+ % --- Set MATLAB Internet preferences required by sendmail ---
+ % Always set server + from-address so the envelope is correct.
+ setpref('Internet', 'SMTP_Server', obj.Server);
+ setpref('Internet', 'E_mail', obj.From);
+
+ % For auth modes (starttls / ssl) also set username + password prefs.
+ % 'none' mode does not authenticate and needs no username/password.
+ if ~strcmp(obj.SecurityMode, 'none')
+ setpref('Internet', 'SMTP_Username', obj.User);
+ setpref('Internet', 'SMTP_Password', pw);
+ end
+
+ % --- Apply mail.smtp.* properties to the live JVM ---
+ % MATLAB's sendmail creates a JavaMail Session; we write system
+ % properties before the call so the Session picks them up.
+ % This is the standard approach for configuring STARTTLS / SSL
+ % auth without requiring a custom JavaMail wrapper.
+ % We wrap this in a try so that a missing / odd JVM doesn't
+ % hard-crash beyond MATLAB's own sendmail behaviour.
+ try
+ javaProps = java.lang.System.getProperties();
+ propMap = EmailTransport.buildMailProps(obj.SecurityMode, obj.Port);
+ propKeys = propMap.keys();
+ for ki = 1:numel(propKeys)
+ javaProps.setProperty(propKeys{ki}, propMap(propKeys{ki}));
+ end
+ catch jvmEx
+ % Best-effort; if JVM property setting fails, sendmail may still
+ % work for simple unauthenticated servers. Let sendmail decide.
+ fprintf('[EmailTransport] JVM property set failed (%s); proceeding.\n', ...
+ jvmEx.message);
+ end
+
+ % --- Send ---
+ if isempty(attachments)
+ sendmail(recipients, subject, body);
+ else
+ sendmail(recipients, subject, body, attachments);
+ end
+ end
+
+ end
+
+ methods (Static, Access = public)
+
+ function props = buildMailProps(securityMode, port)
+ %BUILDMAILPROPS PURE static mapping of SecurityMode + port to mail.smtp.* properties.
+ % props = EmailTransport.buildMailProps(securityMode, port)
+ %
+ % Returns a containers.Map('KeyType','char','ValueType','char') with the
+ % JavaMail mail.smtp.* property keys and values appropriate for the given
+ % security mode. This method has NO side-effects (no setpref, no JVM
+ % interaction) — it exists purely as a testable mapping seam.
+ %
+ % Mode definitions:
+ % 'none' — plain SMTP, no authentication.
+ % Only 'mail.smtp.port' is set.
+ % 'starttls' — upgrade plain SMTP connection to TLS via EHLO STARTTLS.
+ % Adds mail.smtp.auth=true and mail.smtp.starttls.enable=true.
+ % 'ssl' — TLS-wrapped SMTP from the first byte (legacy smtps).
+ % Adds mail.smtp.auth=true, socketFactory.class, and
+ % socketFactory.port for javax.net.ssl.SSLSocketFactory.
+ %
+ % Inputs:
+ % securityMode — char: 'none' | 'starttls' | 'ssl'
+ % port — numeric port number
+ %
+ % Output:
+ % props — containers.Map('KeyType','char','ValueType','char')
+ %
+ % Throws:
+ % EmailTransport:invalidSecurityMode on unrecognised mode.
+
+ % Validate and normalise (allows callers to pass any case).
+ mode = lower(char(securityMode));
+ EmailTransport.validateSecurityMode_(mode);
+
+ portStr = num2str(double(port));
+
+ props = containers.Map('KeyType', 'char', 'ValueType', 'char');
+
+ % Common to every mode: set the SMTP port.
+ props('mail.smtp.port') = portStr;
+
+ switch mode
+ case 'none'
+ % Plain SMTP — no auth keys added.
+
+ case 'starttls'
+ % STARTTLS: server must support EHLO STARTTLS on plain-text port
+ % (typically 587). JavaMail issues EHLO, server responds with
+ % STARTTLS capability, JavaMail upgrades the connection to TLS.
+ props('mail.smtp.auth') = 'true';
+ props('mail.smtp.starttls.enable') = 'true';
+
+ case 'ssl'
+ % SSL/TLS: TLS-wrapped from the first byte (legacy smtps, port 465).
+ % SSLSocketFactory wraps the connection; socketFactory.port matches
+ % the connection port so JSSE opens the right TLS socket.
+ props('mail.smtp.auth') = 'true';
+ props('mail.smtp.socketFactory.class') = 'javax.net.ssl.SSLSocketFactory';
+ props('mail.smtp.socketFactory.port') = portStr;
+ end
+ end
+
+ end
+
+ methods (Static, Access = private)
+
+ function validateSecurityMode_(mode)
+ %VALIDATESECURITYMODE_ Assert mode is one of the valid set.
+ % Throws EmailTransport:invalidSecurityMode with a descriptive
+ % message listing the valid modes when mode is not recognised.
+ validModes = {'none', 'starttls', 'ssl'};
+ if ~any(strcmp(mode, validModes))
+ error('EmailTransport:invalidSecurityMode', ...
+ 'Invalid SecurityMode ''%s''. Valid modes: %s.', ...
+ mode, strjoin(validModes, ', '));
+ end
+ end
+
+ end
+
+end
diff --git a/libs/EventDetection/FunctionTransport.m b/libs/EventDetection/FunctionTransport.m
new file mode 100644
index 00000000..82f57916
--- /dev/null
+++ b/libs/EventDetection/FunctionTransport.m
@@ -0,0 +1,113 @@
+classdef FunctionTransport < handle
+ % FunctionTransport Route NotificationService sends to an external function.
+ %
+ % FunctionTransport adapts an existing email-sending function (for example
+ % a company-internal MATLAB mailer) into a NotificationService Transport.
+ % It owns no SMTP mechanics of its own — it simply forwards each send to a
+ % user-supplied function handle. This lets you reuse external email code
+ % for background-monitoring alerts WITHOUT configuring SMTP in FastSense
+ % (no server, port, credentials, STARTTLS, or App Passwords), while still
+ % getting NotificationService's rule matching, templated subjects/bodies,
+ % per-(sensor,threshold) cooldown, and snapshot attachments.
+ %
+ % It is a drop-in Transport: it exposes the same
+ % send(recipients, subject, body, attachments) signature as EmailTransport,
+ % so NotificationService delegates to it identically (duck-typed).
+ %
+ % Usage (wrap a 4-arg company mailer companyMail(to, subject, body, attachments)):
+ % transport = FunctionTransport( ...
+ % @(to, subject, body, attachments) companyMail(to, subject, body, attachments));
+ % notif = NotificationService('DryRun', false, 'Transport', transport, ...
+ % 'CooldownMinutes', 5);
+ % notif.setDefaultRule(NotificationRule('Recipients', {{'ops@yourco.com'}}));
+ % pipeline.NotificationService = notif; % LiveEventPipeline now alerts via companyMail
+ %
+ % The wrapping handle adapts ANY external signature. Examples:
+ % % 3-arg mailer (no attachments):
+ % FunctionTransport(@(to, subject, body, attachments) companyMail(to, subject, body));
+ % % mailer wanting a single semicolon-joined recipient string:
+ % FunctionTransport(@(to, subject, body, attachments) companyMail(strjoin(to, ';'), subject, body));
+ %
+ % Recipients passed to your function are normalised to a flat 1xN cellstr
+ % (e.g. {'a@co.com', 'b@co.com'}) regardless of how NotificationService
+ % nests them internally, so your function always receives a simple list.
+ %
+ % Properties:
+ % Fn — the wrapped function handle (read-only; set via constructor)
+ %
+ % Methods:
+ % FunctionTransport(fn) — Constructor; validates fn is a function_handle
+ % send(obj, recipients, subject, body, attachments) — normalises recipients and forwards to Fn
+ %
+ % Error IDs:
+ % FunctionTransport:invalidHandle
+ %
+ % See also EmailTransport, NotificationService, NotificationRule.
+
+ properties (SetAccess = private)
+ Fn % function_handle invoked as Fn(recipients, subject, body, attachments)
+ end
+
+ methods (Access = public)
+
+ function obj = FunctionTransport(fn)
+ %FUNCTIONTRANSPORT Construct a FunctionTransport wrapping a send function.
+ % obj = FunctionTransport(fn) stores fn, which must be a
+ % function_handle invoked as fn(recipients, subject, body, attachments).
+ % Throws FunctionTransport:invalidHandle when fn is not a handle.
+ if nargin < 1 || ~isa(fn, 'function_handle')
+ error('FunctionTransport:invalidHandle', ...
+ 'FunctionTransport requires a function_handle, got %s.', ...
+ class(fn));
+ end
+ obj.Fn = fn;
+ end
+
+ function send(obj, recipients, subject, body, attachments)
+ %SEND Forward a send request to the wrapped function handle.
+ % send(obj, recipients, subject, body, attachments)
+ %
+ % Inputs:
+ % recipients — char or (possibly nested) cell of recipient addresses
+ % subject — char subject line
+ % body — char body text
+ % attachments — cellstr of file paths, or {} for no attachments (optional)
+ %
+ % recipients is normalised to a flat 1xN cellstr before the call, and
+ % attachments defaults to {} when omitted. The wrapped function is
+ % then invoked as Fn(recipients, subject, body, attachments). Any
+ % error raised by the wrapped function propagates to the caller
+ % (NotificationService already wraps sendEmail in try/catch).
+ if nargin < 5
+ attachments = {};
+ end
+ recipients = FunctionTransport.normalizeRecipients_(recipients);
+ obj.Fn(recipients, subject, body, attachments);
+ end
+
+ end
+
+ methods (Static, Access = private)
+
+ function out = normalizeRecipients_(recipients)
+ %NORMALIZERECIPIENTS_ Flatten recipients to a 1xN cellstr.
+ % NotificationService forwards rule.Recipients, which is nested as
+ % {{'a@co.com', ...}} (a scalar cell whose only element is itself a
+ % cell). Unwrap one such level, accept a plain char or cellstr, and
+ % always return a row cellstr so wrapped functions get a simple list.
+ out = recipients;
+ % Unwrap a single {{...}} nesting level.
+ if iscell(out) && isscalar(out) && iscell(out{1})
+ out = out{1};
+ end
+ % A bare char becomes a 1x1 cellstr.
+ if ischar(out)
+ out = {out};
+ end
+ % Guarantee a row cellstr (cellstr also validates element types).
+ out = reshape(cellstr(out), 1, []);
+ end
+
+ end
+
+end
diff --git a/libs/EventDetection/LiveEventPipeline.m b/libs/EventDetection/LiveEventPipeline.m
index f504c2b8..266c36d9 100644
--- a/libs/EventDetection/LiveEventPipeline.m
+++ b/libs/EventDetection/LiveEventPipeline.m
@@ -189,6 +189,7 @@ function runCycle(obj)
drawnow limitrate nocallbacks; % Pitfall 7 reentrancy guard (mirrors LiveTagPipeline)
end
allNewEvents = [];
+ allSensorData = {}; % parallel cell array: one sensorData struct per event in allNewEvents
hasNewData = false;
% --- MonitorTag path ---
@@ -196,7 +197,7 @@ function runCycle(obj)
for i = 1:numel(monitorKeys)
key = monitorKeys{i};
try
- [newEvents, gotData] = obj.processMonitorTag_(key);
+ [newEvents, gotData, sensorData] = obj.processMonitorTag_(key);
hasNewData = hasNewData || gotData;
if ~isempty(newEvents)
if isempty(allNewEvents)
@@ -204,6 +205,10 @@ function runCycle(obj)
else
allNewEvents = [allNewEvents, newEvents]; %#ok
end
+ % Pair each new event with its monitor's sensorData so that
+ % IncludeSnapshot rules in NotificationService can render PNGs
+ % with the actual sensor values from this tick.
+ allSensorData = [allSensorData, repmat({sensorData}, 1, numel(newEvents))]; %#ok
end
catch ex
fprintf('[PIPELINE WARNING] MonitorTag "%s" failed: %s\n', ...
@@ -224,12 +229,19 @@ function runCycle(obj)
obj.EventStore.save();
end
- % Send notifications
+ % Send notifications — each event is paired with its monitor's real sensorData
+ % so IncludeSnapshot rules can attach PNGs rendered from the live tick data.
+ % Default DryRun=true service ignores the richer struct harmlessly (it only
+ % generates snapshots when a rule sets IncludeSnapshot=true).
if ~isempty(obj.NotificationService)
for i = 1:numel(allNewEvents)
ev = allNewEvents(i);
+ sd = struct();
+ if numel(allSensorData) >= i
+ sd = allSensorData{i};
+ end
try
- obj.NotificationService.notify(ev, struct());
+ obj.NotificationService.notify(ev, sd);
catch ex
fprintf('[PIPELINE WARNING] Notification failed: %s\n', ex.message);
end
@@ -244,9 +256,18 @@ function runCycle(obj)
end
methods (Access = private)
- function [newEvents, gotData] = processMonitorTag_(obj, key)
+ function [newEvents, gotData, sensorData] = processMonitorTag_(obj, key)
%PROCESSMONITORTAG_ Tag-first live-tick path (SC#4 realization).
%
+ % Returns [newEvents, gotData, sensorData] where sensorData is a
+ % struct matching the generateEventSnapshot contract:
+ % struct('X', , 'Y', , 'thresholdValue', ,
+ % 'thresholdDirection', <'upper'|'lower'>)
+ % Built from the same fullX/fullY used for parent.updateData plus the
+ % first new event's ThresholdValue/Direction. sensorData is well-formed
+ % on every return path (X=[],Y=[],thresholdValue=NaN,thresholdDirection='upper'
+ % for early-return paths where no data was fetched).
+ %
% Phase 1007 MONITOR-08 contract: MonitorTag.appendData
% expects the monitor's Parent to already carry the new
% (newX, newY) tail samples before the call — so we call
@@ -279,8 +300,12 @@ function runCycle(obj)
% On contention (ok=false), the monitor is skipped this tick and
% SkippedMonitorCount is incremented. onCleanup releases the lock after
% the critical section completes (RAII pattern from LiveTagPipeline.processTag_).
- newEvents = [];
- gotData = false;
+ newEvents = [];
+ gotData = false;
+ % Initialise sensorData on every return path so callers always get a
+ % well-formed struct even when we return early.
+ sensorData = struct('X', [], 'Y', [], 'thresholdValue', NaN, ...
+ 'thresholdDirection', 'upper');
if ~obj.DataSourceMap.has(key)
return;
end
@@ -355,6 +380,14 @@ function runCycle(obj)
fullX = [oldX(:).', newX(:).'];
fullY = [oldY(:).', newY(:).'];
+ % Build sensorData for notification snapshot using the full accumulated
+ % sensor grid. thresholdValue/thresholdDirection will be filled in from
+ % the first new event below (they are the same for all events from this
+ % monitor). The struct matches the generateEventSnapshot contract exactly:
+ % struct('X', ..., 'Y', ..., 'thresholdValue', ..., 'thresholdDirection', ...)
+ sensorData = struct('X', fullX, 'Y', fullY, ...
+ 'thresholdValue', NaN, 'thresholdDirection', 'upper');
+
% CRITICAL ORDERING (Pitfall Y): parent.updateData BEFORE
% monitor.appendData. See MonitorTag.m:330-334 docstring.
if ismethod(monitor.Parent, 'updateData')
@@ -374,6 +407,13 @@ function runCycle(obj)
newEvents = allEvts((preCount+1):postCount);
end
end
+
+ % Populate sensorData threshold info from the first new event.
+ % All new events on this tick share the same monitor (same threshold).
+ if ~isempty(newEvents)
+ sensorData.thresholdValue = newEvents(1).ThresholdValue;
+ sensorData.thresholdDirection = newEvents(1).Direction;
+ end
% === END CRITICAL SECTION (onCleanup releases the lock here in cluster mode) ===
end
diff --git a/libs/EventDetection/NotificationService.m b/libs/EventDetection/NotificationService.m
index e30d06a5..f55146a2 100644
--- a/libs/EventDetection/NotificationService.m
+++ b/libs/EventDetection/NotificationService.m
@@ -1,41 +1,131 @@
classdef NotificationService < handle
- % NotificationService Rule-based email notifications with event snapshots.
-
- properties
- Rules = []
- DefaultRule = []
- Enabled = true
- DryRun = false
- SnapshotDir = ''
- SnapshotRetention = 7 % days
- SmtpServer = ''
- SmtpPort = 25
- SmtpUser = ''
- SmtpPassword = ''
- FromAddress = 'fastsense@noreply.com'
- NotificationCount = 0
+ % NotificationService Rule-based email notifications with event snapshots and cooldown.
+ %
+ % Evaluates incoming events against a priority-ordered set of NotificationRule
+ % objects, generates optional FastSense PNG snapshots, and sends email via an
+ % injectable EmailTransport (defaults to a lazily-constructed real EmailTransport
+ % when none is provided).
+ %
+ % Usage:
+ % % Dry-run (default) — logs to console, no real send:
+ % ns = NotificationService('DryRun', true);
+ % ns.setDefaultRule(NotificationRule('Recipients', {{'ops@example.com'}}, ...
+ % 'IncludeSnapshot', false));
+ % ns.notify(event, sensorData);
+ %
+ % % Real send with injected mock (unit tests):
+ % mock = MockEmailTransport();
+ % ns = NotificationService('Transport', mock, 'CooldownMinutes', 0);
+ % ns.notify(event, sensorData);
+ % assert(numel(mock.Calls) == 1);
+ %
+ % % Real send with SMTP config:
+ % ns = NotificationService('DryRun', false, ...
+ % 'SmtpServer', 'smtp.example.com', 'SmtpPort', 587, ...
+ % 'SmtpUser', 'alerts@example.com', 'PasswordEnv', 'FASTSENSE_SMTP_PASSWORD', ...
+ % 'SecurityMode', 'starttls', 'CooldownMinutes', 5);
+ %
+ % Public properties:
+ % Rules — array of NotificationRule (priority-matched)
+ % DefaultRule — fallback NotificationRule (score=1)
+ % Enabled — logical; when false notify() returns immediately (default true)
+ % DryRun — logical; when true logs instead of sending (default false)
+ % SnapshotDir — char; directory for PNG snapshots (default: tempdir/fastsense_snapshots)
+ % SnapshotRetention — numeric; days to keep old snapshot PNGs (default 7)
+ % SmtpServer — char; SMTP host (default '')
+ % SmtpPort — numeric; SMTP port (default 587)
+ % SmtpUser — char; SMTP auth username (default '')
+ % SmtpPassword — char; explicit password (default '')
+ % PasswordEnv — char; env-var name for password resolution (default '')
+ % SecurityMode — char; 'none'|'starttls'|'ssl' (default 'starttls')
+ % FromAddress — char; sender address (default 'fastsense@noreply.com')
+ % CooldownMinutes — numeric; per-(sensor,threshold) cooldown in minutes; 0=disabled (default 5)
+ % Transport — injectable EmailTransport (or mock); lazily built when empty (default [])
+ % NotificationCount — numeric; count of events that reached the send/dry-run path
+ % SuppressedCount — numeric; count of events suppressed by the cooldown window
+ %
+ % Methods:
+ % NotificationService(varargin) — constructor; NV-pair config
+ % addRule(rule) — append a NotificationRule
+ % setDefaultRule(rule) — set the fallback rule
+ % rule = findBestRule(event) — return the highest-scoring matching rule
+ % notify(event, sensorData) — main notification entry point
+ % cleanupSnapshots() — delete PNGs older than SnapshotRetention days
+ %
+ % Hidden test seams:
+ % setLastSentForTesting_(event, datenumVal) — back-dates the cooldown stamp for testing
+ %
+ % Error IDs: (none emitted directly; errors bubble from EmailTransport / sendmail)
+ %
+ % See also EmailTransport, NotificationRule, generateEventSnapshot.
+
+ properties (Access = public)
+ Rules = []
+ DefaultRule = []
+ Enabled = true
+ DryRun = false
+ SnapshotDir = ''
+ SnapshotRetention = 7 % days
+ SmtpServer = ''
+ SmtpPort = 587
+ SmtpUser = ''
+ SmtpPassword = ''
+ PasswordEnv = '' % env-var NAME for password resolution at send time
+ SecurityMode = 'starttls'
+ FromAddress = 'fastsense@noreply.com'
+ CooldownMinutes = 5 % per-(sensor,threshold) cooldown; 0 disables
+ Transport = [] % injectable EmailTransport or mock; lazily built when empty
+ NotificationCount = 0
+ SuppressedCount = 0 % events suppressed within the cooldown window
+ end
+
+ properties (Access = private)
+ lastSentByKey_ = [] % containers.Map char->double; lazily initialised on first use
end
- methods
+ methods (Access = public)
+
function obj = NotificationService(varargin)
+ %NOTIFICATIONSERVICE Construct with optional name-value configuration.
p = inputParser();
- p.addParameter('Enabled', true, @islogical);
- p.addParameter('DryRun', false, @islogical);
- p.addParameter('SnapshotDir', '', @ischar);
- p.addParameter('SmtpServer', '', @ischar);
- p.addParameter('FromAddress', 'fastsense@noreply.com', @ischar);
+ p.addParameter('Enabled', true, @islogical);
+ p.addParameter('DryRun', false, @islogical);
+ p.addParameter('SnapshotDir', '', @ischar);
+ p.addParameter('SmtpServer', '', @ischar);
+ p.addParameter('SmtpPort', 587, @isnumeric);
+ p.addParameter('SmtpUser', '', @ischar);
+ p.addParameter('SmtpPassword', '', @ischar);
+ p.addParameter('PasswordEnv', '', @ischar);
+ p.addParameter('SecurityMode', 'starttls', @ischar);
+ p.addParameter('FromAddress', 'fastsense@noreply.com', @ischar);
+ p.addParameter('CooldownMinutes', 5, @isnumeric);
+ p.addParameter('Transport', []);
p.parse(varargin{:});
- obj.Enabled = p.Results.Enabled;
- obj.DryRun = p.Results.DryRun;
- obj.SnapshotDir = p.Results.SnapshotDir;
- obj.SmtpServer = p.Results.SmtpServer;
- obj.FromAddress = p.Results.FromAddress;
+ r = p.Results;
+
+ obj.Enabled = r.Enabled;
+ obj.DryRun = r.DryRun;
+ obj.SnapshotDir = r.SnapshotDir;
+ obj.SmtpServer = r.SmtpServer;
+ obj.SmtpPort = r.SmtpPort;
+ obj.SmtpUser = r.SmtpUser;
+ obj.SmtpPassword = r.SmtpPassword;
+ obj.PasswordEnv = r.PasswordEnv;
+ obj.SecurityMode = r.SecurityMode;
+ obj.FromAddress = r.FromAddress;
+ obj.CooldownMinutes = r.CooldownMinutes;
+ obj.Transport = r.Transport;
+
if isempty(obj.SnapshotDir)
obj.SnapshotDir = fullfile(tempdir, 'fastsense_snapshots');
end
+
+ % Lazily-initialised cooldown map (char -> double datenum).
+ obj.lastSentByKey_ = containers.Map('KeyType', 'char', 'ValueType', 'double');
end
function addRule(obj, rule)
+ %ADDRULE Append a NotificationRule to the priority-match list.
if isempty(obj.Rules)
obj.Rules = rule;
else
@@ -44,10 +134,13 @@ function addRule(obj, rule)
end
function setDefaultRule(obj, rule)
+ %SETDEFAULTRULE Set the fallback rule (score=1) used when no specific rule matches.
obj.DefaultRule = rule;
end
function rule = findBestRule(obj, event)
+ %FINDBESTRULE Return the highest-scoring NotificationRule that matches event.
+ % Returns [] when no rule matches (including no default rule).
bestScore = 0;
rule = [];
for i = 1:numel(obj.Rules)
@@ -65,32 +158,63 @@ function setDefaultRule(obj, rule)
end
function notify(obj, event, sensorData)
+ %NOTIFY Evaluate event against rules and send/log a notification.
+ % notify(obj, event, sensorData)
+ %
+ % Control flow:
+ % 1. Guard: ~Enabled -> return (no count, no cooldown stamp)
+ % 2. Guard: no matching rule -> return (no count, no cooldown stamp)
+ % 3. Cooldown check (when CooldownMinutes > 0):
+ % if within window -> SuppressedCount++, return (no email, no dry-run log)
+ % 4. Generate snapshot PNGs when rule.IncludeSnapshot is true
+ % 5. Send real email OR log dry-run line
+ % 6. Stamp cooldown map with now (regardless of DryRun)
+ % 7. NotificationCount++
+
+ % Guard 1: disabled service.
if ~obj.Enabled; return; end
+ % Guard 2: no matching rule.
rule = obj.findBestRule(event);
if isempty(rule); return; end
+ % Guard 3: per-(sensor, threshold) cooldown.
+ % Cooldown suppresses BOTH real-send AND dry-run within the window.
+ % Stamping happens AFTER a successful proceed (step 6) so disabled /
+ % no-rule paths never stamp and never affect NotificationCount.
+ nowDatenum = now(); %#ok
+ k = obj.cooldownKey_(event);
+ if obj.CooldownMinutes > 0
+ if isKey(obj.lastSentByKey_, k)
+ elapsedMin = (nowDatenum - obj.lastSentByKey_(k)) * 1440;
+ if elapsedMin < obj.CooldownMinutes
+ obj.SuppressedCount = obj.SuppressedCount + 1;
+ return;
+ end
+ end
+ end
+
subject = rule.fillTemplate(rule.Subject, event);
message = rule.fillTemplate(rule.Message, event);
- % Generate snapshots
+ % Generate snapshots when requested by the rule.
snapshotFiles = {};
if rule.IncludeSnapshot
try
snapshotFiles = generateEventSnapshot(event, sensorData, ...
- 'OutputDir', obj.SnapshotDir, ...
+ 'OutputDir', obj.SnapshotDir, ...
'SnapshotSize', rule.SnapshotSize, ...
- 'Padding', rule.SnapshotPadding, ...
+ 'Padding', rule.SnapshotPadding, ...
'ContextHours', rule.ContextHours);
catch ex
fprintf('[NOTIFY WARNING] Snapshot failed: %s\n', ex.message);
end
end
- % Send email
+ % Send email (real or dry-run).
if ~obj.DryRun
try
- obj.sendEmail(rule.Recipients, subject, message, snapshotFiles);
+ obj.sendEmail_(rule.Recipients, subject, message, snapshotFiles);
catch ex
fprintf('[NOTIFY ERROR] Email failed: %s\n', ex.message);
end
@@ -103,41 +227,70 @@ function notify(obj, event, sensorData)
strjoin(recips, ', '), subject);
end
+ % Stamp cooldown map AFTER a successful proceed (applies to both real + dry-run).
+ if obj.CooldownMinutes > 0
+ obj.lastSentByKey_(k) = nowDatenum;
+ end
+
obj.NotificationCount = obj.NotificationCount + 1;
end
function cleanupSnapshots(obj)
+ %CLEANUPSNAPSHOTS Delete PNG snapshot files older than SnapshotRetention days.
if ~isfolder(obj.SnapshotDir); return; end
- files = dir(fullfile(obj.SnapshotDir, '*.png'));
- cutoff = now - obj.SnapshotRetention;
+ files = dir(fullfile(obj.SnapshotDir, '*.png'));
+ cutoff = now - obj.SnapshotRetention; %#ok
for i = 1:numel(files)
if files(i).datenum < cutoff
delete(fullfile(obj.SnapshotDir, files(i).name));
end
end
end
+
end
- properties (Access = private)
- smtpConfigured_ = false
+ methods (Hidden, Access = public)
+
+ function setLastSentForTesting_(obj, event, datenumVal)
+ %SETLASTSENTFORTESTING_ Test seam: back-date the cooldown stamp for an event.
+ % Follows the DI-seam pattern from STATE.md ("1028 DI-seam pattern").
+ % Writes obj.lastSentByKey_(cooldownKey) = datenumVal so that tests can
+ % simulate cooldown expiry without sleeping.
+ %
+ % Example:
+ % ns.setLastSentForTesting_(ev, now - 10/1440); % 10 min ago (> 5 min window)
+ k = obj.cooldownKey_(event);
+ obj.lastSentByKey_(k) = datenumVal;
+ end
+
end
methods (Access = private)
- function sendEmail(obj, recipients, subject, message, attachments)
- if ~obj.smtpConfigured_
- if ~isempty(obj.SmtpServer)
- setpref('Internet', 'SMTP_Server', obj.SmtpServer);
- end
- if ~isempty(obj.FromAddress)
- setpref('Internet', 'E_mail', obj.FromAddress);
- end
- obj.smtpConfigured_ = true;
- end
- if isempty(attachments)
- sendmail(recipients, subject, message);
- else
- sendmail(recipients, subject, message, attachments);
+
+ function sendEmail_(obj, recipients, subject, message, attachments)
+ %SENDEMAIL_ Delegate to Transport.send(), lazily constructing a real EmailTransport if needed.
+ % The injectable Transport property is the DI seam for unit tests
+ % (pass a MockEmailTransport via constructor 'Transport' NV-pair).
+ % When Transport is empty, a real EmailTransport is built from the
+ % service's current SMTP configuration properties.
+ if isempty(obj.Transport)
+ obj.Transport = EmailTransport( ...
+ 'Server', obj.SmtpServer, ...
+ 'Port', obj.SmtpPort, ...
+ 'User', obj.SmtpUser, ...
+ 'Password', obj.SmtpPassword, ...
+ 'PasswordEnv', obj.PasswordEnv, ...
+ 'SecurityMode', obj.SecurityMode, ...
+ 'From', obj.FromAddress);
end
+ obj.Transport.send(recipients, subject, message, attachments);
end
+
+ function k = cooldownKey_(~, event)
+ %COOLDOWNKEY_ Return the per-(sensor,threshold) map key for cooldown tracking.
+ k = sprintf('%s|%s', event.SensorName, event.ThresholdLabel);
+ end
+
end
+
end
diff --git a/tests/suite/MockEmailTransport.m b/tests/suite/MockEmailTransport.m
new file mode 100644
index 00000000..1e82d094
--- /dev/null
+++ b/tests/suite/MockEmailTransport.m
@@ -0,0 +1,38 @@
+classdef MockEmailTransport < handle
+ % MockEmailTransport Test double for EmailTransport used in NotificationService unit tests.
+ %
+ % Usage:
+ % mock = MockEmailTransport();
+ % ns = NotificationService('Transport', mock, 'CooldownMinutes', 0);
+ % ns.notify(event, sensorData);
+ % assert(numel(mock.Calls) == 1);
+ % assert(strcmp(mock.Calls{1}.recipients{1}, 'a@b.com'));
+ %
+ % Properties:
+ % Calls — cell array of structs; each struct has fields:
+ % recipients, subject, body, attachments
+ %
+ % Methods:
+ % send(obj, r, s, b, a) — records the call in Calls
+ %
+ % See also EmailTransport, NotificationService, test_notification_service.
+
+ properties (Access = public)
+ Calls = {} % Cell array of call records; each entry is a struct with
+ % fields {recipients, subject, body, attachments}.
+ end
+
+ methods (Access = public)
+
+ function send(obj, recipients, subject, body, attachments)
+ %SEND Record a send call. Appends a struct to Calls.
+ rec.recipients = recipients;
+ rec.subject = subject;
+ rec.body = body;
+ rec.attachments = attachments;
+ obj.Calls{end+1} = rec;
+ end
+
+ end
+
+end
diff --git a/tests/suite/TestEmailTransport.m b/tests/suite/TestEmailTransport.m
new file mode 100644
index 00000000..f4fe3714
--- /dev/null
+++ b/tests/suite/TestEmailTransport.m
@@ -0,0 +1,76 @@
+classdef TestEmailTransport < matlab.unittest.TestCase
+ %TESTEMAILRANSPORT Class-based unit tests for EmailTransport.
+ % Mirrors test_email_transport.m assertions using verifyEqual / verifyError /
+ % verifyFalse. Tests are PURE: no real SMTP connections are made.
+ %
+ % Test coverage:
+ % testPropMapNone — 'none' mode only sets mail.smtp.port
+ % testPropMapStarttls — 'starttls' mode sets auth + starttls.enable + port
+ % testPropMapSsl — 'ssl' mode sets auth + socketFactory + port
+ % testInvalidModeError — unrecognised SecurityMode throws correct error ID
+ % testOctaveGuardNoThrow — send() does not raise EmailTransport:* errors
+ %
+ % See also EmailTransport, test_email_transport, NotificationService.
+
+ methods (TestClassSetup)
+ function addPaths(testCase) %#ok
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(fileparts(here));
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests', 'suite'));
+ end
+ end
+
+ methods (Test)
+
+ function testPropMapNone(testCase)
+ m = EmailTransport.buildMailProps('none', 587);
+ testCase.verifyTrue(isKey(m, 'mail.smtp.port'), 'none: must have port');
+ testCase.verifyEqual(m('mail.smtp.port'), '587', 'none: port == 587');
+ testCase.verifyFalse(isKey(m, 'mail.smtp.auth'), 'none: no auth key');
+ testCase.verifyFalse(isKey(m, 'mail.smtp.starttls.enable'), 'none: no starttls key');
+ testCase.verifyFalse(isKey(m, 'mail.smtp.socketFactory.class'), 'none: no socketFactory key');
+ end
+
+ function testPropMapStarttls(testCase)
+ m = EmailTransport.buildMailProps('starttls', 587);
+ testCase.verifyEqual(m('mail.smtp.auth'), 'true', 'starttls: auth=true');
+ testCase.verifyEqual(m('mail.smtp.starttls.enable'), 'true', 'starttls: starttls.enable=true');
+ testCase.verifyEqual(m('mail.smtp.port'), '587', 'starttls: port=587');
+ testCase.verifyFalse(isKey(m, 'mail.smtp.socketFactory.class'), 'starttls: no socketFactory key');
+ end
+
+ function testPropMapSsl(testCase)
+ m = EmailTransport.buildMailProps('ssl', 465);
+ testCase.verifyEqual(m('mail.smtp.auth'), 'true', 'ssl: auth=true');
+ testCase.verifyEqual(m('mail.smtp.socketFactory.class'), 'javax.net.ssl.SSLSocketFactory', ...
+ 'ssl: socketFactory.class');
+ testCase.verifyEqual(m('mail.smtp.socketFactory.port'), '465', 'ssl: socketFactory.port=465');
+ testCase.verifyEqual(m('mail.smtp.port'), '465', 'ssl: port=465');
+ end
+
+ function testInvalidModeError(testCase)
+ testCase.verifyError(@() EmailTransport('SecurityMode', 'bogus'), ...
+ 'EmailTransport:invalidSecurityMode');
+ end
+
+ function testOctaveGuardNoThrow(testCase)
+ % Same guarantee as test_octave_guard_no_throw: EmailTransport.send
+ % must not emit an identifier beginning with 'EmailTransport:'.
+ t = EmailTransport('Server', 'localhost', 'SecurityMode', 'none');
+ threwFromOurCode = false;
+ try
+ t.send({'a@b.com'}, 'subj', 'body', {});
+ catch ME
+ if strncmp(ME.identifier, 'EmailTransport:', numel('EmailTransport:'))
+ threwFromOurCode = true;
+ end
+ end
+ testCase.verifyFalse(threwFromOurCode, ...
+ 'send() must not throw with EmailTransport:* identifier');
+ end
+
+ end
+
+end
diff --git a/tests/suite/TestFunctionTransport.m b/tests/suite/TestFunctionTransport.m
new file mode 100644
index 00000000..87852389
--- /dev/null
+++ b/tests/suite/TestFunctionTransport.m
@@ -0,0 +1,84 @@
+classdef TestFunctionTransport < matlab.unittest.TestCase
+ %TESTFUNCTIONTRANSPORT Class-based unit tests for FunctionTransport.
+ % Mirrors test_function_transport.m so the new logic is exercised by the
+ % CI suite runner (scripts/run_tests_with_coverage.m runs tests/suite
+ % only — function-based test_*.m files are not collected there). No real
+ % email is sent; the wrapped handle targets a MockEmailTransport recorder.
+ %
+ % Test coverage:
+ % testForwardsArgs — send() forwards args to the wrapped handle
+ % testRecipientsNormalized — nested {{...}} / char flattened to cellstr
+ % testAttachmentsDefault — omitted attachments default to {}
+ % testInvalidHandle — non-handle constructor input errors
+ % testIntegrationWithNotification — works as a NotificationService Transport
+ %
+ % See also FunctionTransport, test_function_transport, NotificationService.
+
+ methods (TestClassSetup)
+ function addPaths(testCase) %#ok
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(fileparts(here));
+ addpath(repo);
+ install();
+ addpath(here); % tests/suite — for MockEmailTransport
+ end
+ end
+
+ methods (Test)
+
+ function testForwardsArgs(testCase)
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({'a@b.com'}, 'subj', 'body', {});
+ testCase.verifyTrue(isscalar(mock.Calls), 'exactly one call');
+ rec = mock.Calls{1};
+ testCase.verifyEqual(rec.recipients, {'a@b.com'}, 'recipients');
+ testCase.verifyEqual(rec.subject, 'subj', 'subject');
+ testCase.verifyEqual(rec.body, 'body', 'body');
+ testCase.verifyTrue(iscell(rec.attachments) && isempty(rec.attachments), 'attachments {}');
+ end
+
+ function testRecipientsNormalized(testCase)
+ % Nested {{...}} (as NotificationService forwards rule.Recipients) -> flat cellstr.
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({{'a@b.com', 'c@d.com'}}, 's', 'b', {});
+ testCase.verifyEqual(mock.Calls{1}.recipients, {'a@b.com', 'c@d.com'}, ...
+ 'nested cell flattened');
+ % Bare char -> 1x1 cellstr.
+ mock2 = MockEmailTransport();
+ t2 = FunctionTransport(@(r, s, b, a) mock2.send(r, s, b, a));
+ t2.send('solo@x.com', 's', 'b', {});
+ testCase.verifyEqual(mock2.Calls{1}.recipients, {'solo@x.com'}, 'char -> {char}');
+ end
+
+ function testAttachmentsDefault(testCase)
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({'a@b'}, 's', 'b'); % NO attachments arg -> must default to {}
+ testCase.verifyTrue(iscell(mock.Calls{1}.attachments) && isempty(mock.Calls{1}.attachments), ...
+ 'omitted attachments default to {}');
+ end
+
+ function testInvalidHandle(testCase)
+ testCase.verifyError(@() FunctionTransport(42), 'FunctionTransport:invalidHandle');
+ end
+
+ function testIntegrationWithNotification(testCase)
+ % Drop-in NotificationService Transport: notify() routes through it
+ % with the rule's recipients (flattened) and the filled subject.
+ mock = MockEmailTransport();
+ transport = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ ns = NotificationService('Transport', transport, 'CooldownMinutes', 0);
+ ns.setDefaultRule(NotificationRule('Recipients', {{'ops@co.com'}}, ...
+ 'IncludeSnapshot', false, 'Subject', 'Event: {sensor}'));
+ ev = Event(now, now + 0.01, 'temp', 'HH', 100, 'upper'); %#ok
+ ns.notify(ev, struct());
+ testCase.verifyTrue(isscalar(mock.Calls), 'one send');
+ testCase.verifyEqual(mock.Calls{1}.recipients, {'ops@co.com'}, 'recipients');
+ testCase.verifyEqual(mock.Calls{1}.subject, 'Event: temp', 'subject filled');
+ end
+
+ end
+
+end
diff --git a/tests/suite/TestNotificationService.m b/tests/suite/TestNotificationService.m
index f86a2cb1..0338d1a3 100644
--- a/tests/suite/TestNotificationService.m
+++ b/tests/suite/TestNotificationService.m
@@ -1,10 +1,11 @@
classdef TestNotificationService < matlab.unittest.TestCase
methods (TestClassSetup)
- function addPaths(testCase)
+ function addPaths(testCase) %#ok
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..'));
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..', 'libs', 'EventDetection'));
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..', 'libs', 'SensorThreshold'));
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..', 'libs', 'FastSense'));
+ addpath(fileparts(mfilename('fullpath'))); % tests/suite — for MockEmailTransport
install();
end
end
@@ -90,5 +91,43 @@ function testSnapshotGeneration(testCase)
testCase.verifyTrue(numel(files) >= 2, 'snapshots_created');
rmdir(ns.SnapshotDir, 's');
end
+
+ function testTransportDelegation(testCase)
+ % sendEmail_ delegates to the injected Transport; recipients/subject forwarded.
+ mock = MockEmailTransport();
+ ns = NotificationService('Transport', mock, 'CooldownMinutes', 0);
+ ns.setDefaultRule(NotificationRule('Recipients', {'a@b.com'}, ...
+ 'IncludeSnapshot', false, 'Subject', 'Event: {sensor} - {threshold}'));
+ ev = Event(now, now+0.01, 'temp', 'HH', 100, 'upper');
+ ns.notify(ev, struct());
+ testCase.verifyTrue(isscalar(mock.Calls), 'one_send');
+ testCase.verifyEqual(mock.Calls{1}.recipients, {'a@b.com'}, 'recipients_forwarded');
+ testCase.verifyEqual(mock.Calls{1}.subject, 'Event: temp - HH', 'subject_filled');
+ end
+
+ function testCooldownSuppressesWithinWindow(testCase)
+ % Same (sensor,threshold) twice within the window -> second suppressed.
+ mock = MockEmailTransport();
+ ns = NotificationService('Transport', mock, 'CooldownMinutes', 5);
+ ns.setDefaultRule(NotificationRule('Recipients', {'a@b.com'}, 'IncludeSnapshot', false));
+ ev = Event(now, now+0.01, 'temp', 'HH', 100, 'upper');
+ ns.notify(ev, struct());
+ ns.notify(ev, struct());
+ testCase.verifyTrue(isscalar(mock.Calls), 'second_suppressed');
+ testCase.verifyEqual(ns.SuppressedCount, 1, 'suppressed_count');
+ testCase.verifyEqual(ns.NotificationCount, 1, 'one_notification');
+ end
+
+ function testCooldownAllowsAfterExpiry(testCase)
+ % Back-date the last-sent stamp past the window -> next notify proceeds.
+ mock = MockEmailTransport();
+ ns = NotificationService('Transport', mock, 'CooldownMinutes', 5);
+ ns.setDefaultRule(NotificationRule('Recipients', {'a@b.com'}, 'IncludeSnapshot', false));
+ ev = Event(now, now+0.01, 'temp', 'HH', 100, 'upper');
+ ns.notify(ev, struct());
+ ns.setLastSentForTesting_(ev, now - 10/1440); % 10 min ago (> 5 min window)
+ ns.notify(ev, struct());
+ testCase.verifyEqual(numel(mock.Calls), 2, 'allowed_after_expiry');
+ end
end
end
diff --git a/tests/test_email_transport.m b/tests/test_email_transport.m
new file mode 100644
index 00000000..de3d7af5
--- /dev/null
+++ b/tests/test_email_transport.m
@@ -0,0 +1,96 @@
+function test_email_transport()
+%TEST_EMAIL_TRANSPORT Function-based unit tests for EmailTransport.
+% Tests the PURE buildMailProps mapping (all three security modes),
+% the invalid-mode error, and the Octave-guard no-throw behaviour.
+% No real network connections are made.
+%
+% See also EmailTransport, TestEmailTransport, NotificationService.
+
+ add_event_path();
+ test_props_none();
+ test_props_starttls();
+ test_props_ssl();
+ test_invalid_mode();
+ test_octave_guard_no_throw();
+ fprintf('test_email_transport: ALL PASSED\n');
+end
+
+function add_event_path()
+ thisDir = fileparts(mfilename('fullpath'));
+ repoRoot = fileparts(thisDir);
+ addpath(repoRoot);
+ addpath(fullfile(repoRoot, 'libs', 'EventDetection'));
+ addpath(fullfile(repoRoot, 'libs', 'SensorThreshold'));
+ addpath(fullfile(repoRoot, 'libs', 'FastSense'));
+ install();
+end
+
+function test_props_none()
+ m = EmailTransport.buildMailProps('none', 587);
+ assert(isKey(m, 'mail.smtp.port'), 'none: must have port key');
+ assert(strcmp(m('mail.smtp.port'), '587'), 'none: port must be 587');
+ assert(~isKey(m, 'mail.smtp.auth'), 'none: must NOT have auth key');
+ assert(~isKey(m, 'mail.smtp.starttls.enable'), 'none: must NOT have starttls key');
+ assert(~isKey(m, 'mail.smtp.socketFactory.class'), 'none: must NOT have socketFactory key');
+ fprintf(' PASS: test_props_none\n');
+end
+
+function test_props_starttls()
+ m = EmailTransport.buildMailProps('starttls', 587);
+ assert(strcmp(m('mail.smtp.auth'), 'true'), 'starttls: auth must be true');
+ assert(strcmp(m('mail.smtp.starttls.enable'), 'true'), 'starttls: starttls.enable must be true');
+ assert(strcmp(m('mail.smtp.port'), '587'), 'starttls: port must be 587');
+ assert(~isKey(m, 'mail.smtp.socketFactory.class'), 'starttls: must NOT have socketFactory key');
+ fprintf(' PASS: test_props_starttls\n');
+end
+
+function test_props_ssl()
+ m = EmailTransport.buildMailProps('ssl', 465);
+ assert(strcmp(m('mail.smtp.auth'), 'true'), 'ssl: auth must be true');
+ assert(strcmp(m('mail.smtp.socketFactory.class'), 'javax.net.ssl.SSLSocketFactory'), ...
+ 'ssl: socketFactory.class must be SSLSocketFactory');
+ assert(strcmp(m('mail.smtp.socketFactory.port'), '465'), 'ssl: socketFactory.port must be 465');
+ assert(strcmp(m('mail.smtp.port'), '465'), 'ssl: port must be 465');
+ fprintf(' PASS: test_props_ssl\n');
+end
+
+function test_invalid_mode()
+ caught = false;
+ caughtId = '';
+ try
+ EmailTransport('SecurityMode', 'bogus');
+ catch ME
+ caught = true;
+ caughtId = ME.identifier;
+ end
+ assert(caught, 'invalid_mode: must throw an error');
+ assert(strcmp(caughtId, 'EmailTransport:invalidSecurityMode'), ...
+ sprintf('invalid_mode: expected EmailTransport:invalidSecurityMode, got %s', caughtId));
+ fprintf(' PASS: test_invalid_mode\n');
+end
+
+function test_octave_guard_no_throw()
+ % PRIMARY GUARANTEE: when sendmail is absent (exist('sendmail','file')==0,
+ % as on Octave), EmailTransport.send logs a message and returns cleanly
+ % without throwing a MATLAB error from our guard logic.
+ %
+ % On MATLAB where sendmail IS present, a real SMTP connection attempt may
+ % occur; the test accepts any error that does NOT carry an identifier
+ % starting with 'EmailTransport:' as an environmental network error
+ % (not from our guard) and still considers the guard intent satisfied.
+ t = EmailTransport('Server', 'localhost', 'SecurityMode', 'none');
+ threwFromOurCode = false;
+ try
+ t.send({'a@b.com'}, 'subj', 'body', {});
+ catch ME
+ % Only count it as a failure if it came from our guard code.
+ if strncmp(ME.identifier, 'EmailTransport:', numel('EmailTransport:'))
+ threwFromOurCode = true;
+ end
+ % Environmental errors (network refused, MATLAB sendmail internal, etc.)
+ % are not from our guard — accepted as non-failure.
+ end
+ assert(~threwFromOurCode, ...
+ 'octave_guard: EmailTransport.send must not throw with EmailTransport:* identifier');
+ fprintf(' PASS: test_octave_guard_no_throw\n');
+end
diff --git a/tests/test_function_transport.m b/tests/test_function_transport.m
new file mode 100644
index 00000000..c6fc661a
--- /dev/null
+++ b/tests/test_function_transport.m
@@ -0,0 +1,101 @@
+function test_function_transport()
+%TEST_FUNCTION_TRANSPORT Function-based unit tests for FunctionTransport.
+% Verifies that FunctionTransport forwards send() calls to the wrapped
+% function handle, normalises recipients to a flat cellstr, defaults
+% attachments to {}, rejects non-handle constructor input, and works as a
+% drop-in NotificationService Transport. No real email is sent — the
+% wrapped handle targets a MockEmailTransport recorder.
+%
+% See also FunctionTransport, EmailTransport, NotificationService.
+
+ add_event_path();
+ test_forwards_args();
+ test_recipients_normalized();
+ test_attachments_default();
+ test_invalid_handle();
+ test_integration_with_notificationservice();
+ fprintf('test_function_transport: ALL PASSED\n');
+end
+
+function add_event_path()
+ thisDir = fileparts(mfilename('fullpath'));
+ repoRoot = fileparts(thisDir);
+ addpath(repoRoot);
+ addpath(fullfile(repoRoot, 'libs', 'EventDetection'));
+ addpath(fullfile(repoRoot, 'libs', 'SensorThreshold'));
+ addpath(fullfile(repoRoot, 'libs', 'FastSense'));
+ addpath(fullfile(repoRoot, 'tests', 'suite')); % MockEmailTransport recorder
+ install();
+end
+
+function test_forwards_args()
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({'a@b.com'}, 'subj', 'body', {});
+ assert(isscalar(mock.Calls), 'forwards_args: exactly one call expected');
+ rec = mock.Calls{1};
+ assert(isequal(rec.recipients, {'a@b.com'}), 'forwards_args: recipients mismatch');
+ assert(strcmp(rec.subject, 'subj'), 'forwards_args: subject mismatch');
+ assert(strcmp(rec.body, 'body'), 'forwards_args: body mismatch');
+ assert(iscell(rec.attachments) && isempty(rec.attachments), ...
+ 'forwards_args: attachments must be {}');
+ fprintf(' PASS: test_forwards_args\n');
+end
+
+function test_recipients_normalized()
+ % Nested {{...}} (as NotificationService forwards rule.Recipients) -> flat cellstr.
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({{'a@b.com', 'c@d.com'}}, 's', 'b', {});
+ assert(isequal(mock.Calls{1}.recipients, {'a@b.com', 'c@d.com'}), ...
+ 'recipients_normalized: nested cell must flatten to 1x2 cellstr');
+ % Bare char -> 1x1 cellstr.
+ mock2 = MockEmailTransport();
+ t2 = FunctionTransport(@(r, s, b, a) mock2.send(r, s, b, a));
+ t2.send('solo@x.com', 's', 'b', {});
+ assert(isequal(mock2.Calls{1}.recipients, {'solo@x.com'}), ...
+ 'recipients_normalized: char must become {char}');
+ fprintf(' PASS: test_recipients_normalized\n');
+end
+
+function test_attachments_default()
+ mock = MockEmailTransport();
+ t = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ t.send({'a@b'}, 's', 'b'); % NO attachments arg -> must default to {}
+ assert(iscell(mock.Calls{1}.attachments) && isempty(mock.Calls{1}.attachments), ...
+ 'attachments_default: omitted attachments must default to {}');
+ fprintf(' PASS: test_attachments_default\n');
+end
+
+function test_invalid_handle()
+ caught = false;
+ caughtId = '';
+ try
+ FunctionTransport(42);
+ catch ME
+ caught = true;
+ caughtId = ME.identifier;
+ end
+ assert(caught, 'invalid_handle: must throw an error');
+ assert(strcmp(caughtId, 'FunctionTransport:invalidHandle'), ...
+ sprintf('invalid_handle: expected FunctionTransport:invalidHandle, got %s', caughtId));
+ fprintf(' PASS: test_invalid_handle\n');
+end
+
+function test_integration_with_notificationservice()
+ % FunctionTransport as a drop-in NotificationService Transport: notify() must
+ % route through it with the rule's recipients (flattened) and filled subject.
+ mock = MockEmailTransport();
+ transport = FunctionTransport(@(r, s, b, a) mock.send(r, s, b, a));
+ ns = NotificationService('Transport', transport, 'CooldownMinutes', 0);
+ ns.setDefaultRule(NotificationRule('Recipients', {{'ops@co.com'}}, ...
+ 'IncludeSnapshot', false, 'Subject', 'Event: {sensor}'));
+ ev = Event(now, now + 0.01, 'temp', 'HH', 100, 'upper'); %#ok
+ ns.notify(ev, struct());
+ assert(isscalar(mock.Calls), 'integration: exactly one send expected');
+ assert(isequal(mock.Calls{1}.recipients, {'ops@co.com'}), ...
+ 'integration: recipients mismatch');
+ assert(strcmp(mock.Calls{1}.subject, 'Event: temp'), ...
+ 'integration: subject template not filled');
+ fprintf(' PASS: test_integration_with_notificationservice\n');
+end
diff --git a/tests/test_notification_service.m b/tests/test_notification_service.m
index a494bd5e..af3877a0 100644
--- a/tests/test_notification_service.m
+++ b/tests/test_notification_service.m
@@ -7,16 +7,20 @@ function test_notification_service()
test_default_rule();
test_disabled();
test_snapshot_generation();
+ test_transport_delegation();
+ test_cooldown_suppresses_within_window();
+ test_cooldown_allows_after_expiry();
fprintf('test_notification_service: ALL PASSED\n');
end
function add_event_path()
- thisDir = fileparts(mfilename('fullpath'));
+ thisDir = fileparts(mfilename('fullpath'));
repoRoot = fileparts(thisDir);
addpath(repoRoot);
addpath(fullfile(repoRoot, 'libs', 'EventDetection'));
addpath(fullfile(repoRoot, 'libs', 'SensorThreshold'));
addpath(fullfile(repoRoot, 'libs', 'FastSense'));
+ addpath(fullfile(repoRoot, 'tests', 'suite'));
install();
end
@@ -64,7 +68,7 @@ function test_notify_dry_run()
ns.setDefaultRule(NotificationRule('Recipients', {{'test@b.com'}}, 'IncludeSnapshot', false));
ev = Event(now, now+0.01, 'temp', 'HH', 100, 'upper');
ev = ev.setStats(105, 10, 90, 105, 98, 99, 3);
- sd = struct('X', linspace(now-1,now,100), 'Y', 80*ones(1,100), ...
+ sd = struct('X', linspace(now-1, now, 100), 'Y', 80*ones(1, 100), ...
'thresholdValue', 100, 'thresholdDirection', 'upper');
% Should not throw (dry run skips actual email)
ns.notify(ev, sd);
@@ -98,7 +102,7 @@ function test_snapshot_generation()
ev = ev.setStats(115, 50, 90, 115, 105, 106, 5);
rng(42);
t = linspace(now-3/24, now, 500);
- y = 80 + 2*randn(1,500);
+ y = 80 + 2*randn(1, 500);
sd = struct('X', t, 'Y', y, 'thresholdValue', 100, 'thresholdDirection', 'upper');
ns.notify(ev, sd);
% Check snapshots were created
@@ -107,3 +111,86 @@ function test_snapshot_generation()
rmdir(ns.SnapshotDir, 's');
fprintf(' PASS: test_snapshot_generation\n');
end
+
+function test_transport_delegation()
+ % Proves that recipients / subject / body are forwarded correctly to Transport.send.
+ mock = MockEmailTransport();
+ recips = {{'a@b.com'}};
+ subjTemplate = 'Event: {sensor} - {threshold}';
+ ns = NotificationService('Transport', mock, 'CooldownMinutes', 0);
+ ns.setDefaultRule(NotificationRule( ...
+ 'Recipients', recips, ...
+ 'Subject', subjTemplate, ...
+ 'IncludeSnapshot', false));
+
+ ev = Event(now, now+0.01, 'sensorA', 'thresh1', 50, 'upper');
+ ev = ev.setStats(55, 5, 48, 55, 51, 51.5, 1.5);
+ sd = struct('X', [now], 'Y', [55], 'thresholdValue', 50, 'thresholdDirection', 'upper');
+ ns.notify(ev, sd);
+
+ assert(numel(mock.Calls) == 1, 'transport_delegation: expected exactly 1 call');
+ call = mock.Calls{1};
+ % Recipients must be forwarded (nested cell as stored in rule.Recipients).
+ assert(iscell(call.recipients), 'recipients_is_cell');
+ % Subject must be template-filled with the event data.
+ expectedSubj = strrep(strrep(subjTemplate, '{sensor}', 'sensorA'), '{threshold}', 'thresh1');
+ assert(strcmp(call.subject, expectedSubj), ...
+ sprintf('subject mismatch: got "%s", expected "%s"', call.subject, expectedSubj));
+ % Body must be non-empty.
+ assert(~isempty(call.body), 'body_non_empty');
+ fprintf(' PASS: test_transport_delegation\n');
+end
+
+function test_cooldown_suppresses_within_window()
+ % Notifying the SAME (sensor, threshold) twice back-to-back suppresses the second.
+ mock2 = MockEmailTransport();
+ ns = NotificationService('Transport', mock2, 'CooldownMinutes', 5);
+ ns.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{'x@y.com'}}, ...
+ 'IncludeSnapshot', false));
+
+ ev = Event(now, now+0.01, 'sensorB', 'thresh2', 10, 'upper');
+ ev = ev.setStats(12, 2, 9, 12, 10.5, 10.6, 0.8);
+ sd = struct('X', [now], 'Y', [12], 'thresholdValue', 10, 'thresholdDirection', 'upper');
+
+ ns.notify(ev, sd); % First notify: proceeds
+ ns.notify(ev, sd); % Second notify: should be suppressed within the 5-min window
+
+ assert(numel(mock2.Calls) == 1, ...
+ sprintf('cooldown_suppresses: mock should have 1 call, got %d', numel(mock2.Calls)));
+ assert(ns.NotificationCount == 1, ...
+ sprintf('cooldown_suppresses: NotificationCount should be 1, got %d', ns.NotificationCount));
+ assert(ns.SuppressedCount == 1, ...
+ sprintf('cooldown_suppresses: SuppressedCount should be 1, got %d', ns.SuppressedCount));
+ fprintf(' PASS: test_cooldown_suppresses_within_window\n');
+end
+
+function test_cooldown_allows_after_expiry()
+ % After expiry, a second notify should go through.
+ % Uses the Hidden test seam setLastSentForTesting_ to back-date the stamp
+ % by 10 minutes (> the 5-minute window) — deterministic, no sleep needed.
+ mock3 = MockEmailTransport();
+ ns = NotificationService('Transport', mock3, 'CooldownMinutes', 5);
+ ns.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{'z@w.com'}}, ...
+ 'IncludeSnapshot', false));
+
+ ev = Event(now, now+0.01, 'sensorC', 'thresh3', 20, 'upper');
+ ev = ev.setStats(22, 3, 18, 22, 20.5, 20.6, 0.9);
+ sd = struct('X', [now], 'Y', [22], 'thresholdValue', 20, 'thresholdDirection', 'upper');
+
+ ns.notify(ev, sd); % First notify: proceeds (mock3.Calls==1)
+ assert(numel(mock3.Calls) == 1, 'after_expiry: first notify must go through');
+
+ % Back-date the cooldown stamp by 10 minutes (>5-min window) so expiry is simulated.
+ ns.setLastSentForTesting_(ev, now() - 10/1440); %#ok
+
+ suppressedBefore = ns.SuppressedCount;
+ ns.notify(ev, sd); % Second notify after expiry: should proceed
+
+ assert(numel(mock3.Calls) == 2, ...
+ sprintf('after_expiry: expected 2 calls after expiry, got %d', numel(mock3.Calls)));
+ assert(ns.SuppressedCount == suppressedBefore, ...
+ 'after_expiry: SuppressedCount must not increase after expiry');
+ fprintf(' PASS: test_cooldown_allows_after_expiry\n');
+end