From 203da7a603d99977b9b735a03fbeff1faa0c930c Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:15:48 +0200 Subject: [PATCH 1/9] feat(260529-rxf-01): add EmailTransport with pure buildMailProps + Octave guard + tests - New libs/EventDetection/EmailTransport.m: handle class with Server/Port/User/ Password/PasswordEnv/SecurityMode/From NV-pair config - PURE static buildMailProps(mode, port) returns containers.Map of mail.smtp.* keys for none/starttls/ssl modes without touching prefs or JVM - send() has Octave guard (exist('sendmail','file')==0 -> log-and-return, no error); applies JVM props then delegates to MATLAB sendmail - EmailTransport:invalidSecurityMode error on unknown mode - tests/test_email_transport.m: function-based tests for all three prop maps, invalid mode, and octave-guard no-throw - tests/suite/TestEmailTransport.m: class-based mirror using verifyEqual/verifyError MATLAB test execution deferred to orchestrator (no MCP access in executor). --- libs/EventDetection/EmailTransport.m | 232 +++++++++++++++++++++++++++ tests/suite/TestEmailTransport.m | 76 +++++++++ tests/test_email_transport.m | 96 +++++++++++ 3 files changed, 404 insertions(+) create mode 100644 libs/EventDetection/EmailTransport.m create mode 100644 tests/suite/TestEmailTransport.m create mode 100644 tests/test_email_transport.m diff --git a/libs/EventDetection/EmailTransport.m b/libs/EventDetection/EmailTransport.m new file mode 100644 index 00000000..697ccb9d --- /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/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/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 From 2ac68876d780ceb7838fa639cfe19cc4fa9f2335 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:18:04 +0200 Subject: [PATCH 2/9] feat(260529-rxf-01): wire EmailTransport delegation + cooldown into NotificationService - NotificationService now accepts SmtpPort(587)/SmtpUser/SmtpPassword/PasswordEnv/ SecurityMode('starttls')/CooldownMinutes(5)/Transport via constructor inputParser - sendEmail_ delegates to Transport.send(...); lazily constructs real EmailTransport when Transport is empty (DI seam for mock injection) - Added per-(SensorName|ThresholdLabel) cooldown in notify(): suppresses both real-send and dry-run within window; stamps AFTER Enabled+rule guards so test_disabled/test_default_rule stay green; SuppressedCount incremented on suppress - Added hidden setLastSentForTesting_ seam for deterministic expiry testing - tests/suite/MockEmailTransport.m: new test double recording send() call args - tests/test_notification_service.m: extended with test_transport_delegation, test_cooldown_suppresses_within_window, test_cooldown_allows_after_expiry; add_event_path now also adds tests/suite to path for MockEmailTransport MATLAB test execution deferred to orchestrator (no MCP access in executor). --- libs/EventDetection/NotificationService.m | 251 +++++++++++++++++----- tests/suite/MockEmailTransport.m | 38 ++++ tests/test_notification_service.m | 93 +++++++- 3 files changed, 330 insertions(+), 52 deletions(-) create mode 100644 tests/suite/MockEmailTransport.m 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/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 From 341bab244aaed46cb0202ed17ca48f02b2890a88 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:20:36 +0200 Subject: [PATCH 3/9] feat(260529-rxf-01): wire real sensorData through LiveEventPipeline live ticks - processMonitorTag_ gains 3rd return value sensorData (struct X/Y/thresholdValue/ thresholdDirection); well-formed empty struct on every early-return path - sensorData built from fullX/fullY accumulated grid + first new event's ThresholdValue/Direction (matches generateEventSnapshot contract exactly) - runCycle accumulates allSensorData cell array in parallel with allNewEvents; notify(ev, sd) now passes real per-event sensorData instead of struct() - NotificationService('DryRun', true) constructor default unchanged (backward-compat) - example_live_pipeline.m: runnable path stays dry-run; added commented real-send config block with SmtpServer/SmtpPort/PasswordEnv/SecurityMode/CooldownMinutes - examples/05-events/smoke_email_send.m: documented manual one-shot real-send using FASTSENSE_SMTP_* env vars; STARTTLS:587; gracefully returns when vars unset; Octave-safe (EmailTransport guard logs-and-skips) MATLAB test execution deferred to orchestrator (no MCP access in executor). --- examples/05-events/example_live_pipeline.m | 13 +++++ examples/05-events/smoke_email_send.m | 56 ++++++++++++++++++++++ libs/EventDetection/LiveEventPipeline.m | 52 +++++++++++++++++--- 3 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 examples/05-events/smoke_email_send.m diff --git a/examples/05-events/example_live_pipeline.m b/examples/05-events/example_live_pipeline.m index 1dacd82a..a485213f 100644 --- a/examples/05-events/example_live_pipeline.m +++ b/examples/05-events/example_live_pipeline.m @@ -141,8 +141,21 @@ 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;) + % 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/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 From 0e88253878b2b3ed79cffcbd49fc559b816f8ea1 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:22:05 +0200 Subject: [PATCH 4/9] docs(260529-rxf-01): complete real-per-event-email-alerts plan Co-Authored-By: Claude Sonnet 4.6 --- .../260529-rxf-SUMMARY.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-SUMMARY.md 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 | From cef1fc542ada12c406dca28a4f4c069126cf482f Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:28:08 +0200 Subject: [PATCH 5/9] style(260529-rxf-01): drop unnecessary brackets in EmailTransport error() Code Analyzer flagged the single-string literal wrapped in [...] in validateSecurityMode_'s error() call. Cosmetic only; test_invalid_mode still green. New file is now fully Code-Analyzer-clean (MISS_HIT was already clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/EventDetection/EmailTransport.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/EventDetection/EmailTransport.m b/libs/EventDetection/EmailTransport.m index 697ccb9d..59c14d99 100644 --- a/libs/EventDetection/EmailTransport.m +++ b/libs/EventDetection/EmailTransport.m @@ -222,7 +222,7 @@ function validateSecurityMode_(mode) validModes = {'none', 'starttls', 'ssl'}; if ~any(strcmp(mode, validModes)) error('EmailTransport:invalidSecurityMode', ... - ['Invalid SecurityMode ''%s''. Valid modes: %s.'], ... + 'Invalid SecurityMode ''%s''. Valid modes: %s.', ... mode, strjoin(validModes, ', ')); end end From 069c9d072e3daea6dd65bfd713a33bc2fc267a34 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:31:27 +0200 Subject: [PATCH 6/9] docs(quick-260529-rxf): record completion in STATE.md + track PLAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STATE.md: add 260529-rxf row to Quick Tasks Completed (Status: Verified) and bump Last activity. STATE.md is force-tracked under the otherwise gitignored .planning/; gsd-tools commit skipped it on a naive .gitignore read, so committed directly. - Force-add the plan doc (260529-rxf-PLAN.md) to match repo convention — prior quick tasks track both PLAN.md and SUMMARY.md; the executor's docs commit (0e882538) captured only the SUMMARY. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/STATE.md | 3 +- .../260529-rxf-PLAN.md | 315 ++++++++++++++++++ 2 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-PLAN.md diff --git a/.planning/STATE.md b/.planning/STATE.md index c1be727e..233602d8 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 quick task 260529-rxf: Real per-event email alerts for background monitoring (EmailTransport + cooldown + live snapshot wiring) ### Note on parallel v4.0 work (main branch state) @@ -93,6 +93,7 @@ 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/) | ## 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). + + + +After completion, create `.planning/quick/260529-rxf-real-per-event-email-alerts-for-backgrou/260529-rxf-SUMMARY.md` + From 706e9d5bf7b76d6852f4d3c119b5b539e872dcc3 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:46:53 +0200 Subject: [PATCH 7/9] feat(EventDetection): add FunctionTransport to route alerts via external mailer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FunctionTransport wraps a user-supplied function handle as a NotificationService Transport, so an existing site/company MATLAB email function can be reused for background-monitoring alerts with NO SMTP config (no server/port/credentials, no Gmail App Password). Drop-in: same send(recipients,subject,body,attachments) signature as EmailTransport (duck-typed), normalizes recipients to a flat cellstr, defaults attachments to {}, Octave-safe (only calls user code). Purely additive — EmailTransport/NotificationService behavior unchanged. transport = FunctionTransport(@(to,subject,body,attachments) ... companyMail(to,subject,body,attachments)); notif = NotificationService('DryRun',false,'Transport',transport); Tests: tests/test_function_transport.m (5/5 — forwarding, recipients normalization, attachments default, invalid-handle error, NotificationService integration). test_notification_service still 10/10. MISS_HIT + Code Analyzer clean. Example gains a commented FunctionTransport option. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/05-events/example_live_pipeline.m | 11 ++ libs/EventDetection/FunctionTransport.m | 113 +++++++++++++++++++++ tests/test_function_transport.m | 101 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 libs/EventDetection/FunctionTransport.m create mode 100644 tests/test_function_transport.m diff --git a/examples/05-events/example_live_pipeline.m b/examples/05-events/example_live_pipeline.m index a485213f..158e0304 100644 --- a/examples/05-events/example_live_pipeline.m +++ b/examples/05-events/example_live_pipeline.m @@ -156,6 +156,17 @@ % '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/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/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 From 43505b1db2ce19d4ab9025198b3173103ea35d1d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 20:47:30 +0200 Subject: [PATCH 8/9] docs(quick-260529-fnt): record FunctionTransport (/gsd:fast) in STATE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 233602d8..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-29 - Completed quick task 260529-rxf: Real per-event email alerts for background monitoring (EmailTransport + cooldown + live snapshot wiring) +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) @@ -94,6 +94,7 @@ Other main PRs (#138, #139, #141, #144, #145, #146) auto-merged without conflict | 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 From a44106ee316049371004c5256ed4dfddf8379e67 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Fri, 29 May 2026 21:25:59 +0200 Subject: [PATCH 9/9] test(EventDetection): class-suite coverage for FunctionTransport + NotificationService new logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged FunctionTransport at 0% and the NotificationService cooldown/delegation lines as uncovered. Root cause: CI's coverage runner (scripts/run_tests_with_coverage.m) uses TestSuite.fromFolder('tests/suite'), so it runs ONLY class-based suites — the function-based test_*.m files (where the new logic was tested) never execute in CI. Mirror those tests into the class suites so the new code is actually CI-gated and covered: - NEW tests/suite/TestFunctionTransport.m (5 tests) — forwarding, recipient normalization, attachments default, invalid-handle error, NotificationService integration. Lands in the E-I batch (passing). - tests/suite/TestNotificationService.m — +3 methods (transport delegation, cooldown-suppress, cooldown-expiry via the Hidden setLastSentForTesting_ seam) and add tests/suite to the path for MockEmailTransport. J-P batch. Local: TestFunctionTransport 5/5, TestNotificationService 10/10. MISS_HIT clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/suite/TestFunctionTransport.m | 84 +++++++++++++++++++++++++++ tests/suite/TestNotificationService.m | 41 ++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/suite/TestFunctionTransport.m 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