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,"",(first " "vs (),x),">"};
+mailewrap:{enlist["<",x,">"],y,enlist"",(first " "vs (),x),">"};
+
+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