modo-email 0.2.0

Email service for modo with SMTP and Resend support
Documentation
modo-email-0.2.0 has been yanked.

modo-email

Email sending for the 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

Feature Default Transport Dependency
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.

# 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

# 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"
        tls: true

All fields have defaults (templates_path defaults to "emails", smtp.port to 587, smtp.tls to true). Only specify what you need to override.

Send a Templated Email

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

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:

---
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

Field Required Description
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:

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

[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:

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:

[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.

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:

Variable Description
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 syntax. All email context variables are available. Auto-escaping is disabled since the content is pre-rendered HTML.

<!-- emails/layouts/minimal.html -->
<html>
    <body style="font-family: sans-serif; padding: 20px;">
        {{content}}
    </body>
</html>

Reference it in a template's frontmatter:

---
subject: "Alert"
layout: minimal
---

Multi-Tenant Usage

Per-Email Sender Override

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:

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.

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:

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:

EmailTemplate {
    subject: "Welcome {{name}}!".to_string(),
    body: "Hi **{{name}}**!".to_string(),
    layout: None,  // None uses the built-in "default" layout
}

Resend Configuration

email:
    transport: resend
    templates_path: "emails"
    default_from_name: "My App"
    default_from_email: "hello@myapp.com"
    resend:
        api_key: "re_..."

Configuration Reference

EmailConfig

Field Type Default Description
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
smtp SmtpConfig see below SMTP settings (requires smtp feature)
resend ResendConfig see below Resend settings (requires resend feature)

SmtpConfig

Field Type Default Description
host String "localhost" SMTP server hostname
port u16 587 SMTP server port
username String "" SMTP auth username
password String "" SMTP auth password
tls bool true Enable STARTTLS (port 587)

ResendConfig

Field Type Default Description
api_key String "" Resend API key

Key Types

Type / Trait Description
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)