Skip to content

Mail (SMTP / LMTP)

Some applications ingest data through SMTP or LMTP instead of HTTP: a ticketing system that opens a case from an inbound email, an archival service fed over LMTP, a parser that extracts an attachment. The mail provider lets a scenario deliver such a message, then verify the downstream effect through the regular API surface.

Use it for

  • Driving an SMTP / LMTP ingestion endpoint so the next HTTP / SQL step can observe the parsed result.
  • Delivering a message with a specific subject, header, or attachment to exercise a parsing rule.
  • Smoke-testing that the mail entry point accepts a well-formed message at all.

Don't use it for

  • Reading or polling a mailbox. Assert the effect of the message through HTTP / SQL / UI instead.
  • A full email client (threads, folders, search). The provider only sends.
  • Production mail delivery, DKIM signing, or deliverability testing.

Targets live under config.mail.targets.<name>. A target is either SMTP or LMTP.

config {
mail = {
targets = {
smtp_inbound = {
protocol = "smtp"
host = "127.0.0.1"
port = 2525
tls = false # implicit TLS
starttls = false # upgrade a plaintext connection
auth = {
username = env("SMTP_USERNAME", "")
password = env("SMTP_PASSWORD", "")
}
}
lmtp_inbound = {
protocol = "lmtp"
network = "tcp" # tcp | unix
address = "127.0.0.1:2424"
}
lmtp_socket = {
protocol = "lmtp"
network = "unix"
address = "/tmp/tales-ingest.sock"
}
}
}
}
FieldProtocolDefaultNotes
protocolbothsmtp or lmtp; anything else is rejected.
host / portsmtpRequired for SMTP.
tlssmtpfalseConnect with implicit TLS.
starttlssmtpfalseUpgrade a plaintext connection. Mutually exclusive with tls.
auth.username/passwordsmtpemptyAUTH PLAIN. Omit for no auth.
insecure_skip_verifysmtpfalseAccept a self-signed server certificate (test servers only).
networklmtptcp or unix.
addresslmtphost:port for tcp, socket path for unix. LMTP has no TLS / auth.
timeoutboth10sBounds the whole exchange (connect + send).

Validation errors are explicit and never echo the password, for example:

mail target "inbound" not found
unsupported mail protocol "imap"; supported protocols: smtp, lmtp
mail target "inbound" has empty SMTP host
mail target "ingest" has unsupported LMTP network "udp"
step "mail" "send_inbound_email" {
target = "smtp_inbound"
message {
subject = "Contract signature"
text = "Hello from Tales"
headers = {
"X-Test-ID" = result.create_case.id
}
}
expect {
json = {
accepted = true
recipients = {
rejected = []
}
}
}
capture {
message_id = response.json.message_id
}
}
FieldRequiredNotes
fromyesA single envelope sender.
to / cc / bccat least oneRecipient lists. The envelope is to ∪ cc ∪ bcc, deduped.
subjectnoQ-encoded automatically when it contains non-ASCII.
textno*Plain-text body (text/plain).
htmlno*HTML body (text/html). With text, produces multipart/alternative.
headersnoArbitrary headers (string / number / bool values).
attachmentno*Repeatable; see below.

* At least one of text, html, or attachment must be present.

If you do not supply a Message-ID header, Tales generates one as <[email protected]>, derived deterministically from the run seed (so identical runs produce identical ids). A supplied Message-ID header is used verbatim. The generated value is always exposed as response.json.message_id. If Date is missing it is set to the current time (non-deterministic, like now_unix).

message {
subject = "HTML test"
text = "Plain fallback"
html = "<p>Hello <strong>Tales</strong></p>"
}

Each attachment block sets filename and exactly one of path or content:

# from a file, resolved relative to the .tales file
attachment {
filename = "contract.pdf"
content_type = "application/pdf"
path = "./fixtures/contract.pdf"
}
# from an evaluated string
attachment {
filename = "proof.json"
content_type = "application/json"
content = jsonencode({ proof_id = result.create_proof.id })
}

content_type is optional; it is inferred from the filename extension when omitted, defaulting to application/octet-stream. Attachments produce a multipart/mixed message with base64-encoded parts.

The provider exposes its result under response.json:

{
"accepted": true,
"rejected": false,
"message_id": "<[email protected]>",
"protocol": "smtp",
"stage": "accepted",
"status_code": null,
"enhanced_status_code": "",
"message": "",
"recipients": {
"accepted": ["[email protected]"],
"rejected": []
}
}
FieldTypeMeaning
acceptedboolAt least one recipient accepted and the message was delivered to it.
rejectedboolA negative reply occurred at any stage.
stagestringaccepted | mail_from | rcpt | data | message.
status_codenumber|nullThe SMTP/LMTP status (e.g. 550); null when accepted.
enhanced_status_codestringe.g. "5.7.1"; "" when absent.
messagestringThe sanitized server reply text.
recipients.acceptedlist(string)Delivered recipients.
recipients.rejectedlist(object){ address, stage, status_code, enhanced_status_code, message }.

A rejection is not a Tales failure — it is a valid protocol response. The provider distinguishes:

  • Transport / runtime errors (cannot connect, timeout, TLS handshake, broken session, MIME build, invalid config) → the step fails immediately.
  • Protocol negative replies (4xx/5xx at MAIL FROM, RCPT, DATA, or the final response) → captured in response.json and assertable with expect.

So expect decides PASS/FAIL: a rejection that matches your assertion is a PASS; a rejection when you asserted accepted = true is an assertion failure (the step fails).

Assert a rejected sender domain:

step "mail" "send_invalid_sender" {
target = "smtp_inbound"
message {
text = "should be rejected"
}
expect {
json = {
accepted = false
rejected = true
stage = one_of(["mail_from", "rcpt", "data", "message"])
status_code = one_of([450, 451, 550, 554])
}
}
}

Assert a recipient rejected at RCPT (the step still passes):

expect {
json = {
accepted = false
rejected = true
stage = "rcpt"
recipients = {
accepted = []
rejected = [
{ address = "[email protected]", status_code = one_of([550, 551, 553]) },
]
}
}
}

Assert a DMARC/DKIM-style policy rejection after DATA:

expect {
json = {
accepted = false
rejected = true
status_code = one_of([550, 554])
message = matches("(?i)(dmarc|dkim|policy|reject|signature)")
}
}

LMTP reports per-recipient final responses, so one accepted and one rejected recipient yields accepted = true, rejected = true:

{
"accepted": true,
"rejected": true,
"protocol": "lmtp",
"stage": "message",
"recipients": {
"accepted": ["[email protected]"],
"rejected": [
{ "address": "[email protected]", "stage": "message", "status_code": 550, "enhanced_status_code": "5.1.1", "message": "user unknown" }
]
}
}

Send the message, capture its id, then verify the application processed it:

step "mail" "send_email" {
target = "smtp_inbound"
message {
subject = "SMTP ingestion test"
text = "Hello from Tales"
}
capture {
message_id = response.json.message_id
}
}
step "http" "assert_ingested" {
request {
method = "GET"
url = "${config.base_url}/cases?message_id=${url_encode(result.send_email.message_id)}"
}
expect {
status = 200
json = {
subject = "SMTP ingestion test"
}
}
}
  • The SMTP password lives in config; it is never copied into reports or error messages.
  • The report request map carries only metadata (protocol, target, addresses, subject, message-id, masked headers, attachment filename / content-type / size) — never the message body or attachment bytes.
  • Sensitive headers (Authorization, Cookie, Set-Cookie, *signature*) are masked through the shared header-masking helper.
  • No IMAP / POP3 reading, no DKIM signing, no SPF / DMARC validation, no MTA / mailbox storage. (The provider can assert a server’s DMARC/policy rejection, but it does not validate DMARC itself.)
  • SMTP AUTH is PLAIN-only (single-line RFC 4954 form). LMTP has no TLS or auth.
  • A fresh connection is opened per step.
  • The Date header and network timing are non-deterministic.
  • Session-setup failures (connect / greeting / EHLO / LHLO / AUTH) stay fatal — only MAIL FROM / RCPT / DATA / final-response rejections are assertable.
  • For an SMTP message-final rejection the single reply applies to the whole transaction (recipients.accepted is empty and the detail is the top-level stage = "message"); LMTP reports it per recipient.