# modo::email
Transactional email with Markdown templates, SMTP delivery, and optional LRU caching.
`Mailer::with_stub_transport` is available with the `test-helpers` feature or in `#[cfg(test)]` blocks.
## Key types
| `Mailer` | Renders templates and delivers email over SMTP (cheap `Clone` via `Arc`) |
| `EmailConfig` | Top-level configuration (deserializes from YAML) |
| `SmtpConfig` / `SmtpSecurity` | SMTP connection settings and TLS mode |
| `SendEmail` | Builder for composing an outgoing email |
| `SenderProfile` | Per-message `From` / `Reply-To` override |
| `RenderedEmail` | Output of `Mailer::render` (subject, HTML, text) |
| `TemplateSource` | Trait for pluggable template loaders |
| `FileSource` / `CachedSource<S>` | Filesystem loader and LRU-caching wrapper |
| `ButtonType` | Button colour variants (`Primary`, `Danger`, etc.) |
## Usage
### Basic example
```rust,no_run
use modo::email::{EmailConfig, Mailer, SendEmail};
#[tokio::main]
async fn main() -> modo::Result<()> {
let mut config = EmailConfig::default();
config.templates_path = "emails".into();
config.default_from_email = "noreply@example.com".into();
config.smtp.host = "smtp.example.com".into();
let mailer = Mailer::new(&config)?;
mailer.send(
SendEmail::new("welcome", "user@example.com")
.var("name", "Dmytro"),
).await?;
Ok(())
}
```
`EmailConfig` and `SmtpConfig` are both `#[non_exhaustive]` — mutate
`EmailConfig::default()` instead of struct-literal construction outside
this crate.
### Template format
Markdown files with YAML frontmatter stored under `EmailConfig::templates_path`:
```text
---
subject: Welcome to {{app_name}}!
layout: base
---
Hi {{name}},
[button|Get started](https://example.com/start)
[button:danger|Delete account](https://example.com/delete)
```
`layout` defaults to `"base"` (built-in responsive HTML layout with dark-mode support).
Custom layouts are `.html` files in `EmailConfig::layouts_path`.
Locale fallback: `{locale}/{name}.md` -> `{default_locale}/{name}.md` -> `{name}.md`.
### Button types
| `[button\|Label](url)` | Primary (`brand_color` var or blue) |
| `[button:danger\|Label](url)` | Red |
| `[button:warning\|Label](url)` | Amber |
| `[button:info\|Label](url)` | Cyan |
| `[button:success\|Label](url)` | Green |
### OTP element
Render a styled one-time-code pill in both HTML and plain-text output:
```text
Your verification code:
[otp|123456]
```
- Syntax: `[otp|CODE]` where `CODE` matches `[A-Za-z0-9-]{1,32}`.
- HTML: monospace pill with letter-spacing, rounded background — fully inline styles.
- Plain text: the code on its own line, surrounded by blank lines.
- Respects code spans (`` `[otp|…]` `` stays literal), fenced blocks, and
backslash escapes (`\[otp|…]` stays literal).
- Invalid codes (empty, too long, containing spaces or punctuation outside
`-`) are left as literal text.
### Custom sender per message
```rust,no_run
use modo::email::{SendEmail, SenderProfile};
let email = SendEmail::new("invoice", "customer@example.com")
.sender(SenderProfile {
from_name: "Billing".into(),
from_email: "billing@example.com".into(),
reply_to: Some("support@example.com".into()),
});
```
### Render without sending
```rust,no_run
use modo::email::{EmailConfig, Mailer, SendEmail};
fn example(mailer: &Mailer) -> modo::Result<()> {
let rendered = mailer.render(&SendEmail::new("welcome", "user@example.com"))?;
println!("{}", rendered.subject);
Ok(())
}
```
### Custom template source
```rust,no_run
use modo::email::{EmailConfig, Mailer, TemplateSource};
use modo::Result;
use std::sync::Arc;
struct DbSource;
impl TemplateSource for DbSource {
fn load(&self, name: &str, _locale: &str, _default_locale: &str) -> Result<String> {
Ok(format!("---\nsubject: {name}\n---\nBody"))
}
}
fn build(config: &EmailConfig) -> Result<Mailer> {
Mailer::with_source(config, Arc::new(DbSource))
}
```
### CSS inlining
When `email.inline_css` is `true` (the default), the rendered HTML is
passed through a CSS inliner that:
- Copies declarations from `<style>` blocks onto matching elements as
inline `style=""` attributes (so clients that strip `<style>` still
render correctly).
- Preserves the original `<style>` block, so `@media` rules — the dark
mode and mobile padding overrides in the default layout — still apply
on clients that honour them.
- Never fetches external stylesheets.
Existing inline `style=""` on an element wins over rules from `<style>`,
per standard CSS specificity.
Disable with:
```yaml
email:
inline_css: false
```
### Layout variables
These placeholders are available in custom layout (`.html`) files:
| `{{content}}` | Rendered Markdown body (injected automatically) |
| `{{logo_section}}` | Logo row — rendered when `logo_url` is set |
| `{{footer_section}}` | Footer row — rendered when `footer_text` is set |
| `{{logo_url}}` | Logo image URL |
| `{{app_url}}` | Optional. When set alongside `logo_url`, wraps the `<img>` in `<a href="{{app_url}}">` |
| `{{footer_text}}` | Footer text content |
## Configuration
```yaml
email:
templates_path: emails
layouts_path: emails/layouts
default_from_name: My App
default_from_email: noreply@example.com
default_locale: en
inline_css: true
cache_templates: true
template_cache_size: 100
smtp:
host: smtp.example.com
port: 587
username: user
password: secret
security: starttls # starttls | tls | none
```
## Error handling
| Missing frontmatter | 400 Bad Request | Template lacks `---` delimiters or `subject` field |
| Invalid address | 400 Bad Request | Malformed `To`, `Cc`, `Bcc`, `From`, or `Reply-To` address |
| No recipients | 400 Bad Request | `SendEmail::to` list is empty at send time |
| SMTP auth mismatch | 400 Bad Request | Only one of `username`/`password` is set |
| Template not found | 404 Not Found | Template file missing for the given name and locale |
| Layout not found | 404 Not Found | Requested layout name not in built-in or custom layouts |
| SMTP transport error | 500 Internal | Failed to build or connect to the SMTP server |
| SMTP delivery error | 500 Internal | Server accepted connection but rejected the message |
| Frontmatter parse error | 500 Internal | YAML in frontmatter is syntactically invalid |
| CSS inline failure | 500 Internal | `inline_css: true` and the rendered HTML cannot be parsed by the inliner (usually a malformed custom layout) |
| Layouts directory unreadable | 500 Internal | `layouts_path` exists but cannot be enumerated, or a `.html` file inside cannot be read |