use crate::{
backend::{
ActionStatus, BackendEvent, LeafPart, MailBackend, MailboxSnapshot, OutgoingMessage,
PartRole, build_compose_body, mailer_header,
},
model::{
Action, ActionType, MailboxKind, Message, MessageAttachment, MessageContent,
MessageContentPart, MessageId, MessageStatus,
},
};
use anyhow::{Context, Result, anyhow};
use jmap_client::{
Method, Set as JmapSet, URI,
client::{Client, Credentials},
core::{
response::{EmailSetResponse, EmailSubmissionSetResponse, TaggedMethodResponse},
set::SetObject,
},
email::{self, Email as JmapEmail, EmailBodyPart, Property as EmailProperty},
identity::Property as IdentityProperty,
mailbox::{Mailbox as JmapMailbox, Property as MailboxProperty, Role as MailboxRole},
};
use lettre::{Message as LettreEmail, message::Mailbox as LettreMailbox};
use std::{
collections::{HashMap, HashSet},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering as AtomicOrdering},
mpsc,
},
time::Duration,
};
use time::OffsetDateTime;
use tokio::{runtime::Runtime, sync::Mutex as AsyncMutex, task::JoinHandle, time::interval};
const INITIAL_FETCH_LIMIT: usize = 128;
const BACKFILL_BATCH_SIZE: usize = 128;
const MAX_BODY_VALUE_BYTES: usize = 512 * 1024;
const EVENT_IDLE_POLL: Duration = Duration::from_secs(45);
const EVENT_RETRY_DELAY: Duration = Duration::from_secs(10);
const SENT_KEYWORDS: [&str; 1] = ["$seen"];
#[cfg(debug_assertions)]
mod debug_logging {
use crate::backend::debug_log::{DebugLog, LogKind, REDACTED};
use std::{fmt::Display, sync::Arc};
const SESSION_SCOPE: &str = "req#000";
fn logger() -> Option<Arc<DebugLog>> {
DebugLog::global(LogKind::Jmap).ok()
}
pub fn register_secret(secret: &str) {
if let Some(logger) = logger() {
logger.register_secret(secret);
}
}
pub fn log_session(payload: impl Display) {
if let Some(logger) = logger() {
logger.log_event(SESSION_SCOPE, "INFO", &payload.to_string());
}
}
pub fn request(method: &str, arguments: impl Display) -> JmapRequest {
let logger = logger();
let scope = match &logger {
Some(logger) => format!("req#{:03}", logger.allocate_connection_id()),
None => SESSION_SCOPE.to_owned(),
};
if let Some(logger) = &logger {
logger.log_event(&scope, "C->S", &format!("{method} {arguments}"));
}
JmapRequest {
logger,
scope,
method: method.to_owned(),
}
}
pub struct JmapRequest {
logger: Option<Arc<DebugLog>>,
scope: String,
method: String,
}
impl JmapRequest {
pub fn response<T, E: Display>(
&self,
result: Result<T, E>,
summary: impl FnOnce(&T) -> String,
) -> Result<T, E> {
if let Some(logger) = &self.logger {
let payload = match &result {
Ok(value) => format!("{} -> {}", self.method, summary(value)),
Err(err) => format!("{} -> ERROR: {err}", self.method),
};
logger.log_event(&self.scope, "S->C", &payload);
}
result
}
}
pub fn redact_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let (authority, path) = match rest.find('/') {
Some(index) => rest.split_at(index),
None => (rest, ""),
};
match authority.rsplit_once('@') {
Some((_, host)) => format!("{scheme}://{REDACTED}@{host}{path}"),
None => url.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_url_drops_userinfo() {
assert_eq!(
redact_url("https://user:s3cret@api.example.com/jmap/session"),
"https://***@api.example.com/jmap/session"
);
}
#[test]
fn redact_url_keeps_urls_without_credentials() {
let url = "https://api.fastmail.com/jmap/session";
assert_eq!(redact_url(url), url);
}
#[test]
fn redact_url_handles_userinfo_without_a_path() {
assert_eq!(
redact_url("https://user:s3cret@api.example.com"),
"https://***@api.example.com"
);
}
#[test]
fn redact_url_leaves_non_urls_alone() {
assert_eq!(redact_url("api.example.com"), "api.example.com");
}
}
}
#[cfg(not(debug_assertions))]
mod debug_logging {
use std::fmt::Display;
pub struct JmapRequest;
pub fn register_secret(_secret: &str) {}
pub fn log_session(_payload: impl Display) {}
pub fn request(_method: &str, _arguments: impl Display) -> JmapRequest {
JmapRequest
}
impl JmapRequest {
pub fn response<T, E: Display>(
&self,
result: Result<T, E>,
_summary: impl FnOnce(&T) -> String,
) -> Result<T, E> {
result
}
}
pub fn redact_url(url: &str) -> String {
url.to_owned()
}
}
#[derive(Clone)]
pub enum JmapAuth {
Basic { username: String, password: String },
Bearer { token: String },
}
impl JmapAuth {
fn credentials(&self) -> Credentials {
match self {
JmapAuth::Basic { username, password } => Credentials::basic(username, password),
JmapAuth::Bearer { token } => Credentials::bearer(token),
}
}
fn secret(&self) -> &str {
match self {
JmapAuth::Basic { password, .. } => password,
JmapAuth::Bearer { token } => token,
}
}
fn credential_description(&self) -> String {
match self {
JmapAuth::Basic { username, .. } => format!("password for {username}"),
JmapAuth::Bearer { .. } => "API token".to_string(),
}
}
fn rejection_hint(&self) -> &'static str {
match self {
JmapAuth::Basic { .. } => {
"if the server expects an API token instead, configure it as `token = \"...\"`"
}
JmapAuth::Bearer { .. } => {
"if the server expects a password instead, configure it as `password = \"...\"`"
}
}
}
}
impl std::fmt::Debug for JmapAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JmapAuth::Basic { username, .. } => write!(f, "Basic {{ username: {username:?} }}"),
JmapAuth::Bearer { .. } => write!(f, "Bearer"),
}
}
}
impl std::fmt::Display for JmapAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JmapAuth::Basic { username, .. } => write!(f, "Basic ({username})"),
JmapAuth::Bearer { .. } => write!(f, "Bearer"),
}
}
}
fn connect_error(err: jmap_client::Error, auth: &JmapAuth, base_url: &str) -> anyhow::Error {
let url = debug_logging::redact_url(base_url);
match http_status(&err) {
Some(status @ (401 | 403)) => anyhow!(
"JMAP server at {url} rejected the {credential} (HTTP {status}); {hint}",
credential = auth.credential_description(),
hint = auth.rejection_hint(),
),
_ => anyhow!("connecting to JMAP server at {url}: {err}"),
}
}
fn http_status(err: &jmap_client::Error) -> Option<u16> {
match err {
jmap_client::Error::Transport(err) => err.status().map(|status| status.as_u16()),
jmap_client::Error::Problem(problem) => {
problem.status().and_then(|status| status.try_into().ok())
}
jmap_client::Error::Server(message) => message
.split_whitespace()
.next()
.and_then(|code| code.parse().ok()),
_ => None,
}
}
#[derive(Clone, Debug)]
pub struct JmapConfig {
pub base_url: String,
pub auth: JmapAuth,
pub trusted_hosts: Vec<String>,
}
pub struct JmapBackend {
runtime: Arc<Runtime>,
config: JmapConfig,
inner: Mutex<Option<Arc<JmapInner>>>,
}
impl JmapBackend {
pub fn new(config: JmapConfig) -> Result<Self> {
let runtime =
Arc::new(Runtime::new().context("failed to create Tokio runtime for JMAP backend")?);
Ok(Self {
runtime,
config,
inner: Mutex::new(None),
})
}
fn inner(&self) -> Result<Arc<JmapInner>> {
let mut guard = self.inner.lock().unwrap();
if let Some(inner) = guard.as_ref() {
return Ok(Arc::clone(inner));
}
let inner = self.runtime.block_on(JmapInner::initialize(
Arc::clone(&self.runtime),
self.config.clone(),
))?;
*guard = Some(Arc::clone(&inner));
Ok(inner)
}
}
impl MailBackend for JmapBackend {
fn load_mailbox(
&self,
mailbox: MailboxKind,
) -> Result<(MailboxSnapshot, mpsc::Receiver<BackendEvent>)> {
let (sender, receiver) = mpsc::channel();
let inner = self.inner()?;
let snapshot = inner.runtime.block_on(async {
inner.set_current_mailbox(mailbox);
inner.stop_backfill().await;
{
let mut guard = inner.events.lock().unwrap();
*guard = Some(sender);
}
let sync = inner
.sync_mailbox(mailbox)
.await
.context("loading mailbox contents")?;
inner
.start_event_loop()
.await
.context("starting JMAP event loop")?;
inner
.start_backfill_if_needed(mailbox)
.await
.context("starting JMAP backfill")?;
Ok::<_, anyhow::Error>(MailboxSnapshot {
total: sync.total,
messages: sync.messages,
})
})?;
Ok((snapshot, receiver))
}
fn load_message(&self, message_id: MessageId) -> Result<MessageContent> {
let inner = self.inner()?;
inner.runtime.block_on(inner.load_message(message_id))
}
fn apply_actions(&self, actions: Vec<Action>) -> Result<mpsc::Receiver<ActionStatus>> {
let (tx, rx) = mpsc::channel();
let inner = self.inner()?;
let runtime = Arc::clone(&inner.runtime);
runtime.spawn(async move {
let mut refresh_needed = false;
for action in actions {
let result = inner.process_action(action.clone()).await;
if result.is_ok() {
refresh_needed = true;
}
if tx
.send(ActionStatus {
action,
result: result.map_err(|err| err.to_string()),
})
.is_err()
{
break;
}
}
if refresh_needed && let Err(err) = inner.refresh_current_mailbox().await {
debug_logging::log_session(format_args!("refresh after actions failed: {err:?}"));
eprintln!("JMAP refresh error after actions: {err:?}");
}
});
Ok(rx)
}
fn send_message(&self, message: OutgoingMessage) -> Result<()> {
let inner = self.inner()?;
let runtime = Arc::clone(&inner.runtime);
runtime.block_on(async move { inner.send_message(message).await })
}
fn save_draft(&self, message: OutgoingMessage) -> Result<()> {
let inner = self.inner()?;
let runtime = Arc::clone(&inner.runtime);
runtime.block_on(async move { inner.save_draft(message).await })
}
fn fetch_attachment_blob(&self, blob_id: &str) -> Result<Vec<u8>> {
let inner = self.inner()?;
let runtime = Arc::clone(&inner.runtime);
let client = Arc::clone(&inner.client);
let blob_id = blob_id.to_string();
runtime.block_on(async move {
let trace = debug_logging::request("Blob/download", format_args!("blobId={blob_id}"));
trace
.response(client.download(&blob_id).await, |blob| {
format!("bytes={}", blob.len())
})
.with_context(|| format!("downloading JMAP blob {blob_id}"))
})
}
}
struct JmapInner {
runtime: Arc<Runtime>,
client: Arc<Client>,
mailboxes: AsyncMutex<MailboxCache>,
state: AsyncMutex<JmapState>,
identity: IdentityInfo,
events: Mutex<Option<mpsc::Sender<BackendEvent>>>,
current_mailbox: Mutex<MailboxKind>,
event_handle: AsyncMutex<Option<JoinHandle<()>>>,
event_cancel: AsyncMutex<Option<Arc<AtomicBool>>>,
backfill_handle: AsyncMutex<Option<JoinHandle<()>>>,
backfill_cancel: AsyncMutex<Option<Arc<AtomicBool>>>,
}
impl JmapInner {
async fn initialize(runtime: Arc<Runtime>, config: JmapConfig) -> Result<Arc<Self>> {
let JmapConfig {
base_url,
auth,
trusted_hosts,
} = config;
debug_logging::register_secret(auth.secret());
let mut builder = Client::new().credentials(auth.credentials());
if !trusted_hosts.is_empty() {
builder = builder.follow_redirects(trusted_hosts);
}
let trace = debug_logging::request(
"Session/get",
format_args!("url={} auth={auth}", debug_logging::redact_url(&base_url)),
);
let client = trace
.response(builder.connect(&base_url).await, |client| {
format!(
"account={} api={}",
client.default_account_id(),
debug_logging::redact_url(client.session().api_url())
)
})
.map_err(|err| connect_error(err, &auth, &base_url))?;
let client = Arc::new(client);
let mailboxes = Self::load_mailboxes(&client).await?;
let identity = Self::load_identity(&client).await?;
Ok(Arc::new(Self {
runtime,
client,
mailboxes: AsyncMutex::new(mailboxes),
state: AsyncMutex::new(JmapState::default()),
identity,
events: Mutex::new(None),
current_mailbox: Mutex::new(MailboxKind::Inbox),
event_handle: AsyncMutex::new(None),
event_cancel: AsyncMutex::new(None),
backfill_handle: AsyncMutex::new(None),
backfill_cancel: AsyncMutex::new(None),
}))
}
fn set_current_mailbox(&self, mailbox: MailboxKind) {
let mut guard = self.current_mailbox.lock().unwrap();
*guard = mailbox;
}
fn current_mailbox(&self) -> MailboxKind {
*self.current_mailbox.lock().unwrap()
}
async fn sync_mailbox(&self, mailbox: MailboxKind) -> Result<MailboxSync> {
let FetchedMailbox {
total,
emails,
has_more,
start_position,
} = self.fetch_mailbox(mailbox).await?;
let mut state = self.state.lock().await;
let switching = state.current_mailbox != Some(mailbox);
if switching {
state.current_sequence.clear();
state.highest_received_index = 0;
state.current_mailbox = Some(mailbox);
}
let previous_highest = state.highest_received_index;
let mut remaining_ids = state.current_sequence.clone();
let mailbox_cache = self.mailboxes.lock().await.clone();
let mut added_ids = Vec::new();
let mut updated_ids = Vec::new();
let mut new_stored = HashMap::new();
let page_len = emails.len();
let capacity = remaining_ids.len().max(page_len);
let mut new_sequence = Vec::with_capacity(capacity);
let mut page_ids = Vec::with_capacity(page_len);
for (index, data) in emails.into_iter().enumerate() {
let (message_id, uid, is_new) = state.ensure_ids(&data.jmap_id);
let seq = total.saturating_sub(start_position + index).max(1) as u32;
let message = build_message(message_id, uid, seq, &data, &mailbox_cache, mailbox)?;
if is_new {
added_ids.push(message_id);
} else if let Some(previous) = state.messages.get(&message_id)
&& flags_changed(&previous.message, &message)
{
updated_ids.push(message_id);
}
if let Some(pos) = remaining_ids.iter().position(|id| *id == message_id) {
remaining_ids.remove(pos);
}
new_sequence.push(message_id);
page_ids.push(message_id);
new_stored.insert(
message_id,
StoredMessage {
message,
jmap_id: data.jmap_id,
},
);
}
new_sequence.extend(remaining_ids);
let removed = state.update_current(mailbox, new_sequence, new_stored);
let new_highest = start_position + page_len;
state.highest_received_index = previous_highest.max(new_highest);
let more_available = has_more && state.highest_received_index < total;
state.set_more_available(more_available);
let mut new_messages = Vec::with_capacity(page_ids.len());
for id in &page_ids {
if let Some(stored) = state.messages.get(id) {
new_messages.push(stored.message.clone());
}
}
let mut added = Vec::with_capacity(added_ids.len());
for id in added_ids {
if let Some(stored) = state.messages.get(&id) {
added.push(stored.message.clone());
}
}
let mut updated = Vec::with_capacity(updated_ids.len());
for id in updated_ids {
if let Some(stored) = state.messages.get(&id) {
updated.push(stored.message.clone());
}
}
new_messages.sort_by_key(|msg| msg.seq);
added.sort_by_key(|msg| msg.seq);
updated.sort_by_key(|msg| msg.seq);
drop(state);
self.update_mailbox_total(mailbox, total).await;
Ok(MailboxSync {
total,
messages: new_messages,
added,
updated,
removed,
})
}
async fn refresh_current_mailbox(self: &Arc<Self>) -> Result<()> {
let mailbox = self.current_mailbox();
self.stop_backfill().await;
let sync = self.sync_mailbox(mailbox).await?;
self.emit_diff(mailbox, sync);
self.start_backfill_if_needed(mailbox).await?;
Ok(())
}
async fn load_message(&self, message_id: MessageId) -> Result<MessageContent> {
let jmap_id = {
let state = self.state.lock().await;
state
.jmap_to_id
.iter()
.find_map(|(jid, id)| (*id == message_id).then_some(jid.clone()))
.ok_or_else(|| anyhow!("message {message_id} not found in cache"))?
};
let mut request = self.client.build();
let get = request.get_email();
get.ids([jmap_id.as_str()]);
get.properties([
EmailProperty::Id,
EmailProperty::BodyStructure,
EmailProperty::BodyValues,
EmailProperty::TextBody,
EmailProperty::HtmlBody,
EmailProperty::Attachments,
EmailProperty::HasAttachment,
EmailProperty::Header(email::Header::as_text("X-Mailer", false)),
]);
{
let args = get.arguments();
args.fetch_all_body_values(true)
.max_body_value_bytes(MAX_BODY_VALUE_BYTES);
}
let trace = debug_logging::request(
"Email/get",
format_args!("id={jmap_id} properties=body maxBodyValueBytes={MAX_BODY_VALUE_BYTES}"),
);
let mut response = trace
.response(request.send_get_email().await, |response| {
format!("emails={}", response.list().len())
})
.context("fetching message body")?;
let email = response
.take_list()
.into_iter()
.next()
.ok_or_else(|| anyhow!("message body not returned by server"))?;
build_message_content(&email)
}
async fn process_action(self: &Arc<Self>, action: Action) -> Result<()> {
match action.action_type {
ActionType::Delete => {
self.move_to_mailbox(action.message_id, MailboxKind::Trash)
.await
}
ActionType::Archive => {
self.move_to_mailbox(action.message_id, MailboxKind::Archive)
.await
}
ActionType::MoveToInboxUnread => {
self.move_to_mailbox(action.message_id, MailboxKind::Inbox)
.await?;
self.set_keyword(action.message_id, "$seen", false).await
}
ActionType::MoveToInboxRead => {
self.move_to_mailbox(action.message_id, MailboxKind::Inbox)
.await?;
self.set_keyword(action.message_id, "$seen", true).await
}
ActionType::MarkAsRead => self.set_keyword(action.message_id, "$seen", true).await,
ActionType::MarkAsStarred => {
self.set_keyword(action.message_id, "$flagged", true).await
}
ActionType::MarkAsUnstarred => {
self.set_keyword(action.message_id, "$flagged", false).await
}
ActionType::MarkAsImportant => {
self.set_keyword(action.message_id, "$important", true)
.await
}
ActionType::MarkAsUnimportant => {
self.set_keyword(action.message_id, "$important", false)
.await
}
ActionType::MoveToSpam => {
self.move_to_mailbox(action.message_id, MailboxKind::Spam)
.await
}
}
}
async fn send_message(self: Arc<Self>, outgoing: OutgoingMessage) -> Result<()> {
let email = self
.build_compose_email(outgoing)
.context("building outgoing message")?;
let raw = email.formatted();
let drafts_id = self
.mailboxes
.lock()
.await
.id_for_kind(MailboxKind::Drafts)
.cloned();
let sent_id = self
.mailboxes
.lock()
.await
.id_for_kind(MailboxKind::Sent)
.cloned();
let draft_id = if let Some(drafts_id) = drafts_id {
let trace = debug_logging::request(
"Email/import",
format_args!(
"bytes={} mailboxIds=[{drafts_id}] keywords=[$draft]",
raw.len()
),
);
let created = trace
.response(
self.client
.email_import(raw.clone(), [drafts_id.clone()], Some(["$draft"]), None)
.await,
|email| format!("id={:?}", email.id()),
)
.context("importing draft for submission")?;
created
.id()
.map(|id| id.to_string())
.ok_or_else(|| anyhow!("email import did not return an identifier"))?
} else {
let trace = debug_logging::request("Email/import", format_args!("bytes={}", raw.len()));
let created = trace
.response(
self.client
.email_import(
raw.clone(),
std::iter::empty::<String>(),
None::<Vec<&str>>,
None,
)
.await,
|email| format!("id={:?}", email.id()),
)
.context("importing message for submission")?;
created
.id()
.map(|id| id.to_string())
.ok_or_else(|| anyhow!("email import did not return an identifier"))?
};
let mut request = self.client.build();
let create_id = {
let submission = request.set_email_submission();
let create_id = submission
.create()
.email_id(draft_id.clone())
.identity_id(self.identity.id.clone())
.create_id()
.ok_or_else(|| anyhow!("email submission was built without a creation id"))?;
describe_sent_copy(
submission.arguments().on_success_update_email(&create_id),
sent_id.as_deref(),
);
create_id
};
let trace = debug_logging::request(
"EmailSubmission/set",
format_args!(
"create emailId={draft_id} identityId={} onSuccessUpdateEmail mailboxIds=[{}] keywords=[{}]",
self.identity.id,
sent_id.as_deref().unwrap_or("unchanged"),
SENT_KEYWORDS.join(" "),
),
);
let responses = trace
.response(request.send().await, |response| {
format!("responses={}", response.method_responses().len())
})
.context("creating JMAP email submission")?
.unwrap_method_responses();
let (submission, update) = split_submission_responses(responses)?;
submission
.ok_or_else(|| anyhow!("server did not answer the email submission"))?
.created(&create_id)
.context("submitting message for delivery")?;
if let Some(mut update) = update {
match update.updated(&draft_id) {
Ok(_) => debug_logging::log_session(format_args!(
"Email/set (implicit) id={draft_id} -> updated"
)),
Err(err) => debug_logging::log_session(format_args!(
"Email/set (implicit) id={draft_id} -> ERROR: {err}"
)),
}
}
Ok(())
}
async fn save_draft(self: Arc<Self>, outgoing: OutgoingMessage) -> Result<()> {
let drafts_id = self
.mailboxes
.lock()
.await
.id_for_kind(MailboxKind::Drafts)
.cloned()
.ok_or_else(|| anyhow!("Drafts mailbox not available on account"))?;
let email = self
.build_compose_email(outgoing)
.context("building draft message")?;
let raw = email.formatted();
let trace = debug_logging::request(
"Email/import",
format_args!(
"bytes={} mailboxIds=[{drafts_id}] keywords=[$draft]",
raw.len()
),
);
trace
.response(
self.client
.email_import(raw, [drafts_id], Some(["$draft"]), None)
.await,
|email| format!("id={:?}", email.id()),
)
.context("importing draft message")?;
Ok(())
}
async fn start_event_loop(self: &Arc<Self>) -> Result<()> {
let mut handle_guard = self.event_handle.lock().await;
if handle_guard.is_some() {
return Ok(());
}
let cancel_flag = Arc::new(AtomicBool::new(false));
{
let mut cancel_guard = self.event_cancel.lock().await;
*cancel_guard = Some(cancel_flag.clone());
}
let this = Arc::clone(self);
let handle = self.runtime.spawn(async move {
this.event_loop(cancel_flag).await;
});
*handle_guard = Some(handle);
Ok(())
}
async fn event_loop(self: Arc<Self>, cancel: Arc<AtomicBool>) {
loop {
if cancel.load(AtomicOrdering::SeqCst) {
break;
}
let mut poll_interval = interval(EVENT_IDLE_POLL);
loop {
if cancel.load(AtomicOrdering::SeqCst) {
return;
}
let _ = poll_interval.tick().await;
if let Err(err) = self.refresh_current_mailbox().await {
debug_logging::log_session(format_args!("background refresh failed: {err:?}"));
eprintln!("JMAP background refresh error: {err:?}");
break;
}
}
if cancel.load(AtomicOrdering::SeqCst) {
break;
}
tokio::time::sleep(EVENT_RETRY_DELAY).await;
}
}
fn emit_diff(&self, mailbox: MailboxKind, sync: MailboxSync) {
for message in sync.added {
self.emit_event(mailbox, BackendEvent::NewMessage(message));
}
for message in sync.updated {
self.emit_event(mailbox, BackendEvent::MessageFlagsChanged(message));
}
for id in sync.removed {
self.emit_event(mailbox, BackendEvent::MessageDeleted(id));
}
}
fn emit_event(&self, mailbox: MailboxKind, event: BackendEvent) {
if self.current_mailbox() != mailbox {
return;
}
if let Some(sender) = self.events.lock().unwrap().as_ref() {
let _ = sender.send(event);
}
}
async fn move_to_mailbox(&self, message_id: MessageId, target: MailboxKind) -> Result<()> {
let target_id = self
.mailboxes
.lock()
.await
.id_for_kind(target)
.cloned()
.ok_or_else(|| anyhow!("mailbox {target} is not available"))?;
let jmap_id = self.lookup_message(message_id).await?;
let trace = debug_logging::request(
"Email/set",
format_args!("update id={jmap_id} mailboxIds=[{target_id}] ({target})"),
);
trace
.response(
self.client.email_set_mailboxes(&jmap_id, [target_id]).await,
|_| "updated".to_owned(),
)
.context("updating mailbox assignment")?;
Ok(())
}
async fn set_keyword(&self, message_id: MessageId, keyword: &str, value: bool) -> Result<()> {
let jmap_id = self.lookup_message(message_id).await?;
if value {
let trace = debug_logging::request(
"Email/set",
format_args!("update id={jmap_id} keywords/{keyword}=true"),
);
trace
.response(
self.client.email_set_keyword(&jmap_id, keyword, true).await,
|_| "updated".to_owned(),
)
.with_context(|| format!("setting keyword {keyword}"))?;
return Ok(());
}
let remaining = self.keywords_without(&jmap_id, keyword).await?;
let trace = debug_logging::request(
"Email/set",
format_args!("update id={jmap_id} keywords=[{}]", remaining.join(" ")),
);
trace
.response(
self.client
.email_set_keywords(&jmap_id, remaining.iter().map(String::as_str))
.await,
|_| "updated".to_owned(),
)
.with_context(|| format!("clearing keyword {keyword}"))?;
Ok(())
}
async fn keywords_without(&self, jmap_id: &str, keyword: &str) -> Result<Vec<String>> {
let trace = debug_logging::request(
"Email/get",
format_args!("id={jmap_id} properties=keywords"),
);
let email = trace
.response(
self.client
.email_get(jmap_id, Some([EmailProperty::Keywords]))
.await,
|email| match email {
Some(email) => format!("keywords=[{}]", email.keywords().join(" ")),
None => "not found".to_owned(),
},
)
.with_context(|| format!("reading keywords of {jmap_id}"))?
.ok_or_else(|| anyhow!("message {jmap_id} not found on server"))?;
Ok(email
.keywords()
.into_iter()
.filter(|existing| *existing != keyword)
.map(str::to_owned)
.collect())
}
async fn lookup_message(&self, message_id: MessageId) -> Result<String> {
let state = self.state.lock().await;
state
.messages
.get(&message_id)
.map(|stored| stored.jmap_id.clone())
.ok_or_else(|| anyhow!("message {message_id} not found in cache"))
}
async fn update_mailbox_total(&self, mailbox: MailboxKind, total: usize) {
let mut cache = self.mailboxes.lock().await;
cache.set_total(mailbox, total);
}
async fn stop_backfill(&self) {
if let Some(cancel) = self.backfill_cancel.lock().await.take() {
cancel.store(true, AtomicOrdering::SeqCst);
}
if let Some(handle) = self.backfill_handle.lock().await.take() {
let _ = handle.await;
}
}
async fn start_backfill_if_needed(self: &Arc<Self>, mailbox: MailboxKind) -> Result<()> {
if self.current_mailbox() != mailbox {
return Ok(());
}
let more_available = {
let mut state = self.state.lock().await;
if state.highest_received_index < state.current_sequence.len() {
state.highest_received_index = state.current_sequence.len();
}
state.more_available()
};
if !more_available {
self.stop_backfill().await;
return Ok(());
}
self.stop_backfill().await;
let cancel_flag = Arc::new(AtomicBool::new(false));
{
let mut cancel_guard = self.backfill_cancel.lock().await;
*cancel_guard = Some(cancel_flag.clone());
}
let this = Arc::clone(self);
let handle = self.runtime.spawn(async move {
this.backfill_loop(mailbox, cancel_flag).await;
});
let mut handle_guard = self.backfill_handle.lock().await;
*handle_guard = Some(handle);
Ok(())
}
async fn backfill_loop(self: Arc<Self>, mailbox: MailboxKind, cancel: Arc<AtomicBool>) {
loop {
if cancel.load(AtomicOrdering::SeqCst) {
break;
}
let cursor = {
let state = self.state.lock().await;
state.next_cursor_index()
};
let page = match self
.fetch_mailbox_page(mailbox, cursor, BACKFILL_BATCH_SIZE)
.await
{
Ok(page) => page,
Err(err) => {
eprintln!("JMAP backfill fetch error: {err:?}");
break;
}
};
let FetchedMailbox {
total,
emails,
has_more,
start_position,
} = page;
if cancel.load(AtomicOrdering::SeqCst) {
break;
}
if emails.is_empty() {
self.state.lock().await.set_more_available(false);
break;
}
self.update_mailbox_total(mailbox, total).await;
let mailbox_cache = {
let cache = self.mailboxes.lock().await;
cache.clone()
};
let batch_len = emails.len();
let prepared_entries = {
let mut state = self.state.lock().await;
let mut entries = Vec::new();
for (offset, data) in emails.into_iter().enumerate() {
let (message_id, uid, _) = state.ensure_ids(&data.jmap_id);
if state.current_sequence.contains(&message_id) {
continue;
}
let seq = total.saturating_sub(start_position + offset).max(1) as u32;
match build_message(message_id, uid, seq, &data, &mailbox_cache, mailbox) {
Ok(message) => entries.push((
message_id,
StoredMessage {
message,
jmap_id: data.jmap_id,
},
)),
Err(err) => {
eprintln!("JMAP backfill build error: {err:?}");
}
}
}
entries
};
if prepared_entries.is_empty() {
let mut state = self.state.lock().await;
let new_highest = start_position + batch_len;
if new_highest > state.highest_received_index {
state.highest_received_index = new_highest;
}
let more_pending = has_more && state.highest_received_index < total;
state.set_more_available(more_pending);
if !more_pending {
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
let (emitted, more_pending) = {
let mut state = self.state.lock().await;
let new_highest = start_position + batch_len;
if new_highest > state.highest_received_index {
state.highest_received_index = new_highest;
}
let more_pending = has_more && state.highest_received_index < total;
state.set_more_available(more_pending);
let emitted = state.append_backfill(prepared_entries);
(emitted, more_pending)
};
if emitted.is_empty() {
if !more_pending {
break;
}
continue;
}
for message in emitted {
self.emit_event(mailbox, BackendEvent::NewMessage(message));
}
if !more_pending {
break;
}
}
}
async fn fetch_mailbox(&self, mailbox: MailboxKind) -> Result<FetchedMailbox> {
self.fetch_mailbox_page(mailbox, 0, INITIAL_FETCH_LIMIT)
.await
}
async fn fetch_mailbox_page(
&self,
mailbox: MailboxKind,
position: usize,
limit: usize,
) -> Result<FetchedMailbox> {
let cache = self.mailboxes.lock().await;
let filter = cache.filter_for(mailbox)?;
drop(cache);
let mut request = self.client.build();
let query = request.query_email();
if let Some(filter) = filter {
query.filter(filter);
}
query
.sort([email::query::Comparator::received_at().descending()])
.limit(limit)
.calculate_total(true);
if position > 0 {
query.position(position as i32);
}
let trace = debug_logging::request(
"Email/query",
format_args!("mailbox={mailbox} position={position} limit={limit}"),
);
let mut query_response = trace
.response(request.send_query_email().await, |response| {
format!("total={:?} ids={}", response.total(), response.ids().len())
})
.context("querying mailbox messages")?;
let total = query_response
.total()
.unwrap_or(position + query_response.ids().len());
let ids = query_response.take_ids();
if ids.is_empty() {
return Ok(FetchedMailbox {
total,
emails: Vec::new(),
has_more: false,
start_position: position,
});
}
let mut request = self.client.build();
let get = request.get_email();
get.ids(ids.iter().map(|id| id.as_str()));
get.properties([
EmailProperty::Id,
EmailProperty::From,
EmailProperty::To,
EmailProperty::Subject,
EmailProperty::SentAt,
EmailProperty::ReceivedAt,
EmailProperty::Size,
EmailProperty::MailboxIds,
EmailProperty::Keywords,
EmailProperty::HasAttachment,
]);
let trace = debug_logging::request(
"Email/get",
format_args!("mailbox={mailbox} ids={} properties=envelope", ids.len()),
);
let mut response = trace
.response(request.send_get_email().await, |response| {
format!("emails={}", response.list().len())
})
.context("fetching mailbox messages")?;
let mut email_map: HashMap<String, FetchedEmail> = response
.take_list()
.into_iter()
.filter_map(FetchedEmail::from_email)
.map(|e| (e.jmap_id.clone(), e))
.collect();
let emails: Vec<FetchedEmail> = ids
.iter()
.filter_map(|id| email_map.remove(id.as_str()))
.collect();
let has_more = if let Some(total) = query_response.total() {
position + ids.len() < total
} else {
ids.len() == limit
};
Ok(FetchedMailbox {
total,
emails,
has_more,
start_position: position,
})
}
async fn load_mailboxes(client: &Client) -> Result<MailboxCache> {
let mut request = client.build();
let get = request.get_mailbox();
get.properties([
MailboxProperty::Id,
MailboxProperty::Name,
MailboxProperty::Role,
MailboxProperty::TotalEmails,
]);
let trace = debug_logging::request(
"Mailbox/get",
format_args!("properties=[id,name,role,totalEmails]"),
);
let mut response = trace
.response(request.send_get_mailbox().await, |response| {
format!("mailboxes={}", response.list().len())
})
.context("fetching available mailboxes")?;
let mailboxes = response.take_list();
Ok(MailboxCache::from_mailboxes(mailboxes))
}
async fn load_identity(client: &Client) -> Result<IdentityInfo> {
let mut request = client.build();
request.add_capability(URI::Submission);
let get = request.get_identity();
get.properties([
IdentityProperty::Id,
IdentityProperty::Email,
IdentityProperty::Name,
]);
let trace =
debug_logging::request("Identity/get", format_args!("properties=[id,email,name]"));
let mut response = trace
.response(request.send_get_identity().await, |response| {
format!("identities={}", response.list().len())
})
.context("fetching account identity")?;
if let Some(identity) = response.take_list().into_iter().next() {
let id = identity
.id()
.map(|id| id.to_string())
.ok_or_else(|| anyhow!("identity has no id"))?;
let email = identity
.email()
.map(|addr| addr.to_string())
.ok_or_else(|| anyhow!("identity has no email address"))?;
let name = identity.name().map(|name| name.to_string());
Ok(IdentityInfo { id, email, name })
} else {
let session = client.session();
let email = session.username().to_string();
Ok(IdentityInfo {
id: email.clone(),
email,
name: None,
})
}
}
fn build_compose_email(&self, outgoing: OutgoingMessage) -> Result<LettreEmail> {
let OutgoingMessage {
to,
cc,
bcc,
subject,
text_body,
html_body,
attachments,
} = outgoing;
if to.is_empty() && cc.is_empty() && bcc.is_empty() {
return Err(anyhow!("message must have at least one recipient"));
}
let from_addr = LettreMailbox::new(
self.identity.name.clone(),
self.identity
.email
.parse()
.with_context(|| format!("invalid identity email: {}", self.identity.email))?,
);
let mut builder = LettreEmail::builder()
.from(from_addr)
.raw_header(mailer_header());
for addr in to {
let mailbox: LettreMailbox = addr
.parse()
.with_context(|| format!("invalid To address: {addr}"))?;
builder = builder.to(mailbox);
}
for addr in cc {
let mailbox: LettreMailbox = addr
.parse()
.with_context(|| format!("invalid Cc address: {addr}"))?;
builder = builder.cc(mailbox);
}
for addr in bcc {
let mailbox: LettreMailbox = addr
.parse()
.with_context(|| format!("invalid Bcc address: {addr}"))?;
builder = builder.bcc(mailbox);
}
builder = builder.subject(subject);
let body = build_compose_body(text_body, html_body, attachments)?;
builder.multipart(body).context("building MIME message")
}
}
#[derive(Clone, Debug)]
struct IdentityInfo {
id: String,
email: String,
name: Option<String>,
}
#[derive(Clone)]
struct MailboxCache {
by_id: HashMap<String, MailboxInfo>,
by_kind: HashMap<MailboxKind, String>,
totals_override: HashMap<MailboxKind, usize>,
}
impl MailboxCache {
fn from_mailboxes(list: Vec<JmapMailbox>) -> Self {
let mut by_id = HashMap::new();
let mut by_kind = HashMap::new();
for mailbox in list {
if let Some(id) = mailbox.id() {
let id = id.to_string();
let role = mailbox.role();
let name = mailbox.name().unwrap_or(id.as_str()).to_string();
let total = mailbox.total_emails();
by_id.insert(
id.clone(),
MailboxInfo {
name,
total_emails: total,
},
);
if let Some(kind) = kind_from_role(&role) {
by_kind.insert(kind, id);
}
}
}
Self {
by_id,
by_kind,
totals_override: HashMap::new(),
}
}
fn id_for_kind(&self, kind: MailboxKind) -> Option<&String> {
self.by_kind.get(&kind)
}
fn name_for_id(&self, id: &str) -> Option<&str> {
self.by_id.get(id).map(|info| info.name.as_str())
}
fn set_total(&mut self, kind: MailboxKind, total: usize) {
if let Some(id) = self.by_kind.get(&kind)
&& let Some(info) = self.by_id.get_mut(id)
{
info.total_emails = total;
return;
}
self.totals_override.insert(kind, total);
}
fn filter_for(&self, kind: MailboxKind) -> Result<Option<email::query::Filter>> {
Ok(match kind {
MailboxKind::Starred => Some(email::query::Filter::has_keyword("$flagged")),
MailboxKind::Important => {
if let Some(id) = self.id_for_kind(MailboxKind::Important) {
Some(email::query::Filter::in_mailbox(id.clone()))
} else {
Some(email::query::Filter::has_keyword("$important"))
}
}
other => {
let id = self
.id_for_kind(other)
.ok_or_else(|| anyhow!("mailbox {other} is not available"))?;
Some(email::query::Filter::in_mailbox(id.clone()))
}
})
}
}
#[derive(Clone)]
struct MailboxInfo {
name: String,
total_emails: usize,
}
#[derive(Default)]
struct JmapState {
messages: HashMap<MessageId, StoredMessage>,
jmap_to_id: HashMap<String, MessageId>,
current_sequence: Vec<MessageId>,
current_mailbox: Option<MailboxKind>,
next_message_id: MessageId,
next_uid: u32,
more_available: bool,
highest_received_index: usize,
}
impl JmapState {
fn ensure_ids(&mut self, jmap_id: &str) -> (MessageId, u32, bool) {
if let Some(id) = self.jmap_to_id.get(jmap_id).copied() {
let uid = self
.messages
.get(&id)
.map(|stored| stored.message.uid)
.unwrap_or_else(|| {
self.next_uid += 1;
self.next_uid - 1
});
(id, uid, false)
} else {
self.next_message_id = self.next_message_id.saturating_add(1).max(1);
let id = self.next_message_id;
self.next_uid = self.next_uid.saturating_add(1).max(1);
let uid = self.next_uid;
self.jmap_to_id.insert(jmap_id.to_string(), id);
(id, uid, true)
}
}
fn update_current(
&mut self,
mailbox: MailboxKind,
new_sequence: Vec<MessageId>,
new_messages: HashMap<MessageId, StoredMessage>,
) -> Vec<MessageId> {
let mut removed = Vec::new();
let new_set: HashSet<_> = new_sequence.iter().copied().collect();
for id in &self.current_sequence {
if !new_set.contains(id) {
removed.push(*id);
}
}
for id in &removed {
if let Some(stored) = self.messages.get_mut(id) {
stored.message.status = match mailbox {
MailboxKind::Trash => MessageStatus::Deleted,
MailboxKind::Spam => MessageStatus::Spam,
_ => stored.message.status,
};
}
}
for (id, stored) in new_messages {
self.messages.insert(id, stored);
}
self.current_sequence = new_sequence;
removed
}
fn append_backfill(&mut self, entries: Vec<(MessageId, StoredMessage)>) -> Vec<Message> {
let mut new_ids = Vec::new();
for (id, stored) in entries {
let already_present = self.current_sequence.contains(&id);
self.messages.insert(id, stored);
if !already_present {
self.current_sequence.push(id);
new_ids.push(id);
}
}
let mut emitted = Vec::with_capacity(new_ids.len());
for id in new_ids {
if let Some(stored) = self.messages.get(&id) {
emitted.push(stored.message.clone());
}
}
emitted.sort_by_key(|msg| msg.seq);
emitted
}
fn set_more_available(&mut self, more: bool) {
self.more_available = more;
}
fn more_available(&self) -> bool {
self.more_available
}
fn next_cursor_index(&self) -> usize {
self.highest_received_index
}
}
struct StoredMessage {
message: Message,
jmap_id: String,
}
struct MailboxSync {
total: usize,
messages: Vec<Message>,
added: Vec<Message>,
updated: Vec<Message>,
removed: Vec<MessageId>,
}
struct FetchedMailbox {
total: usize,
emails: Vec<FetchedEmail>,
has_more: bool,
start_position: usize,
}
struct FetchedEmail {
jmap_id: String,
from: Vec<String>,
to: Vec<String>,
subject: String,
received_at: Option<i64>,
sent_at: Option<i64>,
size: usize,
mailbox_ids: Vec<String>,
keywords: Vec<String>,
has_attachments: bool,
}
impl FetchedEmail {
fn from_email(email: JmapEmail) -> Option<Self> {
let jmap_id = email.id()?.to_string();
let from = email
.from()
.unwrap_or_default()
.iter()
.map(format_address)
.collect();
let to = email
.to()
.unwrap_or_default()
.iter()
.map(format_address)
.collect();
let subject = email.subject().unwrap_or("").to_string();
let received_at = email.received_at();
let sent_at = email.sent_at();
let size = email.size();
let mailbox_ids = email
.mailbox_ids()
.into_iter()
.map(|id| id.to_string())
.collect();
let keywords = email
.keywords()
.into_iter()
.map(|k| k.to_string())
.collect();
let has_attachments = email.has_attachment();
Some(Self {
jmap_id,
from,
to,
subject,
received_at,
sent_at,
size,
mailbox_ids,
keywords,
has_attachments,
})
}
}
fn build_message(
id: MessageId,
uid: u32,
seq: u32,
data: &FetchedEmail,
cache: &MailboxCache,
mailbox: MailboxKind,
) -> Result<Message> {
let sent = data
.sent_at
.or(data.received_at)
.and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
let sender = data
.from
.first()
.cloned()
.unwrap_or_else(|| "Unknown sender".to_string());
let status = determine_status(data, cache);
let starred = data
.keywords
.iter()
.any(|kw| kw.eq_ignore_ascii_case("$flagged"));
let important = data
.keywords
.iter()
.any(|kw| kw.eq_ignore_ascii_case("$important"));
let answered = data
.keywords
.iter()
.any(|kw| kw.eq_ignore_ascii_case("$answered"));
let forwarded = data
.keywords
.iter()
.any(|kw| kw.eq_ignore_ascii_case("$forwarded"));
let current_mailbox_id = cache.id_for_kind(mailbox);
let mut labels = Vec::new();
for mailbox_id in &data.mailbox_ids {
if Some(mailbox_id) == current_mailbox_id {
continue;
}
if let Some(name) = cache.name_for_id(mailbox_id) {
labels.push(name.to_string());
}
}
if starred
&& mailbox != MailboxKind::Starred
&& !labels
.iter()
.any(|label| label.eq_ignore_ascii_case("Starred"))
{
labels.push("Starred".to_string());
}
if important
&& mailbox != MailboxKind::Important
&& !labels
.iter()
.any(|label| label.eq_ignore_ascii_case("Important"))
{
labels.push("Important".to_string());
}
Ok(Message {
id,
sent,
sender,
recipients: data.to.clone(),
subject: data.subject.clone(),
size: data.size,
starred,
important,
answered,
forwarded,
status,
labels,
uid,
seq,
has_attachments: data.has_attachments,
})
}
fn determine_status(data: &FetchedEmail, cache: &MailboxCache) -> MessageStatus {
let contains = |kind: MailboxKind| {
if let Some(id) = cache.id_for_kind(kind) {
data.mailbox_ids.iter().any(|entry| entry == id)
} else {
false
}
};
if contains(MailboxKind::Trash) {
MessageStatus::Deleted
} else if contains(MailboxKind::Spam) {
MessageStatus::Spam
} else if !contains(MailboxKind::Inbox) && contains(MailboxKind::Archive) {
MessageStatus::Archived
} else if data
.keywords
.iter()
.any(|kw| kw.eq_ignore_ascii_case("$seen"))
{
MessageStatus::Read
} else {
MessageStatus::New
}
}
fn format_address(addr: &email::EmailAddress) -> String {
match (addr.name(), addr.email()) {
(Some(name), email) if !name.is_empty() => format!("{name} <{email}>"),
(_, email) => email.to_string(),
}
}
fn flags_changed(before: &Message, after: &Message) -> bool {
before.status != after.status
|| before.starred != after.starred
|| before.important != after.important
|| before.answered != after.answered
|| before.forwarded != after.forwarded
|| before.has_attachments != after.has_attachments
|| before.labels != after.labels
}
fn describe_sent_copy(update: &mut JmapEmail<JmapSet>, sent_id: Option<&str>) {
if let Some(sent_id) = sent_id {
update.mailbox_ids([sent_id]);
}
update.keywords(SENT_KEYWORDS);
}
fn split_submission_responses(
responses: Vec<TaggedMethodResponse>,
) -> Result<(Option<EmailSubmissionSetResponse>, Option<EmailSetResponse>)> {
let mut submission = None;
let mut update = None;
for response in responses {
if response.is_type(Method::SetEmailSubmission) {
submission = Some(
response
.unwrap_set_email_submission()
.context("reading the email submission response")?,
);
} else if response.is_type(Method::SetEmail) {
update = Some(
response
.unwrap_set_email()
.context("reading the response to the message update")?,
);
}
}
Ok((submission, update))
}
fn build_message_content(email: &JmapEmail) -> Result<MessageContent> {
let mailer = email
.header(&email::Header::as_text("X-Mailer", false))
.and_then(|value| match value {
email::HeaderValue::AsText(text) => Some(text.clone()),
email::HeaderValue::AsTextAll(list) => list.first().cloned(),
_ => None,
})
.unwrap_or_default();
let mut parts = Vec::new();
if let Some(text_parts) = email.text_body() {
collect_parts(email, text_parts, &mut parts);
}
if let Some(html_parts) = email.html_body() {
collect_parts(email, html_parts, &mut parts);
}
let attachments = email
.attachments()
.unwrap_or_default()
.iter()
.filter_map(|part| {
let role = jmap_part_role(part);
if role == PartRole::Body {
return None;
}
Some(MessageAttachment {
filename: part.name().map(|name| name.to_string()),
mime_type: part
.content_type()
.unwrap_or("application/octet-stream")
.to_string(),
size: part.size(),
data: None,
blob_id: part.blob_id().map(|id| id.to_string()),
inline: role == PartRole::Inline,
})
})
.collect();
Ok(MessageContent {
mailer,
parts,
attachments,
})
}
fn jmap_part_role(part: &EmailBodyPart) -> PartRole {
let content_type = part.content_type().unwrap_or("application/octet-stream");
LeafPart {
major_type: content_type.split('/').next().unwrap_or_default(),
has_filename: part.name().is_some_and(|name| !name.trim().is_empty()),
disposition: part.content_disposition(),
has_content_id: part.content_id().is_some_and(|cid| !cid.trim().is_empty()),
}
.role()
}
fn collect_parts(
email: &JmapEmail,
segments: &[EmailBodyPart],
parts: &mut Vec<MessageContentPart>,
) {
for segment in segments {
if let Some(part_id) = segment.part_id()
&& let Some(body) = email.body_value(part_id)
{
let content_type = segment.content_type().unwrap_or("text/plain").to_string();
parts.push(MessageContentPart {
content_type,
content: body.value().as_bytes().to_vec(),
});
}
}
}
fn kind_from_role(role: &MailboxRole) -> Option<MailboxKind> {
match role {
MailboxRole::Inbox => Some(MailboxKind::Inbox),
MailboxRole::Archive => Some(MailboxKind::Archive),
MailboxRole::Sent => Some(MailboxKind::Sent),
MailboxRole::Drafts => Some(MailboxKind::Drafts),
MailboxRole::Junk => Some(MailboxKind::Spam),
MailboxRole::Trash => Some(MailboxKind::Trash),
MailboxRole::Important => Some(MailboxKind::Important),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn basic_auth() -> JmapAuth {
JmapAuth::Basic {
username: "rob@example.com".to_string(),
password: "s3cret".to_string(),
}
}
fn bearer_auth() -> JmapAuth {
JmapAuth::Bearer {
token: "an-api-token".to_string(),
}
}
#[test]
fn a_password_is_sent_as_basic_authentication() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port to listen on");
let port = listener
.local_addr()
.expect("the listener to know its address")
.port();
let server = thread::spawn(move || {
let (stream, _) = listener.accept().expect("the client to connect");
let mut reader = BufReader::new(&stream);
let mut request = String::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
request.push_str(&line);
}
(&stream)
.write_all(
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.ok();
request
});
let backend = JmapBackend::new(JmapConfig {
base_url: format!("http://127.0.0.1:{port}"),
auth: basic_auth(),
trusted_hosts: Vec::new(),
})
.expect("the backend to be constructed");
let error = backend
.load_mailbox(MailboxKind::Inbox)
.expect_err("a server that answers 401 to have no mailboxes to hand over");
let request = server.join().expect("the server thread to finish");
let authorization = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.and_then(|line| line.split_once(':'))
.map(|(_, value)| value.trim().to_string())
.unwrap_or_else(|| panic!("the request should carry credentials:\n{request}"));
assert_eq!(
authorization, "Basic cm9iQGV4YW1wbGUuY29tOnMzY3JldA==",
"base64 of rob@example.com:s3cret"
);
assert!(
request.starts_with("GET /.well-known/jmap "),
"the session object is what gets fetched: {request}"
);
let message = format!("{error:#}");
assert!(
message.contains("rejected the password for rob@example.com"),
"{message}"
);
}
#[test]
fn status_is_read_off_a_bare_status_line() {
let err = jmap_client::Error::Server("401 Unauthorized".to_string());
assert_eq!(http_status(&err), Some(401));
}
#[test]
fn a_server_message_without_a_status_reads_as_none() {
let err = jmap_client::Error::Server("connection reset".to_string());
assert_eq!(http_status(&err), None);
}
#[test]
fn a_rejected_password_names_the_account_and_offers_the_token() {
let message = connect_error(
jmap_client::Error::Server("401 Unauthorized".to_string()),
&basic_auth(),
"https://mail.example.com/.well-known/jmap",
)
.to_string();
assert!(
message.contains("rejected the password for rob@example.com"),
"{message}"
);
assert!(message.contains("HTTP 401"), "{message}");
assert!(message.contains("token = "), "{message}");
assert!(!message.contains("s3cret"), "{message}");
}
#[test]
fn a_rejected_token_offers_the_password() {
let message = connect_error(
jmap_client::Error::Server("403 Forbidden".to_string()),
&bearer_auth(),
"https://api.fastmail.com/jmap/session",
)
.to_string();
assert!(message.contains("rejected the API token"), "{message}");
assert!(message.contains("password = "), "{message}");
assert!(!message.contains("an-api-token"), "{message}");
}
#[test]
fn other_failures_are_reported_as_they_come() {
let message = connect_error(
jmap_client::Error::Server("503 Service Unavailable".to_string()),
&basic_auth(),
"https://mail.example.com",
)
.to_string();
assert!(message.contains("connecting to JMAP server"), "{message}");
assert!(message.contains("503"), "{message}");
}
#[test]
fn a_url_with_userinfo_is_redacted_in_errors() {
let message = connect_error(
jmap_client::Error::Server("401 Unauthorized".to_string()),
&basic_auth(),
"https://rob:s3cret@mail.example.com/.well-known/jmap",
)
.to_string();
assert!(!message.contains("s3cret"), "{message}");
}
fn cache(entries: &[(&str, &str, Option<MailboxKind>)]) -> MailboxCache {
let mut by_id = HashMap::new();
let mut by_kind = HashMap::new();
for (id, name, kind) in entries {
by_id.insert(
(*id).to_string(),
MailboxInfo {
name: (*name).to_string(),
total_emails: 0,
},
);
if let Some(kind) = kind {
by_kind.insert(*kind, (*id).to_string());
}
}
MailboxCache {
by_id,
by_kind,
totals_override: HashMap::new(),
}
}
fn email(mailbox_ids: &[&str], keywords: &[&str]) -> FetchedEmail {
FetchedEmail {
jmap_id: "M1".to_string(),
from: vec!["someone@example.com".to_string()],
to: vec!["me@example.com".to_string()],
subject: "Hello".to_string(),
received_at: Some(0),
sent_at: Some(0),
size: 42,
mailbox_ids: mailbox_ids.iter().map(|id| (*id).to_string()).collect(),
keywords: keywords.iter().map(|kw| (*kw).to_string()).collect(),
has_attachments: false,
}
}
fn labels_in(mailbox: MailboxKind, data: &FetchedEmail, cache: &MailboxCache) -> Vec<String> {
build_message(1, 1, 1, data, cache, mailbox)
.expect("message builds")
.labels
}
fn standard_cache() -> MailboxCache {
cache(&[
("mb-inbox", "Inbox", Some(MailboxKind::Inbox)),
("mb-sent", "Sent", Some(MailboxKind::Sent)),
("mb-archive", "Archive", Some(MailboxKind::Archive)),
("mb-news", "Newsletters", None),
])
}
#[test]
fn open_mailbox_is_not_repeated_as_a_label() {
let cache = standard_cache();
assert!(labels_in(MailboxKind::Inbox, &email(&["mb-inbox"], &[]), &cache).is_empty());
assert!(labels_in(MailboxKind::Sent, &email(&["mb-sent"], &[]), &cache).is_empty());
}
#[test]
fn other_mailboxes_stay_visible_as_labels() {
let cache = standard_cache();
let data = email(&["mb-inbox", "mb-news"], &[]);
assert_eq!(
labels_in(MailboxKind::Inbox, &data, &cache),
vec!["Newsletters".to_string()]
);
assert_eq!(
labels_in(MailboxKind::Archive, &data, &cache),
vec!["Inbox".to_string(), "Newsletters".to_string()]
);
}
#[test]
fn localized_mailbox_names_are_matched_by_id() {
let cache = cache(&[("mb-sent", "Gesendet", Some(MailboxKind::Sent))]);
assert!(labels_in(MailboxKind::Sent, &email(&["mb-sent"], &[]), &cache).is_empty());
}
#[test]
fn starred_and_important_are_dropped_in_their_own_mailbox() {
let cache = standard_cache();
let data = email(&["mb-inbox"], &["$flagged", "$important"]);
assert_eq!(
labels_in(MailboxKind::Starred, &data, &cache),
vec!["Inbox".to_string(), "Important".to_string()]
);
assert_eq!(
labels_in(MailboxKind::Important, &data, &cache),
vec!["Inbox".to_string(), "Starred".to_string()]
);
}
#[test]
fn patching_a_keyword_off_produces_the_form_the_server_refuses() {
let mut email = <JmapEmail<JmapSet> as SetObject>::new(None);
email.keyword("$draft", false);
let json = serde_json::to_value(&email).expect("serialising the update");
assert_eq!(json["keywords/$draft"], serde_json::json!(false));
}
#[test]
fn the_sent_copy_is_described_without_patching_anything() {
let mut email = <JmapEmail<JmapSet> as SetObject>::new(None);
describe_sent_copy(&mut email, Some("P6F"));
let json = serde_json::to_value(&email).expect("serialising the update");
assert_eq!(json["mailboxIds"], serde_json::json!({"P6F": true}));
assert_eq!(json["keywords"], serde_json::json!({"$seen": true}));
let patched: Vec<&String> = json
.as_object()
.expect("the update is an object")
.keys()
.filter(|key| key.contains('/'))
.collect();
assert!(
patched.is_empty(),
"left a patch pointer behind: {patched:?}"
);
}
#[test]
fn the_sent_copy_still_drops_the_draft_keyword_without_a_sent_mailbox() {
let mut email = <JmapEmail<JmapSet> as SetObject>::new(None);
describe_sent_copy(&mut email, None);
let json = serde_json::to_value(&email).expect("serialising the update");
assert!(json.get("mailboxIds").is_none());
assert_eq!(json["keywords"], serde_json::json!({"$seen": true}));
}
fn responses(body: serde_json::Value) -> Vec<TaggedMethodResponse> {
serde_json::from_value::<jmap_client::core::response::Response<TaggedMethodResponse>>(body)
.expect("parsing the server response")
.unwrap_method_responses()
}
#[test]
fn the_submission_is_found_behind_the_implicit_update() {
let parsed = responses(serde_json::json!({
"sessionState": "abc",
"methodResponses": [
["EmailSubmission/set", {
"accountId": "u1",
"created": {"c0": {"id": "S1"}}
}, "s0"],
["Email/set", {
"accountId": "u1",
"updated": {"StnBpNCKXfQ7": null}
}, "s0"]
]
}));
assert_eq!(parsed.len(), 2);
let (submission, update) =
split_submission_responses(parsed).expect("both responses are readable");
submission
.expect("the submission response is there")
.created("c0")
.expect("the submission was created");
update.expect("the implicit update response is there");
}
#[test]
fn a_lone_submission_response_is_read_on_its_own() {
let parsed = responses(serde_json::json!({
"sessionState": "abc",
"methodResponses": [
["EmailSubmission/set", {
"accountId": "u1",
"created": {"c0": {"id": "S1"}}
}, "s0"]
]
}));
let (submission, update) =
split_submission_responses(parsed).expect("the response is readable");
assert!(submission.is_some());
assert!(update.is_none());
}
#[test]
fn a_refused_submission_is_reported_as_an_error() {
let parsed = responses(serde_json::json!({
"sessionState": "abc",
"methodResponses": [
["EmailSubmission/set", {
"accountId": "u1",
"notCreated": {"c0": {"type": "forbiddenFrom"}}
}, "s0"]
]
}));
let (submission, _) = split_submission_responses(parsed).expect("the response is readable");
assert!(
submission
.expect("the submission response is there")
.created("c0")
.is_err()
);
}
}