// STRYKE-EMAIL — EMAIL + CAMPAIGN CLIENT FOR STRYKE // SMTP + MASS MAILING + TEMPLATES + COMPLIANCE

stryke package · namespace Email · cdylib libstryke_email · v0.3.0 · 27 public functions · opt-in (kept out of stryke core)

Report GitHub Issues

>_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.

KeyDefaultNotes
host— (required)SMTP server hostname; falls back to $SMTP_HOST
tlsstarttlsstarttls (587), tls (465), or none (25)
portby tlsOverride the TLS-implied default port
usernameSMTP auth user; falls back to $SMTP_USER
passwordSMTP 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.

FunctionReturns
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.

FunctionBehavior
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-UnsubscribeRFC 2369 / 8058 one-click header auto-added to every send_bulk message; build it standalone with unsubscribe_header.
Suppressionsuppress_filter + send_bulk's suppression honor opt-outs and bounces before send.
Rate limitingper_minute / delay_ms for deliverability and provider limits.
ValidationAddress 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.

FunctionSignature & return
versionversion() → package version string
verify_connectionverify_connection(%opts)1 on a successful EHLO/NOOP
sendsend($msg, %opts)1; $msg = { from, to, subject, text?, html?, cc?, bcc?, reply_to?, list_unsubscribe? }
send_rawsend_raw($raw, $from, $to, %opts)1; sends a pre-built RFC 5322 string
send_bulksend_bulk($template, $recipients, %opts){ sent, failed, skipped, results }
send_templatesend_template($template, $vars, $msg, %opts)1; merge then send a single message
build_messagebuild_message($msg) → raw RFC 5322 string (no send)
renderrender($template, $vars) → rendered { subject, text, html } hashref
mergemerge($tmpl, $vars) → one merged string
merge_manymerge_many($tmpl, $rows) → arrayref of merged strings
validatevalidate($address) → truthy when the address is RFC 5322-shaped (no MX lookup)
validate_listvalidate_list($addresses){ valid, invalid }
validate_messagevalidate_message($msg){ ok, errors }
parseparse($address){ name, email }
split_addressessplit_addresses($list)[ { name, email } ] (commas in quotes / angle brackets respected)
format_addressformat_address($email, name => ...) → RFC 5322 address string (inverse of parse)
domaindomain($address) → the domain part (display name stripped)
normalizenormalize($address) → canonical form: name stripped, trimmed, domain lowercased
unsubscribe_headerunsubscribe_header(url => ..., mailto => ...) → List-Unsubscribe header value
suppress_filtersuppress_filter($recipients, $suppression){ kept, removed }
dedupededupe($recipients){ unique, duplicates }
chunkchunk($recipients, $size) → arrayref of batches (0 = one batch)
group_by_domaingroup_by_domain($addresses){ domain => [emails] }
mailtomailto($to, subject => ..., body => ..., cc => ..., bcc => ...) → percent-encoded mailto: URL
parse_urlparse_url($url){ scheme, host, port, tls, username, password }
build_urlbuild_url($parts) → SMTP URI (inverse of parse_url)
redact_urlredact_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 (smtpstls, 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 errorThe 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 failsPass username / password in %opts, or export $SMTP_USER / $SMTP_PASS. verify_connection probes EHLO/NOOP without sending.
recipient skippedIt matched the suppression set (compared by normalized email). Check with suppress_filter first; the count shows up as skipped.
placeholder left blankThe {{key}} had no matching var. Merge renders unknown keys empty by design — verify the recipient's vars dict.
address rejectedvalidate 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:

The full API also lives in the README, and the internals are documented in the engineering report.