main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
//! Read-only SendGrid access, used only to import templates during the move
//! to Resend. Nothing here writes to SendGrid.

use anyhow::{Context, Result, anyhow};
use serde_json::Value;

const API_BASE: &str = "https://api.sendgrid.com/v3";

pub struct SendGridClient {
    api_key: String,
}

/// The active version of one SendGrid dynamic template.
#[derive(Debug, serde::Serialize, Clone)]
pub struct ActiveTemplate {
    pub id: String,
    pub name: String,
    pub subject: String,
    pub html: String,
}

impl SendGridClient {
    pub fn new(api_key: String) -> Self {
        Self { api_key }
    }

    fn get(&self, path: &str) -> Result<Value> {
        ureq::get(&format!("{API_BASE}{path}"))
            .set("Authorization", &format!("Bearer {}", self.api_key))
            .timeout(std::time::Duration::from_secs(20))
            .call()
            .map_err(|e| match e {
                ureq::Error::Status(code, resp) => {
                    anyhow!("SendGrid API returned HTTP {code}: {}", resp.into_string().unwrap_or_default())
                }
                ureq::Error::Transport(t) => anyhow!("SendGrid API request failed: {t}"),
            })?
            .into_json()
            .context("parsing SendGrid response")
    }

    /// Every dynamic template with its active version's subject and HTML.
    pub fn list_active(&self) -> Result<Vec<ActiveTemplate>> {
        let list = self.get("/templates?generations=dynamic&page_size=200")?;
        let ids: Vec<String> = list
            .get("result")
            .and_then(|r| r.as_array())
            .map(|a| a.iter().filter_map(|t| t.get("id")?.as_str().map(String::from)).collect())
            .unwrap_or_default();
        let mut out = Vec::new();
        for id in ids {
            if let Some(t) = self.get_active(&id)? {
                out.push(t);
            }
        }
        Ok(out)
    }

    pub fn get_active(&self, id: &str) -> Result<Option<ActiveTemplate>> {
        let t = self.get(&format!("/templates/{id}"))?;
        let name = t.get("name").and_then(|v| v.as_str()).unwrap_or(id).to_string();
        let versions = t.get("versions").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        let Some(v) = versions.iter().find(|v| v.get("active").and_then(|a| a.as_i64()) == Some(1)) else {
            return Ok(None);
        };
        Ok(Some(ActiveTemplate {
            id: id.to_string(),
            name,
            subject: v.get("subject").and_then(|s| s.as_str()).unwrap_or("").to_string(),
            html: v.get("html_content").and_then(|s| s.as_str()).unwrap_or("").to_string(),
        }))
    }
}

/// Sample values from a SendGrid Studio database, keyed by SendGrid template
/// id then variable name, so imported drafts keep the preview data that was
/// already written for them.
pub fn studio_samples(db_path: &str) -> std::collections::HashMap<String, serde_json::Map<String, Value>> {
    let mut out = std::collections::HashMap::new();
    let Ok(conn) = rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) else {
        return out;
    };
    let Ok(mut stmt) = conn.prepare("SELECT template_id, variables_json FROM manifests") else {
        return out;
    };
    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)));
    if let Ok(rows) = rows {
        for (id, vars) in rows.flatten() {
            let mut map = serde_json::Map::new();
            if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(&vars) {
                for v in arr {
                    if let (Some(name), Some(sample)) = (v.get("name").and_then(|n| n.as_str()), v.get("sample")) {
                        map.insert(name.to_string(), sample.clone());
                    }
                }
            }
            out.insert(id, map);
        }
    }
    out
}