Skip to main content

Overview

TrailBase provides built-in email functionality for sending transactional emails like verification emails, password resets, and custom notifications. Configure SMTP or use the local sendmail transport.

Configuration

SMTP Configuration

Configure SMTP in your config.textproto:
email {
  # Sender information
  sender_address: "[email protected]"
  sender_name: "My Application"
  
  # SMTP server settings
  smtp_host: "smtp.gmail.com"
  smtp_port: 587
  smtp_username: "[email protected]"
  smtp_password: "your-app-password"
  smtp_encryption: STARTTLS
}

Encryption Options

email {
  # No encryption (not recommended for production)
  smtp_encryption: NONE
  
  # STARTTLS (recommended for most providers)
  smtp_encryption: STARTTLS
  
  # Direct TLS connection
  smtp_encryption: TLS
}
When using NONE encryption, passwords are sent in plain text. Only use this for development or trusted networks.

Local Sendmail

If SMTP is not configured, TrailBase falls back to local sendmail:
// From crates/core/src/email.rs
pub(crate) fn new_from_config(config: &Config) -> Mailer {
    return match smtp_from_config(&config.email) {
        Ok(mailer) => mailer,
        Err(err) => {
            info!("Falling back to local sendmail: {err}");
            Self::new_local()
        }
    };
}

Built-in Email Templates

TrailBase includes default templates for common emails:

Email Verification

Sent when users register with email/password:
// Default subject
"Verify your Email Address for {APP_NAME}"

// Default body (HTML)
"""
<p>Welcome {EMAIL}!</p>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="{VERIFICATION_URL}">Verify Email</a></p>
<p>Or copy and paste this code: {CODE}</p>
"""

Password Reset

Sent when users request password reset:
// Default subject
"Reset your Password for {APP_NAME}"

// Default body (HTML)
"""
<p>Hello {EMAIL},</p>
<p>You requested to reset your password. Click the link below to set a new password:</p>
<p><a href="{VERIFICATION_URL}">Reset Password</a></p>
<p>Or use this code: {CODE}</p>
<p>If you didn't request this, please ignore this email.</p>
"""

Change Email Address

Sent when users change their email:
// Default subject
"Change your Email Address for {APP_NAME}"

// Default body (HTML)
"""
<p>Hello {EMAIL},</p>
<p>Please confirm your new email address by clicking the link below:</p>
<p><a href="{VERIFICATION_URL}">Confirm Email Change</a></p>
<p>Or use this code: {CODE}</p>
"""

Custom Templates

Override default templates in your configuration:
email {
  sender_address: "[email protected]"
  
  # Custom verification email template
  user_verification_template {
    subject: "Welcome to {APP_NAME}! Verify your email"
    body: """
      <html>
      <head>
        <style>
          body { font-family: Arial, sans-serif; }
          .button { 
            background: #0066cc; 
            color: white; 
            padding: 12px 24px; 
            text-decoration: none;
            border-radius: 4px;
            display: inline-block;
          }
        </style>
      </head>
      <body>
        <h1>Welcome to {APP_NAME}!</h1>
        <p>Hi there,</p>
        <p>Thanks for signing up. Please verify your email address:</p>
        <p><a href="{VERIFICATION_URL}" class="button">Verify Email</a></p>
        <p>Or enter this code: <strong>{CODE}</strong></p>
        <hr>
        <p><small>If you didn't sign up, you can ignore this email.</small></p>
      </body>
      </html>
    """
  }
  
  # Custom password reset template
  password_reset_template {
    subject: "Reset your {APP_NAME} password"
    body: """
      <html>
      <body>
        <h2>Password Reset Request</h2>
        <p>Click the link below to reset your password:</p>
        <p><a href="{VERIFICATION_URL}">Reset Password</a></p>
        <p>This link expires in 1 hour.</p>
      </body>
      </html>
    """
  }
  
  # Custom email change template  
  change_email_template {
    subject: "Confirm your new email address"
    body: """
      <html>
      <body>
        <p>Please confirm your new email address: {EMAIL}</p>
        <p><a href="{VERIFICATION_URL}">Confirm Change</a></p>
      </body>
      </html>
    """
  }
}

Template Variables

Available variables in templates:
  • {APP_NAME} - Application name from config
  • {EMAIL} - User’s email address
  • {VERIFICATION_URL} - Complete verification URL
  • {SITE_URL} - Site URL from config
  • {CODE} - Verification code

Sending Custom Emails

From Rust

use trailbase_core::api::{Email, EmailError};
use trailbase_core::AppState;

async fn send_welcome_email(
    state: &AppState,
    user_email: &str,
    user_name: &str,
) -> Result<(), EmailError> {
    let subject = format!("Welcome {}", user_name);
    let body = format!(r#"
        <html>
        <body>
            <h1>Welcome, {}!</h1>
            <p>Thanks for joining our platform.</p>
            <p>Get started by exploring our features.</p>
        </body>
        </html>
    "#, user_name);
    
    let email = Email::new(state, user_email, subject, body)?;
    email.send().await?;
    
    Ok(())
}

Email Implementation

From crates/core/src/email.rs:
pub struct Email {
    mailer: Mailer,
    from: Mailbox,
    to: Mailbox,
    subject: String,
    body: String,
}

impl Email {
    pub fn new(
        state: &AppState,
        to: &str,
        subject: String,
        body: String,
    ) -> Result<Self, EmailError> {
        // Constructs email with sender from config
    }
    
    pub async fn send(&self) -> Result<(), EmailError> {
        let email = Message::builder()
            .to(self.to.clone())
            .from(self.from.clone())
            .subject(self.subject.clone())
            .header(ContentType::TEXT_HTML)
            .body(Body::new(self.body.clone()))?;
        
        match self.mailer {
            Mailer::Smtp(ref mailer) => {
                mailer.send(email).await?;
            }
            Mailer::Local(ref mailer) => {
                mailer.send(email).await?;
            }
        };
        
        Ok(())
    }
}

Common SMTP Providers

Gmail

email {
  smtp_host: "smtp.gmail.com"
  smtp_port: 587
  smtp_username: "[email protected]"
  smtp_password: "your-app-password"  # Use App Password, not account password
  smtp_encryption: STARTTLS
}
Gmail App Passwords: Generate an app-specific password at myaccount.google.com/apppasswords

SendGrid

email {
  smtp_host: "smtp.sendgrid.net"
  smtp_port: 587
  smtp_username: "apikey"
  smtp_password: "your-sendgrid-api-key"
  smtp_encryption: STARTTLS
}

Mailgun

email {
  smtp_host: "smtp.mailgun.org"
  smtp_port: 587
  smtp_username: "[email protected]"
  smtp_password: "your-mailgun-smtp-password"
  smtp_encryption: STARTTLS
}

AWS SES

email {
  smtp_host: "email-smtp.us-east-1.amazonaws.com"
  smtp_port: 587
  smtp_username: "your-ses-smtp-username"
  smtp_password: "your-ses-smtp-password"
  smtp_encryption: STARTTLS
}

Postmark

email {
  smtp_host: "smtp.postmarkapp.com"
  smtp_port: 587
  smtp_username: "your-server-api-token"
  smtp_password: "your-server-api-token"
  smtp_encryption: STARTTLS
}

Email in WASM Components

Direct email sending is not currently available in WASM components. Instead, queue email jobs in the database and process them with a scheduled job.
use trailbase_wasm::http::{HttpRoute, Request, routing};
use trailbase_wasm::db::{execute, Value};
use trailbase_wasm::job::Job;
use trailbase_wasm::{Guest, export};

struct EmailJobs;

impl Guest for EmailJobs {
    fn http_handlers() -> Vec<HttpRoute> {
        vec![
            routing::post("/send-notification", queue_notification),
        ]
    }
    
    fn job_handlers() -> Vec<Job> {
        vec![
            Job::minutely("process_email_queue", process_email_queue),
        ]
    }
}

// Endpoint to queue emails
async fn queue_notification(mut req: Request) -> Result<String, HttpError> {
    #[derive(Deserialize)]
    struct NotificationRequest {
        email: String,
        subject: String,
        message: String,
    }
    
    let notification: NotificationRequest = req.body().json().await?;
    
    // Queue email for processing
    execute(
        r#"
        INSERT INTO email_queue (recipient, subject, body, status, created_at)
        VALUES ($1, $2, $3, 'pending', CURRENT_TIMESTAMP)
        "#,
        [
            Value::Text(notification.email),
            Value::Text(notification.subject),
            Value::Text(notification.message),
        ]
    ).await?;
    
    Ok("Email queued".to_string())
}

// Job to process queued emails
async fn process_email_queue() {
    use trailbase_wasm::fetch::{fetch, Request};
    
    let pending = query(
        "SELECT id, recipient, subject, body FROM email_queue WHERE status = 'pending' LIMIT 10",
        []
    ).await.unwrap();
    
    for row in pending {
        let id = row[0].as_integer().unwrap();
        let recipient = row[1].as_text().unwrap();
        let subject = row[2].as_text().unwrap();
        let body = row[3].as_text().unwrap();
        
        // Call admin API to send email (requires internal auth)
        let result = send_email_via_api(recipient, subject, body).await;
        
        match result {
            Ok(_) => {
                execute(
                    "UPDATE email_queue SET status = 'sent', sent_at = CURRENT_TIMESTAMP WHERE id = $1",
                    [Value::Integer(id)]
                ).await.unwrap();
            }
            Err(e) => {
                execute(
                    "UPDATE email_queue SET status = 'failed', error = $1 WHERE id = $2",
                    [Value::Text(e.to_string()), Value::Integer(id)]
                ).await.unwrap();
            }
        }
    }
}

export!(EmailJobs);

Email Queue Schema

CREATE TABLE email_queue (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  recipient TEXT NOT NULL,
  subject TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed')),
  error TEXT,
  created_at INTEGER NOT NULL,
  sent_at INTEGER
);

CREATE INDEX idx_email_queue_status ON email_queue(status, created_at);

Error Handling

use trailbase_core::api::{Email, EmailError};

async fn send_with_retry(
    state: &AppState,
    recipient: &str,
    subject: String,
    body: String,
) -> Result<(), EmailError> {
    let max_retries = 3;
    let mut attempt = 0;
    
    loop {
        attempt += 1;
        
        let email = Email::new(state, recipient, subject.clone(), body.clone())?;
        
        match email.send().await {
            Ok(_) => {
                log::info!("Email sent to {} on attempt {}", recipient, attempt);
                return Ok(());
            }
            Err(e) => {
                if attempt >= max_retries {
                    log::error!("Failed to send email after {} attempts: {}", max_retries, e);
                    return Err(e);
                }
                
                log::warn!("Email send attempt {} failed, retrying: {}", attempt, e);
                tokio::time::sleep(tokio::time::Duration::from_secs(2u64.pow(attempt))).await;
            }
        }
    }
}

Rate Limiting

use std::sync::Arc;
use tokio::sync::Semaphore;

// Limit concurrent email sends
static EMAIL_SEMAPHORE: LazyLock<Arc<Semaphore>> = 
    LazyLock::new(|| Arc::new(Semaphore::new(10)));

async fn send_batch_emails(
    state: &AppState,
    recipients: Vec<(String, String, String)>,  // (email, subject, body)
) -> Result<(), EmailError> {
    let mut tasks = vec![];
    
    for (email, subject, body) in recipients {
        let state = state.clone();
        let permit = EMAIL_SEMAPHORE.clone().acquire_owned().await.unwrap();
        
        let task = tokio::spawn(async move {
            let result = Email::new(&state, &email, subject, body)?
                .send()
                .await;
            drop(permit);  // Release permit
            result
        });
        
        tasks.push(task);
    }
    
    // Wait for all emails
    for task in tasks {
        task.await.unwrap()?;
    }
    
    Ok(())
}

Monitoring

Log Email Activity

CREATE TABLE email_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  recipient TEXT NOT NULL,
  subject TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent', 'failed', 'bounced')),
  error TEXT,
  sent_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX idx_email_log_recipient ON email_log(recipient);
CREATE INDEX idx_email_log_sent_at ON email_log(sent_at);

Track Delivery

async fn log_email_send(
    conn: &Connection,
    recipient: &str,
    subject: &str,
    result: &Result<(), EmailError>,
) {
    let (status, error) = match result {
        Ok(_) => ("sent", None),
        Err(e) => ("failed", Some(e.to_string())),
    };
    
    let _ = conn.execute(
        "INSERT INTO email_log (recipient, subject, status, error, sent_at) VALUES (?, ?, ?, ?, ?)",
        params![recipient, subject, status, error, chrono::Utc::now().timestamp()]
    ).await;
}

Best Practices

1

Use app-specific passwords

Never use your main account password for SMTP. Generate app-specific passwords.
2

Set proper sender info

Configure sender_address and sender_name to avoid spam filters:
email {
  sender_address: "[email protected]"
  sender_name: "Your App Name"
}
3

Configure SPF and DKIM

Set up email authentication records for your domain to improve deliverability.
4

Handle bounces

Monitor bounce rates and remove invalid addresses from your lists.
5

Rate limit sends

Respect SMTP provider limits. Use queues for bulk emails.
6

Test in development

Use services like Mailhog or MailCatcher for testing.

Testing Emails

Development Setup

Use Mailhog for local testing:
# Run Mailhog
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
email {
  smtp_host: "localhost"
  smtp_port: 1025
  smtp_encryption: NONE
  sender_address: "dev@localhost"
}
View emails at http://localhost:8025

Next Steps

Jobs Scheduler

Send scheduled emails

Custom Endpoints

Create email triggers

OAuth Providers

Configure authentication

Server-Side Rendering

Generate HTML emails

Build docs developers (and LLMs) love