mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 23:52:11 +03:00
Currently there is a race between transports to upload sync messages and delete them from `imap_send` table. Sometimes mulitple transports upload the same message and sometimes only some of them "win". With this change only the primary transport will upload the sync message.
2673 lines
96 KiB
Rust
2673 lines
96 KiB
Rust
//! # IMAP handling module.
|
|
//!
|
|
//! uses [async-email/async-imap](https://github.com/async-email/async-imap)
|
|
//! to implement connect, fetch, delete functionality with standard IMAP servers.
|
|
|
|
use std::{
|
|
cmp::max,
|
|
cmp::min,
|
|
collections::{BTreeMap, BTreeSet, HashMap},
|
|
iter::Peekable,
|
|
mem::take,
|
|
sync::atomic::Ordering,
|
|
time::{Duration, UNIX_EPOCH},
|
|
};
|
|
|
|
use anyhow::{Context as _, Result, bail, ensure, format_err};
|
|
use async_channel::{self, Receiver, Sender};
|
|
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
|
|
use deltachat_contact_tools::ContactAddress;
|
|
use futures::{FutureExt as _, TryStreamExt};
|
|
use futures_lite::FutureExt;
|
|
use num_traits::FromPrimitive;
|
|
use ratelimit::Ratelimit;
|
|
use url::Url;
|
|
|
|
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
|
|
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
|
|
use crate::chatlist_events;
|
|
use crate::config::Config;
|
|
use crate::constants::{self, Blocked, Chattype, ShowEmails};
|
|
use crate::contact::{Contact, ContactId, Modifier, Origin};
|
|
use crate::context::Context;
|
|
use crate::events::EventType;
|
|
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
|
use crate::log::{LogExt, warn};
|
|
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
|
|
use crate::mimeparser;
|
|
use crate::net::proxy::ProxyConfig;
|
|
use crate::net::session::SessionStream;
|
|
use crate::oauth2::get_oauth2_access_token;
|
|
use crate::push::encrypt_device_token;
|
|
use crate::receive_imf::{
|
|
ReceivedMsg, from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner,
|
|
};
|
|
use crate::scheduler::connectivity::ConnectivityStore;
|
|
use crate::stock_str;
|
|
use crate::tools::{self, create_id, duration_to_str, time};
|
|
use crate::transport::{
|
|
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
|
|
};
|
|
|
|
pub(crate) mod capabilities;
|
|
mod client;
|
|
mod idle;
|
|
pub mod scan_folders;
|
|
pub mod select_folder;
|
|
pub(crate) mod session;
|
|
|
|
use client::{Client, determine_capabilities};
|
|
use mailparse::SingleInfo;
|
|
use session::Session;
|
|
|
|
pub(crate) const GENERATED_PREFIX: &str = "GEN_";
|
|
|
|
const RFC724MID_UID: &str = "(UID BODY.PEEK[HEADER.FIELDS (\
|
|
MESSAGE-ID \
|
|
X-MICROSOFT-ORIGINAL-MESSAGE-ID\
|
|
)])";
|
|
const BODY_FULL: &str = "(FLAGS BODY.PEEK[])";
|
|
const BODY_PARTIAL: &str = "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])";
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct Imap {
|
|
/// ID of the transport configuration in the `transports` table.
|
|
///
|
|
/// This ID is used to namespace records in the `imap` table.
|
|
transport_id: u32,
|
|
|
|
pub(crate) idle_interrupt_receiver: Receiver<()>,
|
|
|
|
/// Email address.
|
|
pub(crate) addr: String,
|
|
|
|
/// Login parameters.
|
|
lp: Vec<ConfiguredServerLoginParam>,
|
|
|
|
/// Password.
|
|
password: String,
|
|
|
|
/// Proxy configuration.
|
|
proxy_config: Option<ProxyConfig>,
|
|
|
|
strict_tls: bool,
|
|
|
|
oauth2: bool,
|
|
|
|
authentication_failed_once: bool,
|
|
|
|
pub(crate) connectivity: ConnectivityStore,
|
|
|
|
conn_last_try: tools::Time,
|
|
conn_backoff_ms: u64,
|
|
|
|
/// Rate limit for successful IMAP connections.
|
|
///
|
|
/// This rate limit prevents busy loop in case the server refuses logins
|
|
/// or in case connection gets dropped over and over due to IMAP bug,
|
|
/// e.g. the server returning invalid response to SELECT command
|
|
/// immediately after logging in or returning an error in response to LOGIN command
|
|
/// due to internal server error.
|
|
ratelimit: Ratelimit,
|
|
|
|
/// IMAP UID resync request sender.
|
|
pub(crate) resync_request_sender: async_channel::Sender<()>,
|
|
|
|
/// IMAP UID resync request receiver.
|
|
pub(crate) resync_request_receiver: async_channel::Receiver<()>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct OAuth2 {
|
|
user: String,
|
|
access_token: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ServerMetadata {
|
|
/// IMAP METADATA `/shared/comment` as defined in
|
|
/// <https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1>.
|
|
pub comment: Option<String>,
|
|
|
|
/// IMAP METADATA `/shared/admin` as defined in
|
|
/// <https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2>.
|
|
pub admin: Option<String>,
|
|
|
|
pub iroh_relay: Option<Url>,
|
|
|
|
/// JSON with ICE servers for WebRTC calls
|
|
/// and the expiration timestamp.
|
|
///
|
|
/// If JSON is about to expire, new TURN credentials
|
|
/// should be fetched from the server
|
|
/// to be ready for WebRTC calls.
|
|
pub ice_servers: String,
|
|
|
|
/// Timestamp when ICE servers are considered
|
|
/// expired and should be updated.
|
|
pub ice_servers_expiration_timestamp: i64,
|
|
}
|
|
|
|
impl async_imap::Authenticator for OAuth2 {
|
|
type Response = String;
|
|
|
|
fn process(&mut self, _data: &[u8]) -> Self::Response {
|
|
format!(
|
|
"user={}\x01auth=Bearer {}\x01\x01",
|
|
self.user, self.access_token
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Display, PartialEq, Eq, Clone, Copy)]
|
|
pub enum FolderMeaning {
|
|
Unknown,
|
|
|
|
/// Spam folder.
|
|
Spam,
|
|
Inbox,
|
|
Mvbox,
|
|
Trash,
|
|
|
|
/// Virtual folders.
|
|
///
|
|
/// On Gmail there are virtual folders marked as \\All, \\Important and \\Flagged.
|
|
/// Delta Chat ignores these folders because the same messages can be fetched
|
|
/// from the real folder and the result of moving and deleting messages via
|
|
/// virtual folder is unclear.
|
|
Virtual,
|
|
}
|
|
|
|
impl FolderMeaning {
|
|
pub fn to_config(self) -> Option<Config> {
|
|
match self {
|
|
FolderMeaning::Unknown => None,
|
|
FolderMeaning::Spam => None,
|
|
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
|
|
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
|
|
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
|
|
FolderMeaning::Virtual => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
|
|
inner: Peekable<T>,
|
|
}
|
|
|
|
impl<T, I> From<I> for UidGrouper<T>
|
|
where
|
|
T: Iterator<Item = (i64, u32, String)>,
|
|
I: IntoIterator<IntoIter = T>,
|
|
{
|
|
fn from(inner: I) -> Self {
|
|
Self {
|
|
inner: inner.into_iter().peekable(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
|
|
// Tuple of folder, row IDs, and UID range as a string.
|
|
type Item = (String, Vec<i64>, String);
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let (_, _, folder) = self.inner.peek().cloned()?;
|
|
|
|
let mut uid_set = String::new();
|
|
let mut rowid_set = Vec::new();
|
|
|
|
while uid_set.len() < 1000 {
|
|
// Construct a new range.
|
|
if let Some((start_rowid, start_uid, _)) = self
|
|
.inner
|
|
.next_if(|(_, _, start_folder)| start_folder == &folder)
|
|
{
|
|
rowid_set.push(start_rowid);
|
|
let mut end_uid = start_uid;
|
|
|
|
while let Some((next_rowid, next_uid, _)) =
|
|
self.inner.next_if(|(_, next_uid, next_folder)| {
|
|
next_folder == &folder && (*next_uid == end_uid + 1 || *next_uid == end_uid)
|
|
})
|
|
{
|
|
end_uid = next_uid;
|
|
rowid_set.push(next_rowid);
|
|
}
|
|
|
|
let uid_range = UidRange {
|
|
start: start_uid,
|
|
end: end_uid,
|
|
};
|
|
if !uid_set.is_empty() {
|
|
uid_set.push(',');
|
|
}
|
|
uid_set.push_str(&uid_range.to_string());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Some((folder, rowid_set, uid_set))
|
|
}
|
|
}
|
|
|
|
impl Imap {
|
|
/// Creates new disconnected IMAP client using the specific login parameters.
|
|
pub async fn new(
|
|
context: &Context,
|
|
transport_id: u32,
|
|
param: ConfiguredLoginParam,
|
|
idle_interrupt_receiver: Receiver<()>,
|
|
) -> Result<Self> {
|
|
let lp = param.imap.clone();
|
|
let password = param.imap_password.clone();
|
|
let proxy_config = ProxyConfig::load(context).await?;
|
|
let addr = ¶m.addr;
|
|
let strict_tls = param.strict_tls(proxy_config.is_some());
|
|
let oauth2 = param.oauth2;
|
|
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
|
|
Ok(Imap {
|
|
transport_id,
|
|
idle_interrupt_receiver,
|
|
addr: addr.to_string(),
|
|
lp,
|
|
password,
|
|
proxy_config,
|
|
strict_tls,
|
|
oauth2,
|
|
authentication_failed_once: false,
|
|
connectivity: Default::default(),
|
|
conn_last_try: UNIX_EPOCH,
|
|
conn_backoff_ms: 0,
|
|
// 1 connection per minute + a burst of 2.
|
|
ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0),
|
|
resync_request_sender,
|
|
resync_request_receiver,
|
|
})
|
|
}
|
|
|
|
/// Creates new disconnected IMAP client using configured parameters.
|
|
pub async fn new_configured(
|
|
context: &Context,
|
|
idle_interrupt_receiver: Receiver<()>,
|
|
) -> Result<Self> {
|
|
let (transport_id, param) = ConfiguredLoginParam::load(context)
|
|
.await?
|
|
.context("Not configured")?;
|
|
let imap = Self::new(context, transport_id, param, idle_interrupt_receiver).await?;
|
|
Ok(imap)
|
|
}
|
|
|
|
/// Connects to IMAP server and returns a new IMAP session.
|
|
///
|
|
/// Calling this function is not enough to perform IMAP operations. Use [`Imap::prepare`]
|
|
/// instead if you are going to actually use connection rather than trying connection
|
|
/// parameters.
|
|
pub(crate) async fn connect(
|
|
&mut self,
|
|
context: &Context,
|
|
configuring: bool,
|
|
) -> Result<Session> {
|
|
let now = tools::Time::now();
|
|
let until_can_send = max(
|
|
min(self.conn_last_try, now)
|
|
.checked_add(Duration::from_millis(self.conn_backoff_ms))
|
|
.unwrap_or(now),
|
|
now,
|
|
)
|
|
.duration_since(now)?;
|
|
let ratelimit_duration = max(until_can_send, self.ratelimit.until_can_send());
|
|
if !ratelimit_duration.is_zero() {
|
|
warn!(
|
|
context,
|
|
"IMAP got rate limited, waiting for {} until can connect.",
|
|
duration_to_str(ratelimit_duration),
|
|
);
|
|
let interrupted = async {
|
|
tokio::time::sleep(ratelimit_duration).await;
|
|
false
|
|
}
|
|
.race(self.idle_interrupt_receiver.recv().map(|_| true))
|
|
.await;
|
|
if interrupted {
|
|
info!(
|
|
context,
|
|
"Connecting to IMAP without waiting for ratelimit due to interrupt."
|
|
);
|
|
}
|
|
}
|
|
|
|
info!(context, "Connecting to IMAP server.");
|
|
self.connectivity.set_connecting(context);
|
|
|
|
self.conn_last_try = tools::Time::now();
|
|
const BACKOFF_MIN_MS: u64 = 2000;
|
|
const BACKOFF_MAX_MS: u64 = 80_000;
|
|
self.conn_backoff_ms = min(self.conn_backoff_ms, BACKOFF_MAX_MS / 2);
|
|
self.conn_backoff_ms = self.conn_backoff_ms.saturating_add(rand::random_range(
|
|
(self.conn_backoff_ms / 2)..=self.conn_backoff_ms,
|
|
));
|
|
self.conn_backoff_ms = max(BACKOFF_MIN_MS, self.conn_backoff_ms);
|
|
|
|
let login_params = prioritize_server_login_params(&context.sql, &self.lp, "imap").await?;
|
|
let mut first_error = None;
|
|
for lp in login_params {
|
|
info!(context, "IMAP trying to connect to {}.", &lp.connection);
|
|
let connection_candidate = lp.connection.clone();
|
|
let client = match Client::connect(
|
|
context,
|
|
self.proxy_config.clone(),
|
|
self.strict_tls,
|
|
connection_candidate,
|
|
)
|
|
.await
|
|
.context("IMAP failed to connect")
|
|
{
|
|
Ok(client) => client,
|
|
Err(err) => {
|
|
warn!(context, "{err:#}.");
|
|
first_error.get_or_insert(err);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
self.conn_backoff_ms = BACKOFF_MIN_MS;
|
|
self.ratelimit.send();
|
|
|
|
let imap_user: &str = lp.user.as_ref();
|
|
let imap_pw: &str = &self.password;
|
|
|
|
let login_res = if self.oauth2 {
|
|
info!(context, "Logging into IMAP server with OAuth 2.");
|
|
let addr: &str = self.addr.as_ref();
|
|
|
|
let token = get_oauth2_access_token(context, addr, imap_pw, true)
|
|
.await?
|
|
.context("IMAP could not get OAUTH token")?;
|
|
let auth = OAuth2 {
|
|
user: imap_user.into(),
|
|
access_token: token,
|
|
};
|
|
client.authenticate("XOAUTH2", auth).await
|
|
} else {
|
|
info!(context, "Logging into IMAP server with LOGIN.");
|
|
client.login(imap_user, imap_pw).await
|
|
};
|
|
|
|
match login_res {
|
|
Ok(mut session) => {
|
|
let capabilities = determine_capabilities(&mut session).await?;
|
|
let resync_request_sender = self.resync_request_sender.clone();
|
|
|
|
let session = if capabilities.can_compress {
|
|
info!(context, "Enabling IMAP compression.");
|
|
let compressed_session = session
|
|
.compress(|s| {
|
|
let session_stream: Box<dyn SessionStream> = Box::new(s);
|
|
session_stream
|
|
})
|
|
.await
|
|
.context("Failed to enable IMAP compression")?;
|
|
Session::new(
|
|
compressed_session,
|
|
capabilities,
|
|
resync_request_sender,
|
|
self.transport_id,
|
|
)
|
|
} else {
|
|
Session::new(
|
|
session,
|
|
capabilities,
|
|
resync_request_sender,
|
|
self.transport_id,
|
|
)
|
|
};
|
|
|
|
// Store server ID in the context to display in account info.
|
|
let mut lock = context.server_id.write().await;
|
|
lock.clone_from(&session.capabilities.server_id);
|
|
|
|
self.authentication_failed_once = false;
|
|
context.emit_event(EventType::ImapConnected(format!(
|
|
"IMAP-LOGIN as {}",
|
|
lp.user
|
|
)));
|
|
self.connectivity.set_preparing(context);
|
|
info!(context, "Successfully logged into IMAP server.");
|
|
return Ok(session);
|
|
}
|
|
|
|
Err(err) => {
|
|
let imap_user = lp.user.to_owned();
|
|
let message = stock_str::cannot_login(context, &imap_user).await;
|
|
|
|
warn!(context, "IMAP failed to login: {err:#}.");
|
|
first_error.get_or_insert(format_err!("{message} ({err:#})"));
|
|
|
|
// If it looks like the password is wrong, send a notification:
|
|
let _lock = context.wrong_pw_warning_mutex.lock().await;
|
|
if err.to_string().to_lowercase().contains("authentication") {
|
|
if self.authentication_failed_once
|
|
&& !configuring
|
|
&& context.get_config_bool(Config::NotifyAboutWrongPw).await?
|
|
{
|
|
let mut msg = Message::new_text(message);
|
|
if let Err(e) = chat::add_device_msg_with_importance(
|
|
context,
|
|
None,
|
|
Some(&mut msg),
|
|
true,
|
|
)
|
|
.await
|
|
{
|
|
warn!(context, "Failed to add device message: {e:#}.");
|
|
} else {
|
|
context
|
|
.set_config_internal(Config::NotifyAboutWrongPw, None)
|
|
.await
|
|
.log_err(context)
|
|
.ok();
|
|
}
|
|
} else {
|
|
self.authentication_failed_once = true;
|
|
}
|
|
} else {
|
|
self.authentication_failed_once = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(first_error.unwrap_or_else(|| format_err!("No IMAP connection candidates provided")))
|
|
}
|
|
|
|
/// Prepare a new IMAP session.
|
|
///
|
|
/// This creates a new IMAP connection and ensures
|
|
/// that folders are created and IMAP capabilities are determined.
|
|
pub(crate) async fn prepare(&mut self, context: &Context) -> Result<Session> {
|
|
let configuring = false;
|
|
let mut session = match self.connect(context, configuring).await {
|
|
Ok(session) => session,
|
|
Err(err) => {
|
|
self.connectivity.set_err(context, &err);
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
let folders_configured = context
|
|
.sql
|
|
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
|
|
.await?;
|
|
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
|
|
let is_chatmail = match context.get_config_bool(Config::FixIsChatmail).await? {
|
|
false => session.is_chatmail(),
|
|
true => context.get_config_bool(Config::IsChatmail).await?,
|
|
};
|
|
let create_mvbox = !is_chatmail || context.get_config_bool(Config::MvboxMove).await?;
|
|
self.configure_folders(context, &mut session, create_mvbox)
|
|
.await?;
|
|
}
|
|
|
|
Ok(session)
|
|
}
|
|
|
|
/// FETCH-MOVE-DELETE iteration.
|
|
///
|
|
/// Prefetches headers and downloads new message from the folder, moves messages away from the
|
|
/// folder and deletes messages in the folder.
|
|
pub async fn fetch_move_delete(
|
|
&mut self,
|
|
context: &Context,
|
|
session: &mut Session,
|
|
watch_folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
) -> Result<()> {
|
|
if !context.sql.is_open().await {
|
|
// probably shutdown
|
|
bail!("IMAP operation attempted while it is torn down");
|
|
}
|
|
|
|
let msgs_fetched = self
|
|
.fetch_new_messages(context, session, watch_folder, folder_meaning)
|
|
.await
|
|
.context("fetch_new_messages")?;
|
|
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
|
|
// New messages were fetched and shall be deleted later, restart ephemeral loop.
|
|
// Note that the `Config::DeleteDeviceAfter` timer starts as soon as the messages are
|
|
// fetched while the per-chat ephemeral timers start as soon as the messages are marked
|
|
// as noticed.
|
|
context.scheduler.interrupt_ephemeral_task().await;
|
|
}
|
|
|
|
session
|
|
.move_delete_messages(context, watch_folder)
|
|
.await
|
|
.context("move_delete_messages")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetches new messages.
|
|
///
|
|
/// Returns true if at least one message was fetched.
|
|
pub(crate) async fn fetch_new_messages(
|
|
&mut self,
|
|
context: &Context,
|
|
session: &mut Session,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
) -> Result<bool> {
|
|
if should_ignore_folder(context, folder, folder_meaning).await? {
|
|
info!(context, "Not fetching from {folder:?}.");
|
|
session.new_mail = false;
|
|
return Ok(false);
|
|
}
|
|
|
|
let create = false;
|
|
let folder_exists = session
|
|
.select_with_uidvalidity(context, folder, create)
|
|
.await
|
|
.with_context(|| format!("Failed to select folder {folder:?}"))?;
|
|
if !folder_exists {
|
|
return Ok(false);
|
|
}
|
|
|
|
if !session.new_mail {
|
|
info!(context, "No new emails in folder {folder:?}.");
|
|
return Ok(false);
|
|
}
|
|
session.new_mail = false;
|
|
|
|
let mut read_cnt = 0;
|
|
loop {
|
|
let (n, fetch_more) = self
|
|
.fetch_new_msg_batch(context, session, folder, folder_meaning)
|
|
.await?;
|
|
read_cnt += n;
|
|
if !fetch_more {
|
|
return Ok(read_cnt > 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns number of messages processed and whether the function should be called again.
|
|
async fn fetch_new_msg_batch(
|
|
&mut self,
|
|
context: &Context,
|
|
session: &mut Session,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
) -> Result<(usize, bool)> {
|
|
let transport_id = self.transport_id;
|
|
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
|
|
let old_uid_next = get_uid_next(context, transport_id, folder).await?;
|
|
info!(
|
|
context,
|
|
"fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}."
|
|
);
|
|
|
|
let uids_to_prefetch = 500;
|
|
let msgs = session
|
|
.prefetch(old_uid_next, uids_to_prefetch)
|
|
.await
|
|
.context("prefetch")?;
|
|
let read_cnt = msgs.len();
|
|
|
|
let download_limit = context.download_limit().await?;
|
|
let mut uids_fetch = Vec::<(u32, bool /* partially? */)>::with_capacity(msgs.len() + 1);
|
|
let mut uid_message_ids = BTreeMap::new();
|
|
let mut largest_uid_skipped = None;
|
|
let delete_target = context.get_delete_msgs_target().await?;
|
|
|
|
// Store the info about IMAP messages in the database.
|
|
for (uid, ref fetch_response) in msgs {
|
|
let headers = match get_fetch_headers(fetch_response) {
|
|
Ok(headers) => headers,
|
|
Err(err) => {
|
|
warn!(context, "Failed to parse FETCH headers: {err:#}.");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let message_id = prefetch_get_message_id(&headers);
|
|
|
|
// Determine the target folder where the message should be moved to.
|
|
//
|
|
// We only move the messages from the INBOX and Spam folders.
|
|
// This is required to avoid infinite MOVE loop on IMAP servers
|
|
// that alias `DeltaChat` folder to other names.
|
|
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
|
|
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
|
|
// results in the messages getting a new UID,
|
|
// so the messages will be detected as new
|
|
// in the `INBOX.DeltaChat` folder again.
|
|
let delete = if let Some(message_id) = &message_id {
|
|
message::rfc724_mid_exists_ex(context, message_id, "deleted=1")
|
|
.await?
|
|
.is_some_and(|(_msg_id, deleted)| deleted)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Generate a fake Message-ID to identify the message in the database
|
|
// if the message has no real Message-ID.
|
|
let message_id = message_id.unwrap_or_else(create_message_id);
|
|
|
|
if delete {
|
|
info!(context, "Deleting locally deleted message {message_id}.");
|
|
}
|
|
|
|
let _target;
|
|
let target = if delete {
|
|
&delete_target
|
|
} else {
|
|
_target = target_folder(context, folder, folder_meaning, &headers).await?;
|
|
&_target
|
|
};
|
|
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(transport_id, folder, uid, uidvalidity)
|
|
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
|
|
target=excluded.target",
|
|
(
|
|
self.transport_id,
|
|
&message_id,
|
|
&folder,
|
|
uid,
|
|
uid_validity,
|
|
target,
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
// Download only the messages which have reached their target folder if there are
|
|
// multiple devices. This prevents race conditions in multidevice case, where one
|
|
// device tries to download the message while another device moves the message at the
|
|
// same time. Even in single device case it is possible to fail downloading the first
|
|
// message, move it to the movebox and then download the second message before
|
|
// downloading the first one, if downloading from inbox before moving is allowed.
|
|
if folder == target
|
|
// Never download messages directly from the spam folder.
|
|
// If the sender is known, the message will be moved to the Inbox or Mvbox
|
|
// and then we download the message from there.
|
|
// Also see `spam_target_folder_cfg()`.
|
|
&& folder_meaning != FolderMeaning::Spam
|
|
&& prefetch_should_download(
|
|
context,
|
|
&headers,
|
|
&message_id,
|
|
fetch_response.flags(),
|
|
)
|
|
.await.context("prefetch_should_download")?
|
|
{
|
|
match download_limit {
|
|
Some(download_limit) => uids_fetch.push((
|
|
uid,
|
|
fetch_response.size.unwrap_or_default() > download_limit,
|
|
)),
|
|
None => uids_fetch.push((uid, false)),
|
|
}
|
|
uid_message_ids.insert(uid, message_id);
|
|
} else {
|
|
largest_uid_skipped = Some(uid);
|
|
}
|
|
}
|
|
|
|
if !uids_fetch.is_empty() {
|
|
self.connectivity.set_working(context);
|
|
}
|
|
|
|
let (sender, receiver) = async_channel::unbounded();
|
|
|
|
let mut received_msgs = Vec::with_capacity(uids_fetch.len());
|
|
let mailbox_uid_next = session
|
|
.selected_mailbox
|
|
.as_ref()
|
|
.with_context(|| format!("Expected {folder:?} to be selected"))?
|
|
.uid_next
|
|
.unwrap_or_default();
|
|
|
|
let update_uids_future = async {
|
|
let mut largest_uid_fetched: u32 = 0;
|
|
|
|
while let Ok((uid, received_msg_opt)) = receiver.recv().await {
|
|
largest_uid_fetched = max(largest_uid_fetched, uid);
|
|
if let Some(received_msg) = received_msg_opt {
|
|
received_msgs.push(received_msg)
|
|
}
|
|
}
|
|
|
|
largest_uid_fetched
|
|
};
|
|
|
|
let actually_download_messages_future = async {
|
|
let sender = sender;
|
|
let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1));
|
|
let mut fetch_partially = false;
|
|
uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1));
|
|
for (uid, fp) in uids_fetch {
|
|
if fp != fetch_partially {
|
|
session
|
|
.fetch_many_msgs(
|
|
context,
|
|
folder,
|
|
uids_fetch_in_batch.split_off(0),
|
|
&uid_message_ids,
|
|
fetch_partially,
|
|
sender.clone(),
|
|
)
|
|
.await
|
|
.context("fetch_many_msgs")?;
|
|
fetch_partially = fp;
|
|
}
|
|
uids_fetch_in_batch.push(uid);
|
|
}
|
|
|
|
anyhow::Ok(())
|
|
};
|
|
|
|
let (largest_uid_fetched, fetch_res) =
|
|
tokio::join!(update_uids_future, actually_download_messages_future);
|
|
|
|
// Advance uid_next to the largest fetched UID plus 1.
|
|
//
|
|
// This may be larger than `mailbox_uid_next`
|
|
// if the message has arrived after selecting mailbox
|
|
// and determining its UIDNEXT and before prefetch.
|
|
let mut new_uid_next = largest_uid_fetched + 1;
|
|
let fetch_more = fetch_res.is_ok() && {
|
|
let prefetch_uid_next = old_uid_next + uids_to_prefetch;
|
|
// If we have successfully fetched all messages we planned during prefetch,
|
|
// then we have covered at least the range between old UIDNEXT
|
|
// and UIDNEXT of the mailbox at the time of selecting it.
|
|
new_uid_next = max(new_uid_next, min(prefetch_uid_next, mailbox_uid_next));
|
|
|
|
new_uid_next = max(new_uid_next, largest_uid_skipped.unwrap_or(0) + 1);
|
|
|
|
prefetch_uid_next < mailbox_uid_next
|
|
};
|
|
if new_uid_next > old_uid_next {
|
|
set_uid_next(context, self.transport_id, folder, new_uid_next).await?;
|
|
}
|
|
|
|
info!(context, "{} mails read from \"{}\".", read_cnt, folder);
|
|
|
|
if !received_msgs.is_empty() {
|
|
context.emit_event(EventType::IncomingMsgBunch);
|
|
}
|
|
|
|
chat::mark_old_messages_as_noticed(context, received_msgs).await?;
|
|
|
|
// Now fail if fetching failed, so we will
|
|
// establish a new session if this one is broken.
|
|
fetch_res?;
|
|
|
|
Ok((read_cnt, fetch_more))
|
|
}
|
|
|
|
/// Read the recipients from old emails sent by the user and add them as contacts.
|
|
/// This way, we can already offer them some email addresses they can write to.
|
|
///
|
|
/// Then, Fetch the last messages DC_FETCH_EXISTING_MSGS_COUNT emails from the server
|
|
/// and show them in the chat list.
|
|
pub(crate) async fn fetch_existing_msgs(
|
|
&mut self,
|
|
context: &Context,
|
|
session: &mut Session,
|
|
) -> Result<()> {
|
|
add_all_recipients_as_contacts(context, session, Config::ConfiguredMvboxFolder)
|
|
.await
|
|
.context("failed to get recipients from the movebox")?;
|
|
add_all_recipients_as_contacts(context, session, Config::ConfiguredInboxFolder)
|
|
.await
|
|
.context("failed to get recipients from the inbox")?;
|
|
|
|
info!(context, "Done fetching existing messages.");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Synchronizes UIDs for all folders.
|
|
pub(crate) async fn resync_folders(&mut self, context: &Context) -> Result<()> {
|
|
let all_folders = self
|
|
.list_folders()
|
|
.await
|
|
.context("listing folders for resync")?;
|
|
for folder in all_folders {
|
|
let folder_meaning = get_folder_meaning(&folder);
|
|
if !matches!(
|
|
folder_meaning,
|
|
FolderMeaning::Virtual | FolderMeaning::Unknown
|
|
) {
|
|
self.resync_folder_uids(context, folder.name(), folder_meaning)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronizes UIDs in the database with UIDs on the server.
|
|
///
|
|
/// It is assumed that no operations are taking place on the same
|
|
/// folder at the moment. Make sure to run it in the same
|
|
/// thread/task as other network operations on this folder to
|
|
/// avoid race conditions.
|
|
pub(crate) async fn resync_folder_uids(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
) -> Result<()> {
|
|
let uid_validity;
|
|
// Collect pairs of UID and Message-ID.
|
|
let mut msgs = BTreeMap::new();
|
|
|
|
let create = false;
|
|
let folder_exists = self
|
|
.select_with_uidvalidity(context, folder, create)
|
|
.await?;
|
|
let transport_id = self.transport_id();
|
|
if folder_exists {
|
|
let mut list = self
|
|
.uid_fetch("1:*", RFC724MID_UID)
|
|
.await
|
|
.with_context(|| format!("Can't resync folder {folder}"))?;
|
|
while let Some(fetch) = list.try_next().await? {
|
|
let headers = match get_fetch_headers(&fetch) {
|
|
Ok(headers) => headers,
|
|
Err(err) => {
|
|
warn!(context, "Failed to parse FETCH headers: {}", err);
|
|
continue;
|
|
}
|
|
};
|
|
let message_id = prefetch_get_message_id(&headers);
|
|
|
|
if let (Some(uid), Some(rfc724_mid)) = (fetch.uid, message_id) {
|
|
msgs.insert(
|
|
uid,
|
|
(
|
|
rfc724_mid,
|
|
target_folder(context, folder, folder_meaning, &headers).await?,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
info!(
|
|
context,
|
|
"resync_folder_uids: Collected {} message IDs in {folder}.",
|
|
msgs.len(),
|
|
);
|
|
|
|
uid_validity = get_uidvalidity(context, transport_id, folder).await?;
|
|
} else {
|
|
warn!(context, "resync_folder_uids: No folder {folder}.");
|
|
uid_validity = 0;
|
|
}
|
|
|
|
// Write collected UIDs to SQLite database.
|
|
context
|
|
.sql
|
|
.transaction(move |transaction| {
|
|
transaction.execute("DELETE FROM imap WHERE folder=?", (folder,))?;
|
|
for (uid, (rfc724_mid, target)) in &msgs {
|
|
// This may detect previously undetected moved
|
|
// messages, so we update server_folder too.
|
|
transaction.execute(
|
|
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(transport_id, folder, uid, uidvalidity)
|
|
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
|
|
target=excluded.target",
|
|
(transport_id, rfc724_mid, folder, uid, uid_validity, target),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Deletes batch of messages identified by their UID from the currently
|
|
/// selected folder.
|
|
async fn delete_message_batch(
|
|
&mut self,
|
|
context: &Context,
|
|
uid_set: &str,
|
|
row_ids: Vec<i64>,
|
|
) -> Result<()> {
|
|
// mark the message for deletion
|
|
self.add_flag_finalized_with_set(uid_set, "\\Deleted")
|
|
.await?;
|
|
context
|
|
.sql
|
|
.transaction(|transaction| {
|
|
let mut stmt = transaction.prepare("DELETE FROM imap WHERE id = ?")?;
|
|
for row_id in row_ids {
|
|
stmt.execute((row_id,))?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
.context("Cannot remove deleted messages from imap table")?;
|
|
|
|
context.emit_event(EventType::ImapMessageDeleted(format!(
|
|
"IMAP messages {uid_set} marked as deleted"
|
|
)));
|
|
Ok(())
|
|
}
|
|
|
|
/// Moves batch of messages identified by their UID from the currently
|
|
/// selected folder to the target folder.
|
|
async fn move_message_batch(
|
|
&mut self,
|
|
context: &Context,
|
|
set: &str,
|
|
row_ids: Vec<i64>,
|
|
target: &str,
|
|
) -> Result<()> {
|
|
if self.can_move() {
|
|
match self.uid_mv(set, &target).await {
|
|
Ok(()) => {
|
|
// Messages are moved or don't exist, IMAP returns OK response in both cases.
|
|
context
|
|
.sql
|
|
.transaction(|transaction| {
|
|
let mut stmt = transaction.prepare("DELETE FROM imap WHERE id = ?")?;
|
|
for row_id in row_ids {
|
|
stmt.execute((row_id,))?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
.context("Cannot delete moved messages from imap table")?;
|
|
context.emit_event(EventType::ImapMessageMoved(format!(
|
|
"IMAP messages {set} moved to {target}"
|
|
)));
|
|
return Ok(());
|
|
}
|
|
Err(err) => {
|
|
if context.should_delete_to_trash().await? {
|
|
error!(
|
|
context,
|
|
"Cannot move messages {} to {}, no fallback to COPY/DELETE because \
|
|
delete_to_trash is set. Error: {:#}",
|
|
set,
|
|
target,
|
|
err,
|
|
);
|
|
return Err(err.into());
|
|
}
|
|
warn!(
|
|
context,
|
|
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
|
|
set,
|
|
target,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Server does not support MOVE or MOVE failed.
|
|
// Copy messages to the destination folder if needed and mark records for deletion.
|
|
let copy = !context.is_trash(target).await?;
|
|
if copy {
|
|
info!(
|
|
context,
|
|
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
|
|
);
|
|
self.uid_copy(&set, &target).await?;
|
|
} else {
|
|
error!(
|
|
context,
|
|
"Server does not support MOVE, fallback to DELETE {} to {}", set, target,
|
|
);
|
|
}
|
|
context
|
|
.sql
|
|
.transaction(|transaction| {
|
|
let mut stmt = transaction.prepare("UPDATE imap SET target='' WHERE id = ?")?;
|
|
for row_id in row_ids {
|
|
stmt.execute((row_id,))?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
.context("Cannot plan deletion of messages")?;
|
|
if copy {
|
|
context.emit_event(EventType::ImapMessageMoved(format!(
|
|
"IMAP messages {set} copied to {target}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Moves and deletes messages as planned in the `imap` table.
|
|
///
|
|
/// This is the only place where messages are moved or deleted on the IMAP server.
|
|
async fn move_delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
let rows = context
|
|
.sql
|
|
.query_map_vec(
|
|
"SELECT id, uid, target FROM imap
|
|
WHERE folder = ?
|
|
AND target != folder
|
|
ORDER BY target, uid",
|
|
(folder,),
|
|
|row| {
|
|
let rowid: i64 = row.get(0)?;
|
|
let uid: u32 = row.get(1)?;
|
|
let target: String = row.get(2)?;
|
|
Ok((rowid, uid, target))
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
for (target, rowid_set, uid_set) in UidGrouper::from(rows) {
|
|
// Select folder inside the loop to avoid selecting it if there are no pending
|
|
// MOVE/DELETE operations. This does not result in multiple SELECT commands
|
|
// being sent because `select_folder()` does nothing if the folder is already
|
|
// selected.
|
|
let create = false;
|
|
let folder_exists = self
|
|
.select_with_uidvalidity(context, folder, create)
|
|
.await?;
|
|
ensure!(folder_exists, "No folder {folder}");
|
|
|
|
// Empty target folder name means messages should be deleted.
|
|
if target.is_empty() {
|
|
self.delete_message_batch(context, &uid_set, rowid_set)
|
|
.await
|
|
.with_context(|| format!("cannot delete batch of messages {:?}", &uid_set))?;
|
|
} else {
|
|
self.move_message_batch(context, &uid_set, rowid_set, &target)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"cannot move batch of messages {:?} to folder {:?}",
|
|
&uid_set, target
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
// Expunge folder if needed, e.g. if some jobs have
|
|
// deleted messages on the server.
|
|
if let Err(err) = self.maybe_close_folder(context).await {
|
|
warn!(context, "Failed to close folder: {err:#}.");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Uploads sync messages from the `imap_send` table with `\Seen` flag set.
|
|
pub(crate) async fn send_sync_msgs(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
context.send_sync_msg().await?;
|
|
while let Some((id, mime, msg_id, attempts)) = context
|
|
.sql
|
|
.query_row_optional(
|
|
"SELECT id, mime, msg_id, attempts FROM imap_send ORDER BY id LIMIT 1",
|
|
(),
|
|
|row| {
|
|
let id: i64 = row.get(0)?;
|
|
let mime: String = row.get(1)?;
|
|
let msg_id: MsgId = row.get(2)?;
|
|
let attempts: i64 = row.get(3)?;
|
|
Ok((id, mime, msg_id, attempts))
|
|
},
|
|
)
|
|
.await
|
|
.context("Failed to SELECT from imap_send")?
|
|
{
|
|
let res = self
|
|
.append(folder, Some("(\\Seen)"), None, mime)
|
|
.await
|
|
.with_context(|| format!("IMAP APPEND to {folder} failed for {msg_id}"))
|
|
.log_err(context);
|
|
if res.is_ok() {
|
|
msg_id.set_delivered(context).await?;
|
|
}
|
|
const MAX_ATTEMPTS: i64 = 2;
|
|
if res.is_ok() || attempts >= MAX_ATTEMPTS - 1 {
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM imap_send WHERE id=?", (id,))
|
|
.await
|
|
.context("Failed to delete from imap_send")?;
|
|
} else {
|
|
context
|
|
.sql
|
|
.execute("UPDATE imap_send SET attempts=attempts+1 WHERE id=?", (id,))
|
|
.await
|
|
.context("Failed to update imap_send.attempts")?;
|
|
res?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Stores pending `\Seen` flags for messages in `imap_markseen` table.
|
|
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
|
|
let rows = context
|
|
.sql
|
|
.query_map_vec(
|
|
"SELECT imap.id, uid, folder FROM imap, imap_markseen
|
|
WHERE imap.id = imap_markseen.id AND target = folder
|
|
ORDER BY folder, uid",
|
|
[],
|
|
|row| {
|
|
let rowid: i64 = row.get(0)?;
|
|
let uid: u32 = row.get(1)?;
|
|
let folder: String = row.get(2)?;
|
|
Ok((rowid, uid, folder))
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
|
|
let create = false;
|
|
let folder_exists = match self.select_with_uidvalidity(context, &folder, create).await {
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"store_seen_flags_on_imap: Failed to select {folder}, will retry later: {err:#}."
|
|
);
|
|
continue;
|
|
}
|
|
Ok(folder_exists) => folder_exists,
|
|
};
|
|
if !folder_exists {
|
|
warn!(context, "store_seen_flags_on_imap: No folder {folder}.");
|
|
} else if let Err(err) = self.add_flag_finalized_with_set(&uid_set, "\\Seen").await {
|
|
warn!(
|
|
context,
|
|
"Cannot mark messages {uid_set} in {folder} as seen, will retry later: {err:#}."
|
|
);
|
|
continue;
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Marked messages {} in folder {} as seen.", uid_set, folder
|
|
);
|
|
}
|
|
context
|
|
.sql
|
|
.transaction(|transaction| {
|
|
let mut stmt = transaction.prepare("DELETE FROM imap_markseen WHERE id = ?")?;
|
|
for rowid in rowid_set {
|
|
stmt.execute((rowid,))?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
.context("Cannot remove messages marked as seen from imap_markseen table")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronizes `\Seen` flags using `CONDSTORE` extension.
|
|
pub(crate) async fn sync_seen_flags(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
if !self.can_condstore() {
|
|
info!(
|
|
context,
|
|
"Server does not support CONDSTORE, skipping flag synchronization."
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let create = false;
|
|
let folder_exists = self
|
|
.select_with_uidvalidity(context, folder, create)
|
|
.await
|
|
.context("Failed to select folder")?;
|
|
if !folder_exists {
|
|
return Ok(());
|
|
}
|
|
|
|
let mailbox = self
|
|
.selected_mailbox
|
|
.as_ref()
|
|
.with_context(|| format!("No mailbox selected, folder: {folder}"))?;
|
|
|
|
// Check if the mailbox supports MODSEQ.
|
|
// We are not interested in actual value of HIGHESTMODSEQ.
|
|
if mailbox.highest_modseq.is_none() {
|
|
info!(
|
|
context,
|
|
"Mailbox {} does not support mod-sequences, skipping flag synchronization.", folder
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let transport_id = self.transport_id();
|
|
let mut updated_chat_ids = BTreeSet::new();
|
|
let uid_validity = get_uidvalidity(context, transport_id, folder)
|
|
.await
|
|
.with_context(|| format!("failed to get UID validity for folder {folder}"))?;
|
|
let mut highest_modseq = get_modseq(context, transport_id, folder)
|
|
.await
|
|
.with_context(|| format!("failed to get MODSEQ for folder {folder}"))?;
|
|
let mut list = self
|
|
.uid_fetch("1:*", format!("(FLAGS) (CHANGEDSINCE {highest_modseq})"))
|
|
.await
|
|
.context("failed to fetch flags")?;
|
|
|
|
let mut got_unsolicited_fetch = false;
|
|
|
|
while let Some(fetch) = list
|
|
.try_next()
|
|
.await
|
|
.context("failed to get FETCH result")?
|
|
{
|
|
let uid = if let Some(uid) = fetch.uid {
|
|
uid
|
|
} else {
|
|
info!(context, "FETCH result contains no UID, skipping");
|
|
got_unsolicited_fetch = true;
|
|
continue;
|
|
};
|
|
let is_seen = fetch.flags().any(|flag| flag == Flag::Seen);
|
|
if is_seen
|
|
&& let Some(chat_id) = mark_seen_by_uid(context, folder, uid_validity, uid)
|
|
.await
|
|
.with_context(|| {
|
|
format!("failed to update seen status for msg {folder}/{uid}")
|
|
})?
|
|
{
|
|
updated_chat_ids.insert(chat_id);
|
|
}
|
|
|
|
if let Some(modseq) = fetch.modseq {
|
|
if modseq > highest_modseq {
|
|
highest_modseq = modseq;
|
|
}
|
|
} else {
|
|
warn!(context, "FETCH result contains no MODSEQ");
|
|
}
|
|
}
|
|
drop(list);
|
|
|
|
if got_unsolicited_fetch {
|
|
// We got unsolicited FETCH, which means some flags
|
|
// have been modified while our request was in progress.
|
|
// We may or may not have these new flags as a part of the response,
|
|
// so better skip next IDLE and do another round of flag synchronization.
|
|
self.new_mail = true;
|
|
}
|
|
|
|
set_modseq(context, transport_id, folder, highest_modseq)
|
|
.await
|
|
.with_context(|| format!("failed to set MODSEQ for folder {folder}"))?;
|
|
if !updated_chat_ids.is_empty() {
|
|
context.on_archived_chats_maybe_noticed();
|
|
}
|
|
for updated_chat_id in updated_chat_ids {
|
|
context.emit_event(EventType::MsgsNoticed(updated_chat_id));
|
|
chatlist_events::emit_chatlist_item_changed(context, updated_chat_id);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Gets the from, to and bcc addresses from all existing outgoing emails.
|
|
pub async fn get_all_recipients(&mut self, context: &Context) -> Result<Vec<SingleInfo>> {
|
|
let mut uids: Vec<_> = self
|
|
.uid_search(get_imap_self_sent_search_command(context).await?)
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
uids.sort_unstable();
|
|
|
|
let mut result = Vec::new();
|
|
for (_, uid_set) in build_sequence_sets(&uids)? {
|
|
let mut list = self
|
|
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
|
|
.await
|
|
.context("IMAP Could not fetch")?;
|
|
|
|
while let Some(msg) = list.try_next().await? {
|
|
match get_fetch_headers(&msg) {
|
|
Ok(headers) => {
|
|
if let Some(from) = mimeparser::get_from(&headers)
|
|
&& context.is_self_addr(&from.addr).await?
|
|
{
|
|
result.extend(mimeparser::get_recipients(&headers));
|
|
}
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "{}", err);
|
|
continue;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Fetches a list of messages by server UID.
|
|
///
|
|
/// Sends pairs of UID and info about each downloaded message to the provided channel.
|
|
/// Received message info is optional because UID may be ignored
|
|
/// if the message has a `\Deleted` flag.
|
|
///
|
|
/// The channel is used to return the results because the function may fail
|
|
/// due to network errors before it finishes fetching all the messages.
|
|
/// In this case caller still may want to process all the results
|
|
/// received over the channel and persist last seen UID in the database
|
|
/// before bubbling up the failure.
|
|
///
|
|
/// If the message is incorrect or there is a failure to write a message to the database,
|
|
/// it is skipped and the error is logged.
|
|
pub(crate) async fn fetch_many_msgs(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
request_uids: Vec<u32>,
|
|
uid_message_ids: &BTreeMap<u32, String>,
|
|
fetch_partially: bool,
|
|
received_msgs_channel: Sender<(u32, Option<ReceivedMsg>)>,
|
|
) -> Result<()> {
|
|
if request_uids.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
for (request_uids, set) in build_sequence_sets(&request_uids)? {
|
|
info!(
|
|
context,
|
|
"Starting a {} FETCH of message set \"{}\".",
|
|
if fetch_partially { "partial" } else { "full" },
|
|
set
|
|
);
|
|
let mut fetch_responses = self
|
|
.uid_fetch(
|
|
&set,
|
|
if fetch_partially {
|
|
BODY_PARTIAL
|
|
} else {
|
|
BODY_FULL
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| {
|
|
format!("fetching messages {} from folder \"{}\"", &set, folder)
|
|
})?;
|
|
|
|
// Map from UIDs to unprocessed FETCH results. We put unprocessed FETCH results here
|
|
// when we want to process other messages first.
|
|
let mut uid_msgs = HashMap::with_capacity(request_uids.len());
|
|
|
|
let mut count = 0;
|
|
for &request_uid in &request_uids {
|
|
// Check if FETCH response is already in `uid_msgs`.
|
|
let mut fetch_response = uid_msgs.remove(&request_uid);
|
|
|
|
// Try to find a requested UID in returned FETCH responses.
|
|
while fetch_response.is_none() {
|
|
let Some(next_fetch_response) = fetch_responses
|
|
.try_next()
|
|
.await
|
|
.context("Failed to process IMAP FETCH result")?
|
|
else {
|
|
// No more FETCH responses received from the server.
|
|
break;
|
|
};
|
|
|
|
if let Some(next_uid) = next_fetch_response.uid {
|
|
if next_uid == request_uid {
|
|
fetch_response = Some(next_fetch_response);
|
|
} else if !request_uids.contains(&next_uid) {
|
|
// (size of `request_uids` is bounded by IMAP command length limit,
|
|
// search in this vector is always fast)
|
|
|
|
// Unwanted UIDs are possible because of unsolicited responses, e.g. if
|
|
// another client changes \Seen flag on a message after we do a prefetch but
|
|
// before fetch. It's not an error if we receive such unsolicited response.
|
|
info!(
|
|
context,
|
|
"Skipping not requested FETCH response for UID {}.", next_uid
|
|
);
|
|
} else if uid_msgs.insert(next_uid, next_fetch_response).is_some() {
|
|
warn!(context, "Got duplicated UID {}.", next_uid);
|
|
}
|
|
} else {
|
|
info!(context, "Skipping FETCH response without UID.");
|
|
}
|
|
}
|
|
|
|
let fetch_response = match fetch_response {
|
|
Some(fetch) => fetch,
|
|
None => {
|
|
warn!(
|
|
context,
|
|
"Missed UID {} in the server response.", request_uid
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
count += 1;
|
|
|
|
let is_deleted = fetch_response.flags().any(|flag| flag == Flag::Deleted);
|
|
let (body, partial) = if fetch_partially {
|
|
(fetch_response.header(), fetch_response.size) // `BODY.PEEK[HEADER]` goes to header() ...
|
|
} else {
|
|
(fetch_response.body(), None) // ... while `BODY.PEEK[]` goes to body() - and includes header()
|
|
};
|
|
|
|
if is_deleted {
|
|
info!(context, "Not processing deleted msg {}.", request_uid);
|
|
received_msgs_channel.send((request_uid, None)).await?;
|
|
continue;
|
|
}
|
|
|
|
let body = if let Some(body) = body {
|
|
body
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Not processing message {} without a BODY.", request_uid
|
|
);
|
|
received_msgs_channel.send((request_uid, None)).await?;
|
|
continue;
|
|
};
|
|
|
|
let is_seen = fetch_response.flags().any(|flag| flag == Flag::Seen);
|
|
|
|
let Some(rfc724_mid) = uid_message_ids.get(&request_uid) else {
|
|
error!(
|
|
context,
|
|
"No Message-ID corresponding to UID {} passed in uid_messsage_ids.",
|
|
request_uid
|
|
);
|
|
continue;
|
|
};
|
|
|
|
info!(
|
|
context,
|
|
"Passing message UID {} to receive_imf().", request_uid
|
|
);
|
|
let res = receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await;
|
|
let received_msg = match res {
|
|
Err(err) => {
|
|
warn!(context, "receive_imf error: {err:#}.");
|
|
|
|
let text = format!(
|
|
"❌ Failed to receive a message: {err:#}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
|
|
);
|
|
let mut msg = Message::new_text(text);
|
|
add_device_msg(context, None, Some(&mut msg)).await?;
|
|
None
|
|
}
|
|
Ok(msg) => msg,
|
|
};
|
|
received_msgs_channel
|
|
.send((request_uid, received_msg))
|
|
.await?;
|
|
}
|
|
|
|
// If we don't process the whole response, IMAP client is left in a broken state where
|
|
// it will try to process the rest of response as the next response.
|
|
//
|
|
// Make sure to not ignore the errors, because
|
|
// if connection times out, it will return
|
|
// infinite stream of `Some(Err(_))` results.
|
|
while fetch_responses
|
|
.try_next()
|
|
.await
|
|
.context("Failed to drain FETCH responses")?
|
|
.is_some()
|
|
{}
|
|
|
|
if count != request_uids.len() {
|
|
warn!(
|
|
context,
|
|
"Failed to fetch all UIDs: got {}, requested {}, we requested the UIDs {:?}.",
|
|
count,
|
|
request_uids.len(),
|
|
request_uids,
|
|
);
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Successfully received {} UIDs.",
|
|
request_uids.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Retrieves server metadata if it is supported.
|
|
///
|
|
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
|
|
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
|
|
/// metadata.
|
|
pub(crate) async fn fetch_metadata(&mut self, context: &Context) -> Result<()> {
|
|
if !self.can_metadata() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut lock = context.metadata.write().await;
|
|
if let Some(ref mut old_metadata) = *lock {
|
|
let now = time();
|
|
|
|
// Refresh TURN server credentials if they expire in 12 hours.
|
|
if now + 3600 * 12 < old_metadata.ice_servers_expiration_timestamp {
|
|
return Ok(());
|
|
}
|
|
|
|
info!(context, "ICE servers expired, requesting new credentials.");
|
|
let mailbox = "";
|
|
let options = "";
|
|
let metadata = self
|
|
.get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)")
|
|
.await?;
|
|
let mut got_turn_server = false;
|
|
for m in metadata {
|
|
if m.entry == "/shared/vendor/deltachat/turn"
|
|
&& let Some(value) = m.value
|
|
{
|
|
match create_ice_servers_from_metadata(context, &value).await {
|
|
Ok((parsed_timestamp, parsed_ice_servers)) => {
|
|
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
|
|
old_metadata.ice_servers = parsed_ice_servers;
|
|
got_turn_server = false;
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !got_turn_server {
|
|
// Set expiration timestamp 7 days in the future so we don't request it again.
|
|
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
|
|
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
info!(
|
|
context,
|
|
"Server supports metadata, retrieving server comment and admin contact."
|
|
);
|
|
|
|
let mut comment = None;
|
|
let mut admin = None;
|
|
let mut iroh_relay = None;
|
|
let mut ice_servers = None;
|
|
let mut ice_servers_expiration_timestamp = 0;
|
|
|
|
let mailbox = "";
|
|
let options = "";
|
|
let metadata = self
|
|
.get_metadata(
|
|
mailbox,
|
|
options,
|
|
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn)",
|
|
)
|
|
.await?;
|
|
for m in metadata {
|
|
match m.entry.as_ref() {
|
|
"/shared/comment" => {
|
|
comment = m.value;
|
|
}
|
|
"/shared/admin" => {
|
|
admin = m.value;
|
|
}
|
|
"/shared/vendor/deltachat/irohrelay" => {
|
|
if let Some(value) = m.value {
|
|
if let Ok(url) = Url::parse(&value) {
|
|
iroh_relay = Some(url);
|
|
} else {
|
|
warn!(
|
|
context,
|
|
"Got invalid URL from iroh relay metadata: {:?}.", value
|
|
);
|
|
}
|
|
}
|
|
}
|
|
"/shared/vendor/deltachat/turn" => {
|
|
if let Some(value) = m.value {
|
|
match create_ice_servers_from_metadata(context, &value).await {
|
|
Ok((parsed_timestamp, parsed_ice_servers)) => {
|
|
ice_servers_expiration_timestamp = parsed_timestamp;
|
|
ice_servers = Some(parsed_ice_servers);
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let ice_servers = if let Some(ice_servers) = ice_servers {
|
|
ice_servers
|
|
} else {
|
|
// Set expiration timestamp 7 days in the future so we don't request it again.
|
|
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
|
|
create_fallback_ice_servers(context).await?
|
|
};
|
|
|
|
*lock = Some(ServerMetadata {
|
|
comment,
|
|
admin,
|
|
iroh_relay,
|
|
ice_servers,
|
|
ice_servers_expiration_timestamp,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Stores device token into /private/devicetoken IMAP METADATA of the Inbox.
|
|
pub(crate) async fn register_token(&mut self, context: &Context) -> Result<()> {
|
|
if context.push_subscribed.load(Ordering::Relaxed) {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(device_token) = context.push_subscriber.device_token().await else {
|
|
return Ok(());
|
|
};
|
|
|
|
if self.can_metadata() && self.can_push() {
|
|
let old_encrypted_device_token =
|
|
context.get_config(Config::EncryptedDeviceToken).await?;
|
|
|
|
// Whether we need to update encrypted device token.
|
|
let device_token_changed = old_encrypted_device_token.is_none()
|
|
|| context.get_config(Config::DeviceToken).await?.as_ref() != Some(&device_token);
|
|
|
|
let new_encrypted_device_token;
|
|
if device_token_changed {
|
|
let encrypted_device_token = encrypt_device_token(&device_token)
|
|
.context("Failed to encrypt device token")?;
|
|
|
|
// We expect that the server supporting `XDELTAPUSH` capability
|
|
// has non-synchronizing literals support as well:
|
|
// <https://www.rfc-editor.org/rfc/rfc7888>.
|
|
let encrypted_device_token_len = encrypted_device_token.len();
|
|
|
|
// Store device token saved on the server
|
|
// to prevent storing duplicate tokens.
|
|
// The server cannot deduplicate on its own
|
|
// because encryption gives a different
|
|
// result each time.
|
|
context
|
|
.set_config_internal(Config::DeviceToken, Some(&device_token))
|
|
.await?;
|
|
context
|
|
.set_config_internal(
|
|
Config::EncryptedDeviceToken,
|
|
Some(&encrypted_device_token),
|
|
)
|
|
.await?;
|
|
|
|
if encrypted_device_token_len <= 4096 {
|
|
new_encrypted_device_token = Some(encrypted_device_token);
|
|
} else {
|
|
// If Apple or Google (FCM) gives us a very large token,
|
|
// do not even try to give it to IMAP servers.
|
|
//
|
|
// Limit of 4096 is arbitrarily selected
|
|
// to be the same as required by LITERAL- IMAP extension.
|
|
//
|
|
// Dovecot supports LITERAL+ and non-synchronizing literals
|
|
// of any length, but there is no reason for tokens
|
|
// to be that large even after OpenPGP encryption.
|
|
warn!(context, "Device token is too long for LITERAL-, ignoring.");
|
|
new_encrypted_device_token = None;
|
|
}
|
|
} else {
|
|
new_encrypted_device_token = old_encrypted_device_token;
|
|
}
|
|
|
|
// Store new encrypted device token on the server
|
|
// even if it is the same as the old one.
|
|
if let Some(encrypted_device_token) = new_encrypted_device_token {
|
|
let folder = context
|
|
.get_config(Config::ConfiguredInboxFolder)
|
|
.await?
|
|
.context("INBOX is not configured")?;
|
|
|
|
self.run_command_and_check_ok(&format_setmetadata(
|
|
&folder,
|
|
&encrypted_device_token,
|
|
))
|
|
.await
|
|
.context("SETMETADATA command failed")?;
|
|
|
|
context.push_subscribed.store(true, Ordering::Relaxed);
|
|
}
|
|
} else if !context.push_subscriber.heartbeat_subscribed().await {
|
|
let context = context.clone();
|
|
// Subscribe for heartbeat notifications.
|
|
tokio::spawn(async move { context.push_subscriber.subscribe(&context).await });
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn format_setmetadata(folder: &str, device_token: &str) -> String {
|
|
let device_token_len = device_token.len();
|
|
format!(
|
|
"SETMETADATA \"{folder}\" (/private/devicetoken {{{device_token_len}+}}\r\n{device_token})"
|
|
)
|
|
}
|
|
|
|
impl Session {
|
|
/// Returns success if we successfully set the flag or we otherwise
|
|
/// think add_flag should not be retried: Disconnection during setting
|
|
/// the flag, or other imap-errors, returns Ok as well.
|
|
///
|
|
/// Returning error means that the operation can be retried.
|
|
async fn add_flag_finalized_with_set(&mut self, uid_set: &str, flag: &str) -> Result<()> {
|
|
if flag == "\\Deleted" {
|
|
self.selected_folder_needs_expunge = true;
|
|
}
|
|
let query = format!("+FLAGS ({flag})");
|
|
let mut responses = self
|
|
.uid_store(uid_set, &query)
|
|
.await
|
|
.with_context(|| format!("IMAP failed to store: ({uid_set}, {query})"))?;
|
|
while let Some(_response) = responses.try_next().await? {
|
|
// Read all the responses
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Attempts to configure mvbox.
|
|
///
|
|
/// Tries to find any folder examining `folders` in the order they go. If none is found, tries
|
|
/// to create any folder in the same order. This method does not use LIST command to ensure that
|
|
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
|
|
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
|
|
///
|
|
/// Returns first found or created folder name.
|
|
async fn configure_mvbox<'a>(
|
|
&mut self,
|
|
context: &Context,
|
|
folders: &[&'a str],
|
|
create_mvbox: bool,
|
|
) -> Result<Option<&'a str>> {
|
|
// Close currently selected folder if needed.
|
|
// We are going to select folders using low-level EXAMINE operations below.
|
|
self.maybe_close_folder(context).await?;
|
|
|
|
for folder in folders {
|
|
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);
|
|
let res = self.examine(&folder).await;
|
|
if res.is_ok() {
|
|
info!(
|
|
context,
|
|
"MVBOX-folder {:?} successfully selected, using it.", &folder
|
|
);
|
|
self.close().await?;
|
|
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
|
|
// emails moved before that wouldn't be fetched but considered "old" instead.
|
|
let create = false;
|
|
let folder_exists = self
|
|
.select_with_uidvalidity(context, folder, create)
|
|
.await?;
|
|
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
|
|
return Ok(Some(folder));
|
|
}
|
|
}
|
|
|
|
if !create_mvbox {
|
|
return Ok(None);
|
|
}
|
|
// Some servers require namespace-style folder names like "INBOX.DeltaChat", so we try all
|
|
// the variants here.
|
|
for folder in folders {
|
|
match self
|
|
.select_with_uidvalidity(context, folder, create_mvbox)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
info!(context, "MVBOX-folder {} created.", folder);
|
|
return Ok(Some(folder));
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "Cannot create MVBOX-folder {:?}: {}", folder, err);
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
impl Imap {
|
|
pub(crate) async fn configure_folders(
|
|
&mut self,
|
|
context: &Context,
|
|
session: &mut Session,
|
|
create_mvbox: bool,
|
|
) -> Result<()> {
|
|
let mut folders = session
|
|
.list(Some(""), Some("*"))
|
|
.await
|
|
.context("list_folders failed")?;
|
|
let mut delimiter = ".".to_string();
|
|
let mut delimiter_is_default = true;
|
|
let mut folder_configs = BTreeMap::new();
|
|
|
|
while let Some(folder) = folders.try_next().await? {
|
|
info!(context, "Scanning folder: {:?}", folder);
|
|
|
|
// Update the delimiter iff there is a different one, but only once.
|
|
if let Some(d) = folder.delimiter()
|
|
&& delimiter_is_default
|
|
&& !d.is_empty()
|
|
&& delimiter != d
|
|
{
|
|
delimiter = d.to_string();
|
|
delimiter_is_default = false;
|
|
}
|
|
|
|
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
|
|
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
|
|
if let Some(config) = folder_meaning.to_config() {
|
|
// Always takes precedence
|
|
folder_configs.insert(config, folder.name().to_string());
|
|
} else if let Some(config) = folder_name_meaning.to_config() {
|
|
// only set if none has been already set
|
|
folder_configs
|
|
.entry(config)
|
|
.or_insert_with(|| folder.name().to_string());
|
|
}
|
|
}
|
|
drop(folders);
|
|
|
|
info!(context, "Using \"{}\" as folder-delimiter.", delimiter);
|
|
|
|
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
|
|
let mvbox_folder = session
|
|
.configure_mvbox(context, &["DeltaChat", &fallback_folder], create_mvbox)
|
|
.await
|
|
.context("failed to configure mvbox")?;
|
|
|
|
context
|
|
.set_config_internal(Config::ConfiguredInboxFolder, Some("INBOX"))
|
|
.await?;
|
|
if let Some(mvbox_folder) = mvbox_folder {
|
|
info!(context, "Setting MVBOX FOLDER TO {}", &mvbox_folder);
|
|
context
|
|
.set_config_internal(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
|
|
.await?;
|
|
}
|
|
for (config, name) in folder_configs {
|
|
context.set_config_internal(config, Some(&name)).await?;
|
|
}
|
|
context
|
|
.sql
|
|
.set_raw_config_int(
|
|
constants::DC_FOLDERS_CONFIGURED_KEY,
|
|
constants::DC_FOLDERS_CONFIGURED_VERSION,
|
|
)
|
|
.await?;
|
|
|
|
info!(context, "FINISHED configuring IMAP-folders.");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
/// Return whether the server sent an unsolicited EXISTS or FETCH response.
|
|
///
|
|
/// Drains all responses from `session.unsolicited_responses` in the process.
|
|
///
|
|
/// If this returns `true`, this means that new emails arrived
|
|
/// or flags have been changed.
|
|
/// In this case we may want to skip next IDLE and do a round
|
|
/// of fetching new messages and synchronizing seen flags.
|
|
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
|
|
use UnsolicitedResponse::*;
|
|
use async_imap::imap_proto::Response;
|
|
use async_imap::imap_proto::ResponseCode;
|
|
|
|
let folder = self.selected_folder.as_deref().unwrap_or_default();
|
|
let mut should_refetch = false;
|
|
while let Ok(response) = self.unsolicited_responses.try_recv() {
|
|
match response {
|
|
Exists(_) => {
|
|
info!(
|
|
context,
|
|
"Need to refetch {folder:?}, got unsolicited EXISTS {response:?}"
|
|
);
|
|
should_refetch = true;
|
|
}
|
|
|
|
Expunge(_) | Recent(_) => {}
|
|
Other(ref response_data) => {
|
|
match response_data.parsed() {
|
|
Response::Fetch { .. } => {
|
|
info!(
|
|
context,
|
|
"Need to refetch {folder:?}, got unsolicited FETCH {response:?}"
|
|
);
|
|
should_refetch = true;
|
|
}
|
|
|
|
// We are not interested in the following responses and they are are
|
|
// sent quite frequently, so, we ignore them without logging them.
|
|
Response::Done {
|
|
code: Some(ResponseCode::CopyUid(_, _, _)),
|
|
..
|
|
} => {}
|
|
|
|
_ => {
|
|
info!(context, "{folder:?}: got unsolicited response {response:?}")
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
info!(context, "{folder:?}: got unsolicited response {response:?}")
|
|
}
|
|
}
|
|
}
|
|
Ok(should_refetch)
|
|
}
|
|
}
|
|
|
|
async fn should_move_out_of_spam(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<bool> {
|
|
if headers.get_header_value(HeaderDef::ChatVersion).is_some() {
|
|
// If this is a chat message (i.e. has a ChatVersion header), then this might be
|
|
// a securejoin message. We can't find out at this point as we didn't prefetch
|
|
// the SecureJoin header. So, we always move chat messages out of Spam.
|
|
// Two possibilities to change this would be:
|
|
// 1. Remove the `&& !context.is_spam_folder(folder).await?` check from
|
|
// `fetch_new_messages()`, and then let `receive_imf()` check
|
|
// if it's a spam message and should be hidden.
|
|
// 2. Or add a flag to the ChatVersion header that this is a securejoin
|
|
// request, and return `true` here only if the message has this flag.
|
|
// `receive_imf()` can then check if the securejoin request is valid.
|
|
return Ok(true);
|
|
}
|
|
|
|
if let Some(msg) = get_prefetch_parent_message(context, headers).await? {
|
|
if msg.chat_blocked != Blocked::Not {
|
|
// Blocked or contact request message in the spam folder, leave it there.
|
|
return Ok(false);
|
|
}
|
|
} else {
|
|
let from = match mimeparser::get_from(headers) {
|
|
Some(f) => f,
|
|
None => return Ok(false),
|
|
};
|
|
// No chat found.
|
|
let (from_id, blocked_contact, _origin) =
|
|
match from_field_to_contact_id(context, &from, None, true, true)
|
|
.await
|
|
.context("from_field_to_contact_id")?
|
|
{
|
|
Some(res) => res,
|
|
None => {
|
|
warn!(
|
|
context,
|
|
"Contact with From address {:?} cannot exist, not moving out of spam", from
|
|
);
|
|
return Ok(false);
|
|
}
|
|
};
|
|
if blocked_contact {
|
|
// Contact is blocked, leave the message in spam.
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(chat_id_blocked) = ChatIdBlocked::lookup_by_contact(context, from_id).await? {
|
|
if chat_id_blocked.blocked != Blocked::Not {
|
|
return Ok(false);
|
|
}
|
|
} else if from_id != ContactId::SELF {
|
|
// No chat with this contact found.
|
|
return Ok(false);
|
|
}
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
/// Returns target folder for a message found in the Spam folder.
|
|
/// If this returns None, the message will not be moved out of the
|
|
/// Spam folder, and as `fetch_new_messages()` doesn't download
|
|
/// messages from the Spam folder, the message will be ignored.
|
|
async fn spam_target_folder_cfg(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<Option<Config>> {
|
|
if !should_move_out_of_spam(context, headers).await? {
|
|
return Ok(None);
|
|
}
|
|
|
|
if needs_move_to_mvbox(context, headers).await?
|
|
// If OnlyFetchMvbox is set, we don't want to move the message to
|
|
// the inbox where we wouldn't fetch it again:
|
|
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
|
|
{
|
|
Ok(Some(Config::ConfiguredMvboxFolder))
|
|
} else {
|
|
Ok(Some(Config::ConfiguredInboxFolder))
|
|
}
|
|
}
|
|
|
|
/// Returns `ConfiguredInboxFolder` or `ConfiguredMvboxFolder` if
|
|
/// the message needs to be moved from `folder`. Otherwise returns `None`.
|
|
pub async fn target_folder_cfg(
|
|
context: &Context,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<Option<Config>> {
|
|
if context.is_mvbox(folder).await? {
|
|
return Ok(None);
|
|
}
|
|
|
|
if folder_meaning == FolderMeaning::Spam {
|
|
spam_target_folder_cfg(context, headers).await
|
|
} else if folder_meaning == FolderMeaning::Inbox
|
|
&& needs_move_to_mvbox(context, headers).await?
|
|
{
|
|
Ok(Some(Config::ConfiguredMvboxFolder))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
pub async fn target_folder(
|
|
context: &Context,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<String> {
|
|
match target_folder_cfg(context, folder, folder_meaning, headers).await? {
|
|
Some(config) => match context.get_config(config).await? {
|
|
Some(target) => Ok(target),
|
|
None => Ok(folder.to_string()),
|
|
},
|
|
None => Ok(folder.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn needs_move_to_mvbox(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<bool> {
|
|
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
|
|
if !context.get_config_bool(Config::IsChatmail).await?
|
|
&& has_chat_version
|
|
&& headers
|
|
.get_header_value(HeaderDef::AutoSubmitted)
|
|
.filter(|val| val.eq_ignore_ascii_case("auto-generated"))
|
|
.is_some()
|
|
&& let Some(from) = mimeparser::get_from(headers)
|
|
&& context.is_self_addr(&from.addr).await?
|
|
{
|
|
return Ok(true);
|
|
}
|
|
if !context.get_config_bool(Config::MvboxMove).await? {
|
|
return Ok(false);
|
|
}
|
|
|
|
if headers
|
|
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
|
.is_some()
|
|
{
|
|
// do not move setup messages;
|
|
// there may be a non-delta device that wants to handle it
|
|
return Ok(false);
|
|
}
|
|
|
|
if has_chat_version {
|
|
Ok(true)
|
|
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
|
|
match parent.is_dc_message {
|
|
MessengerMessage::No => Ok(false),
|
|
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
|
|
// TODO: lots languages missing - maybe there is a list somewhere on other MUAs?
|
|
// however, if we fail to find out the sent-folder,
|
|
// only watching this folder is not working. at least, this is no show stopper.
|
|
// CAVE: if possible, take care not to add a name here that is "sent" in one language
|
|
// but sth. different in others - a hard job.
|
|
fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
|
|
// source: <https://stackoverflow.com/questions/2185391/localized-gmail-imap-folders>
|
|
const SPAM_NAMES: &[&str] = &[
|
|
"spam",
|
|
"junk",
|
|
"Correio electrónico não solicitado",
|
|
"Correo basura",
|
|
"Lixo",
|
|
"Nettsøppel",
|
|
"Nevyžádaná pošta",
|
|
"No solicitado",
|
|
"Ongewenst",
|
|
"Posta indesiderata",
|
|
"Skräp",
|
|
"Wiadomości-śmieci",
|
|
"Önemsiz",
|
|
"Ανεπιθύμητα",
|
|
"Спам",
|
|
"垃圾邮件",
|
|
"垃圾郵件",
|
|
"迷惑メール",
|
|
"스팸",
|
|
];
|
|
const TRASH_NAMES: &[&str] = &[
|
|
"Trash",
|
|
"Bin",
|
|
"Caixote do lixo",
|
|
"Cestino",
|
|
"Corbeille",
|
|
"Papelera",
|
|
"Papierkorb",
|
|
"Papirkurv",
|
|
"Papperskorgen",
|
|
"Prullenbak",
|
|
"Rubujo",
|
|
"Κάδος απορριμμάτων",
|
|
"Корзина",
|
|
"Кошик",
|
|
"ゴミ箱",
|
|
"垃圾桶",
|
|
"已删除邮件",
|
|
"휴지통",
|
|
];
|
|
let lower = folder_name.to_lowercase();
|
|
|
|
if lower == "inbox" {
|
|
FolderMeaning::Inbox
|
|
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
|
|
FolderMeaning::Spam
|
|
} else if TRASH_NAMES.iter().any(|s| s.to_lowercase() == lower) {
|
|
FolderMeaning::Trash
|
|
} else {
|
|
FolderMeaning::Unknown
|
|
}
|
|
}
|
|
|
|
fn get_folder_meaning_by_attrs(folder_attrs: &[NameAttribute]) -> FolderMeaning {
|
|
for attr in folder_attrs {
|
|
match attr {
|
|
NameAttribute::Trash => return FolderMeaning::Trash,
|
|
NameAttribute::Junk => return FolderMeaning::Spam,
|
|
NameAttribute::All | NameAttribute::Flagged => return FolderMeaning::Virtual,
|
|
NameAttribute::Extension(label) => {
|
|
match label.as_ref() {
|
|
"\\Spam" => return FolderMeaning::Spam,
|
|
"\\Important" => return FolderMeaning::Virtual,
|
|
_ => {}
|
|
};
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
FolderMeaning::Unknown
|
|
}
|
|
|
|
pub(crate) fn get_folder_meaning(folder: &Name) -> FolderMeaning {
|
|
match get_folder_meaning_by_attrs(folder.attributes()) {
|
|
FolderMeaning::Unknown => get_folder_meaning_by_name(folder.name()),
|
|
meaning => meaning,
|
|
}
|
|
}
|
|
|
|
/// Parses the headers from the FETCH result.
|
|
fn get_fetch_headers(prefetch_msg: &Fetch) -> Result<Vec<mailparse::MailHeader<'_>>> {
|
|
match prefetch_msg.header() {
|
|
Some(header_bytes) => {
|
|
let (headers, _) = mailparse::parse_headers(header_bytes)?;
|
|
Ok(headers)
|
|
}
|
|
None => Ok(Vec::new()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn prefetch_get_message_id(headers: &[mailparse::MailHeader]) -> Option<String> {
|
|
headers
|
|
.get_header_value(HeaderDef::XMicrosoftOriginalMessageId)
|
|
.or_else(|| headers.get_header_value(HeaderDef::MessageId))
|
|
.and_then(|msgid| mimeparser::parse_message_id(&msgid).ok())
|
|
}
|
|
|
|
pub(crate) fn create_message_id() -> String {
|
|
format!("{}{}", GENERATED_PREFIX, create_id())
|
|
}
|
|
|
|
/// Returns chat by prefetched headers.
|
|
async fn prefetch_get_chat(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<Option<chat::Chat>> {
|
|
let parent = get_prefetch_parent_message(context, headers).await?;
|
|
if let Some(parent) = &parent {
|
|
return Ok(Some(
|
|
chat::Chat::load_from_db(context, parent.get_chat_id()).await?,
|
|
));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Determines whether the message should be downloaded based on prefetched headers.
|
|
pub(crate) async fn prefetch_should_download(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
message_id: &str,
|
|
mut flags: impl Iterator<Item = Flag<'_>>,
|
|
) -> Result<bool> {
|
|
if message::rfc724_mid_exists(context, message_id)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
markseen_on_imap_table(context, message_id).await?;
|
|
return Ok(false);
|
|
}
|
|
|
|
// We do not know the Message-ID or the Message-ID is missing (in this case, we create one in
|
|
// the further process).
|
|
|
|
if let Some(chat) = prefetch_get_chat(context, headers).await?
|
|
&& chat.typ == Chattype::Group
|
|
&& !chat.id.is_special()
|
|
{
|
|
// This might be a group command, like removing a group member.
|
|
// We really need to fetch this to avoid inconsistent group state.
|
|
return Ok(true);
|
|
}
|
|
|
|
let maybe_ndn = if let Some(from) = headers.get_header_value(HeaderDef::From_) {
|
|
let from = from.to_ascii_lowercase();
|
|
from.contains("mailer-daemon") || from.contains("mail-daemon")
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Autocrypt Setup Message should be shown even if it is from non-chat client.
|
|
let is_autocrypt_setup_message = headers
|
|
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
|
.is_some();
|
|
|
|
let from = match mimeparser::get_from(headers) {
|
|
Some(f) => f,
|
|
None => return Ok(false),
|
|
};
|
|
let (_from_id, blocked_contact, origin) =
|
|
match from_field_to_contact_id(context, &from, None, true, true).await? {
|
|
Some(res) => res,
|
|
None => return Ok(false),
|
|
};
|
|
// prevent_rename=true as this might be a mailing list message and in this case it would be bad if we rename the contact.
|
|
// (prevent_rename is the last argument of from_field_to_contact_id())
|
|
|
|
if flags.any(|f| f == Flag::Draft) {
|
|
info!(context, "Ignoring draft message");
|
|
return Ok(false);
|
|
}
|
|
|
|
let is_chat_message = headers.get_header_value(HeaderDef::ChatVersion).is_some();
|
|
let accepted_contact = origin.is_known();
|
|
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
|
|
.await?
|
|
.map(|parent| match parent.is_dc_message {
|
|
MessengerMessage::No => false,
|
|
MessengerMessage::Yes | MessengerMessage::Reply => true,
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let show_emails =
|
|
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
|
|
|
|
let show = is_autocrypt_setup_message
|
|
|| match show_emails {
|
|
ShowEmails::Off => is_chat_message || is_reply_to_chat_message,
|
|
ShowEmails::AcceptedContacts => {
|
|
is_chat_message || is_reply_to_chat_message || accepted_contact
|
|
}
|
|
ShowEmails::All => true,
|
|
};
|
|
|
|
let should_download = (show && !blocked_contact) || maybe_ndn;
|
|
Ok(should_download)
|
|
}
|
|
|
|
/// Marks messages in `msgs` table as seen, searching for them by UID.
|
|
///
|
|
/// Returns updated chat ID if any message was marked as seen.
|
|
async fn mark_seen_by_uid(
|
|
context: &Context,
|
|
folder: &str,
|
|
uid_validity: u32,
|
|
uid: u32,
|
|
) -> Result<Option<ChatId>> {
|
|
if let Some((msg_id, chat_id)) = context
|
|
.sql
|
|
.query_row_optional(
|
|
"SELECT id, chat_id FROM msgs
|
|
WHERE id > 9 AND rfc724_mid IN (
|
|
SELECT rfc724_mid FROM imap
|
|
WHERE folder=?1
|
|
AND uidvalidity=?2
|
|
AND uid=?3
|
|
LIMIT 1
|
|
)",
|
|
(&folder, uid_validity, uid),
|
|
|row| {
|
|
let msg_id: MsgId = row.get(0)?;
|
|
let chat_id: ChatId = row.get(1)?;
|
|
Ok((msg_id, chat_id))
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("failed to get msg and chat ID for IMAP message {folder}/{uid}"))?
|
|
{
|
|
let updated = context
|
|
.sql
|
|
.execute(
|
|
"UPDATE msgs SET state=?1
|
|
WHERE (state=?2 OR state=?3)
|
|
AND id=?4",
|
|
(
|
|
MessageState::InSeen,
|
|
MessageState::InFresh,
|
|
MessageState::InNoticed,
|
|
msg_id,
|
|
),
|
|
)
|
|
.await
|
|
.with_context(|| format!("failed to update msg {msg_id} state"))?
|
|
> 0;
|
|
|
|
if updated {
|
|
msg_id
|
|
.start_ephemeral_timer(context)
|
|
.await
|
|
.with_context(|| format!("failed to start ephemeral timer for message {msg_id}"))?;
|
|
Ok(Some(chat_id))
|
|
} else {
|
|
// Message state has not changed.
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
// There is no message is `msgs` table matching the given UID.
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Schedule marking the message as Seen on IMAP by adding all known IMAP messages corresponding to
|
|
/// the given Message-ID to `imap_markseen` table.
|
|
pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT OR IGNORE INTO imap_markseen (id)
|
|
SELECT id FROM imap WHERE rfc724_mid=?",
|
|
(message_id,),
|
|
)
|
|
.await?;
|
|
context.scheduler.interrupt_inbox().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// uid_next is the next unique identifier value from the last time we fetched a folder
|
|
/// See <https://tools.ietf.org/html/rfc3501#section-2.3.1.1>
|
|
/// This function is used to update our uid_next after fetching messages.
|
|
pub(crate) async fn set_uid_next(
|
|
context: &Context,
|
|
transport_id: u32,
|
|
folder: &str,
|
|
uid_next: u32,
|
|
) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (transport_id, folder, uid_next) VALUES (?, ?,?)
|
|
ON CONFLICT(transport_id, folder) DO UPDATE SET uid_next=excluded.uid_next",
|
|
(transport_id, folder, uid_next),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// uid_next is the next unique identifier value from the last time we fetched a folder
|
|
/// See <https://tools.ietf.org/html/rfc3501#section-2.3.1.1>
|
|
/// This method returns the uid_next from the last time we fetched messages.
|
|
/// We can compare this to the current uid_next to find out whether there are new messages
|
|
/// and fetch from this value on to get all new messages.
|
|
async fn get_uid_next(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT uid_next FROM imap_sync WHERE transport_id=? AND folder=?",
|
|
(transport_id, folder),
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
pub(crate) async fn set_uidvalidity(
|
|
context: &Context,
|
|
transport_id: u32,
|
|
folder: &str,
|
|
uidvalidity: u32,
|
|
) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (transport_id, folder, uidvalidity) VALUES (?,?,?)
|
|
ON CONFLICT(transport_id, folder) DO UPDATE SET uidvalidity=excluded.uidvalidity",
|
|
(transport_id, folder, uidvalidity),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_uidvalidity(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT uidvalidity FROM imap_sync WHERE transport_id=? AND folder=?",
|
|
(transport_id, folder),
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
pub(crate) async fn set_modseq(
|
|
context: &Context,
|
|
transport_id: u32,
|
|
folder: &str,
|
|
modseq: u64,
|
|
) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (transport_id, folder, modseq) VALUES (?,?,?)
|
|
ON CONFLICT(transport_id, folder) DO UPDATE SET modseq=excluded.modseq",
|
|
(transport_id, folder, modseq),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Result<u64> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT modseq FROM imap_sync WHERE transport_id=? AND folder=?",
|
|
(transport_id, folder),
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
/// Compute the imap search expression for all self-sent mails (for all self addresses)
|
|
pub(crate) async fn get_imap_self_sent_search_command(context: &Context) -> Result<String> {
|
|
// See https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4 for syntax of SEARCH and OR
|
|
let mut search_command = format!("FROM \"{}\"", context.get_primary_self_addr().await?);
|
|
|
|
for item in context.get_secondary_self_addrs().await? {
|
|
search_command = format!("OR ({search_command}) (FROM \"{item}\")");
|
|
}
|
|
|
|
Ok(search_command)
|
|
}
|
|
|
|
/// Whether to ignore fetching messages from a folder.
|
|
///
|
|
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
|
|
/// not explicitly watched should not be fetched.
|
|
async fn should_ignore_folder(
|
|
context: &Context,
|
|
folder: &str,
|
|
folder_meaning: FolderMeaning,
|
|
) -> Result<bool> {
|
|
if !context.get_config_bool(Config::OnlyFetchMvbox).await? {
|
|
return Ok(false);
|
|
}
|
|
Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam))
|
|
}
|
|
|
|
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
|
|
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
|
|
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
|
|
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
|
|
// first, try to find consecutive ranges:
|
|
let mut ranges: Vec<UidRange> = vec![];
|
|
|
|
for ¤t in uids {
|
|
if let Some(last) = ranges.last_mut()
|
|
&& last.end + 1 == current
|
|
{
|
|
last.end = current;
|
|
continue;
|
|
}
|
|
|
|
ranges.push(UidRange {
|
|
start: current,
|
|
end: current,
|
|
});
|
|
}
|
|
|
|
// Second, sort the uids into uid sets that are each below ~1000 characters
|
|
let mut result = vec![];
|
|
let (mut last_uids, mut last_str) = (Vec::new(), String::new());
|
|
for range in ranges {
|
|
last_uids.reserve((range.end - range.start + 1).try_into()?);
|
|
(range.start..=range.end).for_each(|u| last_uids.push(u));
|
|
if !last_str.is_empty() {
|
|
last_str.push(',');
|
|
}
|
|
last_str.push_str(&range.to_string());
|
|
|
|
if last_str.len() > 990 {
|
|
result.push((take(&mut last_uids), take(&mut last_str)));
|
|
}
|
|
}
|
|
result.push((last_uids, last_str));
|
|
|
|
result.retain(|(_, s)| !s.is_empty());
|
|
Ok(result)
|
|
}
|
|
|
|
struct UidRange {
|
|
start: u32,
|
|
end: u32,
|
|
// If start == end, then this range represents a single number
|
|
}
|
|
|
|
impl std::fmt::Display for UidRange {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
if self.start == self.end {
|
|
write!(f, "{}", self.start)
|
|
} else {
|
|
write!(f, "{}:{}", self.start, self.end)
|
|
}
|
|
}
|
|
}
|
|
async fn add_all_recipients_as_contacts(
|
|
context: &Context,
|
|
session: &mut Session,
|
|
folder: Config,
|
|
) -> Result<()> {
|
|
let mailbox = if let Some(m) = context.get_config(folder).await? {
|
|
m
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Folder {} is not configured, skipping fetching contacts from it.", folder
|
|
);
|
|
return Ok(());
|
|
};
|
|
let create = false;
|
|
let folder_exists = session
|
|
.select_with_uidvalidity(context, &mailbox, create)
|
|
.await
|
|
.with_context(|| format!("could not select {mailbox}"))?;
|
|
if !folder_exists {
|
|
return Ok(());
|
|
}
|
|
|
|
let recipients = session
|
|
.get_all_recipients(context)
|
|
.await
|
|
.context("could not get recipients")?;
|
|
|
|
let mut any_modified = false;
|
|
for recipient in recipients {
|
|
let recipient_addr = match ContactAddress::new(&recipient.addr) {
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Could not add contact for recipient with address {:?}: {:#}",
|
|
recipient.addr,
|
|
err
|
|
);
|
|
continue;
|
|
}
|
|
Ok(recipient_addr) => recipient_addr,
|
|
};
|
|
|
|
let (_, modified) = Contact::add_or_lookup(
|
|
context,
|
|
&recipient.display_name.unwrap_or_default(),
|
|
&recipient_addr,
|
|
Origin::OutgoingTo,
|
|
)
|
|
.await?;
|
|
if modified != Modifier::None {
|
|
any_modified = true;
|
|
}
|
|
}
|
|
if any_modified {
|
|
context.emit_event(EventType::ContactsChanged(None));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod imap_tests;
|