Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mail

SMTP over lettre. Arcature owns the ergonomics; lettre owns the protocol and the certified rustls plus aws-lc-rs stack owns TLS.

Mail is a value, not a namespace. There is no Mail::to("a@b.com") static constructor — to is a method on a facade that already knows the mailer and the From address.

The transport

use std::time::Duration;
use arcature::mail::{Mailer, SmtpConfig, SmtpCredentials, TlsMode};

let config = SmtpConfig::new("smtp.example.com")?
    .port(587)
    .tls_mode(TlsMode::StartTls)
    .credentials(SmtpCredentials::new("user", "secret"))
    .timeout(Duration::from_secs(10));

let mailer = Mailer::smtp(config)?;

SmtpConfig::from_url("smtps://user:pass@host:465") parses a connection URL instead.

Mailer is Clone + Send + Sync + 'static. Application::mail(config) builds one at startup.

Credentials never reach a log. SmtpCredentials has a Debug that prints only the type name and deliberately has no Display; SmtpConfig implements both by hand and never emits the password or the full URL.

Sending

Implement Mailable for the message type. The build method receives an Email builder that already has From and To set:

use arcature::mail::{Email, EmailError, Mailable};
use arcature::mail::lettre::message::Message;

pub struct WelcomeEmail {
    pub name: String,
}

impl Mailable for WelcomeEmail {
    fn build(&self, email: Email) -> Result<Message, EmailError> {
        email
            .subject(format!("Welcome, {}", self.name))
            .plain(format!("Welcome, {}", self.name))
    }
}

Then send:

use arcature::mail::Mail;

let mail = Mail::from_str(mailer, "noreply@example.com")?;
mail.to(user.email).send(&WelcomeEmail { name: user.name }).await?;

Mail::new(mailer, mailbox) takes an already-parsed Mailbox if you have one; parse_mailbox("Ada <ada@example.com>") produces one.

The message builder

Email::builder() starts a message. The chainers — from, reply_to, to, cc, bcc, subject — return Self and do not fail. Only the body terminators return a Result, because that is where the message is actually assembled:

let message = Email::builder()
    .from(parse_mailbox("noreply@example.com")?)
    .to(parse_mailbox("ada@example.com")?)
    .subject("Welcome")
    .html("<h1>Welcome</h1>")?;
TerminatorProduces
plain(body)text/plain
html(body)text/html
alternative(plain, html)multipart/alternative
mixed(..)multipart/mixed
plain_with_attachments(body, attachments)text plus attachments
alternative_with_attachments(plain, html, attachments)both, plus attachments

EmailAttachment::new(..) builds an attachment. Its Debug redacts the body bytes — an attachment in a log line is a data leak, not a diagnostic.

Email::from_builder(builder) and email.into_builder() cross to and from lettre’s own MessageBuilder when the wrapper runs out.

Testing

Mailer::capture_ok() records every message in memory and always succeeds. Mailer::capture_error() always fails the send, which is the one a retry path needs. Neither opens a socket.

let mailer = Mailer::capture_ok();
let mail = Mail::from_str(mailer.clone(), "noreply@example.com")?;
mail.to("ada@example.com").send(&WelcomeEmail { name: "Ada".into() }).await?;

let sent = mailer.captured().await.expect("capture mailer");
assert_eq!(sent.len(), 1);

captured() returns None for an SMTP mailer. is_capture() and is_smtp() report which kind you hold, so a production guard can refuse to start with a capture transport.

What this module does not own

SMTP, TLS, or cryptography. The lettre crate is re-exported as arcature::mail::lettre.

Notifications sit on top of this

Mail is one channel. The notifications feature adds the layer above it: one event, told to one person, over whichever channels apply – mail plus an in-app inbox (notifications-db), a live push over WebSocket/SSE (notifications-broadcast), or mail handed to the job queue so the send does not happen inside the request (notifications-queue).

Reach for it when the same event has to arrive more than one way, or when “who gets told what” is a decision worth writing down once. A single transactional mail is what this chapter already covers. arc make:notification <name> writes the starting point.