moonmail

RFC-driven SMTP client and MIME message builder for MoonBit.

email
smtp
mime
mail
message
moon add howhere7/moonmail@0.1.0
Download zip
Author
Version
0.1.0
License
Apache-2.0
Last updated
6 hours ago
Downloads
3

Dependencies

README

#MoonMail

A transport-agnostic, RFC-driven email sending library for MoonBit. MoonMail builds RFC 5322/5321 messages with full MIME encoding (including an original Quoted-Printable implementation) and drives a complete SMTP client session over a pluggable transport.

#Features

  • Message modelMailMessage, MailAddress (addr-spec parsing, display names, comments), header building, folding and line-length control.
  • Header-injection protection — any CR/LF or NUL in a header value is rejected before it can reach the wire (MM_INJ_001 / MM_INJ_002).
  • MIMEContent-Type parsing, Base64 and original Quoted-Printable encoders, 7bit/8bit, multipart/mixed|alternative|related, boundaries, attachments and inline images (CID).
  • MessageBuilder — a fluent API for plain text, HTML, alternatives, attachments and multiple recipients (To/Cc/Bcc).
  • CRLF normalization + dot-stuffing — arbitrary newlines become CRLF; lines starting with . are escaped for SMTP DATA.
  • SMTP clientEHLO/HELO, capability parsing, AUTH LOGIN/PLAIN/CRAM-MD5 (HMAC-MD5 built on the gmlewis/md5 dependency), MAIL/RCPT/DATA/QUIT, multi-line reply parsing, enhanced status codes and error classification.
  • Transport abstraction — the protocol core never touches a socket. It talks to a SmtpTransport: FakeTransport (deterministic, offline, scripted server) or SocketTransport (native, over moonbitlang/async/socket).
  • Diagnostics — every session returns a SmtpTrace (command -> reply steps, capabilities, auth mechanism). Credentials and full AUTH payloads are never recorded.

#Quick start

import "howhere7/moonmail"

async fn main {
let builder = @moonmail.MessageBuilder::new()
let _ = builder
.from("alice@example.com")
.to("bob@example.com")
.subject("Hello")
.text("This is a plain text message.")
let msg = builder.build()
let env = builder.envelope()

// Deterministic offline session using the scripted server.
let script = [
@moonmail.FakeStep::greeting(["220 smtp.example.com ESMTP"]),
@moonmail.FakeStep::reply("EHLO localhost", ["250 OK"]),
@moonmail.FakeStep::reply("MAIL FROM:<alice@example.com>", ["250 2.1.0 OK"]),
@moonmail.FakeStep::reply("RCPT TO:<bob@example.com>", ["250 2.1.5 OK"]),
@moonmail.FakeStep::reply("DATA", ["354 End data"]),
@moonmail.FakeStep::reply(".", ["250 2.0.0 OK"]),
@moonmail.FakeStep::reply("QUIT", ["221 2.0.0 Bye"]),
]
let client = @moonmail.SmtpClient::new(
@moonmail.FakeTransport::new(script),
@moonmail.SmtpConfig::new("localhost"),
)
let trace = client.send(msg, env, @moonmail.RenderConfig::deterministic())
println(trace.render())
}

To send over a real connection on native, use SocketTransport:

///|
let config = @moonmail.SmtpConfig::new("smtp.example.com", port=587).with_auth(
@moonmail.AuthMethod::Plain("user", "password"),
)

///|
let client = @moonmail.SmtpClient::new(@moonmail.SocketTransport::new(), config)

///|
let trace = client.send(msg, env, @moonmail.RenderConfig::new())

#CLI

moonmail send --smtp smtp.example.com --port 587 \ --from user@example.com --to a@b.com --cc c@d.com \ --subject "title" --body text.txt [--html] \ [--attach f1] [--attach f2] \ [--auth-user u --auth-pass p] moonmail explain scenario.json # replay a FakeTransport script offline

Run from the repository root with:

moon run cmd/main --target native -- send --help moon run cmd/main --target native -- explain testdata/scenarios/basic.json

#Examples

Five runnable examples live under examples/:

exampledemonstrates
send_plainplain text message
send_htmlHTML body in multipart/alternative
send_attachmentbinary attachment in multipart/mixed
send_multipleTo/Cc/Bcc multi-recipient envelope
fake_sessionfull scripted session over FakeTransport (offline)

moon run examples/fake_session --target native

#Testing

moon check --deny-warn moon check --target native/js/wasm-gc --deny-warn moon test --target native

The core package checks on native, js and wasm-gc. The socket adapter and the CLI/examples are native-only (the socket adapter has js/wasm stubs that raise Unsupported). All core tests run offline; the only network test spins up a local mock SMTP server over TcpServer on native.

#Documentation

  • docs/SCOPE.md — what MoonMail does and does not do
  • docs/DESIGN.md — architecture and layering
  • docs/MIME_MODEL.md — the MIME representation
  • docs/SMTP_SUPPORT.md — RFC support matrix
  • docs/REASON_CODES.md — stable reason codes
  • docs/SECURITY_MODEL.md — security properties
  • docs/TESTING.md — test strategy
  • docs/LIMITATIONS.md — known limitations

#License

Apache-2.0.

#
SmtpTransport

pub(open) trait SmtpTransport {
async fn connect(Self, SmtpConfig) -> Unit raise MailFailure
async fn write_line(Self, String) -> Unit raise MailFailure
async fn read_line(Self) -> String raise MailFailure
fn close(Self) -> Unit
}

The transport abstraction. The SMTP session talks only to this interface: the network adapter (socket_transport) and the deterministic scripted server (FakeTransport) both implement it. The core never touches a socket directly, so it stays portable across backends.

#
MailFailure

pub(all) suberror MailFailure {
MailFailure(MailError)
} derive(
Debug
)

Raised by MoonMail functions that fail. The payload is a MailError that carries the stable reason code and a human-readable message.
impl Show for MailFailure

#
MailFailure::auth

fn MailFailure::auth(message : String) -> MailFailure

#
MailFailure::config

fn MailFailure::config(message : String) -> MailFailure

#
MailFailure::content_length

fn MailFailure::content_length(message : String) -> MailFailure

#
MailFailure::encode

fn MailFailure::encode(message : String) -> MailFailure

#
MailFailure::header_injection

fn MailFailure::header_injection(message : String) -> MailFailure

#
MailFailure::invalid_address

fn MailFailure::invalid_address(message : String) -> MailFailure

#
MailFailure::invalid_header

fn MailFailure::invalid_header(message : String) -> MailFailure

#
MailFailure::io

fn MailFailure::io(message : String) -> MailFailure

#
MailFailure::mail_error

fn MailFailure::mail_error(self : MailFailure) -> MailError

#
MailFailure::mime

fn MailFailure::mime(message : String) -> MailFailure

#
MailFailure::of

fn MailFailure::of(kind : MailErrorKind, reason : ReasonCode, message : String) -> MailFailure

#
MailFailure::smtp

fn MailFailure::smtp(reason : ReasonCode, message : String) -> MailFailure

#
MailFailure::smtp_permanent

fn MailFailure::smtp_permanent(message : String) -> MailFailure

#
MailFailure::smtp_transient

fn MailFailure::smtp_transient(message : String) -> MailFailure

#
MailFailure::timeout

fn MailFailure::timeout(message : String) -> MailFailure

#
MailFailure::transport

fn MailFailure::transport(message : String) -> MailFailure

#
MailFailure::unsupported

fn MailFailure::unsupported(message : String) -> MailFailure

#
Attachment

pub struct Attachment {
data : Bytes
filename : String
content_type : String
cid : String
} derive(Eq,
Debug
)

A single attachment or inline image attached to a message.

#
Attachment::file

fn Attachment::file(data : Bytes, filename : String) -> Attachment

#
Attachment::inline_image

fn Attachment::inline_image(data : Bytes, filename : String, cid : String) -> Attachment

#
AuthMethod

pub(all) enum AuthMethod {
Login(String, String)
Plain(String, String)
CramMd5(String, String)
} derive(Eq,
Debug
)

SMTP authentication mechanism selection.

#
AuthMethod::username

fn AuthMethod::username(self : AuthMethod) -> String

#
ContentTransferEncoding

pub(all) enum ContentTransferEncoding {
SevenBit
EightBit
Base64
QuotedPrintable
Auto
} derive(Eq,
Debug
)

Transfer-encoding choice for a body part (RFC 2045 section 6.1).

#
ContentTransferEncoding::to_string

fn ContentTransferEncoding::to_string(self : ContentTransferEncoding) -> String

#
ContentType

pub struct ContentType {
type_ : String
subtype : String
params : Array[(String, String)]
} derive(Eq,
Debug
)

A parsed MIME Content-Type: a type/subtype plus ordered parameters (RFC 2045 section 5.1).

#
ContentType::get_param

fn ContentType::get_param(self : ContentType, name : String) -> String?

#
ContentType::is_multipart

fn ContentType::is_multipart(self : ContentType) -> Bool

#
ContentType::new

fn ContentType::new(type_ : String, subtype : String) -> ContentType

#
ContentType::params

fn ContentType::params(self : ContentType) -> Array[(String, String)]

#
ContentType::parse

fn ContentType::parse(input : String) -> ContentType raise MailFailure

Parse a Content-Type header value into a ContentType.

#
ContentType::set_param

fn ContentType::set_param(self : ContentType, name : String, value : String) -> Unit

Add (or replace) a parameter like boundary=... or charset=utf-8.

#
ContentType::subtype_name

fn ContentType::subtype_name(self : ContentType) -> String

#
ContentType::to_string

fn ContentType::to_string(self : ContentType) -> String

Render the Content-Type header value, e.g. multipart/mixed; boundary="abc".

#
ContentType::type_name

fn ContentType::type_name(self : ContentType) -> String

#
Disposition

pub(all) enum Disposition {
Inline
Attachment
} derive(Eq,
Debug
)

Content-Disposition disposition type (RFC 2183).
impl Show for Disposition

#
Disposition::to_string

fn Disposition::to_string(self : Disposition) -> String

#
Envelope

pub struct Envelope {
from : MailAddress
recipients : Array[MailAddress]
} derive(Eq,
Debug
)

The SMTP envelope: the sender (MAIL FROM) and the recipients (RCPT TO).

#
Envelope::from

fn Envelope::from(self : Envelope) -> MailAddress

#
Envelope::mail_from

fn Envelope::mail_from(self : Envelope) -> String

The reverse-path as required by MAIL FROM, with an empty sender (<>) allowed for null reverse-paths used in bounces.

#
Envelope::new

fn Envelope::new(from : MailAddress, recipients : Array[MailAddress]) -> Envelope

#
Envelope::rcpt_to

fn Envelope::rcpt_to(self : Envelope) -> Array[String]

One RCPT TO per recipient.

#
Envelope::recipients

fn Envelope::recipients(self : Envelope) -> Array[MailAddress]

#
FakeStep

pub struct FakeStep {
expect : String
send : Array[String]
} derive(Eq,
Debug
)

One step of a FakeTransport script: the client command to expect (verbatim, or "" for a spontaneous server reply such as the greeting, or "*" to accept any line) and the reply lines to send back.

#
FakeStep::greeting

fn FakeStep::greeting(send : Array[String]) -> FakeStep

#
FakeStep::new

fn FakeStep::new(expect : String, send : Array[String]) -> FakeStep

#
FakeStep::reply

fn FakeStep::reply(expect : String, send : Array[String]) -> FakeStep

#
FakeTransport

pub struct FakeTransport {
script : Array[FakeStep]
step_index : Int
reply_queue : Array[String]
expecting : String
data_mode : Bool
received : Array[String]
data_lines : Array[String]
connected : Bool
failure : String?
}

A deterministic, scripted SMTP server used for offline testing. It implements SmtpTransport and records every client command so tests can assert the exact command sequence and the message body transmitted.

#
FakeTransport::data_lines

fn FakeTransport::data_lines(self : FakeTransport) -> Array[String]

The message body lines received during the DATA phase.

#
FakeTransport::failure

fn FakeTransport::failure(self : FakeTransport) -> String?

The first script mismatch description, if any.

#
FakeTransport::is_complete

fn FakeTransport::is_complete(self : FakeTransport) -> Bool

True when every script step was consumed (the conversation completed).

#
FakeTransport::is_connected

fn FakeTransport::is_connected(self : FakeTransport) -> Bool

Whether the transport is connected.

#
FakeTransport::new

#
FakeTransport::received

fn FakeTransport::received(self : FakeTransport) -> Array[String]

Every client command line received (verbatim).

#
MailAddress

pub struct MailAddress {
display_name : String
local_part : String
domain : String
} derive(Eq,
Debug
)

A parsed email address: an optional display name plus the local@domain addr-spec (RFC 5322 section 3.4).
impl Show for MailAddress

#
MailAddress::addr_spec

fn MailAddress::addr_spec(self : MailAddress) -> String

#
MailAddress::display_name

fn MailAddress::display_name(self : MailAddress) -> String

#
MailAddress::domain

fn MailAddress::domain(self : MailAddress) -> String

#
MailAddress::local_part

fn MailAddress::local_part(self : MailAddress) -> String

#
MailAddress::new

fn MailAddress::new(local_part : String, domain : String, display_name? : String) -> MailAddress

#
MailAddress::parse

fn MailAddress::parse(input : String) -> MailAddress raise MailFailure

Parse a single mailbox:
  • "Display Name" <local@domain>
  • local@domain (comment)
  • local@domain
  • Display Name <local@domain>

#
MailAddress::to_string

fn MailAddress::to_string(self : MailAddress) -> String

Render the address back to a single RFC 5322 mailbox: Display Name <local@domain> when a display name is present, otherwise the bare addr-spec.

#
MailError

pub struct MailError {
kind : MailErrorKind
reason : ReasonCode
message : String
} derive(Eq,
Debug
)

The unified error type for MoonMail. Every failure carries a stable ReasonCode (see docs/REASON_CODES.md) plus a human-readable message.
impl Show for MailError

#
MailError::auth

fn MailError::auth(message : String) -> MailError

#
MailError::config

fn MailError::config(message : String) -> MailError

#
MailError::content_length

fn MailError::content_length(message : String) -> MailError

#
MailError::encode

fn MailError::encode(message : String) -> MailError

#
MailError::header_injection

fn MailError::header_injection(message : String) -> MailError

#
MailError::invalid_address

fn MailError::invalid_address(message : String) -> MailError

#
MailError::invalid_header

fn MailError::invalid_header(message : String) -> MailError

#
MailError::io

fn MailError::io(message : String) -> MailError

#
MailError::kind

fn MailError::kind(self : MailError) -> MailErrorKind

#
MailError::message

fn MailError::message(self : MailError) -> String

#
MailError::mime

fn MailError::mime(message : String) -> MailError

#
MailError::of

fn MailError::of(kind : MailErrorKind, reason : ReasonCode, message : String) -> MailError

#
MailError::reason

fn MailError::reason(self : MailError) -> ReasonCode

#
MailError::smtp

fn MailError::smtp(reason : ReasonCode, message : String) -> MailError

#
MailError::smtp_permanent

fn MailError::smtp_permanent(message : String) -> MailError

#
MailError::smtp_transient

fn MailError::smtp_transient(message : String) -> MailError

#
MailError::timeout

fn MailError::timeout(message : String) -> MailError

#
MailError::transport

fn MailError::transport(message : String) -> MailError

#
MailError::unsupported

fn MailError::unsupported(message : String) -> MailError

#
MailErrorKind

pub(all) enum MailErrorKind {
InvalidAddress
InvalidHeader
HeaderInjection
Mime
Encode
ContentLength
Smtp
SmtpPermanent
SmtpTransient
Auth
Transport
Config
Timeout
Unsupported
Io
} derive(Eq,
Debug
)

Coarse category used to classify where a failure happened.

#
MailErrorKind::to_string

fn MailErrorKind::to_string(self : MailErrorKind) -> String

#
MailMessage

pub struct MailMessage {
headers : Array[(String, String)]
body : MimeBody
} derive(Eq,
Debug
)

A parsed email message: header fields plus a MIME body tree.

#
MailMessage::add_header

fn MailMessage::add_header(self : MailMessage, name : String, value : String) -> Unit

#
MailMessage::body

fn MailMessage::body(self : MailMessage) -> MimeBody

#
MailMessage::header

fn MailMessage::header(self : MailMessage, name : String) -> String?

Find the first header value for name (case-insensitive), or None.

#
MailMessage::headers

fn MailMessage::headers(self : MailMessage) -> Array[(String, String)]

#
MailMessage::new

fn MailMessage::new(body : MimeBody) -> MailMessage

#
MailMessage::set_header

fn MailMessage::set_header(self : MailMessage, name : String, value : String) -> Unit

Set (replacing any existing) header name.

#
MessageBuilder

pub struct MessageBuilder {
from : MailAddress?
to : Array[MailAddress]
cc : Array[MailAddress]
bcc : Array[MailAddress]
reply_to : Array[MailAddress]
subject : String
text_part : String?
html_part : String?
attachments : Array[Attachment]
inline_images : Array[Attachment]
custom_headers : Array[(String, String)]
date : String
message_id : String
boundary_seed : String
}

Fluent builder for MailMessage. Addresses and header values are validated (including header-injection checks) as they are added, so a built message is always safe to render.

#
MessageBuilder::attach

fn MessageBuilder::attach(self : MessageBuilder, data : Bytes, filename : String, content_type? : String) -> MessageBuilder

Add a binary attachment with an optional explicit Content-Type.

#
MessageBuilder::bcc

fn MessageBuilder::bcc(self : MessageBuilder, list : String) -> MessageBuilder raise MailFailure

#
MessageBuilder::boundary_seed

fn MessageBuilder::boundary_seed(self : MessageBuilder, seed : String) -> MessageBuilder

#
MessageBuilder::build

Assemble the final message tree plus headers.

#
MessageBuilder::cc

fn MessageBuilder::cc(self : MessageBuilder, list : String) -> MessageBuilder raise MailFailure

#
MessageBuilder::date

fn MessageBuilder::date(self : MessageBuilder, date : String) -> MessageBuilder

#
MessageBuilder::envelope

fn MessageBuilder::envelope(self : MessageBuilder) -> Envelope raise MailFailure

The envelope derived from the builder: the sender plus To/Cc/Bcc recipients. The sender must be set before calling this.

#
MessageBuilder::from

fn MessageBuilder::from(self : MessageBuilder, addr : String) -> MessageBuilder raise MailFailure

#
MessageBuilder::from_addr

fn MessageBuilder::from_addr(self : MessageBuilder, addr : MailAddress) -> MessageBuilder

#
MessageBuilder::header

fn MessageBuilder::header(self : MessageBuilder, name : String, value : String) -> MessageBuilder raise MailFailure

Add a custom header. The value is checked for CR/LF injection immediately.

#
MessageBuilder::html

fn MessageBuilder::html(self : MessageBuilder, content : String) -> MessageBuilder

#
MessageBuilder::inline_image

fn MessageBuilder::inline_image(self : MessageBuilder, data : Bytes, filename : String, cid : String) -> MessageBuilder

#
MessageBuilder::message_id

fn MessageBuilder::message_id(self : MessageBuilder, message_id : String) -> MessageBuilder

#
MessageBuilder::new

#
MessageBuilder::reply_to

fn MessageBuilder::reply_to(self : MessageBuilder, list : String) -> MessageBuilder raise MailFailure

#
MessageBuilder::subject

fn MessageBuilder::subject(self : MessageBuilder, subject : String) -> MessageBuilder raise MailFailure

#
MessageBuilder::text

fn MessageBuilder::text(self : MessageBuilder, content : String) -> MessageBuilder

#
MessageBuilder::to

fn MessageBuilder::to(self : MessageBuilder, list : String) -> MessageBuilder raise MailFailure

#
MimeBody

pub(all) enum MimeBody {
Text(String)
Html(String)
Binary(Bytes, String)
MultiPart(Array[Part], MimeSubtype)
} derive(Eq,
Debug
)

The body of a message or part.

#
MimeSubtype

pub(all) enum MimeSubtype {
Mixed
Alternative
Related
} derive(Eq,
Debug
)

MIME multipart subtype.
impl Show for MimeSubtype

#
MimeSubtype::to_string

fn MimeSubtype::to_string(self : MimeSubtype) -> String

#
Part

pub struct Part {
headers : Array[(String, String)]
body : MimeBody
} derive(Eq,
Debug
)

A single MIME part: its own headers plus a body.

#
Part::add_header

fn Part::add_header(self : Part, name : String, value : String) -> Unit

#
Part::body

fn Part::body(self : Part) -> MimeBody

#
Part::header

fn Part::header(self : Part, name : String) -> String?

Find the first header value for name (case-insensitive), or None.

#
Part::headers

fn Part::headers(self : Part) -> Array[(String, String)]

#
Part::new

fn Part::new() -> Part

#
Part::set_header

fn Part::set_header(self : Part, name : String, value : String) -> Unit

#
ReasonCode

pub(all) enum ReasonCode {
MM_HEADER_001
MM_HEADER_002
MM_HEADER_003
MM_HEADER_004
MM_ADDR_001
MM_ADDR_002
MM_ADDR_003
MM_ADDR_004
MM_ADDR_005
MM_ADDR_006
MM_ADDR_007
MM_ADDR_008
MM_ENCOD_001
MM_ENCOD_002
MM_ENCOD_003
MM_ENCOD_004
MM_ENCOD_005
MM_MIME_001
MM_MIME_002
MM_MIME_003
MM_MIME_004
MM_MIME_005
MM_MIME_006
MM_CRLF_001
MM_DOT_001
MM_INJ_001
MM_INJ_002
MM_INJ_003
MM_SMTP_001
MM_SMTP_002
MM_SMTP_003
MM_SMTP_004
MM_SMTP_005
MM_SMTP_006
MM_SMTP_007
MM_AUTH_001
MM_AUTH_002
MM_TRANS_001
MM_TRANS_002
Other
} derive(Eq,
Debug
)

Stable, documented reason codes used to classify the outcome of every encoding, parsing and protocol step. Codes are permanent identifiers: they never change meaning across releases, and new codes are only appended. The full mapping is documented in docs/REASON_CODES.md.
impl Show for ReasonCode

#
ReasonCode::to_string

fn ReasonCode::to_string(self : ReasonCode) -> String

#
RenderConfig

pub struct RenderConfig {
date : String
message_id : String
boundary_seed : String
} derive(Eq,
Debug
)

Deterministic rendering controls. The date, message-id and boundary seed are all injected by the caller so that tests are reproducible; nothing here reads the system clock or an RNG.

#
RenderConfig::deterministic

fn RenderConfig::deterministic() -> RenderConfig

A fully deterministic default configuration (no date, no message-id, fixed seed).

#
RenderConfig::new

fn RenderConfig::new(date? : String, message_id? : String, boundary_seed? : String) -> RenderConfig

#
ReplyLine

pub struct ReplyLine {
code : Int
enhanced_code : String?
text : String
more : Bool
} derive(Eq,
Debug
)

A single SMTP reply line: a 3-digit code, an optional enhanced status code (RFC 3463, e.g. 2.1.0), the text, and whether more lines follow.

#
ServerReply

pub struct ServerReply {
code : Int
enhanced_code : String?
lines : Array[String]
text : String
} derive(Eq,
Debug
)

A fully collected server reply (all lines of a multi-line response).
impl Show for ServerReply

#
ServerReply::code

fn ServerReply::code(self : ServerReply) -> Int

#
ServerReply::enhanced_code

fn ServerReply::enhanced_code(self : ServerReply) -> String?

#
ServerReply::from_lines

fn ServerReply::from_lines(lines : Array[String]) -> ServerReply

Build a ServerReply from the collected reply lines (the raw lines that were received, including code prefixes).

#
ServerReply::is_intermediate

fn ServerReply::is_intermediate(self : ServerReply) -> Bool

The reply carries a 3xx intermediate code.

#
ServerReply::is_permanent

fn ServerReply::is_permanent(self : ServerReply) -> Bool

The reply carries a 5xx permanent negative completion code.

#
ServerReply::is_positive

fn ServerReply::is_positive(self : ServerReply) -> Bool

The reply carries a 2xx positive completion code.

#
ServerReply::is_transient

fn ServerReply::is_transient(self : ServerReply) -> Bool

The reply carries a 4xx transient negative completion code.

#
ServerReply::lines

fn ServerReply::lines(self : ServerReply) -> Array[String]

#
ServerReply::text

fn ServerReply::text(self : ServerReply) -> String

#
SmtpCaps

pub struct SmtpCaps {
auth : Array[String]
size : Int?
has_8bitmime : Bool
has_smtputf8 : Bool
has_pipelining : Bool
has_starttls : Bool
raw : Array[String]
} derive(Eq,
Debug
)

The capabilities advertised by the server in the EHLO reply.

#
SmtpCaps::auth

fn SmtpCaps::auth(self : SmtpCaps) -> Array[String]

#
SmtpCaps::empty

fn SmtpCaps::empty() -> SmtpCaps

#
SmtpCaps::has_8bitmime

fn SmtpCaps::has_8bitmime(self : SmtpCaps) -> Bool

#
SmtpCaps::has_pipelining

fn SmtpCaps::has_pipelining(self : SmtpCaps) -> Bool

#
SmtpCaps::has_smtputf8

fn SmtpCaps::has_smtputf8(self : SmtpCaps) -> Bool

#
SmtpCaps::has_starttls

fn SmtpCaps::has_starttls(self : SmtpCaps) -> Bool

#
SmtpCaps::size

fn SmtpCaps::size(self : SmtpCaps) -> Int?

#
SmtpCaps::supports_auth

fn SmtpCaps::supports_auth(self : SmtpCaps, mech : String) -> Bool

True when the server advertises mech in its AUTH capability (case-insensitive).

#
SmtpClient

pub struct SmtpClient[T] {
session : SmtpSession[T]
}

The high-level SMTP client: drives a full session over a SmtpTransport from a rendered message and an envelope, and returns the diagnostic trace.

#
SmtpClient::new

fn[T] SmtpClient::new(transport : T, config : SmtpConfig) -> SmtpClient[T]

#
SmtpClient::send

async fn[T : SmtpTransport] SmtpClient::send(self : SmtpClient[T], message : MailMessage, envelope : Envelope, render : RenderConfig) -> SmtpTrace raise MailFailure

Render a MailMessage and send it over the session.

#
SmtpClient::send_rendered

async fn[T : SmtpTransport] SmtpClient::send_rendered(self : SmtpClient[T], wire : String, envelope : Envelope) -> SmtpTrace raise MailFailure

Send an already rendered wire message (CRLF line endings) over the session. The message is dot-stuffed and line-length checked before transmission.

#
SmtpClient::session

fn[T] SmtpClient::session(self : SmtpClient[T]) -> SmtpSession[T]

Access the underlying session (and through it the transport), for inspection in tests and tooling.

#
SmtpClient::transport

fn[T] SmtpClient::transport(self : SmtpClient[T]) -> T

Access the transport captured inside the client (post-send state).

#
SmtpConfig

pub struct SmtpConfig {
host : String
port : Int
auth : AuthMethod?
timeout_ms : Int64
connect_retries : Int
} derive(Eq,
Debug
)

Connection configuration for an SMTP session.

#
SmtpConfig::auth

fn SmtpConfig::auth(self : SmtpConfig) -> AuthMethod?

#
SmtpConfig::connect_retries

fn SmtpConfig::connect_retries(self : SmtpConfig) -> Int

#
SmtpConfig::host

fn SmtpConfig::host(self : SmtpConfig) -> String

#
SmtpConfig::new

fn SmtpConfig::new(host : String, port? : Int) -> SmtpConfig

#
SmtpConfig::port

fn SmtpConfig::port(self : SmtpConfig) -> Int

#
SmtpConfig::timeout_ms

fn SmtpConfig::timeout_ms(self : SmtpConfig) -> Int64

#
SmtpConfig::with_auth

fn SmtpConfig::with_auth(self : SmtpConfig, auth : AuthMethod) -> SmtpConfig

#
SmtpConfig::with_connect_retries

fn SmtpConfig::with_connect_retries(self : SmtpConfig, retries : Int) -> SmtpConfig

#
SmtpConfig::with_timeout

fn SmtpConfig::with_timeout(self : SmtpConfig, timeout_ms : Int64) -> SmtpConfig

#
SmtpSession

pub struct SmtpSession[T] {
transport : T
config : SmtpConfig
state : SmtpState
caps : SmtpCaps
trace : SmtpTrace
}

A stateful SMTP client session over a SmtpTransport. The session never touches a socket itself; all I/O goes through the injected transport (FakeTransport for deterministic tests, the async socket adapter on native).

#
SmtpSession::auth

async fn[T : SmtpTransport] SmtpSession::auth(self : SmtpSession[T], auth : AuthMethod) -> Unit raise MailFailure

Authenticate using the configured method (MM_AUTH_001 / MM_AUTH_002).

#
SmtpSession::caps

fn[T] SmtpSession::caps(self : SmtpSession[T]) -> SmtpCaps

#
SmtpSession::close

fn[T : SmtpTransport] SmtpSession::close(self : SmtpSession[T]) -> Unit

#
SmtpSession::connect

async fn[T : SmtpTransport] SmtpSession::connect(self : SmtpSession[T]) -> Unit raise MailFailure

Connect and read the server greeting (220).

#
SmtpSession::ehlo

async fn[T : SmtpTransport] SmtpSession::ehlo(self : SmtpSession[T]) -> Unit raise MailFailure

Negotiate with EHLO (falling back to HELO when the server rejects EHLO).

#
SmtpSession::mail_from

async fn[T : SmtpTransport] SmtpSession::mail_from(self : SmtpSession[T], reverse_path : String) -> Unit raise MailFailure

#
SmtpSession::new

fn[T] SmtpSession::new(transport : T, config : SmtpConfig) -> SmtpSession[T]

#
SmtpSession::quit

async fn[T : SmtpTransport] SmtpSession::quit(self : SmtpSession[T]) -> Unit raise MailFailure

Send QUIT and close.

#
SmtpSession::rcpt_to

async fn[T : SmtpTransport] SmtpSession::rcpt_to(self : SmtpSession[T], forward_path : String) -> Unit raise MailFailure

#
SmtpSession::send_data

async fn[T : SmtpTransport] SmtpSession::send_data(self : SmtpSession[T], lines : Array[String]) -> Unit raise MailFailure

Send the DATA command, the already dot-stuffed message lines, and the terminating . (MM_SMTP_007).

#
SmtpSession::state

fn[T] SmtpSession::state(self : SmtpSession[T]) -> SmtpState

#
SmtpSession::trace

fn[T] SmtpSession::trace(self : SmtpSession[T]) -> SmtpTrace

#
SmtpState

pub enum SmtpState {
Disconnected
Connected
EhloDone
AuthDone
MailFromDone
RcptDone
DataDone
QuitDone
} derive(Eq,
Debug
)

The session state machine tracks the SMTP conversation progress.

#
SmtpStep

pub struct SmtpStep {
command : String
code : Int
text : String
duration_ms : Int64
} derive(Eq,
Debug
)

One recorded command -> response interaction of an SMTP session.

#
SmtpTrace

pub struct SmtpTrace {
steps : Array[SmtpStep]
capabilities : SmtpCaps
auth_used : String
final_code : Int
diagnostics : Array[String]
} derive(Eq,
Debug
)

A full diagnostic report of an SMTP session: every step, the negotiated capabilities, the auth mechanism used, and any diagnostics accumulated.

#
SmtpTrace::add_diagnostic

fn SmtpTrace::add_diagnostic(self : SmtpTrace, message : String) -> Unit

#
SmtpTrace::add_step

fn SmtpTrace::add_step(self : SmtpTrace, command : String, reply : ServerReply, duration_ms : Int64) -> Unit

#
SmtpTrace::auth_used

fn SmtpTrace::auth_used(self : SmtpTrace) -> String

#
SmtpTrace::capabilities

fn SmtpTrace::capabilities(self : SmtpTrace) -> SmtpCaps

#
SmtpTrace::diagnostics

fn SmtpTrace::diagnostics(self : SmtpTrace) -> Array[String]

#
SmtpTrace::empty

fn SmtpTrace::empty() -> SmtpTrace

#
SmtpTrace::final_code

fn SmtpTrace::final_code(self : SmtpTrace) -> Int

#
SmtpTrace::render

fn SmtpTrace::render(self : SmtpTrace) -> String

Render the trace as a readable transcript, e.g. for moonmail explain.

#
SmtpTrace::steps

fn SmtpTrace::steps(self : SmtpTrace) -> Array[SmtpStep]

#
SocketTransport

pub struct SocketTransport {
conn :
Tcp
?
}

A real SMTP transport over moonbitlang/async/socket (native only). This file is compiled only for the native target; the js and wasm targets use stubs that raise Unsupported.

#
SocketTransport::new

#
CRAM_MD5_VECTOR_CHALLENGE

let CRAM_MD5_VECTOR_CHALLENGE : String

A CRAM-MD5 test vector for documentation purposes (RFC 2195 example values come from the smtplib documentation).

#
DATA_TERMINATOR

let DATA_TERMINATOR : String

The SMTP data terminator.

#
assemble_multipart

fn assemble_multipart(children : Array[String], boundary : String) -> String raise MailFailure

A MIME multipart container rendered on the wire. children are the fully rendered wire texts of the child parts, wrapped between --boundary delimiter lines (RFC 2046 section 5.1).

Layout:
--boundary\r\n <child 1 rendered, ending with CRLF> --boundary\r\n <child 2 rendered> --boundary--\r\n

#
auth_plain_credentials

fn auth_plain_credentials(username : String, password : String) -> String

RFC 4616 PLAIN credentials: \0username\0password, base64 encoded.

#
base64_decode_str

fn base64_decode_str(s : String) -> String raise MailFailure

Base64-decode a string.

#
base64_encode_str

fn base64_encode_str(s : String) -> String

Base64-encode a string (used for AUTH LOGIN/PLAIN payloads).

#
base64_mime_decode

fn base64_mime_decode(text : String) -> Bytes raise MailFailure

Base64-decode MIME text (whitespace/newlines tolerated, per RFC 2045).

#
base64_mime_encode

fn base64_mime_encode(data : Bytes) -> String

Base64-encode data for MIME: the output is split into lines of exactly 76 characters separated by CRLF (MM_ENCOD_002, RFC 2045 section 6.8). Uses the gmlewis/base64 standard encoding as a dependency.

#
boundary_collides

fn boundary_collides(boundary : String, body : String) -> Bool

Check that boundary does not occur anywhere inside body (MM_MIME_002). A boundary line is --boundary at the start of a line; to be safe we search for --boundary anywhere in the body.

#
bytes_to_hex

fn bytes_to_hex(b : Bytes) -> String

Encode bytes to a lowercase hex string.

#
bytes_to_string

fn bytes_to_string(b : Bytes) -> String

Decode bytes back to a string, replacing any malformed sequences lossily. Only use for content that is expected to be text.

#
check_header_value

fn check_header_value(value : String, context : String) -> Unit raise MailFailure

Check a header value for injection: CR, LF and NUL are all rejected (RFC 5322 3.6.8 / security model). See injection.mbt.

#
check_line_lengths

fn check_line_lengths(s : String, max : Int) -> Unit raise MailFailure

Check that every line of s (split on LF) is at most max bytes. Used to enforce the RFC 5322 998-octet hard limit before transmission (MM_ENCOD_005).

#
check_seven_bit

fn check_seven_bit(s : String) -> Unit raise MailFailure

Check a 7bit body: every byte must be <= 127 (MM_ENCOD_003).

#
contains_crlf

fn contains_crlf(s : String) -> Bool

True when the string contains a CR, LF or bare control newline, which must never appear inside a header field value.

#
cram_md5_response

fn cram_md5_response(username : String, password : String, challenge_b64 : String) -> String raise MailFailure

Build the CRAM-MD5 client response: username <hmac-md5-hex>. challenge_b64 is the text after 334 in the server challenge.

#
display_name_to_string

fn display_name_to_string(display_name : String) -> String

Quote the display name when it contains characters outside the safe atom set.

#
dot_stuff

fn dot_stuff(s : String) -> String

Dot-stuff every line of s: a line starting with . gets a leading . prepended (MM_DOT_001). Operates on the already CRLF-normalized text.

#
encode_auth_cram_md5

fn encode_auth_cram_md5() -> String

#
encode_auth_login

fn encode_auth_login() -> String

#
encode_auth_plain

fn encode_auth_plain(credentials_b64 : String) -> String

#
encode_body

fn encode_body(content : String, encoding : ContentTransferEncoding) -> (String, ContentTransferEncoding) raise MailFailure

Encode text content with an explicit transfer encoding. Returns the encoded body (which already uses CRLF line endings) together with the effective encoding that was actually applied (relevant for Auto).

#
encode_data

fn encode_data() -> String

#
encode_ehlo

fn encode_ehlo(host : String) -> String

SMTP command encoding helpers. Every command is a single line (no CRLF).

#
encode_helo

fn encode_helo(host : String) -> String

#
encode_mail_from

fn encode_mail_from(reverse_path : String) -> String

#
encode_noop

fn encode_noop() -> String

#
encode_quit

fn encode_quit() -> String

#
encode_rcpt_to

fn encode_rcpt_to(forward_path : String) -> String

#
encode_rset

fn encode_rset() -> String

#
fixedarray_to_string

fn fixedarray_to_string(fa : FixedArray[Byte], len : Int) -> String

Convert the first len bytes of a FixedArray into a string.

#
generate_boundary

fn generate_boundary(seed : String) -> String

Boundary generation for multipart messages (RFC 2046 section 5.1.1). The boundary is derived from a caller-supplied seed so that rendering is deterministic and reproducible in tests.

#
hex_to_bytes

fn hex_to_bytes(s : String) -> Bytes raise MailFailure

Decode a hex string to bytes. Raises on odd length or non-hex input.

#
hmac_md5

fn hmac_md5(key : Bytes, msg : Bytes) -> String raise MailFailure

HMAC-MD5 (RFC 2104) with a 64-byte block, built on the MD5 dependency. Returns the digest as a lowercase hex string (MM_AUTH_001).

#
index_of

fn index_of(s : String, sub : String) -> Int

Find the byte index of sub in s, or -1 when absent.

#
is_pure_ascii

fn is_pure_ascii(s : String) -> Bool

True when every byte of text is ASCII (<= 127).

#
is_retryable

fn is_retryable(err : MailError) -> Bool

True when a failure is worth retrying.

#
is_valid_boundary

fn is_valid_boundary(boundary : String) -> Bool

Characters permitted inside a boundary value: [0-9a-zA-Z'()+_,-./:=?] (MM_MIME_003, RFC 2046).

#
is_valid_field_name

fn is_valid_field_name(name : String) -> Bool

A field-name is any printable ASCII character except : (RFC 5322 3.6.1, ftext). MoonMail additionally requires a nonempty name.

#
join_crlf

fn join_crlf(lines : Array[String]) -> String

Join lines with CRLF.

#
make_disposition

fn make_disposition(disposition : Disposition, filename : String) -> String raise MailFailure

Build a Content-Disposition header value. The filename is sanitized (MM_INJ_003); ASCII filenames use filename="...", non-ASCII filenames use RFC 2231 extended notation filename*=utf-8''... (MM_MIME_006).

#
make_header

fn make_header(name : String, value : String, max_width? : Int) -> String raise MailFailure

Build a single (possibly folded) header field Name: value followed by CRLF. The value is validated against CR/LF injection and folded so that no line exceeds max_width (default 78) columns, per RFC 5322 2.2.3.

#
normalize_crlf

fn normalize_crlf(s : String) -> String

Normalize every line ending to CRLF: \r\n stays, a lone \r becomes \r\n, and a lone \n becomes \r\n (MM_CRLF_001).

#
parse_address_list

fn parse_address_list(input : String) -> Array[MailAddress] raise MailFailure

Parse a comma separated list of addresses (e.g. a To header value).

#
parse_caps

fn parse_caps(lines : Array[String]) -> SmtpCaps

Parse the EHLO capability lines (the reply lines after the greeting line, without their numeric prefixes). MM_SMTP_004.

#
parse_cte

fn parse_cte(v : String) -> ContentTransferEncoding raise MailFailure

#
parse_reply_line

fn parse_reply_line(line : String) -> ReplyLine?

Parse a single SMTP reply line of the form ddd[- ]text where - means more lines follow and means this is the last line.

#
qp_decode

fn qp_decode(input : String) -> String raise MailFailure

Decode Quoted-Printable text back to a string. Soft line breaks (=\r\n) are removed; =XX escapes are decoded. Raises on malformed input.

#
qp_encode

fn qp_encode(input : String) -> String

Quoted-Printable encoding (RFC 2045 section 6.7), implemented from scratch.

Rules applied:
  • Tab and space must be encoded when at the end of a line.
  • = must always be encoded as =3D.
  • Printable ASCII 33..126 except = is passed through.
  • All other bytes (control and >127) are encoded as =XX.
  • Lines are limited to 76 characters including any trailing soft break (MM_ENCOD_001); a soft break is a trailing =.
  • Line endings are CRLF.

#
redact_command

fn redact_command(command : String) -> String

Redact a command for the trace: AUTH payloads (which contain credentials) are never recorded (MM_SMTP_001 security model, section 15).

#
reject_crlf

fn reject_crlf(value : String, context : String) -> Unit raise MailFailure

Reject any CR, LF or NUL byte in value. This is the core header-injection guard (MM_INJ_001 / MM_INJ_002): user-controlled input must not introduce new header fields or terminate the header block.

#
render_message

fn render_message(msg : MailMessage, config : RenderConfig) -> String raise MailFailure

Render a MailMessage to its RFC 5322 / MIME wire form (CRLF line endings). All header values are validated against injection before being emitted.

#
sanitize_filename

fn sanitize_filename(name : String) -> String

Sanitize a filename for use inside Content-Disposition: strips path separators, CR/LF, NUL and other control characters (MM_INJ_003).

#
slice_array

fn[T] slice_array(arr : Array[T], start : Int) -> Array[T]

Copy arr[start:] into a new array.

#
split_address_list

fn split_address_list(input : String) -> Array[String]

Split an address list on commas, respecting quoted strings and comments.

#
string_to_bytes

fn string_to_bytes(s : String) -> Bytes

UTF-8 encode a string to bytes (replaces the deprecated String::to_bytes).

#
strip_reply_code

fn strip_reply_code(line : String) -> String

Strip the leading ddd[- ] reply-code prefix from a line, returning just the text (e.g. 250-8BITMIME -> 8BITMIME).

#
to_hex_upper

fn to_hex_upper(v : Int) -> Byte

The uppercase hex character for a nibble.

#
to_lines

fn to_lines(s : String) -> Array[String]

Split text into lines on \n, stripping a trailing \r. A trailing newline does not produce an empty final line.

#
unfold_header_value

fn unfold_header_value(value : String) -> String

Remove folding whitespace: each CRLF followed by WSP is removed and the WSP collapsed. Used by tests and by MIME parsers.

#
unique_boundary

fn unique_boundary(seed : String, body : String) -> String

Generate a boundary that is valid and does not collide with body. The seed can be reused: a counter is appended until no collision occurs.

#
validate_addr_spec

fn validate_addr_spec(local_part : String, domain : String) -> Unit raise MailFailure

Validate a local-part / domain pair as an addr-spec (RFC 5322 3.4.1).

#
with_retry

async fn[T] with_retry(max_retries : Int, f : () -> T raise MailFailure) -> T raise MailFailure

Execute f, retrying up to max_retries total attempts when it fails with a retryable error (transport or transient SMTP failure). Permanent errors (SmtpPermanent, Auth, Config, ContentLength, Timeout) are raised immediately. This gives callers bounded retry with no external waiting beyond a single scheduler pause between attempts.