use chrono::{DateTime, Utc};
use serde_json::{Map, Value};
use crate::flags::flags_to_keywords;
use crate::ids::format_mailbox_id;
use crate::types::{Message, ParsedBody};
const BODY_PROPS: &[&str] = &["bodyValues", "textBody", "htmlBody", "attachments"];
pub fn wants_body(properties: &Option<Vec<&str>>) -> bool {
let Some(props) = properties.as_ref() else {
return true;
};
BODY_PROPS.iter().any(|b| props.contains(b))
}
pub fn build_email_meta(msg: &Message, email_id: &str, properties: &Option<Vec<&str>>) -> Value {
let include_all = properties.is_none();
let props = properties.as_ref();
let wants = |name: &str| include_all || props.is_some_and(|p| p.contains(&name));
let mut obj = Map::new();
obj.insert("id".to_string(), Value::String(email_id.to_string()));
if wants("threadId") {
obj.insert(
"threadId".to_string(),
Value::String(msg.thread_id.clone()),
);
}
if wants("mailboxIds") {
let mb_key = format_mailbox_id(msg.mailbox_id);
obj.insert(
"mailboxIds".to_string(),
serde_json::json!({ mb_key: true }),
);
}
if wants("keywords") {
obj.insert("keywords".to_string(), flags_to_keywords(msg.flags));
}
if wants("from") {
obj.insert("from".to_string(), parse_address_list(&msg.sender));
}
if wants("to") {
obj.insert("to".to_string(), parse_address_list(&msg.recipients));
}
if wants("subject") {
obj.insert("subject".to_string(), Value::String(msg.subject.clone()));
}
if wants("receivedAt") {
obj.insert(
"receivedAt".to_string(),
Value::String(epoch_to_utc_string(msg.internal_date)),
);
}
if wants("sentAt") {
obj.insert(
"sentAt".to_string(),
Value::String(epoch_to_utc_string(msg.date)),
);
}
if wants("size") {
obj.insert("size".to_string(), Value::Number(msg.size.into()));
}
if wants("messageId") {
obj.insert(
"messageId".to_string(),
serde_json::json!([msg.message_id]),
);
}
if wants("inReplyTo") {
if msg.in_reply_to.is_empty() {
obj.insert("inReplyTo".to_string(), Value::Null);
} else {
obj.insert(
"inReplyTo".to_string(),
serde_json::json!([msg.in_reply_to]),
);
}
}
if wants("preview") {
let preview = msg
.new_content
.as_deref()
.unwrap_or(&msg.subject)
.chars()
.take(256)
.collect::<String>();
obj.insert("preview".to_string(), Value::String(preview));
}
if wants("hasAttachment") {
obj.insert("hasAttachment".to_string(), Value::Bool(false));
}
Value::Object(obj)
}
pub fn extend_with_body(obj: &mut Value, parsed: Option<&ParsedBody>, properties: &Option<Vec<&str>>) {
let include_all = properties.is_none();
let props = properties.as_ref();
let wants = |name: &str| include_all || props.is_some_and(|p| p.contains(&name));
let Some(map) = obj.as_object_mut() else {
return;
};
if let Some(parsed) = parsed {
if wants("bodyValues") {
let mut bv = Map::new();
if let Some(ref text) = parsed.text {
bv.insert(
"t1".to_string(),
serde_json::json!({"value": text, "isEncodingProblem": false, "isTruncated": false}),
);
}
if let Some(ref html) = parsed.html {
bv.insert(
"h1".to_string(),
serde_json::json!({"value": html, "isEncodingProblem": false, "isTruncated": false}),
);
}
map.insert("bodyValues".to_string(), Value::Object(bv));
}
if wants("textBody") {
if parsed.text.is_some() {
map.insert(
"textBody".to_string(),
serde_json::json!([{"partId": "t1", "type": "text/plain"}]),
);
} else {
map.insert("textBody".to_string(), serde_json::json!([]));
}
}
if wants("htmlBody") {
if parsed.html.is_some() {
map.insert(
"htmlBody".to_string(),
serde_json::json!([{"partId": "h1", "type": "text/html"}]),
);
} else {
map.insert("htmlBody".to_string(), serde_json::json!([]));
}
}
if wants("attachments") {
let att_list: Vec<Value> = parsed
.attachments
.iter()
.map(|a| {
serde_json::json!({
"name": a.filename,
"type": a.content_type,
"size": a.size
})
})
.collect();
map.insert("attachments".to_string(), Value::Array(att_list));
map.insert(
"hasAttachment".to_string(),
Value::Bool(!parsed.attachments.is_empty()),
);
}
} else {
if wants("bodyValues") {
map.insert("bodyValues".to_string(), serde_json::json!({}));
}
if wants("textBody") {
map.insert("textBody".to_string(), serde_json::json!([]));
}
if wants("htmlBody") {
map.insert("htmlBody".to_string(), serde_json::json!([]));
}
if wants("attachments") {
map.insert("attachments".to_string(), serde_json::json!([]));
}
}
}
pub fn parse_address_list(raw: &str) -> Value {
if raw.is_empty() {
return Value::Array(vec![]);
}
let addrs: Vec<Value> = raw
.split(',')
.map(|s| {
let s = s.trim();
if let Some(pos) = s.find('<') {
let name = s[..pos].trim().trim_matches('"').to_string();
let email = s[pos + 1..].trim_end_matches('>').to_string();
serde_json::json!({"name": name, "email": email})
} else {
serde_json::json!({"name": null, "email": s})
}
})
.collect();
Value::Array(addrs)
}
pub fn epoch_to_utc_string(epoch: i64) -> String {
DateTime::<Utc>::from_timestamp(epoch, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_message() -> Message {
Message {
id: 1,
mailbox_id: 2,
uid: 3,
sender: "Alice <alice@example.com>".into(),
recipients: "bob@example.com".into(),
subject: "hi".into(),
date: 1_700_000_000,
size: 42,
flags: crate::types::FLAG_SEEN,
internal_date: 1_700_000_001,
message_id: "abc@example.com".into(),
in_reply_to: String::new(),
thread_id: "t1".into(),
user_address: "bob@example.com".into(),
new_content: Some("snippet".into()),
blob_id: "blob-1".into(),
}
}
#[test]
fn parse_address_with_display_name() {
let v = parse_address_list("Alice <alice@example.com>");
assert_eq!(v[0]["name"], "Alice");
assert_eq!(v[0]["email"], "alice@example.com");
}
#[test]
fn parse_address_bare() {
let v = parse_address_list("alice@example.com");
assert_eq!(v[0]["name"], Value::Null);
assert_eq!(v[0]["email"], "alice@example.com");
}
#[test]
fn parse_address_empty() {
assert_eq!(parse_address_list(""), serde_json::json!([]));
}
#[test]
fn parse_address_multiple() {
let v = parse_address_list("alice@example.com, bob@example.com");
assert_eq!(v.as_array().unwrap().len(), 2);
}
#[test]
fn epoch_zero_renders() {
let s = epoch_to_utc_string(0);
assert!(s.starts_with("1970-01-01"));
}
#[test]
fn epoch_out_of_range_is_empty() {
assert_eq!(epoch_to_utc_string(i64::MAX), "");
}
#[test]
fn build_email_meta_minimal_with_id() {
let msg = sample_message();
let v = build_email_meta(&msg, "msg-1", &Some(vec!["subject"]));
assert_eq!(v["id"], "msg-1");
assert_eq!(v["subject"], "hi");
assert!(v.get("threadId").is_none());
}
#[test]
fn build_email_meta_include_all() {
let msg = sample_message();
let v = build_email_meta(&msg, "msg-1", &None);
assert_eq!(v["threadId"], "t1");
assert_eq!(v["mailboxIds"]["mb-2"], true);
assert_eq!(v["keywords"]["$seen"], true);
assert_eq!(v["preview"], "snippet");
}
#[test]
fn extend_with_body_text_and_html() {
let msg = sample_message();
let mut obj = build_email_meta(&msg, "msg-1", &None);
let parsed = ParsedBody {
text: Some("hello".into()),
html: Some("<p>hi</p>".into()),
attachments: vec![],
};
extend_with_body(&mut obj, Some(&parsed), &None);
assert_eq!(obj["bodyValues"]["t1"]["value"], "hello");
assert_eq!(obj["bodyValues"]["h1"]["value"], "<p>hi</p>");
assert_eq!(obj["textBody"][0]["partId"], "t1");
assert_eq!(obj["htmlBody"][0]["partId"], "h1");
}
#[test]
fn extend_with_body_no_raw_yields_empty() {
let msg = sample_message();
let mut obj = build_email_meta(&msg, "msg-1", &None);
extend_with_body(&mut obj, None, &None);
assert_eq!(obj["bodyValues"], serde_json::json!({}));
assert_eq!(obj["textBody"], serde_json::json!([]));
}
#[test]
fn wants_body_logic() {
assert!(wants_body(&None));
assert!(wants_body(&Some(vec!["bodyValues"])));
assert!(!wants_body(&Some(vec!["subject", "from"])));
}
#[test]
fn in_reply_to_null_when_empty() {
let msg = sample_message();
let v = build_email_meta(&msg, "msg-1", &Some(vec!["inReplyTo"]));
assert_eq!(v["inReplyTo"], Value::Null);
}
#[test]
fn in_reply_to_array_when_set() {
let mut msg = sample_message();
msg.in_reply_to = "parent@example.com".into();
let v = build_email_meta(&msg, "msg-1", &Some(vec!["inReplyTo"]));
assert_eq!(v["inReplyTo"], serde_json::json!(["parent@example.com"]));
}
}