# modo-email
Email sending for the [modo](https://github.com/dmitrymomot/modo) framework. Markdown templates, responsive HTML output, pluggable transports, multi-tenant sender customization.
## Features
- **Markdown templates** with YAML frontmatter and `{{var}}` substitution
- **Button syntax** — `[button|Label](url)` renders as email-safe table-based buttons
- **Responsive HTML layout** with dark mode support, or bring your own
- **Plain text fallback** auto-generated from Markdown
- **Transports** — SMTP (default) and Resend HTTP API
- **Multi-tenant** — per-email `SenderProfile` override and brand context variables
- **Serializable payload** (`SendEmailPayload`) for async sending via modo-jobs
- **Custom template sources** — implement `TemplateProvider` for DB-backed templates, APIs, etc.
## Feature Flags
| `smtp` | Yes | SMTP via lettre | `lettre` |
| `resend` | No | Resend HTTP API | `reqwest` |
Both features can be enabled simultaneously. The active transport is selected by `transport` in config.
```toml
# SMTP only (default):
modo-email = "0.2"
# Resend only:
modo-email = { version = "0.2", default-features = false, features = ["resend"] }
# Both available:
modo-email = { version = "0.2", features = ["resend"] }
```
## Usage
### Configuration
```yaml
# config.yaml
email:
transport: smtp # or "resend"
templates_path: "emails"
default_from_name: "My App"
default_from_email: "hello@myapp.com"
default_reply_to: "support@myapp.com"
smtp:
host: "smtp.example.com"
port: 587
username: "user"
password: "pass"
security: starttls # none | starttls | implicit_tls
```
All fields have defaults (`templates_path` defaults to `"emails"`, `smtp.host` to `"localhost"`, `smtp.port` to `587`, `smtp.security` to `starttls`). Only specify what you need to override.
### Send a Templated Email
```rust
use modo_email::{mailer, EmailConfig, SendEmail};
// Build the mailer from config (uses FilesystemProvider + configured transport).
let config: EmailConfig = /* load from YAML */;
let m = mailer(&config)?;
m.send(
&SendEmail::new("welcome", "user@example.com")
.var("name", "Alice")
.var("dashboard_url", "https://app.com/dashboard"),
).await?;
```
### Render Without Sending
```rust
let message = m.render(
&SendEmail::new("welcome", "user@example.com")
.var("name", "Alice"),
)?;
println!("Subject: {}", message.subject);
println!("HTML: {}", message.html);
println!("Text: {}", message.text);
```
## Template Format
Templates are `.md` files with YAML frontmatter:
```markdown
---
subject: "Welcome {{name}}!"
layout: default
---
Hi **{{name}}**,
Thanks for joining! Here's what you can do next.
[button|Get Started]({{dashboard_url}})
If you have questions, just reply to this email.
```
### Frontmatter Fields
| `subject` | Yes | Email subject line. Supports `{{var}}` placeholders. |
| `layout` | No | Layout name to wrap the body in. Falls back to `"default"` built-in. |
### Variable Substitution
Use `{{key}}` or `{{ key }}` (whitespace is trimmed). Pass variables via the builder:
```rust
SendEmail::new("invoice", "user@example.com")
.var("name", "Alice")
.var("amount", "$49.00")
.var("invoice_url", "https://app.com/invoices/123")
```
Unresolved placeholders are left as-is in the output.
### Button Syntax
```markdown
[button|Click Me](https://example.com)
```
Renders as an email-safe, table-based CTA button with a default indigo (`#4F46E5`) background. Customize the color per-email with the `brand_color` variable:
```rust
SendEmail::new("welcome", "user@example.com")
.var("brand_color", "#E11D48")
```
`brand_color` must be a valid CSS hex color (`#RGB` or `#RRGGBB`); invalid values fall back to the default.
Regular Markdown links render as styled inline links:
```markdown
[View docs](https://docs.example.com)
```
## Directory Structure
### Single Language
```
emails/
welcome.md
reset_password.md
invoice.md
```
### Multi-Language (Locale Subdirectories)
```
emails/
welcome.md # default / fallback
reset_password.md
de/
welcome.md # German override
reset_password.md
fr/
welcome.md # French override
layouts/
default.html # custom layout (overrides built-in)
minimal.html # additional named layout
```
Locale resolution: if `de/welcome.md` exists, it is used; otherwise falls back to `welcome.md`.
```rust
SendEmail::new("welcome", "user@example.com")
.locale("de")
.var("name", "Hans")
```
## Layouts
The built-in `default` layout provides a responsive, dark-mode-aware HTML email wrapper. It supports these context variables:
| `content` | Rendered HTML body (injected automatically) |
| `subject` | Email subject (injected automatically) |
| `logo_url` | Optional logo image URL |
| `product_name` | Optional product name (logo alt text) |
| `footer_text` | Optional footer text |
To override the built-in layout or add custom ones, place `.html` files in `{templates_path}/layouts/`:
```
emails/layouts/default.html # overrides built-in default
emails/layouts/minimal.html # new named layout
```
Layouts use [MiniJinja](https://docs.rs/minijinja) syntax. All email context variables are available. Auto-escaping is disabled since the content is pre-rendered HTML.
```html
<html>
<body style="font-family: sans-serif; padding: 20px;">
{{content}}
</body>
</html>
```
Reference it in a template's frontmatter:
```markdown
---
subject: "Alert"
layout: minimal
---
```
## Multi-Tenant Usage
### Per-Email Sender Override
```rust
use modo_email::{SendEmail, SenderProfile};
let tenant_sender = SenderProfile {
from_name: "Tenant Corp".to_string(),
from_email: "notifications@tenant.com".to_string(),
reply_to: Some("support@tenant.com".to_string()),
};
m.send(
&SendEmail::new("welcome", "user@example.com")
.sender(&tenant_sender)
.var("name", "Bob"),
).await?;
```
Without `.sender()`, the mailer uses the default sender from `EmailConfig`.
### Brand Context Variables
Pass brand-specific variables to customize templates and layouts per tenant:
```rust
use std::collections::HashMap;
let mut brand = HashMap::new();
brand.insert("logo_url".to_string(), serde_json::json!("https://tenant.com/logo.png"));
brand.insert("product_name".to_string(), serde_json::json!("Tenant Corp"));
brand.insert("brand_color".to_string(), serde_json::json!("#E11D48"));
brand.insert("footer_text".to_string(), serde_json::json!("(c) 2026 Tenant Corp"));
m.send(
&SendEmail::new("welcome", "user@example.com")
.context(&brand)
.var("name", "Alice"),
).await?;
```
## Async Sending via modo-jobs
The mailer is registered as a service on the jobs builder, not on the app. Job handlers
access it via the `Service<T>` parameter, which the `#[job]` macro resolves from the
jobs service registry.
```rust
use modo_email::{Mailer, SendEmail, SendEmailPayload};
use modo::extractor::Service;
use modo_jobs::JobQueue;
// In main: build and register the mailer with the jobs runner.
// let mailer = modo_email::mailer(&config.email)?;
// let jobs = modo_jobs::new(&db, &config.jobs)
// .service(mailer)
// .run()
// .await?;
// Job worker — Service<Mailer> resolves from the jobs service registry:
#[modo_jobs::job(queue = "email", max_attempts = 3, timeout = "30s")]
async fn send_email(
Service(mailer): Service<Mailer>,
payload: SendEmailPayload,
) -> Result<(), modo::Error> {
let email = SendEmail::from(payload);
mailer.send(&email).await
}
// Enqueue from an HTTP handler using the generated struct method:
#[modo::handler(POST, "/signup")]
async fn signup_handler(
queue: JobQueue,
input: modo::extractor::JsonReq<SignupInput>,
) -> modo::JsonResult<()> {
let payload = SendEmailPayload::from(
SendEmail::new("welcome", &input.email)
.var("name", &input.name),
);
SendEmailJob::enqueue(&queue, &payload).await?;
Ok(modo::Json(()))
}
```
`SendEmailJob` is the struct generated by `#[job]` for the `send_email` function (PascalCase + `Job` suffix). Import `Service` from `modo::extractor::Service`.
## Custom TemplateProvider
Implement `TemplateProvider` to load templates from a database, API, or any other source:
```rust
use modo_email::{EmailConfig, EmailTemplate, TemplateProvider, mailer_with};
use std::sync::Arc;
struct DbTemplateProvider;
impl TemplateProvider for DbTemplateProvider {
fn get(&self, name: &str, locale: &str) -> Result<EmailTemplate, modo::Error> {
let raw = todo!("load raw template string from DB");
EmailTemplate::parse(&raw)
}
}
let provider = Arc::new(DbTemplateProvider);
let m = mailer_with(&config, provider)?;
```
Build an `EmailTemplate` directly (without parsing) when you control the source:
```rust
EmailTemplate {
subject: "Welcome {{name}}!".to_string(),
body: "Hi **{{name}}**!".to_string(),
layout: None, // None uses the built-in "default" layout
}
```
### Resend Configuration
```yaml
email:
transport: resend
templates_path: "emails"
default_from_name: "My App"
default_from_email: "hello@myapp.com"
resend:
api_key: "re_..."
```
## Configuration Reference
### `EmailConfig`
| `transport` | `smtp` / `resend` | `smtp` | Which transport backend to use |
| `templates_path` | `String` | `"emails"` | Directory containing `.md` templates |
| `default_from_name` | `String` | `""` | Default sender display name |
| `default_from_email` | `String` | `""` | Default sender email address |
| `default_reply_to` | `Option<String>` | `None` | Default reply-to address |
| `cache_templates` | `bool` | `true` | Cache compiled templates; set to `false` for hot-reload |
| `template_cache_size` | `usize` | `100` | Maximum number of templates kept in the LRU cache |
| `smtp` | `SmtpConfig` | see below | SMTP settings (requires `smtp` feature) |
| `resend` | `ResendConfig` | see below | Resend settings (requires `resend` feature) |
### `SmtpConfig`
| `host` | `String` | `"localhost"` | SMTP server hostname |
| `port` | `u16` | `587` | SMTP server port |
| `username` | `String` | `""` | SMTP auth username (skipped if empty) |
| `password` | `String` | `""` | SMTP auth password |
| `security` | `SmtpSecurity` | `starttls` | TLS mode: `none`, `starttls` (port 587), or `implicit_tls` (port 465) |
### `ResendConfig`
| `api_key` | `String` | `""` | Resend API key |
## Key Types
| `Mailer` | Central service: render and deliver emails |
| `SendEmail` | Builder for a single email send request |
| `SendEmailPayload` | Serializable mirror of `SendEmail` for job queue payloads |
| `MailMessage` | Fully-rendered email ready for delivery (html, text, subject...) |
| `SenderProfile` | Sender identity (`from_name`, `from_email`, `reply_to`) |
| `EmailTemplate` | Parsed template (subject, body, optional layout name) |
| `TemplateProvider` | Trait for custom template sources |
| `MailTransport` | Async trait for custom delivery backends |
| `FilesystemProvider` | Built-in filesystem template provider |
| `LayoutEngine` | MiniJinja-based HTML layout renderer |
| `EmailConfig` | Top-level config struct (transport, paths, defaults) |