mod methods;
use std::sync::Arc;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use hmac::{Hmac, Mac};
use rand::{rngs::OsRng, RngCore};
use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
pub const DEFAULT_BASE_URL: &str = "https://api.mailkite.dev";
pub const DEFAULT_TOLERANCE_MS: i64 = 5 * 60 * 1000;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct Error {
pub status: u16,
pub message: String,
pub body: Option<Value>,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for Error {}
fn local_err(message: impl Into<String>) -> Error {
Error { status: 0, message: message.into(), body: None }
}
type TokenProvider = Arc<dyn Fn() -> Result<String, Error> + Send + Sync>;
#[derive(Clone)]
pub struct Client {
token: String,
token_provider: Option<TokenProvider>,
base_url: String,
}
impl Client {
pub fn new(token: impl Into<String>) -> Client {
Client {
token: token.into(),
token_provider: None,
base_url: DEFAULT_BASE_URL.to_string(),
}
}
pub fn new_with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Client {
Client {
token: token.into(),
token_provider: None,
base_url: base_url.into().trim_end_matches('/').to_string(),
}
}
pub fn new_with_token<F>(get_token: F) -> Client
where
F: Fn() -> Result<String, Error> + Send + Sync + 'static,
{
Client {
token: String::new(),
token_provider: Some(Arc::new(get_token)),
base_url: DEFAULT_BASE_URL.to_string(),
}
}
fn resolve_token(&self) -> Result<String, Error> {
match &self.token_provider {
Some(p) => p(),
None => Ok(self.token.clone()),
}
}
pub fn request(&self, method: &str, path: &str, body: Option<Value>) -> Result<Value, Error> {
let url = format!("{}{}", self.base_url, path);
let tok = self.resolve_token()?;
let req = ureq::request(method, &url).set("Authorization", &format!("Bearer {}", tok));
let result = match body {
Some(v) => {
let s = serde_json::to_string(&v).map_err(|e| local_err(e.to_string()))?;
req.set("Content-Type", "application/json").send_string(&s)
}
None => req.call(),
};
handle_response(result)
}
fn request_binary(
&self,
path: &str,
data: &[u8],
filename: &str,
content_type: &str,
retention_days: i64,
) -> Result<Value, Error> {
let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
let mut query: Vec<String> = Vec::new();
if !filename.is_empty() {
query.push(format!("filename={}", enc(filename)));
}
if retention_days != 0 {
query.push(format!("retentionDays={}", retention_days));
}
let mut url = format!("{}{}", self.base_url, path);
if !query.is_empty() {
url.push('?');
url.push_str(&query.join("&"));
}
let tok = self.resolve_token()?;
let result = ureq::request("POST", &url)
.set("Authorization", &format!("Bearer {}", tok))
.set("Content-Type", ct)
.send_bytes(data);
handle_response(result)
}
pub fn upload_attachment(&self, file: AttachmentUpload) -> Result<Value, Error> {
if let Some(url) = non_empty(&file.url) {
let mut obj = serde_json::Map::new();
obj.insert("url".into(), json!(url));
add_meta(&mut obj, &file);
return self.request("POST", "/v1/attachments", Some(Value::Object(obj)));
}
let has_path = non_empty(&file.path).is_some();
if file.bytes.is_some() || has_path {
let mut data = file.bytes.clone().unwrap_or_default();
let mut filename = file.filename.clone().unwrap_or_default();
let mut content_type = file.content_type.clone().unwrap_or_default();
if let Some(path) = non_empty(&file.path) {
data = std::fs::read(path).map_err(|e| local_err(e.to_string()))?;
if filename.is_empty() {
filename = basename(path);
}
if content_type.is_empty() {
content_type = guess_content_type(path);
}
}
if content_type.is_empty() {
content_type = guess_content_type(&filename);
}
return self.request_binary(
"/v1/attachments",
&data,
&filename,
&content_type,
file.retention_days.unwrap_or(0),
);
}
if let Some(content) = non_empty(&file.content) {
let mut obj = serde_json::Map::new();
obj.insert("content".into(), json!(content));
add_meta(&mut obj, &file);
return self.request("POST", "/v1/attachments", Some(Value::Object(obj)));
}
Err(local_err(
"mailkite: upload_attachment needs one of url, bytes, path, or content",
))
}
pub fn verify_webhook(&self, signature: &str, payload: &str, secret: &str) -> bool {
verify_webhook(signature, payload, secret)
}
pub fn verify_webhook_with_tolerance(
&self,
signature: &str,
payload: &str,
secret: &str,
tolerance_ms: i64,
) -> bool {
verify_webhook_with_tolerance(signature, payload, secret, tolerance_ms)
}
pub fn reply_ok(&self) -> &'static str {
reply_ok()
}
pub fn reply_spam(&self) -> &'static str {
reply_spam()
}
pub fn reply_drop(&self) -> &'static str {
reply_drop()
}
pub fn reply_block_sender(&self) -> &'static str {
reply_block_sender()
}
pub fn encrypt(&self, plaintext: &str, public_key_pem: &str) -> Result<String, Error> {
encrypt(plaintext, public_key_pem)
}
pub fn decrypt(&self, envelope_json: &str, private_key_pem: &str) -> Result<String, Error> {
decrypt(envelope_json, private_key_pem)
}
}
fn handle_response(result: Result<ureq::Response, ureq::Error>) -> Result<Value, Error> {
match result {
Ok(resp) => {
let text = resp.into_string().unwrap_or_default();
if text.is_empty() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
}
}
Err(ureq::Error::Status(code, resp)) => {
let text = resp.into_string().unwrap_or_default();
let data: Option<Value> = if text.is_empty() {
None
} else {
serde_json::from_str(&text).ok()
};
let msg = data
.as_ref()
.and_then(|d| d.get("error"))
.and_then(|e| e.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("HTTP {}", code));
Err(Error { status: code, message: msg, body: data })
}
Err(ureq::Error::Transport(t)) => Err(local_err(t.to_string())),
}
}
pub fn enc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
pub fn page_query(before: Option<i64>, limit: Option<i64>, search: Option<&str>) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(b) = before {
parts.push(format!("before={}", b));
}
if let Some(l) = limit {
parts.push(format!("limit={}", l));
}
if let Some(s) = search {
parts.push(format!("search={}", enc(s)));
}
if parts.is_empty() {
String::new()
} else {
format!("?{}", parts.join("&"))
}
}
#[derive(Debug, Clone, Default)]
pub struct AttachmentUpload {
pub url: Option<String>,
pub content: Option<String>,
pub filename: Option<String>,
pub content_type: Option<String>,
pub retention_days: Option<i64>,
pub path: Option<String>,
pub bytes: Option<Vec<u8>>,
}
fn non_empty(o: &Option<String>) -> Option<&str> {
o.as_deref().filter(|s| !s.is_empty())
}
fn add_meta(obj: &mut serde_json::Map<String, Value>, file: &AttachmentUpload) {
if let Some(f) = non_empty(&file.filename) {
obj.insert("filename".into(), json!(f));
}
if let Some(ct) = non_empty(&file.content_type) {
obj.insert("contentType".into(), json!(ct));
}
if let Some(rd) = file.retention_days.filter(|v| *v != 0) {
obj.insert("retentionDays".into(), json!(rd));
}
}
fn basename(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
fn guess_content_type(name: &str) -> String {
let ext = match name.rfind('.') {
Some(pos) => name[pos + 1..].to_lowercase(),
None => String::new(),
};
let ct = match ext.as_str() {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"csv" => "text/csv",
"txt" => "text/plain",
"html" => "text/html",
"json" => "application/json",
"zip" => "application/zip",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ics" | "ical" => "text/calendar",
_ => "application/octet-stream",
};
ct.to_string()
}
pub fn verify_webhook(signature: &str, payload: &str, secret: &str) -> bool {
verify_webhook_with_tolerance(signature, payload, secret, DEFAULT_TOLERANCE_MS)
}
pub fn verify_webhook_with_tolerance(
signature: &str,
payload: &str,
secret: &str,
tolerance_ms: i64,
) -> bool {
if signature.is_empty() {
return false;
}
let mut t = "";
let mut v1 = "";
for seg in signature.split(',') {
if let Some(i) = seg.find('=') {
let key = seg[..i].trim();
let val = seg[i + 1..].trim();
match key {
"t" => t = val,
"v1" => v1 = val,
_ => {}
}
}
}
if t.is_empty() || v1.is_empty() {
return false;
}
let ts: i64 = match t.parse() {
Ok(v) => v,
Err(_) => return false,
};
if tolerance_ms > 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
if (now - ts).abs() > tolerance_ms {
return false;
}
}
let mut mac = match <HmacSha256 as Mac>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(t.as_bytes());
mac.update(b".");
mac.update(payload.as_bytes());
let expected = to_hex(&mac.finalize().into_bytes());
constant_time_eq(expected.as_bytes(), v1.as_bytes())
}
pub fn reply_ok() -> &'static str {
"{\"status\":\"ok\"}"
}
pub fn reply_spam() -> &'static str {
"{\"status\":\"spam\"}"
}
pub fn reply_drop() -> &'static str {
"{\"status\":\"drop\"}"
}
pub fn reply_block_sender() -> &'static str {
"{\"status\":\"ok\",\"actions\":[{\"type\":\"block-sender\"}]}"
}
#[derive(Debug, Serialize, Deserialize)]
struct Envelope {
v: i64,
#[serde(rename = "keyAlg")]
key_alg: String,
fp: String,
enc: String,
iv: String,
#[serde(rename = "wrappedKey")]
wrapped_key: String,
ciphertext: String,
}
pub fn encrypt(plaintext: &str, public_key_pem: &str) -> Result<String, Error> {
let der = pem_to_der(public_key_pem)?;
let pub_key = RsaPublicKey::from_public_key_der(&der)
.map_err(|e| local_err(format!("mailkite: parse public key: {}", e)))?;
let fp = to_hex(&Sha256::digest(&der));
let mut raw_key = [0u8; 32];
OsRng.fill_bytes(&mut raw_key);
let mut iv = [0u8; 12];
OsRng.fill_bytes(&mut iv);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&raw_key));
let ct = cipher
.encrypt(Nonce::from_slice(&iv), plaintext.as_bytes())
.map_err(|_| local_err("mailkite: aes-gcm encrypt failed"))?;
let wrapped = pub_key
.encrypt(&mut OsRng, Oaep::new::<Sha256>(), &raw_key)
.map_err(|e| local_err(format!("mailkite: wrap content key: {}", e)))?;
let env = Envelope {
v: 1,
key_alg: "RSA-OAEP-256".to_string(),
fp,
enc: "A256GCM".to_string(),
iv: STANDARD.encode(iv),
wrapped_key: STANDARD.encode(wrapped),
ciphertext: STANDARD.encode(ct),
};
serde_json::to_string(&env).map_err(|e| local_err(e.to_string()))
}
pub fn decrypt(envelope_json: &str, private_key_pem: &str) -> Result<String, Error> {
let env: Envelope = serde_json::from_str(envelope_json)
.map_err(|e| local_err(format!("mailkite: parse envelope: {}", e)))?;
let priv_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem)
.map_err(|e| local_err(format!("mailkite: parse private key: {}", e)))?;
let wrapped = STANDARD
.decode(env.wrapped_key.as_bytes())
.map_err(|e| local_err(format!("mailkite: decode wrappedKey: {}", e)))?;
let iv = STANDARD
.decode(env.iv.as_bytes())
.map_err(|e| local_err(format!("mailkite: decode iv: {}", e)))?;
let ct = STANDARD
.decode(env.ciphertext.as_bytes())
.map_err(|e| local_err(format!("mailkite: decode ciphertext: {}", e)))?;
let raw_key = priv_key
.decrypt(Oaep::new::<Sha256>(), &wrapped)
.map_err(|e| local_err(format!("mailkite: unwrap content key: {}", e)))?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&raw_key));
let pt = cipher
.decrypt(Nonce::from_slice(&iv), ct.as_ref())
.map_err(|_| local_err("mailkite: decrypt failed"))?;
String::from_utf8(pt).map_err(|e| local_err(format!("mailkite: utf8: {}", e)))
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, Error> {
let mut b64 = String::new();
for line in pem.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with("-----") {
continue;
}
b64.push_str(t);
}
STANDARD
.decode(b64.as_bytes())
.map_err(|e| local_err(format!("mailkite: decode PEM: {}", e)))
}
fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}