use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::email::Email;
use crate::error::MailError;
use crate::mailer::{DeliveryResult, Mailer};
pub struct JmapMailer {
session_url: String,
auth: JmapAuth,
client: Client,
session: parking_lot::RwLock<Option<JmapSession>>,
}
#[derive(Clone)]
enum JmapAuth {
Basic { username: String, password: String },
Bearer { token: String },
}
#[derive(Clone)]
struct JmapSession {
api_url: String,
account_id: String,
identity_id: Option<String>,
drafts_mailbox_id: Option<String>,
}
impl JmapMailer {
#[allow(clippy::new_ret_no_self)]
pub fn new(url: &str) -> JmapBuilder {
let session_url = if url.ends_with("/session") || url.contains("/.well-known/jmap") {
url.to_string()
} else {
format!("{}/.well-known/jmap", url.trim_end_matches('/'))
};
JmapBuilder {
session_url,
auth: None,
client: None,
test_session: None,
}
}
async fn get_session(&self) -> Result<JmapSession, MailError> {
{
let guard = self.session.read();
if let Some(ref session) = *guard {
return Ok(session.clone());
}
}
let session = self.fetch_session().await?;
{
let mut guard = self.session.write();
*guard = Some(session.clone());
}
Ok(session)
}
async fn fetch_session(&self) -> Result<JmapSession, MailError> {
let req = self.apply_auth(self.client.get(&self.session_url));
let response = req.send().await?;
if !response.status().is_success() {
return Err(MailError::provider_with_status(
"jmap",
format!("Session discovery failed: {}", response.status()),
response.status().as_u16(),
));
}
let session: JmapSessionResponse = response.json().await?;
let account_id = session
.primary_accounts
.get("urn:ietf:params:jmap:mail")
.or_else(|| {
session
.primary_accounts
.get("urn:ietf:params:jmap:submission")
})
.or_else(|| session.accounts.keys().next())
.ok_or_else(|| MailError::Configuration("No JMAP mail account found".into()))?
.clone();
let identity_id = None;
Ok(JmapSession {
api_url: session.api_url,
account_id,
identity_id,
drafts_mailbox_id: None, })
}
fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
JmapAuth::Basic { username, password } => req.basic_auth(username, Some(password)),
JmapAuth::Bearer { token } => req.bearer_auth(token),
}
}
async fn get_identity_id(&self, session: &JmapSession) -> Result<String, MailError> {
if let Some(ref id) = session.identity_id {
return Ok(id.clone());
}
let request = JmapRequest {
using: vec![
"urn:ietf:params:jmap:core".into(),
"urn:ietf:params:jmap:submission".into(),
],
method_calls: vec![(
"Identity/get".into(),
json!({
"accountId": session.account_id,
}),
"i0".into(),
)],
};
let req = self.apply_auth(self.client.post(&session.api_url));
let response = req
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
return Ok(session.account_id.clone());
}
let jmap_response: JmapResponse = response.json().await?;
for (method, result, _) in jmap_response.method_responses {
if method == "Identity/get" {
if let Some(list) = result.get("list").and_then(|l| l.as_array()) {
if let Some(first) = list.first() {
if let Some(id) = first.get("id").and_then(|i| i.as_str()) {
return Ok(id.to_string());
}
}
}
}
}
Ok(session.account_id.clone())
}
async fn get_drafts_mailbox_id(&self, session: &JmapSession) -> Result<String, MailError> {
if let Some(ref id) = session.drafts_mailbox_id {
return Ok(id.clone());
}
let request = JmapRequest {
using: vec![
"urn:ietf:params:jmap:core".into(),
"urn:ietf:params:jmap:mail".into(),
],
method_calls: vec![(
"Mailbox/get".into(),
json!({
"accountId": session.account_id,
}),
"m0".into(),
)],
};
let req = self.apply_auth(self.client.post(&session.api_url));
let response = req
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
return Err(MailError::provider_with_status(
"jmap",
"Failed to fetch mailboxes",
response.status().as_u16(),
));
}
let jmap_response: JmapResponse = response.json().await?;
for (method, result, _) in jmap_response.method_responses {
if method == "Mailbox/get" {
if let Some(list) = result.get("list").and_then(|l| l.as_array()) {
for mailbox in list {
if mailbox.get("role").and_then(|r| r.as_str()) == Some("drafts") {
if let Some(id) = mailbox.get("id").and_then(|i| i.as_str()) {
return Ok(id.to_string());
}
}
}
for mailbox in list {
if mailbox.get("role").and_then(|r| r.as_str()) == Some("inbox") {
if let Some(id) = mailbox.get("id").and_then(|i| i.as_str()) {
return Ok(id.to_string());
}
}
}
if let Some(first) = list.first() {
if let Some(id) = first.get("id").and_then(|i| i.as_str()) {
return Ok(id.to_string());
}
}
}
}
}
Err(MailError::Configuration("No mailboxes found".into()))
}
fn build_email_object(&self, email: &Email, mailbox_id: &str) -> Result<Value, MailError> {
let from = email.from.as_ref().ok_or(MailError::MissingField("from"))?;
if email.to.is_empty() {
return Err(MailError::MissingField("to"));
}
let from_addrs: Vec<Value> = vec![json!({
"name": from.name,
"email": from.email,
})];
let to_addrs: Vec<Value> = email
.to
.iter()
.map(|a| {
json!({
"name": a.name,
"email": a.email,
})
})
.collect();
let cc_addrs: Option<Vec<Value>> = if email.cc.is_empty() {
None
} else {
Some(
email
.cc
.iter()
.map(|a| {
json!({
"name": a.name,
"email": a.email,
})
})
.collect(),
)
};
let bcc_addrs: Option<Vec<Value>> = if email.bcc.is_empty() {
None
} else {
Some(
email
.bcc
.iter()
.map(|a| {
json!({
"name": a.name,
"email": a.email,
})
})
.collect(),
)
};
let reply_to: Option<Vec<Value>> = if email.reply_to.is_empty() {
None
} else {
Some(
email
.reply_to
.iter()
.map(|a| {
json!({
"name": a.name,
"email": a.email,
})
})
.collect(),
)
};
let mut body_values: HashMap<String, Value> = HashMap::new();
let mut text_body: Vec<Value> = vec![];
let mut html_body: Vec<Value> = vec![];
if let Some(ref text) = email.text_body {
body_values.insert(
"text".into(),
json!({
"value": text,
"isEncodingProblem": false,
"isTruncated": false,
}),
);
text_body.push(json!({
"partId": "text",
"type": "text/plain",
}));
}
if let Some(ref html) = email.html_body {
body_values.insert(
"html".into(),
json!({
"value": html,
"isEncodingProblem": false,
"isTruncated": false,
}),
);
html_body.push(json!({
"partId": "html",
"type": "text/html",
}));
}
let attachments: Option<Vec<Value>> = if email.attachments.is_empty() {
None
} else {
Some(
email
.attachments
.iter()
.enumerate()
.map(|(i, a)| {
let part_id = format!("att{}", i);
body_values.insert(
part_id.clone(),
json!({
"value": a.base64_data(),
"isEncodingProblem": false,
"isTruncated": false,
}),
);
let mut att = json!({
"partId": part_id,
"type": a.content_type,
"name": a.filename,
"disposition": if a.is_inline() { "inline" } else { "attachment" },
});
if let Some(ref cid) = a.content_id {
att["cid"] = json!(cid);
}
att
})
.collect(),
)
};
let headers: Option<Vec<Value>> = if email.headers.is_empty() {
None
} else {
Some(
email
.headers
.iter()
.map(|(k, v)| {
json!({
"name": k,
"value": v,
})
})
.collect(),
)
};
let mut email_obj = json!({
"mailboxIds": { mailbox_id: true },
"from": from_addrs,
"to": to_addrs,
"subject": email.subject,
"bodyValues": body_values,
});
if !text_body.is_empty() {
email_obj["textBody"] = json!(text_body);
}
if !html_body.is_empty() {
email_obj["htmlBody"] = json!(html_body);
}
if let Some(cc) = cc_addrs {
email_obj["cc"] = json!(cc);
}
if let Some(bcc) = bcc_addrs {
email_obj["bcc"] = json!(bcc);
}
if let Some(rt) = reply_to {
email_obj["replyTo"] = json!(rt);
}
if let Some(atts) = attachments {
email_obj["attachments"] = json!(atts);
}
if let Some(hdrs) = headers {
email_obj["headers"] = json!(hdrs);
}
email_obj["keywords"] = json!({ "$draft": true });
Ok(email_obj)
}
}
pub struct JmapBuilder {
session_url: String,
auth: Option<JmapAuth>,
client: Option<Client>,
test_session: Option<(String, String, Option<String>)>, }
impl JmapBuilder {
pub fn credentials(mut self, username: &str, password: &str) -> Self {
self.auth = Some(JmapAuth::Basic {
username: username.to_string(),
password: password.to_string(),
});
self
}
pub fn bearer_token(mut self, token: &str) -> Self {
self.auth = Some(JmapAuth::Bearer {
token: token.to_string(),
});
self
}
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
#[doc(hidden)]
pub fn test_session(mut self, api_url: &str, account_id: &str) -> Self {
self.test_session = Some((api_url.to_string(), account_id.to_string(), None));
self
}
#[doc(hidden)]
pub fn test_session_with_mailbox(
mut self,
api_url: &str,
account_id: &str,
drafts_mailbox_id: &str,
) -> Self {
self.test_session = Some((
api_url.to_string(),
account_id.to_string(),
Some(drafts_mailbox_id.to_string()),
));
self
}
pub fn build(self) -> JmapMailer {
let session = self
.test_session
.map(|(api_url, account_id, drafts_mailbox_id)| JmapSession {
api_url,
account_id,
identity_id: Some("default".to_string()),
drafts_mailbox_id,
});
JmapMailer {
session_url: self.session_url,
auth: self.auth.unwrap_or(JmapAuth::Basic {
username: String::new(),
password: String::new(),
}),
client: self.client.unwrap_or_default(),
session: parking_lot::RwLock::new(session),
}
}
}
#[async_trait]
impl Mailer for JmapMailer {
async fn deliver(&self, email: &Email) -> Result<DeliveryResult, MailError> {
let session = self.get_session().await?;
let identity_id = self.get_identity_id(&session).await?;
let mailbox_id = self.get_drafts_mailbox_id(&session).await?;
let email_obj = self.build_email_object(email, &mailbox_id)?;
let request = JmapRequest {
using: vec![
"urn:ietf:params:jmap:core".into(),
"urn:ietf:params:jmap:mail".into(),
"urn:ietf:params:jmap:submission".into(),
],
method_calls: vec![
(
"Email/set".into(),
json!({
"accountId": session.account_id,
"create": {
"draft": email_obj,
},
}),
"e0".into(),
),
(
"EmailSubmission/set".into(),
json!({
"accountId": session.account_id,
"create": {
"sub": {
"emailId": "#draft",
"identityId": identity_id,
},
},
"onSuccessDestroyEmail": ["#sub"],
}),
"s0".into(),
),
],
};
let req = self.apply_auth(self.client.post(&session.api_url));
let response = req
.header("Content-Type", "application/json")
.header("User-Agent", format!("missive/{}", crate::VERSION))
.json(&request)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(MailError::provider_with_status(
"jmap",
format!("JMAP request failed: {}", error_text),
status.as_u16(),
));
}
let jmap_response: JmapResponse = response.json().await?;
for (method, result, _) in &jmap_response.method_responses {
if method == "error" {
let error_type = result
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown");
let description = result
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("Unknown error");
return Err(MailError::ProviderError {
provider: "jmap",
message: format!("{}: {}", error_type, description),
status: None,
});
}
if method == "Email/set" || method == "EmailSubmission/set" {
if let Some(not_created) = result.get("notCreated") {
if let Some(obj) = not_created.as_object() {
if let Some((_, error)) = obj.into_iter().next() {
let error_type = error
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown");
let description = error
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("Creation failed");
return Err(MailError::ProviderError {
provider: "jmap",
message: format!("{}: {}", error_type, description),
status: None,
});
}
}
}
}
}
let mut submission_id = uuid::Uuid::new_v4().to_string();
for (method, result, _) in &jmap_response.method_responses {
if method == "EmailSubmission/set" {
if let Some(created) = result.get("created") {
if let Some(sub) = created.get("sub") {
if let Some(id) = sub.get("id").and_then(|i| i.as_str()) {
submission_id = id.to_string();
}
}
}
}
}
Ok(DeliveryResult::with_response(
submission_id,
json!({ "provider": "jmap" }),
))
}
fn provider_name(&self) -> &'static str {
"jmap"
}
}
#[derive(Debug, Serialize)]
struct JmapRequest {
using: Vec<String>,
#[serde(rename = "methodCalls")]
method_calls: Vec<(String, Value, String)>,
}
#[derive(Debug, Deserialize)]
struct JmapResponse {
#[serde(rename = "methodResponses")]
method_responses: Vec<(String, Value, String)>,
}
#[derive(Debug, Deserialize)]
struct JmapSessionResponse {
#[serde(rename = "apiUrl")]
api_url: String,
accounts: HashMap<String, Value>,
#[serde(rename = "primaryAccounts", default)]
primary_accounts: HashMap<String, String>,
}