>_STRYKE-EMAIL
Email + campaign client for stryke. Single send, personalized mass mailing, {{merge}} templates, List-Unsubscribe headers, suppression lists, and rate limiting — all over your own authenticated SMTP. Built on lettre with a pooled blocking transport (rustls, no tokio), cached per (host, port, tls, user). An opt-in package, kept out of the stryke core binary so the daily-driver install stays slim.
On this page
Install & load model
s add github.com/MenkeTechnologies/stryke-email
On first use Email, stryke dlopens the cdylib in-process and registers every email__* export via rust_ffi::load_cdylib. Each wrapper builds a JSON args dict, calls the FFI symbol, and parses the JSON return; an error key in the return is turned into a die of the form Email::<fn>: <reason>. A pooled SmtpTransport is cached per (host, port, tls, user) tuple for the life of the process, so a whole mass mailing reuses one authenticated connection rather than reconnecting per message.
The template, address, and compliance helpers take no connection and are unit-tested in-crate, so they validate in CI with no SMTP server.
Connecting (SMTP config)
Every send function takes connection parameters as trailing %opts. When host is omitted, the wrapper falls back to the $SMTP_HOST / $SMTP_USER / $SMTP_PASS environment variables.
| Key | Default | Notes |
|---|---|---|
| host | — (required) | SMTP server hostname; falls back to $SMTP_HOST |
| tls | starttls | starttls (587), tls (465), or none (25) |
| port | by tls | Override the TLS-implied default port |
| username | — | SMTP auth user; falls back to $SMTP_USER |
| password | — | SMTP auth password; falls back to $SMTP_PASS |
use Email
var %conn = ( host => "smtp.example.com", username => "me", password => $ENV{SMTP_PASS} )
# probe the connection before a campaign (EHLO/NOOP); returns 1 on success
p Email::verify_connection(%conn)
Send one message
Email::send($msg, %opts) takes a message hashref — from, to, subject, optional text / html / cc / bcc / reply_to / list_unsubscribe. to/cc/bcc accept a string or an arrayref. With both text and html set, a multipart/alternative message is built. Returns 1 on success.
Email::send(
{ from => "Me <me@example.com>",
to => "customer@example.com",
subject => "Your license key",
text => "Thanks for your purchase. Key: ABC-123",
html => "<p>Thanks for your purchase. Key: <code>ABC-123</code></p>" },
%conn,
)
Send a pre-built message
Email::send_raw($raw, $from, $to, %opts) sends a full RFC 5322 message string you already assembled (for example, one returned by Email::build_message after signing or queueing). The envelope from and to (string or arrayref) are passed explicitly.
val $raw = Email::build_message(
{ from => "Me <me@example.com>", to => "you@example.com",
subject => "Preview", text => "queued earlier" }
)
# ... store / sign / inspect $raw ...
Email::send_raw($raw, "me@example.com", "you@example.com", %conn)
Templates & merge
The merge engine is a deliberate mustache-lite: {{ key }} substitution only — no conditionals, no loops, unknown keys render empty. Keys are trimmed; string values insert verbatim, other JSON values insert their string form.
| Function | Returns |
|---|---|
| Email::merge($tmpl, $vars) | One string with {{key}} substituted from the $vars dict |
| Email::merge_many($tmpl, $rows) | Arrayref of strings — the template rendered once per var dict in $rows |
| Email::render($template, $vars) | A rendered { subject, text, html } hashref (each field merged with $vars) |
p Email::merge("Hi {{name}}", { name => "Ada" }) # "Hi Ada"
p Email::merge("Hi {{missing}}!", {}) # "Hi !" (unknown key -> empty)
p Email::merge_many("{{p}} is out", [ { p => "v2" }, { p => "v3" } ])
val $r = Email::render(
{ subject => "{{product}} update", html => "<p>Hi {{name}}</p>" },
{ product => "Audio-Haxor", name => "Ada" },
)
Mass mailing / campaigns
Personalized, suppression-aware, rate-limited, with the unsubscribe header injected automatically. send_bulk iterates the recipient list once: per recipient it merges the subject/text/html template with the recipient's vars (plus email and name), skips suppressed addresses, injects List-Unsubscribe (whose URL can itself be per-recipient via {{email}}), sends, and records the outcome.
val $res = Email::send_bulk(
{ subject => "Hi {{name}}, {{product}} v2 is out",
html => "<p>Hi {{name}}, your {{product}} update is ready.</p>" },
[
{ email => "ada@example.com", name => "Ada", vars => { product => "Audio-Haxor" } },
{ email => "alan@example.com", name => "Alan", vars => { product => "traderview" } },
],
from => "Me <me@example.com>",
suppression => $opt_outs, # arrayref of opted-out emails
rate => { per_minute => 120 }, # or { delay_ms => 500 }
list_unsubscribe => { url => "https://example.com/u?e={{email}}", mailto => "unsub@example.com" },
%conn,
)
p $res->{sent} # 2
p $res->{failed} # 0
p $res->{skipped} # suppressed count
p $res->{results} # [ { email, ok, error? }, ... ]
The result set { sent, failed, skipped, results } lets the caller retry failures and update its own bounce / opt-out state. rate accepts an explicit delay_ms between sends, or a per_minute rate the cdylib converts into a sleep interval.
Preflight & list ops
Network-free helpers to prepare and de-risk a recipient list before a campaign. All are pure and CI-tested.
| Function | Behavior |
|---|---|
| Email::validate_message($msg) | Returns { ok, errors } — flags missing/invalid from, no recipients, all-invalid recipients, empty body |
| Email::build_message($msg) | Builds the raw RFC 5322 string without sending (preview / sign / queue) |
| Email::dedupe($recipients) | Returns { unique, duplicates } — drops repeats by normalized email (first wins) |
| Email::chunk($recipients, $size) | Arrayref of batches of at most $size (0 = one batch) for per-batch provider limits |
| Email::group_by_domain($addresses) | Returns { "example.com" => [emails], ... } for per-domain throttling |
| Email::suppress_filter($recip, $supp) | Returns { kept, removed } split by a suppression arrayref |
val $vm = Email::validate_message({ from => "me@x.com", to => "you@x.com", text => "hi" })
p $vm->{ok} # true
val $d = Email::dedupe([ { email => "a@x.com" }, { email => "A@x.com" } ])
p scalar @{ $d->{unique} } # 1 (normalized match)
p Email::chunk([1, 2, 3, 4, 5], 2) # [ [1,2], [3,4], [5] ]
p Email::group_by_domain([ "a@x.com", "b@y.com", "c@x.com" ])
Compliance, built in
For sending to your own opted-in recipients through your own SMTP. The package ships the mechanisms that keep that legitimate:
| List-Unsubscribe | RFC 2369 / 8058 one-click header auto-added to every send_bulk message; build it standalone with unsubscribe_header. |
| Suppression | suppress_filter + send_bulk's suppression honor opt-outs and bounces before send. |
| Rate limiting | per_minute / delay_ms for deliverability and provider limits. |
| Validation | Address checks (validate, validate_list, validate_message) before sending. |
Consent and honoring unsubscribes (CAN-SPAM, GDPR, your provider's ToS) is the sender's responsibility — the package gives you the tools, not a way around them.
Full function reference
27 public functions across the Email namespace. Send functions take trailing %opts SMTP params; everything else is network-free.
| Function | Signature & return |
|---|---|
| version | version() → package version string |
| verify_connection | verify_connection(%opts) → 1 on a successful EHLO/NOOP |
| send | send($msg, %opts) → 1; $msg = { from, to, subject, text?, html?, cc?, bcc?, reply_to?, list_unsubscribe? } |
| send_raw | send_raw($raw, $from, $to, %opts) → 1; sends a pre-built RFC 5322 string |
| send_bulk | send_bulk($template, $recipients, %opts) → { sent, failed, skipped, results } |
| send_template | send_template($template, $vars, $msg, %opts) → 1; merge then send a single message |
| build_message | build_message($msg) → raw RFC 5322 string (no send) |
| render | render($template, $vars) → rendered { subject, text, html } hashref |
| merge | merge($tmpl, $vars) → one merged string |
| merge_many | merge_many($tmpl, $rows) → arrayref of merged strings |
| validate | validate($address) → truthy when the address is RFC 5322-shaped (no MX lookup) |
| validate_list | validate_list($addresses) → { valid, invalid } |
| validate_message | validate_message($msg) → { ok, errors } |
| parse | parse($address) → { name, email } |
| split_addresses | split_addresses($list) → [ { name, email } ] (commas in quotes / angle brackets respected) |
| format_address | format_address($email, name => ...) → RFC 5322 address string (inverse of parse) |
| domain | domain($address) → the domain part (display name stripped) |
| normalize | normalize($address) → canonical form: name stripped, trimmed, domain lowercased |
| unsubscribe_header | unsubscribe_header(url => ..., mailto => ...) → List-Unsubscribe header value |
| suppress_filter | suppress_filter($recipients, $suppression) → { kept, removed } |
| dedupe | dedupe($recipients) → { unique, duplicates } |
| chunk | chunk($recipients, $size) → arrayref of batches (0 = one batch) |
| group_by_domain | group_by_domain($addresses) → { domain => [emails] } |
| mailto | mailto($to, subject => ..., body => ..., cc => ..., bcc => ...) → percent-encoded mailto: URL |
| parse_url | parse_url($url) → { scheme, host, port, tls, username, password } |
| build_url | build_url($parts) → SMTP URI (inverse of parse_url) |
| redact_url | redact_url($url) → URI with the password replaced by *** |
SMTP URL helpers
Parse, rebuild, and safely log SMTP connection strings. parse_url derives the TLS mode (smtps → tls, otherwise starttls) and the default port (465 / 587) from the scheme; redact_url masks the password before it reaches a log line.
val $parts = Email::parse_url("smtps://me:secret@mail.example.com:465")
# { scheme => "smtps", host => "mail.example.com", port => 465, tls => "tls", username => "me", ... }
p Email::build_url({ host => "h", port => 587, username => "u", password => "p", tls => "starttls" })
p Email::redact_url("smtps://user:secret@mail:465") # "smtps://user:***@mail:465"
p Email::mailto("you@x.com", subject => "Hi there") # "mailto:you@x.com?subject=Hi%20there"
Build & test
make debug # cargo build make test # cargo test, then `s test t/` (live sends need $SMTP_HOST) make install # s pkg install -g .
cargo test runs the in-crate unit tests (merge, validation, parsing, unsubscribe value, multipart build, URL round-trip, normalize) with no server. Point $SMTP_HOST at a local catch-all like MailHog (tls => none, port 1025) to exercise the send path without emailing anyone real.
Troubleshooting
| relay / starttls error | The TLS mode does not match the port. Set tls => tls for 465, starttls for 587, none for a plaintext catch-all on 25/1025. |
| auth fails | Pass username / password in %opts, or export $SMTP_USER / $SMTP_PASS. verify_connection probes EHLO/NOOP without sending. |
| recipient skipped | It matched the suppression set (compared by normalized email). Check with suppress_filter first; the count shows up as skipped. |
| placeholder left blank | The {{key}} had no matching var. Merge renders unknown keys empty by design — verify the recipient's vars dict. |
| address rejected | validate requires one @, a non-empty local part, and a dotted domain with no whitespace. It is a shape check, not an MX/deliverability lookup. |
Why a package, not a builtin
The SMTP + TLS stack (lettre, rustls, the message builder) ships once, on demand, as an opt-in package — stryke core is never linked against it. The stryke side is a thin .stk wrapper; the heavy code lives in the libstryke_email cdylib and is dlopened on first use Email. The cdylib holds one pooled blocking transport per (host, port, tls, user) tuple, reused across a whole mass mailing — no per-message reconnect.
Sibling packages
Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo:
- stryke-arrow — Apache Arrow / Parquet / Feather
- stryke-aws — S3, DynamoDB, SQS, Lambda, STS
- stryke-azure — Blob, Queues, Cosmos DB, Key Vault, Entra
- stryke-clickhouse — ClickHouse
- stryke-demo — live demos for every connector
- stryke-docker — Docker daemon API
- stryke-duckdb — embedded DuckDB SQL engine
- stryke-fleet — parallel expect/PTY automation
- stryke-gcp — Cloud Storage, Pub/Sub, Secret Manager, BigQuery, Firestore
- stryke-grpc — reflection-based gRPC client
- stryke-gui — mouse / keyboard / screen / clipboard automation
- stryke-k8s — Kubernetes
- stryke-kafka — Apache Kafka
- stryke-mcpd — MCP servers without a runtime
- stryke-mongo — MongoDB
- stryke-mssql — Microsoft SQL Server
- stryke-mysql — MySQL / MariaDB
- stryke-neo4j — Neo4j graph (Bolt)
- stryke-office — Office docs / PDF / images
- stryke-parquet — Parquet file inspector
- stryke-polars — Polars + ndarray + linalg + FFT
- stryke-postgres — PostgreSQL
- stryke-redis — Redis / Valkey
- stryke-scrape — web scraping / crawling
- stryke-scylla — ScyllaDB / Cassandra
- stryke-search — Elasticsearch / OpenSearch
- stryke-selenium — browser automation (WebDriver)
- stryke-spark — Spark Connect (no JVM)
- stryke-utils — pure-stryke utility belt
- stryke-zmq — ZeroMQ messaging
The full API also lives in the README, and the internals are documented in the engineering report.