Services

Email APIs

Email sent through Resend, Postmark, SendGrid or Mailgun's HTTP APIs with one call and one message shape, attachments included, the provider picked by a word; and the request shown before it is sent.

Version 1.0.0 · 2 functions · 3 crates

#email#api#notifications#integration

Functions

What use mail_api.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mailapi_send, mailapi_request from mail_api (Quillo 0.6).

mailapi_send(provider: str, config: any, message: any) → anyor an error

Sends message through provider ("resend", "postmark", "sendgrid" or "mailgun"). message holds from (or MAIL_FROM), to (one address, a comma-separated list or a list), subject, text and/or html, and maybe cc, bcc, reply_to, tags, headers and attachments ([{ path }] or [{ filename, content (base64), content_type }]). config may hold api_key (else RESEND_API_KEY, POSTMARK_SERVER_TOKEN, SENDGRID_API_KEY or MAILGUN_API_KEY), base_url (else MAIL_API_URL, else the provider's), and Mailgun's domain (MAILGUN_DOMAIN) and region ("eu"). Gives { ok, status, id, error }.

mailapi_request(provider: str, config: any, message: any) → anyor an error

The HTTP request mailapi_send would make, without sending it: { method, url, headers, content_type, body }, the key in the headers masked. For tests, logs, and seeing how each provider wants the message.

Get it

$ quillo install mail_api               # mail_api.qwer into this folder$ quillo install mail_api --example     # and its example, mail_api.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/mail_api.qwer$ curl -O https://quillolang.org/plugins/files/mail_api.qwe$ curl -O https://quillolang.org/plugins/files/mail_api_sample.txt

Then, in your program, all of it or only what you call:

main.qwe
use mail_api.qweruse mailapi_send, mailapi_request from mail_api      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }

Example

mail_api.qwe
// Order confirmations sent through whichever email API the shop has a key for, chosen by// MAIL_PROVIDER, with the invoice attached.////   MAIL_PROVIDER=resend RESEND_API_KEY=re_... MAIL_FROM="Shop <shop@example.com>" quillo run mail_api.qwe//   then open http://localhost:8080/confirm and http://localhost:8080/preview//// MAIL_API_URL (here SINK_URL, in tests) points every provider at another server.use "mail_api.qwer"let PROVIDER = env("MAIL_PROVIDER", "resend")let CONFIG = { base_url: env("MAIL_API_URL", null) ?? env("SINK_URL", null), domain: env("MAILGUN_DOMAIN", "mg.example.com") }fn confirmation(to) = {    from: env("MAIL_FROM", "Quillo Shop <shop@example.com>"),    to,    subject: "Your order A-1042",    text: "Thanks! Your order A-1042 is on its way.",    html: "<p>Thanks! Your order <b>A-1042</b> is on its way.</p>",    reply_to: "help@example.com",    tags: ["order-confirmation"],    attachments: [{ path: "mail_api_sample.txt", filename: "invoice-A-1042.txt", content_type: "text/plain" }],}route GET /confirm = mailapi_send(PROVIDER, CONFIG, confirmation("Ada Lovelace <ada@example.org>"))?// The same message as each provider wants it.route GET /preview = {    resend: mailapi_request("resend", CONFIG, confirmation("ada@example.org"))?.body,    postmark: mailapi_request("postmark", CONFIG, confirmation("ada@example.org"))?.body.To,    sendgrid: mailapi_request("sendgrid", CONFIG, confirmation("Ada Lovelace <ada@example.org>"))?.body.personalizations,    mailgun: mailapi_request("mailgun", CONFIG, { ...confirmation("ada@example.org"), attachments: [] })?.url,    missing: mailapi_request("resend", CONFIG, { to: "ada@example.org", subject: "Hi" }).error,}
$ quillo run mail_api.qwe$ curl 'localhost:8080/confirm'$ curl 'localhost:8080/preview'

It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.

Source

mail_api.qwer
//! Email sent through Resend, Postmark, SendGrid or Mailgun's HTTP APIs with one call and one message shape (from, to, cc, bcc, reply-to, subject, text, HTML, tags, headers, attachments), the provider picked by a word; and the request shown before it is sent.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::engine::general_purpose::STANDARD;use base64::Engine;use serde_json::{json, Map, Value};struct Request {    method: &'static str,    url: String,    headers: Vec<(String, String)>,    content_type: String,    body: Vec<u8>,}fn env(k: &str) -> Option<String> {    std::env::var(k).ok().filter(|v| !v.trim().is_empty())}fn conf(config: &Value, key: &str, env_key: &str) -> Option<String> {    config[key].as_str().map(str::to_string).filter(|s| !s.is_empty()).or_else(|| env(env_key))}fn list(v: &Value) -> Vec<String> {    match v {        Value::String(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),        Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),        _ => vec![],    }}/// `Name <a@b.c>` split into the address and the name.fn mailbox(s: &str) -> (String, Option<String>) {    match (s.rfind('<'), s.rfind('>')) {        (Some(i), Some(j)) if j > i => (s[i + 1..j].trim().to_string(), Some(s[..i].trim().trim_matches('"').to_string()).filter(|n| !n.is_empty())),        _ => (s.trim().to_string(), None),    }}struct Attachment {    filename: String,    content_type: String,    bytes: Vec<u8>,}fn attachments(m: &Value) -> Result<Vec<Attachment>, String> {    let mut out = vec![];    for a in m["attachments"].as_array().into_iter().flatten() {        let bytes = if let Some(p) = a["path"].as_str() {            std::fs::read(p).map_err(|e| format!("attachment {}: {}", p, e))?        } else if let Some(c) = a["content"].as_str() {            STANDARD.decode(c).map_err(|_| "an attachment's content is base64".to_string())?        } else {            return Err("an attachment has a path, or content in base64".into());        };        let filename = a["filename"].as_str().map(str::to_string).or_else(|| a["path"].as_str().and_then(|p| p.rsplit('/').next()).map(str::to_string)).unwrap_or_else(|| "attachment".into());        let content_type = a["content_type"].as_str().unwrap_or("application/octet-stream").to_string();        out.push(Attachment { filename, content_type, bytes });    }    Ok(out)}fn form(pairs: &[(String, String)]) -> Vec<u8> {    let enc = |s: &str| s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect::<String>();    pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::<Vec<_>>().join("&").into_bytes()}fn multipart(pairs: &[(String, String)], files: &[Attachment]) -> (String, Vec<u8>) {    let boundary = format!("quillo-{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()));    let mut b = Vec::new();    for (k, v) in pairs {        b.extend(format!("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, k, v).as_bytes());    }    for f in files {        b.extend(format!("--{}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", boundary, f.filename.replace('"', ""), f.content_type).as_bytes());        b.extend(&f.bytes);        b.extend(b"\r\n");    }    b.extend(format!("--{}--\r\n", boundary).as_bytes());    (format!("multipart/form-data; boundary={}", boundary), b)}fn build(provider: &str, config: &Value, m: &Value) -> Result<Request, String> {    let from = m["from"].as_str().map(str::to_string).or_else(|| env("MAIL_FROM")).ok_or("the message needs a from (or MAIL_FROM)")?;    let to = list(&m["to"]);    if to.is_empty() {        return Err("the message needs a to".into());    }    let (cc, bcc) = (list(&m["cc"]), list(&m["bcc"]));    let subject = m["subject"].as_str().ok_or("the message needs a subject")?;    let (text, html) = (m["text"].as_str(), m["html"].as_str());    if text.is_none() && html.is_none() {        return Err("the message needs text or html".into());    }    let reply_to = m["reply_to"].as_str();    let headers: Map<String, Value> = m["headers"].as_object().cloned().unwrap_or_default();    let tags: Vec<String> = list(&m["tags"]);    let files = attachments(m)?;    let key = |k: &str, e: &str| conf(config, "api_key", e).ok_or_else(|| format!("{} needs an api_key (or {})", k, e));    let base = |default: &str| conf(config, "base_url", "MAIL_API_URL").unwrap_or_else(|| default.to_string()).trim_end_matches('/').to_string();    let json_req = |url: String, headers: Vec<(String, String)>, body: Value| Request { method: "POST", url, headers, content_type: "application/json".into(), body: serde_json::to_vec(&body).unwrap_or_default() };    Ok(match provider.to_lowercase().as_str() {        "resend" => {            let mut b = json!({ "from": from, "to": to, "subject": subject });            if !cc.is_empty() { b["cc"] = json!(cc); }            if !bcc.is_empty() { b["bcc"] = json!(bcc); }            if let Some(r) = reply_to { b["reply_to"] = json!(r); }            if let Some(t) = text { b["text"] = json!(t); }            if let Some(h) = html { b["html"] = json!(h); }            if !headers.is_empty() { b["headers"] = Value::Object(headers); }            if !tags.is_empty() { b["tags"] = json!(tags.iter().map(|t| json!({ "name": "category", "value": t })).collect::<Vec<_>>()); }            if !files.is_empty() { b["attachments"] = json!(files.iter().map(|f| json!({ "filename": f.filename, "content": STANDARD.encode(&f.bytes), "content_type": f.content_type })).collect::<Vec<_>>()); }            json_req(format!("{}/emails", base("https://api.resend.com")), vec![("Authorization".into(), format!("Bearer {}", key("Resend", "RESEND_API_KEY")?))], b)        }        "postmark" => {            let mut b = json!({ "From": from, "To": to.join(", "), "Subject": subject, "MessageStream": config["stream"].as_str().unwrap_or("outbound") });            if !cc.is_empty() { b["Cc"] = json!(cc.join(", ")); }            if !bcc.is_empty() { b["Bcc"] = json!(bcc.join(", ")); }            if let Some(r) = reply_to { b["ReplyTo"] = json!(r); }            if let Some(t) = text { b["TextBody"] = json!(t); }            if let Some(h) = html { b["HtmlBody"] = json!(h); }            if let Some(t) = tags.first() { b["Tag"] = json!(t); }            if !headers.is_empty() { b["Headers"] = json!(headers.iter().map(|(k, v)| json!({ "Name": k, "Value": v })).collect::<Vec<_>>()); }            if !files.is_empty() { b["Attachments"] = json!(files.iter().map(|f| json!({ "Name": f.filename, "Content": STANDARD.encode(&f.bytes), "ContentType": f.content_type })).collect::<Vec<_>>()); }            json_req(format!("{}/email", base("https://api.postmarkapp.com")), vec![("X-Postmark-Server-Token".into(), key("Postmark", "POSTMARK_SERVER_TOKEN")?), ("Accept".into(), "application/json".into())], b)        }        "sendgrid" => {            let addr = |s: &str| { let (e, n) = mailbox(s); match n { Some(n) => json!({ "email": e, "name": n }), None => json!({ "email": e }) } };            let mut p = json!({ "to": to.iter().map(|t| addr(t)).collect::<Vec<_>>() });            if !cc.is_empty() { p["cc"] = json!(cc.iter().map(|t| addr(t)).collect::<Vec<_>>()); }            if !bcc.is_empty() { p["bcc"] = json!(bcc.iter().map(|t| addr(t)).collect::<Vec<_>>()); }            let mut content = vec![];            if let Some(t) = text { content.push(json!({ "type": "text/plain", "value": t })); }            if let Some(h) = html { content.push(json!({ "type": "text/html", "value": h })); }            let mut b = json!({ "personalizations": [p], "from": addr(&from), "subject": subject, "content": content });            if let Some(r) = reply_to { b["reply_to"] = addr(r); }            if !tags.is_empty() { b["categories"] = json!(tags); }            if !headers.is_empty() { b["headers"] = Value::Object(headers); }            if !files.is_empty() { b["attachments"] = json!(files.iter().map(|f| json!({ "filename": f.filename, "content": STANDARD.encode(&f.bytes), "type": f.content_type, "disposition": "attachment" })).collect::<Vec<_>>()); }            json_req(format!("{}/v3/mail/send", base("https://api.sendgrid.com")), vec![("Authorization".into(), format!("Bearer {}", key("SendGrid", "SENDGRID_API_KEY")?))], b)        }        "mailgun" => {            let domain = conf(config, "domain", "MAILGUN_DOMAIN").ok_or("Mailgun needs a domain (or MAILGUN_DOMAIN)")?;            let default = if config["region"].as_str() == Some("eu") { "https://api.eu.mailgun.net" } else { "https://api.mailgun.net" };            let mut pairs: Vec<(String, String)> = vec![("from".into(), from.clone()), ("subject".into(), subject.into())];            pairs.extend(to.iter().map(|t| ("to".to_string(), t.clone())));            pairs.extend(cc.iter().map(|t| ("cc".to_string(), t.clone())));            pairs.extend(bcc.iter().map(|t| ("bcc".to_string(), t.clone())));            if let Some(t) = text { pairs.push(("text".into(), t.into())); }            if let Some(h) = html { pairs.push(("html".into(), h.into())); }            if let Some(r) = reply_to { pairs.push(("h:Reply-To".into(), r.into())); }            pairs.extend(tags.iter().map(|t| ("o:tag".to_string(), t.clone())));            pairs.extend(headers.iter().map(|(k, v)| (format!("h:{}", k), v.as_str().unwrap_or("").to_string())));            let auth = format!("Basic {}", STANDARD.encode(format!("api:{}", key("Mailgun", "MAILGUN_API_KEY")?)));            let url = format!("{}/v3/{}/messages", base(default), domain);            if files.is_empty() {                Request { method: "POST", url, headers: vec![("Authorization".into(), auth)], content_type: "application/x-www-form-urlencoded".into(), body: form(&pairs) }            } else {                let (ct, body) = multipart(&pairs, &files);                Request { method: "POST", url, headers: vec![("Authorization".into(), auth)], content_type: ct, body }            }        }        other => return Err(format!("unknown provider `{}`: resend, postmark, sendgrid or mailgun", other)),    })}/// Sends `message` through `provider` ("resend", "postmark", "sendgrid" or "mailgun"). `message` holds `from` (or MAIL_FROM), `to`/// (one address, a comma-separated list or a list), `subject`, `text` and/or `html`, and maybe `cc`, `bcc`, `reply_to`, `tags`,/// `headers` and `attachments` (`[{ path }]` or `[{ filename, content (base64), content_type }]`). `config` may hold `api_key`/// (else RESEND_API_KEY, POSTMARK_SERVER_TOKEN, SENDGRID_API_KEY or MAILGUN_API_KEY), `base_url` (else MAIL_API_URL, else the/// provider's), and Mailgun's `domain` (MAILGUN_DOMAIN) and `region` ("eu"). Gives `{ ok, status, id, error }`.pub fn mailapi_send(provider: &str, config: serde_json::Value, message: serde_json::Value) -> Result<Value, String> {    let r = build(provider, &config, &message)?;    let mut req = ureq::request(r.method, &r.url).timeout(std::time::Duration::from_secs(20)).set("Content-Type", &r.content_type).set("User-Agent", "quillo-mail-api/1.0");    for (k, v) in &r.headers {        req = req.set(k, v);    }    let (status, body) = match req.send_bytes(&r.body) {        Ok(res) => (res.status(), res.into_string().unwrap_or_default()),        Err(ureq::Error::Status(code, res)) => (code, res.into_string().unwrap_or_default()),        Err(e) => return Err(format!("{}: {}", provider, e)),    };    let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null);    let id = v["id"].as_str().or(v["MessageID"].as_str()).map(str::to_string);    let error = if (200..300).contains(&status) { None } else { v["message"].as_str().or(v["Message"].as_str()).or(v["errors"][0]["message"].as_str()).map(str::to_string).or(Some(body.chars().take(300).collect())) };    Ok(json!({ "ok": (200..300).contains(&status), "status": status, "id": id, "error": error, "provider": provider.to_lowercase() }))}/// The HTTP request `mailapi_send` would make, without sending it: `{ method, url, headers, content_type, body }`, the key in the/// headers masked. For tests, logs, and seeing how each provider wants the message.pub fn mailapi_request(provider: &str, config: serde_json::Value, message: serde_json::Value) -> Result<Value, String> {    let r = build(provider, &config, &message)?;    let headers: Map<String, Value> = r.headers.iter().map(|(k, v)| {        let masked = if k.eq_ignore_ascii_case("authorization") || k.to_lowercase().contains("token") { format!("{}…", v.chars().take(10).collect::<String>()) } else { v.clone() };        (k.clone(), json!(masked))    }).collect();    let body = if r.content_type == "application/json" { serde_json::from_slice(&r.body).unwrap_or(Value::Null) } else { json!(String::from_utf8_lossy(&r.body)) };    Ok(json!({ "method": r.method, "url": r.url, "headers": headers, "content_type": r.content_type, "body": body }))}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Email APIs: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.