botserver/docs/STALWART_API_MAPPING.md
Rodrigo Rodriguez (Pragmatismo) 26f7643f5c feat(auth): Add OAuth login for Google, Discord, Reddit, Twitter, Microsoft, Facebook
- Create core/oauth module with OAuthProvider enum and shared types
- Implement providers.rs with auth URLs, token exchange, user info endpoints
- Add routes for /auth/oauth/providers, /auth/oauth/{provider}, and callbacks
- Update login.html with OAuth button grid and dynamic provider loading
- Add OAuth config settings to config.csv with setup documentation and links
- Uses HTMX for login form, minimal JS for OAuth provider visibility
2025-12-04 22:53:40 -03:00

18 KiB

Stalwart API Mapping for General Bots

Version: 6.1.0
Purpose: Map Stalwart native features vs General Bots custom tables


Overview

Stalwart Mail Server provides a comprehensive REST Management API. Many email features that we might implement in our database are already available natively in Stalwart. This document maps what to use from Stalwart vs what we manage ourselves.


API Base URL

https://{stalwart_host}:{port}/api

Default ports:

  • HTTP: 8080
  • HTTPS: 443

Feature Mapping

USE STALWART API (Do NOT create tables)

Feature Stalwart Endpoint Notes
User/Account Management GET/POST/PATCH/DELETE /principal/{id} Create, update, delete email accounts
Email Queue GET /queue/messages List queued messages for delivery
Queue Status GET /queue/status Check if queue is running
Queue Control PATCH /queue/status/start PATCH /queue/status/stop Start/stop queue processing
Reschedule Delivery PATCH /queue/messages/{id} Retry failed deliveries
Cancel Delivery DELETE /queue/messages/{id} Cancel queued message
Distribution Lists POST /principal with type: "list" Mailing lists are "principals"
DKIM Signatures POST /dkim Create DKIM keys per domain
DNS Records GET /dns/records/{domain} Get required DNS records
Spam Training POST /spam-filter/train/spam POST /spam-filter/train/ham Train spam filter
Spam Classification POST /spam-filter/classify Test spam score
Telemetry/Metrics GET /telemetry/metrics Server metrics for monitoring
Live Metrics GET /telemetry/metrics/live Real-time metrics (WebSocket)
Logs GET /logs Query server logs
Traces GET /telemetry/traces Delivery traces
Live Tracing GET /telemetry/traces/live Real-time tracing
DMARC Reports GET /reports/dmarc Incoming DMARC reports
TLS Reports GET /reports/tls TLS-RPT reports
ARF Reports GET /reports/arf Abuse feedback reports
Troubleshooting GET /troubleshoot/delivery/{recipient} Debug delivery issues
DMARC Check POST /troubleshoot/dmarc Test DMARC/SPF/DKIM
Settings GET/POST /settings Server configuration
Undelete GET/POST /store/undelete/{account_id} Recover deleted messages
Account Purge GET /store/purge/account/{id} Purge account data
Encryption Settings GET/POST /account/crypto Encryption-at-rest
2FA/App Passwords GET/POST /account/auth Authentication settings

⚠️ USE BOTH (Stalwart + Our Tables)

Feature Stalwart Our Table Why Both?
Auto-Responders Sieve scripts via settings email_auto_responders We store UI config, sync to Stalwart Sieve
Email Rules/Filters Sieve scripts email_rules We store UI-friendly rules, compile to Sieve
Shared Mailboxes Principal with shared access shared_mailboxes We track permissions, Stalwart handles access

USE OUR TABLES (Stalwart doesn't provide)

Feature Our Table Why?
Global Email Signature global_email_signatures Bot-level branding, not in Stalwart
User Email Signature email_signatures User preferences, append before send
Scheduled Send scheduled_emails We queue and release at scheduled time
Email Templates email_templates Business templates with variables
Email Labels email_labels, email_label_assignments UI organization, not IMAP folders
Email Tracking sent_email_tracking (existing) Open/click tracking pixels

Stalwart API Integration Code

Client Setup

// src/email/stalwart_client.rs
pub struct StalwartClient {
    base_url: String,
    auth_token: String,
    http_client: reqwest::Client,
}

impl StalwartClient {
    pub fn new(base_url: &str, token: &str) -> Self {
        Self {
            base_url: base_url.to_string(),
            auth_token: token.to_string(),
            http_client: reqwest::Client::new(),
        }
    }

    async fn request<T: DeserializeOwned>(&self, method: Method, path: &str, body: Option<Value>) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let mut req = self.http_client.request(method, &url)
            .header("Authorization", format!("Bearer {}", self.auth_token));
        
        if let Some(b) = body {
            req = req.json(&b);
        }
        
        let resp = req.send().await?;
        let data: ApiResponse<T> = resp.json().await?;
        Ok(data.data)
    }
}

Queue Monitoring (for Analytics Dashboard)

impl StalwartClient {
    /// Get email queue status for monitoring dashboard
    pub async fn get_queue_status(&self) -> Result<QueueStatus> {
        let status: bool = self.request(Method::GET, "/api/queue/status", None).await?;
        let messages: QueueList = self.request(Method::GET, "/api/queue/messages?limit=100", None).await?;
        
        Ok(QueueStatus {
            is_running: status,
            total_queued: messages.total,
            messages: messages.items,
        })
    }

    /// Get queued message details
    pub async fn get_queued_message(&self, message_id: &str) -> Result<QueuedMessage> {
        self.request(Method::GET, &format!("/api/queue/messages/{}", message_id), None).await
    }

    /// Retry failed delivery
    pub async fn retry_delivery(&self, message_id: &str) -> Result<bool> {
        self.request(Method::PATCH, &format!("/api/queue/messages/{}", message_id), None).await
    }

    /// Cancel queued message
    pub async fn cancel_delivery(&self, message_id: &str) -> Result<bool> {
        self.request(Method::DELETE, &format!("/api/queue/messages/{}", message_id), None).await
    }

    /// Stop all queue processing
    pub async fn stop_queue(&self) -> Result<bool> {
        self.request(Method::PATCH, "/api/queue/status/stop", None).await
    }

    /// Resume queue processing
    pub async fn start_queue(&self) -> Result<bool> {
        self.request(Method::PATCH, "/api/queue/status/start", None).await
    }
}

Account/Principal Management

impl StalwartClient {
    /// Create email account
    pub async fn create_account(&self, email: &str, password: &str, display_name: &str) -> Result<u64> {
        let body = json!({
            "type": "individual",
            "name": email.split('@').next().unwrap_or(email),
            "emails": [email],
            "secrets": [password],
            "description": display_name,
            "quota": 0,
            "roles": ["user"]
        });
        
        self.request(Method::POST, "/api/principal", Some(body)).await
    }

    /// Create distribution list
    pub async fn create_distribution_list(&self, name: &str, email: &str, members: Vec<String>) -> Result<u64> {
        let body = json!({
            "type": "list",
            "name": name,
            "emails": [email],
            "members": members,
            "description": format!("Distribution list: {}", name)
        });
        
        self.request(Method::POST, "/api/principal", Some(body)).await
    }

    /// Get account details
    pub async fn get_account(&self, account_id: &str) -> Result<Principal> {
        self.request(Method::GET, &format!("/api/principal/{}", account_id), None).await
    }

    /// Update account
    pub async fn update_account(&self, account_id: &str, updates: Vec<AccountUpdate>) -> Result<()> {
        let body: Vec<Value> = updates.iter().map(|u| json!({
            "action": u.action,
            "field": u.field,
            "value": u.value
        })).collect();
        
        self.request(Method::PATCH, &format!("/api/principal/{}", account_id), Some(json!(body))).await
    }

    /// Delete account
    pub async fn delete_account(&self, account_id: &str) -> Result<()> {
        self.request(Method::DELETE, &format!("/api/principal/{}", account_id), None).await
    }
}

Sieve Rules (Auto-Responders & Filters)

impl StalwartClient {
    /// Set vacation/out-of-office auto-responder via Sieve
    pub async fn set_auto_responder(&self, account_id: &str, config: &AutoResponderConfig) -> Result<()> {
        let sieve_script = self.generate_vacation_sieve(config);
        
        let updates = vec![json!({
            "type": "set",
            "prefix": format!("sieve.scripts.{}.vacation", account_id),
            "value": sieve_script
        })];
        
        self.request(Method::POST, "/api/settings", Some(json!(updates))).await
    }

    fn generate_vacation_sieve(&self, config: &AutoResponderConfig) -> String {
        let mut script = String::from("require [\"vacation\", \"variables\"];\n\n");
        
        if let Some(start) = &config.start_date {
            script.push_str(&format!("# Active from: {}\n", start));
        }
        if let Some(end) = &config.end_date {
            script.push_str(&format!("# Active until: {}\n", end));
        }
        
        script.push_str(&format!(
            r#"vacation :days 1 :subject "{}" "{}";"#,
            config.subject.replace('"', "\\\""),
            config.body_plain.replace('"', "\\\"")
        ));
        
        script
    }

    /// Set email filter rule via Sieve
    pub async fn set_filter_rule(&self, account_id: &str, rule: &EmailRule) -> Result<()> {
        let sieve_script = self.generate_filter_sieve(rule);
        
        let updates = vec![json!({
            "type": "set",
            "prefix": format!("sieve.scripts.{}.filter_{}", account_id, rule.id),
            "value": sieve_script
        })];
        
        self.request(Method::POST, "/api/settings", Some(json!(updates))).await
    }

    fn generate_filter_sieve(&self, rule: &EmailRule) -> String {
        let mut script = String::from("require [\"fileinto\", \"reject\", \"vacation\"];\n\n");
        
        // Generate conditions
        for condition in &rule.conditions {
            match condition.field.as_str() {
                "from" => script.push_str(&format!(
                    "if header :contains \"From\" \"{}\" {{\n",
                    condition.value
                )),
                "subject" => script.push_str(&format!(
                    "if header :contains \"Subject\" \"{}\" {{\n",
                    condition.value
                )),
                _ => {}
            }
        }
        
        // Generate actions
        for action in &rule.actions {
            match action.action_type.as_str() {
                "move" => script.push_str(&format!("  fileinto \"{}\";\n", action.value)),
                "delete" => script.push_str("  discard;\n"),
                "mark_read" => script.push_str("  setflag \"\\\\Seen\";\n"),
                _ => {}
            }
        }
        
        if rule.stop_processing {
            script.push_str("  stop;\n");
        }
        
        script.push_str("}\n");
        script
    }
}

Telemetry & Monitoring

impl StalwartClient {
    /// Get server metrics for dashboard
    pub async fn get_metrics(&self) -> Result<Metrics> {
        self.request(Method::GET, "/api/telemetry/metrics", None).await
    }

    /// Get server logs
    pub async fn get_logs(&self, page: u32, limit: u32) -> Result<LogList> {
        self.request(
            Method::GET, 
            &format!("/api/logs?page={}&limit={}", page, limit), 
            None
        ).await
    }

    /// Get delivery traces
    pub async fn get_traces(&self, trace_type: &str, page: u32) -> Result<TraceList> {
        self.request(
            Method::GET,
            &format!("/api/telemetry/traces?type={}&page={}&limit=50", trace_type, page),
            None
        ).await
    }

    /// Get specific trace details
    pub async fn get_trace(&self, trace_id: &str) -> Result<Vec<TraceEvent>> {
        self.request(Method::GET, &format!("/api/telemetry/trace/{}", trace_id), None).await
    }

    /// Get DMARC reports
    pub async fn get_dmarc_reports(&self, page: u32) -> Result<ReportList> {
        self.request(Method::GET, &format!("/api/reports/dmarc?page={}&limit=50", page), None).await
    }

    /// Get TLS reports
    pub async fn get_tls_reports(&self, page: u32) -> Result<ReportList> {
        self.request(Method::GET, &format!("/api/reports/tls?page={}&limit=50", page), None).await
    }
}

Spam Filter

impl StalwartClient {
    /// Train message as spam
    pub async fn train_spam(&self, raw_message: &str) -> Result<()> {
        self.http_client
            .post(&format!("{}/api/spam-filter/train/spam", self.base_url))
            .header("Authorization", format!("Bearer {}", self.auth_token))
            .header("Content-Type", "message/rfc822")
            .body(raw_message.to_string())
            .send()
            .await?;
        Ok(())
    }

    /// Train message as ham (not spam)
    pub async fn train_ham(&self, raw_message: &str) -> Result<()> {
        self.http_client
            .post(&format!("{}/api/spam-filter/train/ham", self.base_url))
            .header("Authorization", format!("Bearer {}", self.auth_token))
            .header("Content-Type", "message/rfc822")
            .body(raw_message.to_string())
            .send()
            .await?;
        Ok(())
    }

    /// Classify message (get spam score)
    pub async fn classify_message(&self, message: &SpamClassifyRequest) -> Result<SpamClassifyResult> {
        self.request(Method::POST, "/api/spam-filter/classify", Some(json!(message))).await
    }
}

Monitoring Dashboard Integration

Endpoints to Poll

Metric Endpoint Poll Interval
Queue Size GET /queue/messages 30s
Queue Status GET /queue/status 30s
Server Metrics GET /telemetry/metrics 60s
Recent Logs GET /logs?limit=100 60s
Delivery Traces GET /telemetry/traces?type=delivery.attempt-start 60s
Failed Deliveries GET /queue/messages?filter=status:failed 60s

WebSocket Endpoints (Real-time)

Feature Endpoint Token Endpoint
Live Metrics ws://.../telemetry/metrics/live GET /telemetry/live/metrics-token
Live Traces ws://.../telemetry/traces/live GET /telemetry/live/tracing-token

Tables to REMOVE from Migration

Based on this mapping, these tables are REDUNDANT and should be removed:

-- REMOVE: Stalwart handles distribution lists via principals
-- DROP TABLE IF EXISTS distribution_lists;

-- KEEP: We need this for UI config, but sync to Stalwart Sieve
-- email_auto_responders (KEEP but add stalwart_sieve_id column)

-- KEEP: We need this for UI config, but sync to Stalwart Sieve  
-- email_rules (KEEP but add stalwart_sieve_id column)

Migration Updates Needed

The current 6.1.0_enterprise_suite migration already correctly:

  1. Keeps global_email_signatures - Stalwart doesn't have this
  2. Keeps email_signatures - User preference, not in Stalwart
  3. Keeps scheduled_emails - We manage scheduling
  4. Keeps email_templates - Business feature
  5. Keeps email_labels - UI organization
  6. Has stalwart_sieve_id in email_auto_responders - For sync
  7. Has stalwart_sieve_id in email_rules - For sync
  8. Has stalwart_account_id in shared_mailboxes - For sync

The distribution_lists table could potentially be removed since Stalwart handles lists as principals, BUT we may want to keep it for:

  • Caching/faster lookups
  • UI metadata not stored in Stalwart
  • Offline resilience

Recommendation: Keep distribution_lists but sync with Stalwart principals.


Sync Strategy

On Create (Our DB → Stalwart)

async fn create_distribution_list(db: &Pool, stalwart: &StalwartClient, list: NewDistributionList) -> Result<Uuid> {
    // 1. Create in Stalwart first
    let stalwart_id = stalwart.create_distribution_list(
        &list.name,
        &list.email_alias,
        list.members.clone()
    ).await?;
    
    // 2. Store in our DB with stalwart reference
    let id = db.insert_distribution_list(DistributionList {
        name: list.name,
        email_alias: list.email_alias,
        members_json: serde_json::to_string(&list.members)?,
        stalwart_principal_id: Some(stalwart_id.to_string()),
        ..Default::default()
    }).await?;
    
    Ok(id)
}

On Update (Sync both)

async fn update_distribution_list(db: &Pool, stalwart: &StalwartClient, id: Uuid, updates: ListUpdates) -> Result<()> {
    // 1. Get current record
    let list = db.get_distribution_list(&id).await?;
    
    // 2. Update Stalwart if we have a reference
    if let Some(stalwart_id) = &list.stalwart_principal_id {
        stalwart.update_principal(stalwart_id, updates.to_stalwart_updates()).await?;
    }
    
    // 3. Update our DB
    db.update_distribution_list(&id, updates).await?;
    
    Ok(())
}

On Delete (Both)

async fn delete_distribution_list(db: &Pool, stalwart: &StalwartClient, id: Uuid) -> Result<()> {
    let list = db.get_distribution_list(&id).await?;
    
    // 1. Delete from Stalwart
    if let Some(stalwart_id) = &list.stalwart_principal_id {
        stalwart.delete_principal(stalwart_id).await?;
    }
    
    // 2. Delete from our DB
    db.delete_distribution_list(&id).await?;
    
    Ok(())
}

Summary

Category Use Stalwart Use Our Tables Use Both
Account Management
Email Queue
Queue Monitoring
Distribution Lists
Auto-Responders
Email Rules/Filters
Shared Mailboxes
Email Signatures
Scheduled Send
Email Templates
Email Labels
Email Tracking
Spam Training
Telemetry/Logs
DMARC/TLS Reports