diff --git a/di/email/email.md b/di/email/email.md new file mode 100644 index 00000000..0abc9e0d --- /dev/null +++ b/di/email/email.md @@ -0,0 +1,247 @@ +# Email + +`email.q` provides a self-contained HTML email module. It supports two transports: the system `sendmail` utility, or SMTP via `curl`. HTML construction utilities are ported from [qmail](https://github.com/BestiaPL/qmail). + +Alert and report result handlers compatible with the TorQ reporter process are included, ported from `code/processes/reporter.q`. + +## Requirements + +One of the following must be installed and configured: + +- **sendmail via msmtp** — lightweight sendmail replacement. Used when no `smtpurl` is configured. See [Sendmail setup (msmtp)](#sendmail-setup-msmtp) below. +- **curl** — used when `smtpurl` is set in config. Most systems have this by default. + +## Sendmail setup (msmtp) + +`msmtp` is the recommended sendmail transport. It acts as a drop-in `sendmail` replacement and routes mail through an SMTP relay. + +### 1. Install + +```bash +sudo apt install msmtp msmtp-mta +``` + +`msmtp-mta` creates the `/usr/sbin/sendmail` symlink that the module uses. + +### 2. Configure + +Create `~/.msmtprc`: + +``` +defaults +auth on +tls on +tls_trust_file /etc/ssl/certs/ca-certificates.crt +logfile ~/.msmtp.log + +account default +host smtp.gmail.com +port 587 +from me@example.com +user me@example.com +password myapppassword +``` + +Set permissions (msmtp refuses to run if the file is world-readable): + +```bash +chmod 600 ~/.msmtprc +``` + +For Gmail, `password` must be an [App Password](https://myaccount.google.com/apppasswords) — not your account password. App Passwords require 2-Step Verification to be enabled on the account. + +### 3. Test outside q + +```bash +echo -e "To: me@example.com\nSubject: test\n\ntest body" | sendmail me@example.com +cat ~/.msmtp.log +``` + +A successful send logs `exitcode=EX_OK`. If it fails, the log contains the SMTP error. + +### 4. Test from q + +```q +email:use`di.email +log:use`di.log +log.init[logconfig] +logdep:`info`warn`error!(log.info;log.warn;log.error) + +email.init[ + `mailfrom`enabled!("me@example.com";1b); + `log`send!(logdep;::)] + +email.test[`$"me@example.com"] +``` + +## Configuration + +Passed as the first dictionary to `init`. All keys are optional. + +| Key | Type | Default | Description | +|---|---|---|---| +| `mailfrom` | string or symbol | `"torq@localhost"` | From address on outgoing emails | +| `enabled` | boolean | `0b` | Set `1b` to allow emails to be sent | +| `smtpurl` | string or symbol | `""` | SMTP server URL e.g. `"smtp://smtp.gmail.com:587"`. When set, curl is used instead of sendmail | +| `smtpuser` | string or symbol | `""` | SMTP username | +| `smtppassword` | string | `""` | SMTP password | +| `smtpssl` | boolean | `1b` | Require TLS (`--ssl-reqd`). Set `0b` to disable | + +## Dependencies + +Passed as the second dictionary to `init`. Pass `(::)` to use all defaults. + +| Key | Required | Type | Description | +|---|---|---|---| +| `` `log `` | yes | dict | Logger with keys `` `info`warn`error ``, each `{[c;m]}`. Required — `init` throws if absent. See `di.log` for a default implementation. | +| `` `send `` | no | function | `{[frm;to;sub;body;att]}` — injectable send function. Pass `(::)` or omit to use curl smtp when `smtpurl` is set, otherwise sendmail. | + +## Core Structures + +- **`history`** (table, `.z.M`) — Append-only log of every `senddefault` call: + - `time` (timestamp), `recipients` (symbol), `subject` (any), `status` (symbol: `` `sent `` / `` `failed `` / `` `disabled ``), `bytes` (long: `0j` on success, `-1j` on failure or disabled) +- **`alertstats`** (keyed table, `.z.M`) — Tracks last alert send time per `procname`+`alertname` pair for cooldown enforcement + +## Main Functions + +### `init` +Parameters: `[config; deps]` + +Initialises the module. Pass `(::)` for config to use defaults (email disabled, sendmail transport). A `log` dependency is always required — `init` throws if it is absent. + +When `smtpurl` is set in config and no custom `send` is injected, the curl SMTP transport is used automatically. + +### `senddefault` +Parameters: `[msgdict]` + +Sends an HTML email. `msgdict` keys: +- `to` — symbol or symbol list of recipients +- `subject` — string +- `body` — list of strings (plain strings are wrapped in a styled `

` tag; pre-built HTML strings are passed through as-is; a timestamp footer is appended automatically) +- `attachment` — (optional) hsym file path + +Returns `1b` on success, `0b` on send failure, `-1` if disabled. Every attempt is logged to `history`. + +### `test` +Parameters: `[to]` + +Sends a test email to `to` (symbol). Returns `1b` on success. + +### `alert` +Parameters: `[period; recipients]` + +Returns a result handler projection `{[data]}` for the TorQ reporter `resulthandler` column. + +- `period` — timespan cooldown e.g. `00:02:00` +- `recipients` — string or list of strings (email addresses) +- `data.result` must have a `messages` column (list of strings) + +### `report` +Parameters: `[temppath; recipients; filename; filetype]` + +Returns a result handler projection `{[data]}` for the TorQ reporter `resulthandler` column. Writes result to `temppath/filename.filetype`, emails it as an attachment, then deletes the temp file. + +### `getstatus` +Returns the full `history` table. + +## HTML Helpers + +These functions are exported and can be used to build rich HTML email bodies before passing to `senddefault`. + +| Function | Parameters | Description | +|---|---|---| +| `addtext` | `[text]` | Wrap a string in a styled `

` tag | +| `mailheading` | `[level; text]` | Heading `

`–`

` | +| `mailbold` | `[text]` | Bold text | +| `mailitalic` | `[text]` | Italic text | +| `mailtable` | `[t]` | Render a q table as an HTML table | +| `ztable` | `[t]` | Table with alternating row colours | +| `maildict` | `[d]` | Render a q dict as an HTML table | +| `zdict` | `[d]` | Dict table with alternating row colours | +| `addcolor` | `[color; text]` | Apply font colour | +| `mailbgcolor` | `[hex; text]` | Apply background colour | +| `mailsize` | `[px; text]` | Set font size in pixels | +| `mailcolors` | `[color; bg; size; text]` | Combined colour/background/size | +| `mailurl` | `[url; text]` | Hyperlink | + +## Usage Examples + +Every example requires a logger. Define one before calling `init`: + +```q +logdep:`info`warn`error!( + {[c;m] -1 "INFO [",string[c],"] ",m;}; + {[c;m] -1 "WARN [",string[c],"] ",m;}; + {[c;m] -2 "ERROR [",string[c],"] ",m;}); +``` + +### 1. Send a plain email via sendmail + +```q +email:use`di.email +email.init[ + `mailfrom`enabled!("me@example.com";1b); + `log`send!(logdep;::)] +email.senddefault`to`subject`body!(`$"ops@example.com";"Deployed";enlist"Build 42 deployed.") +``` + +### 2. Send via SMTP + +```q +email:use`di.email +email.init[ + `mailfrom`enabled`smtpurl`smtpuser`smtppassword!( + "me@example.com"; + 1b; + "smtp://smtp.gmail.com:587"; + "me@example.com"; + "myapppassword"); + `log`send!(logdep;::)] +email.senddefault`to`subject`body!(`$"ops@example.com";"Deployed";enlist"Build 42 deployed.") +``` + +### 3. Send an HTML table + +```q +email:use`di.email +email.init[ + `mailfrom`enabled!("me@example.com";1b); + `log`send!(logdep;::)] +results:([]sym:`AAPL`GOOG;price:182.5 141.3) +body:email.ztable[results] +email.senddefault`to`subject`body!(`$"ops@example.com";"EOD Prices";body) +``` + +### 4. Test transport connectivity + +```q +email:use`di.email +email.init[ + `mailfrom`enabled!("me@example.com";1b); + `log`send!(logdep;::)] +email.test[`$"me@example.com"] +``` + +### 5. Use as a reporter alert handler + +``` +rdbmemorycheck|.checks.memorycheck[1500000000]|email.alert[00:02;getenv`DEMOEMAILRECEIVER]|||rdb|rdb1|00:00:00|23:59:59|00:05:00|00:00:20|0 1 2 3 4 5 6 +``` + +### 6. Use as a reporter report handler + +``` +eodreport|hloc[.proc.cd[];.proc.cd[];0D01]|email.report[getenv[`TORQHOME];getenv`DEMOEMAILRECEIVER;"eodreport";"csv"]|||rdb||18:00|18:00|00:00|00:05|2 3 4 5 6 +``` + +### 7. Testing with a mock send function + +```q +mocklog:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}) +mocksend:{[frm;to;sub;body;att]} +email:use`di.email +email.init[ + `mailfrom`enabled!("me@example.com";1b); + `log`send!(mocklog;mocksend)] +email.senddefault`to`subject`body!(`$"a@b.com";"test";enlist"hello") +``` diff --git a/di/email/email.q b/di/email/email.q new file mode 100644 index 00000000..c2b4ea7f --- /dev/null +++ b/di/email/email.q @@ -0,0 +1,428 @@ +/ module for sending html emails via the system sendmail utility +/ ported from torq code/common/email.q and code/processes/reporter.q +/ html construction and sendmail transport ported from qmail (github.com/BestiaPL/qmail) +/ no c library or smtp server required + +/ ============================================================ +/ sendmail utilities (ported from qmail) +/ ============================================================ + +utilityexists:{not 0b~@[system;"which ",x," 2>/dev/null";{0b}]}; + +hsym2str:{[x] $[":"=first s:string x;1_s;s]}; + +checkfile:{if[not x~key x:hsym x;'"file not found: ",hsym2str x]}; + +base64encode:$[.z.K >= 3.6;76 cut .Q.btoa@;{ + c:count[x]mod 3; + pc:count p:(0x;0x0000;0x00)c; + b:.Q.b6 2 sv/: 6 cut raze 0b vs/: x,p; + 76 cut(neg[pc] _ b),pc#"="}]; + +encodefile:{ + checkfile x; + base64encode read1 x; + }; + +mimetype:{[a] + if[not utilityexists "file"; :"text/plain"]; + checkfile a; + trim last ":" vs first @[system;"file --mime-type ",hsym2str a;{enlist ": text/plain"}]; + }; + +mailheader:{[] + ("";"") + }; + +mailfooter:("";""); + +template0:{[frm;to;sub;body] + enlist["From: ",frm], + enlist["To: ",to], + enlist["Subject: ",sub], + enlist["MIME-Version: 1.0"], + enlist["Content-Type: text/html; charset=UTF-8"], + enlist[""], + mailheader[], + body, + mailfooter + }; + +mailtemplate:{[frm;to;sub;body;att] + if[not count att where not null att,:();:template0[frm;to;sub;body]]; + boundary:"====",string[rand 0Ng],"===="; + enlist["From: ",frm], + enlist["To: ",to], + enlist["Subject: ",sub], + enlist["Content-Type: multipart/mixed; boundary=\"",boundary,"\""], + enlist["MIME-Version: 1.0"], + enlist[""], + enlist["--",boundary], + enlist["Content-Type: text/html"], + enlist[""], + mailheader[], + body, + mailfooter, + (raze {[a;boundary] + fn:last "/"vs hsym2str a; + enlist[""], + enlist["--",boundary], + enlist["Content-Transfer-Encoding: base64"], + enlist["Content-Type: ",mimetype[a],"; name=",fn], + enlist["Content-Disposition: attachment; filename=",fn], + enlist[""], + encodefile[a] + }[;boundary] each att), + enlist["--",boundary,"--"] + }; + +mailsend:{[frm;to;sub;body;att] + / send an html email via the system sendmail utility + / frm - string from address + / to - string, comma-delimited recipient addresses + / sub - string subject + / body - list of strings (html content) + / att - "" for no attachment, or list of hsym file paths + if[not utilityexists "sendmail";'"sendmail not found on this system"]; + if[not att~"";if[10h=type att;att:enlist att]]; + fn:hsym`$first system"mktemp /tmp/qmail.XXXXXXXXXX"; + fn 0: mailtemplate[frm;to;sub;body;att]; + @[system;"sendmail -t < ",1_string fn;{[fn;e]hdel fn;'"sendmail error: ",e}[fn]]; + hdel fn; + }; + +/ ============================================================ +/ html construction helpers (ported from qmail) +/ ============================================================ + +mailstring:{$[10h=abs type x;x;(type[x] in 0 98 99h) or (100h",y,""}; +mailewrap:{enlist["<",x,">"],y,enlist""}; + +addtext:{mailwrap[addstyle["p";`body];x]}; +mailheading:{mailwrap[addstyle["h",x;`body];y]}; +mailbold:{mailwrap[addstyle["b";`body];mailstring x]}; +mailitalic:{mailwrap[addstyle["i";`body];mailstring x]}; + +mailcolors:{[color;bg;sz;text] + styledict:(`$("color";"background-color";"font-size";"display"))!(color;bg;$[count sz;sz,"px";""];"inline"); + styledict:#[;styledict]where not ""~/:styledict; + mailwrap["p style=\"",(dict2css cssbody[],styledict),"\"";mailstring[text]]}; + +addcolor:{mailcolors[x;"";"";y]}; +mailsize:{mailcolors["";"";x;y]}; +mailbgcolor:{mailcolors["";x;"";y]}; + +mailurl:{[u;txt]mailwrap[addstyle["a href=\"",u,"\"";`body];txt]}; +setbookmark:{[id]""}; +getbookmark:{[id;txt]mailurl["#",id;txt]}; + +mailrow:{mailewrap["tr";mailwrap[x]each mailstring each y]}; + +table0:{[t;alt] + h:mailrow[addstyle["th";`table`header];cols t]; + b:raze mailrow'[addstyle["td"] each`table`row,/:$[alt;?[1=til[count t]mod 2;`odd;`even];count[t]#`even];flip value flip 0!t]; + mailewrap[addstyle["table";`table`all];h,b] + }; + +mailtable:{table0[x;0b]}; +ztable:{table0[x;1b]}; + +dict0:{[d;alt] + b:raze mailrow'[addstyle["td"] each`table`row,/:$[alt;?[1=til[count d]mod 2;`odd;`even];count[d]#`even];flip(key;value)@\:d]; + mailewrap["table";b] + }; + +maildict:{dict0[x;0b]}; +zdict:{dict0[x;1b]}; + +colornormalize:{[low;high;x]0f | 1f & (x - low)%(high - low)}; +colorhex2html:{"#",raze string x}; + +colorhsv2rgb:{[h;s;v] + C:v*s; + H:(h mod 360f)%60f; + X:C * 1 - abs -1f + H mod 2; + m:v-C; + D:`s#0 1 2 3 4 5 6f!(1 2 0;2 1 0;0 1 2;0 2 1;2 0 1;1 0 2;0 0 0); + `byte$255*m + (0f;C;X)D H + }; + +colorhuemap:(!). flip ( + (`red;0);(`orange;30);(`yellow;60);(`lime;90);(`green;120); + (`turquoise;150);(`cyan;180);(`blue;240);(`purple;270); + (`pink;300);(`violet;330)); + +colorizemono:{[color;min_val;max_val;x] + s_values:colornormalize[min_val;max_val;x]; + colorhsv2rgb[$[-11h=type color;colorhuemap[color];color];;1f]each s_values + }; + +colorizestereo:{[color_min;color_max;min_val;max_val;pivot_val;x] + low:x&1"; + @[{system x};cmd;{[fn;e]hdel fn;'"curl smtp error: ",e}[fn]]; + hdel fn; + }; + +stringnestedlists:{[res] + / convert any nested int/float list columns to space-delimited strings for serialisation + / ported from torq reporter.q stringnestedlists + nestedtypes:upper .Q.t except " c"; + $[count select from meta[res] where t in nestedtypes; + {[t;c] ![t;();0b;(enlist c)!enlist((';{" " sv string x});c)]}/[res;exec c from meta[res] where t in nestedtypes]; + res]; + }; + +writetofile:{[temppath;reportname;filetype;data] + / write data`result to disk as csv or txt and return the file path hsym + / ported from torq reporter.q writetofile + ty:`$filetype; + if[not ty in key .h.tx; + .z.m.logerr[`email;"writetofile: filetype not supported: ",filetype]; + 'filetype," is not a supported file type"; + ]; + res:stringnestedlists[data`result]; + filepath:`$temppath,"/",reportname,".",filetype; + .[{hsym[x] 0:.h.tx[y;z]};(filepath;ty;res);{[e].z.m.logerr[`email;"writetofile: ",e]}]; + :hsym filepath; + }; + +loghistory:{[recipients;subject;status;bytes] + / append one row to the persistent send history table + .z.M.history insert (.z.p;recipients;subject;status;`long$bytes); + }; + +/ ============================================================ +/ public api +/ ============================================================ + +senddefault:{[msgdict] + / send an html email via the system sendmail utility + / msgdict keys: to (symbol or symbol list), subject (string), body (list of strings) + / optionally: attachment (file path hsym) + / returns 1b on success, 0b on send failure, -1 if disabled + if[not enabled; + .z.m.logerr[`email;"email sending is not enabled"]; + loghistory[msgdict`to;msgdict`subject;`disabled;-1]; + :-1; + ]; + to:","sv string$[-11h=type msgdict`to;enlist msgdict`to;msgdict`to]; + htmlbody:{$[10h=type x;$[count x;$["<"=first x;x;addtext x];""];x]}'[msgdict[`body],enlist "email generated at ",(string .z.p)]; + att:$[`attachment in key msgdict;enlist msgdict`attachment;""]; + res:.[send;(mailfrom;to;msgdict`subject;htmlbody;att);{[e].z.m.logerr[`email;"send failed: ",e];0b}]; + ok:not res~0b; + loghistory[msgdict`to;msgdict`subject;`failed`sent ok;$[ok;0j;-1j]]; + $[ok; + .z.m.loginfo[`email;"email sent"]; + .z.m.logerr[`email;"failed to send email"]]; + :ok; + }; + +test:{[to] + / send a test email to verify sendmail connectivity + / to - symbol e.g. `$"user@example.com" + / returns 1b on success, 0b on failure + :senddefault`to`subject`body!(to;"test email";enlist"this is a test email to verify sendmail is configured correctly"); + }; + +alerthandler:{[period;recipients;data] + / result handler for the torq reporter alert - invoked via the alert[] projection + / ported from torq reporter.q emailalert + / period - timespan cooldown e.g. 00:02:00 + / recipients - string or list of strings (email addresses) + / data - reporter data dict: result (table with messages col), name, procname, queryid + lasttime:0p^exec first lastsent from alertstats where procname=(data`procname),alertname=(data`name); + result:data`result; + if[not count result; + .z.m.loginfo[`email;"emailalert: nothing to email"]; + :(); + ]; + if[period > .z.p - lasttime; + .z.m.loginfo[`email;"emailalert: suppressed, previous email was too recent"]; + :(); + ]; + .z.M.alertstats upsert (data`procname;data`name;.z.p); + subject:"Process [",(string data`procname),"] has triggered an alert [",(string data`name),"]"; + .z.m.loginfo[`email;"emailalert: sending warning email"]; + res:senddefault[`to`subject`body!(`$recipients;subject;(),result`messages)]; + $[res>0; + .z.m.loginfo[`email;"emailalert: sent alert for: ",string data`name]; + .z.m.logerr[`email;"emailalert: failed to send alert for: ",string data`name]]; + }; + +alert:{[period;recipients] + / create a reporter result handler that sends an alert email with cooldown + / period - timespan cooldown between successive alerts e.g. 00:02:00 + / recipients - string or list of strings (email addresses) + / returns a projection {[data]} compatible with the torq reporter resulthandler column + :alerthandler[period;recipients;]; + }; + +reporthandler:{[temppath;recipients;filename;filetype;data] + / result handler for the torq reporter report - invoked via the report[] projection + / ported from torq reporter.q emailreport + / temppath - string path for temporary report files e.g. getenv[`TORQHOME] + / recipients - string or list of strings (email addresses) + / filename - string output filename stem and email subject label + / filetype - string file format e.g. "csv" or "txt" + / data - reporter data dict: result (table), name, queryid + filepath:writetofile[temppath;filename;filetype;data]; + subject:"Report '",(string data`name),"' has been generated [",(string .z.d),"]"; + body:"A report has been generated. Please see the attached file for the results."; + .z.m.loginfo[`email;"emailreport: sending email with attached report"]; + res:senddefault[`to`subject`body`attachment!(`$recipients;subject;enlist body;filepath)]; + if[res<1;.z.m.logerr[`email;"emailreport: failed to send email"]]; + .z.m.loginfo[`email;"emailreport: removing temporary file: ",string filepath]; + @[hdel;filepath;{[e].z.m.logerr[`email;"emailreport: failed to delete temp file: ",e]}]; + }; + +report:{[temppath;recipients;filename;filetype] + / create a reporter result handler that writes the result to file and emails it as an attachment + / temppath - string path for temporary report files e.g. getenv[`TORQHOME] + / recipients - string or list of strings (email addresses) + / filename - string output filename stem and email subject label + / filetype - string file format e.g. "csv" or "txt" + / returns a projection {[data]} compatible with the torq reporter resulthandler column + :reporthandler[temppath;recipients;filename;filetype;]; + }; + +getstatus:{[] + / return the full send history table + :history; + }; + +init:{[config;deps] + / initialise module with email config and optional injected dependencies + / config - dict with any of: + / mailfrom (string or symbol) - from address + / enabled (boolean) - gate for sending + / smtpurl (string or symbol) - e.g. "smtp://smtp.gmail.com:587" + / smtpuser (string or symbol) - smtp username + / smtppassword (string) - smtp password + / smtpssl (boolean) - require tls (default 1b) + / pass (::) for config to use defaults (email disabled, sendmail transport) + / deps - `log`send!(logdict;sendfunc) + / `log: `info`warn`error!({[c;m]};{[c;m]};{[c;m]}) - required; init throws if absent + / `send: {[frm;to;sub;body;att]} - optional, (::) = use smtp or sendmail + / examples: + / email.init[config; `log`send!(logdep; ::)] / inject log, default send + / email.init[config; `log`send!(logdep; mysend)] / inject both + .z.m.mailfrom:"torq@localhost"; + .z.m.enabled:0b; + .z.m.smtpurl:""; + .z.m.smtpuser:""; + .z.m.smtppassword:""; + .z.m.smtpssl:1b; + logdict:$[99h=type deps;$[(`log in key deps) and not (::)~deps`log;deps`log;()!()];()!()]; + if[not count logdict;'"di.email: log dependency is required; pass `log`send!(logdep;::) - see di.log"]; + .z.m.loginfo:logdict`info; + .z.m.logwarn:logdict`warn; + .z.m.logerr:logdict`error; + sendinjected:$[99h=type deps;(`send in key deps) and not (::)~deps`send;0b]; + .z.m.send:$[sendinjected;deps`send;defaultsend]; + if[99h=type config; + if[`mailfrom in key config;.z.m.mailfrom:$[10h=type config`mailfrom;config`mailfrom;string config`mailfrom]]; + if[`enabled in key config;.z.m.enabled:config`enabled]; + if[`smtpurl in key config;.z.m.smtpurl:$[10h=type config`smtpurl;config`smtpurl;string config`smtpurl]]; + if[`smtpuser in key config;.z.m.smtpuser:$[10h=type config`smtpuser;config`smtpuser;string config`smtpuser]]; + if[`smtppassword in key config;.z.m.smtppassword:$[10h=type config`smtppassword;config`smtppassword;string config`smtppassword]]; + if[`smtpssl in key config;.z.m.smtpssl:config`smtpssl]; + ]; + / if smtp url is configured and send was not explicitly injected, use curl smtp transport + if[count smtpurl;if[not sendinjected;.z.m.send:smtpsend_]]; + }; diff --git a/di/email/init.q b/di/email/init.q new file mode 100644 index 00000000..73729c5a --- /dev/null +++ b/di/email/init.q @@ -0,0 +1,4 @@ +/ load core functionality and bundled sendmail/html utilities +\l ::email.q + +export:([init;senddefault;test;alert;report;getstatus]) diff --git a/di/email/test.csv b/di/email/test.csv new file mode 100644 index 00000000..d5610352 --- /dev/null +++ b/di/email/test.csv @@ -0,0 +1,200 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,email:use`di.email,1,1,load module into session +before,0,0,q,mocklog:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}),1,1,define no-op mock logger +before,0,0,q,"mocksend:{[frm;to;sub;body;att]}",1,1,define no-op mock send (no sendmail call) + +/ Test 0: init with defaults leaves email disabled and config empty +run,0,0,q,"email.init[(::);`log`send!(mocklog;::)]",1,1,init with defaults and injected logger +true,0,0,q,not .m.di.0email.enabled,1,1,email disabled by default +true,0,0,q,"""torq@localhost""~.m.di.0email.mailfrom",1,1,default mailfrom is torq@localhost + +/ Test 1: init with custom config sets values correctly +run,0,0,q,"email.init[`mailfrom`enabled!(""me@example.com"";0b);`log`send!(mocklog;::)]",1,1,init with string mailfrom +true,0,0,q,"""me@example.com""~.m.di.0email.mailfrom",1,1,mailfrom set from string +run,0,0,q,"email.init[`mailfrom`enabled!(`$""me@example.com"";0b);`log`send!(mocklog;::)]",1,1,init with symbol mailfrom +true,0,0,q,"""me@example.com""~.m.di.0email.mailfrom",1,1,symbol mailfrom converted to string + +/ Test 2: init wires injected logger into individual loginfo/logwarn/logerr slots +run,0,0,q,"email.init[(::);`log`send!(mocklog;::)]",1,1,init with injected logger +true,0,0,q,mocklog[`info]~.m.di.0email.loginfo,1,1,injected info function stored +true,0,0,q,mocklog[`warn]~.m.di.0email.logwarn,1,1,injected warn function stored +true,0,0,q,mocklog[`error]~.m.di.0email.logerr,1,1,injected error function stored + +/ Test 3: init errors when log dep not provided +fail,0,0,q,email.init[(::);(::)],1,1,init without log dep throws an error +fail,0,0,q,"email.init[(::);`log`send!(::;mocksend)]",1,1,init with log set to (::) throws an error + +/ Test 4: init wires injected send function +run,0,0,q,"email.init[(::);`log`send!(mocklog;mocksend)]",1,1,init with injected send +true,0,0,q,mocksend~.m.di.0email.send,1,1,injected send stored + +/ Test 5: init falls back to defaultsend when send dep not provided +run,0,0,q,"email.init[(::);`log`send!(mocklog;::)]",1,1,init with log injected but no send dep +true,0,0,q,.m.di.0email.defaultsend~.m.di.0email.send,1,1,default send used + +/ Test 6: senddefault returns -1 and logs disabled entry when email is disabled +run,0,0,q,"email.init[(::);`log`send!(mocklog;::)]",1,1,reset to defaults (disabled) +run,0,0,q,before:count email.getstatus[],1,1,capture history row count +run,0,0,q,"res:email.senddefault[`to`subject`body!(`$""a@b.com"";""subj"";enlist""body"")]",1,1,send while disabled +true,0,0,q,-1~res,1,1,returns -1 when disabled +true,0,0,q,1=(count email.getstatus[])-before,1,1,one row appended to history +true,0,0,q,`disabled~last exec status from email.getstatus[],1,1,status is disabled + +/ Test 7: senddefault with injected send returns 1b on success +run,0,0,q,"email.init[`mailfrom`enabled!(""me@example.com"";1b);`log`send!(mocklog;mocksend)]",1,1,init with mock send and enabled +run,0,0,q,before:count email.getstatus[],1,1,capture history row count +run,0,0,q,"res:email.senddefault[`to`subject`body!(`$""a@b.com"";""subj"";enlist""body"")]",1,1,send with mock send +true,0,0,q,1b~res,1,1,returns 1b on success +true,0,0,q,1=(count email.getstatus[])-before,1,1,one row appended to history +true,0,0,q,`sent~last exec status from email.getstatus[],1,1,status is sent + +/ Test 8: senddefault with failing send returns 0b and logs failed status +run,0,0,q,"failsend:{[frm;to;sub;body;att]'""smtp error""}",1,1,define failing send +run,0,0,q,"email.init[(enlist`enabled)!enlist 1b;`log`send!(mocklog;failsend)]",1,1,init with failing send +run,0,0,q,before:count email.getstatus[],1,1,capture count +run,0,0,q,"res:email.senddefault[`to`subject`body!(`$""a@b.com"";""subj"";enlist""body"")]",1,1,invoke send that throws +true,0,0,q,0b~res,1,1,returns 0b on failure +true,0,0,q,`failed~last exec status from email.getstatus[],1,1,status is failed + +/ Test 9: getstatus returns history table with expected columns and correct type +run,0,0,q,h:email.getstatus[],1,1,fetch history +true,0,0,q,`time`recipients`subject`status`bytes~cols h,1,1,correct column names +true,0,0,q,98h=type h,1,1,history is a table + +/ Test 10: alert handler returns empty on empty result +run,0,0,q,"email.init[(::);`log`send!(mocklog;::)]",1,1,reset +run,0,0,q,"h:email.alert[0D00:02;""a@b.com""]",1,1,create alert handler +run,0,0,q,"data:`procname`name`result`queryid!(`testproc;`testreport;([]messages:());1)",1,1,reporter data with empty result +run,0,0,q,res:h[data],1,1,invoke handler +true,0,0,q,()~res,1,1,empty result returns () + +/ Test 11: alert handler attempts send on non-empty result +run,0,0,q,"email.init[(enlist`enabled)!enlist 1b;`log`send!(mocklog;mocksend)]",1,1,init with mock send +run,0,0,q,before:count email.getstatus[],1,1,capture count +run,0,0,q,"h:email.alert[0D00:02;""a@b.com""]",1,1,fresh handler +run,0,0,q,"data:`procname`name`result`queryid!(`testproc;`newreport;([]messages:enlist""mem high"");1)",1,1,non-empty result +run,0,0,q,h[data],1,1,invoke handler +true,0,0,q,1=(count email.getstatus[])-before,1,1,one send attempt logged + +/ Test 12: alert handler respects cooldown for same procname+alertname +run,0,0,q,before:count email.getstatus[],1,1,capture count +run,0,0,q,h[data],1,1,second call within cooldown window +true,0,0,q,before=count email.getstatus[],1,1,no send within cooldown window + +/ Test 13: alert handler allows send for different alertname +run,0,0,q,before:count email.getstatus[],1,1,capture count +run,0,0,q,"data2:`procname`name`result`queryid!(`testproc;`otheralert;([]messages:enlist""row low"");2)",1,1,different alert name +run,0,0,q,h[data2],1,1,invoke handler with different name +true,0,0,q,1=(count email.getstatus[])-before,1,1,separate cooldown per alertname + +/ Test 14: report handler writes file and logs send attempt +run,0,0,q,temppath:system"cd",1,1,use cwd as temppath +run,0,0,q,rh:email.report[temppath;"a@b.com";"testreport";"csv"],1,1,create report handler +run,0,0,q,"rdata:`procname`name`result`queryid!(`testproc;`testreport;([]sym:`a`b;price:1.0 2.0);1)",1,1,reporter data dict +run,0,0,q,before:count email.getstatus[],1,1,capture count +run,0,0,q,rh[rdata],1,1,invoke handler +true,0,0,q,1=(count email.getstatus[])-before,1,1,one send attempt logged +true,0,0,q,"not (hsym`$temppath,""/testreport.csv"") in key hsym`$temppath",1,1,temp file cleaned up + +/ Test 15: addtext wraps text in styled

+run,0,0,q,"t:.m.di.0email.addtext ""hello""",1,1,build addtext result +true,0,0,q,"""

""~-4#t",1,1,addtext closes with

+true,0,0,q,"0""~-5#h1",1,1,mailheading h1 closes +true,0,0,q,"0""~-4#mbi",1,1,mailbold closes with +run,0,0,q,"mii:.m.di.0email.mailitalic ""x""",1,1,build italic span +true,0,0,q,"""""~-4#mii",1,1,mailitalic closes with + +/ Test 18: mailstring converts types to strings +true,0,0,q,"""hello""~.m.di.0email.mailstring ""hello""",1,1,mailstring passes through string +true,0,0,q,"""abc""~.m.di.0email.mailstring `abc",1,1,mailstring converts symbol to string + +/ Test 19: addcolor sets only color - no spurious font-size (mailcolors bug fix) +run,0,0,q,"ac:.m.di.0email.addcolor[""red"";""text""]",1,1,build addcolor result +true,0,0,q,"0""~-4#u",1,1,mailurl closes with +true,0,0,q,"0""~last mt",1,1,mailtable closes with +true,0,0,q,"0""~/:tmpl",1,1,template0 has html open tag +true,0,0,q,"(first where """"~/:tmpl)""~/:tmpl",1,1,blank separator comes before html body + +/ Test 29: dict2css produces semicolon-separated key:value css +run,0,0,q,"css:.m.di.0email.dict2css[(`$""color"";`$""background-color"")!(""red"";""blue"")]",1,1,build css from two-key dict +true,0,0,q,"0