mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 13:36:30 +03:00
2506 lines
88 KiB
Rust
2506 lines
88 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,
|
|
cmp::max,
|
|
collections::{BTreeMap, BTreeSet},
|
|
};
|
|
|
|
use anyhow::{anyhow, bail, format_err, Context as _, Result};
|
|
use async_imap::types::{
|
|
Fetch, Flag, Mailbox, Name, NameAttribute, Quota, QuotaRoot, UnsolicitedResponse,
|
|
};
|
|
use async_std::channel::Receiver;
|
|
use async_std::prelude::*;
|
|
use num_traits::FromPrimitive;
|
|
|
|
use crate::chat;
|
|
use crate::chat::ChatId;
|
|
use crate::chat::ChatIdBlocked;
|
|
use crate::constants::{
|
|
Blocked, Chattype, ShowEmails, Viewtype, DC_CONTACT_ID_SELF, DC_FETCH_EXISTING_MSGS_COUNT,
|
|
DC_FOLDERS_CONFIGURED_VERSION, DC_LP_AUTH_OAUTH2,
|
|
};
|
|
use crate::context::Context;
|
|
use crate::dc_receive_imf::{
|
|
dc_receive_imf_inner, from_field_to_contact_id, get_prefetch_parent_message, ReceivedMsg,
|
|
};
|
|
use crate::events::EventType;
|
|
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
|
use crate::job::{self, Action};
|
|
use crate::login_param::{CertificateChecks, LoginParam, ServerLoginParam};
|
|
use crate::login_param::{ServerAddress, Socks5Config};
|
|
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
|
|
use crate::mimeparser;
|
|
use crate::oauth2::dc_get_oauth2_access_token;
|
|
use crate::param::Params;
|
|
use crate::provider::Socket;
|
|
use crate::scheduler::InterruptInfo;
|
|
use crate::stock_str;
|
|
use crate::{config::Config, scheduler::connectivity::ConnectivityStore};
|
|
|
|
mod client;
|
|
mod idle;
|
|
pub mod scan_folders;
|
|
pub mod select_folder;
|
|
mod session;
|
|
|
|
use client::Client;
|
|
use mailparse::SingleInfo;
|
|
use session::Session;
|
|
|
|
use self::select_folder::NewlySelected;
|
|
|
|
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ImapActionResult {
|
|
Failed,
|
|
RetryLater,
|
|
Success,
|
|
}
|
|
|
|
/// Prefetch:
|
|
/// - Message-ID to check if we already have the message.
|
|
/// - In-Reply-To and References to check if message is a reply to chat message.
|
|
/// - Chat-Version to check if a message is a chat message
|
|
/// - Autocrypt-Setup-Message to check if a message is an autocrypt setup message,
|
|
/// not necessarily sent by Delta Chat.
|
|
const PREFETCH_FLAGS: &str = "(UID RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
|
|
MESSAGE-ID \
|
|
FROM \
|
|
IN-REPLY-TO REFERENCES \
|
|
CHAT-VERSION \
|
|
AUTOCRYPT-SETUP-MESSAGE\
|
|
)])";
|
|
const RFC724MID_UID: &str = "(UID BODY.PEEK[HEADER.FIELDS (\
|
|
MESSAGE-ID \
|
|
X-MICROSOFT-ORIGINAL-MESSAGE-ID\
|
|
)])";
|
|
const JUST_UID: &str = "(UID)";
|
|
const BODY_FULL: &str = "(FLAGS BODY.PEEK[])";
|
|
const BODY_PARTIAL: &str = "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])";
|
|
|
|
#[derive(Debug)]
|
|
pub struct Imap {
|
|
idle_interrupt: Receiver<InterruptInfo>,
|
|
config: ImapConfig,
|
|
session: Option<Session>,
|
|
should_reconnect: bool,
|
|
login_failed_once: bool,
|
|
|
|
/// True if CAPABILITY command was run successfully once and config.can_* contain correct
|
|
/// values.
|
|
capabilities_determined: bool,
|
|
|
|
pub(crate) connectivity: ConnectivityStore,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct OAuth2 {
|
|
user: String,
|
|
access_token: String,
|
|
}
|
|
|
|
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, PartialEq, Clone, Copy)]
|
|
enum FolderMeaning {
|
|
Unknown,
|
|
Spam,
|
|
Sent,
|
|
Drafts,
|
|
Other,
|
|
}
|
|
|
|
impl FolderMeaning {
|
|
fn to_config(self) -> Option<Config> {
|
|
match self {
|
|
FolderMeaning::Unknown => None,
|
|
FolderMeaning::Spam => Some(Config::ConfiguredSpamFolder),
|
|
FolderMeaning::Sent => Some(Config::ConfiguredSentboxFolder),
|
|
FolderMeaning::Drafts => None,
|
|
FolderMeaning::Other => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ImapConfig {
|
|
pub addr: String,
|
|
pub lp: ServerLoginParam,
|
|
pub socks5_config: Option<Socks5Config>,
|
|
pub strict_tls: bool,
|
|
pub oauth2: bool,
|
|
pub selected_folder: Option<String>,
|
|
pub selected_mailbox: Option<Mailbox>,
|
|
pub selected_folder_needs_expunge: bool,
|
|
|
|
pub can_idle: bool,
|
|
|
|
/// True if the server has MOVE capability as defined in
|
|
/// <https://tools.ietf.org/html/rfc6851>
|
|
pub can_move: bool,
|
|
|
|
/// True if the server has QUOTA capability as defined in
|
|
/// <https://tools.ietf.org/html/rfc2087>
|
|
pub can_check_quota: bool,
|
|
|
|
/// True if the server has CONDSTORE capability as defined in
|
|
/// <https://tools.ietf.org/html/rfc7162>
|
|
pub can_condstore: bool,
|
|
}
|
|
|
|
impl Imap {
|
|
/// Creates new disconnected IMAP client using the specific login parameters.
|
|
///
|
|
/// `addr` is used to renew token if OAuth2 authentication is used.
|
|
pub async fn new(
|
|
lp: &ServerLoginParam,
|
|
socks5_config: Option<Socks5Config>,
|
|
addr: &str,
|
|
oauth2: bool,
|
|
provider_strict_tls: bool,
|
|
idle_interrupt: Receiver<InterruptInfo>,
|
|
) -> Result<Self> {
|
|
if lp.server.is_empty() || lp.user.is_empty() || lp.password.is_empty() {
|
|
bail!("Incomplete IMAP connection parameters");
|
|
}
|
|
|
|
let strict_tls = match lp.certificate_checks {
|
|
CertificateChecks::Automatic => provider_strict_tls,
|
|
CertificateChecks::Strict => true,
|
|
CertificateChecks::AcceptInvalidCertificates
|
|
| CertificateChecks::AcceptInvalidCertificates2 => false,
|
|
};
|
|
let config = ImapConfig {
|
|
addr: addr.to_string(),
|
|
lp: lp.clone(),
|
|
socks5_config,
|
|
strict_tls,
|
|
oauth2,
|
|
selected_folder: None,
|
|
selected_mailbox: None,
|
|
selected_folder_needs_expunge: false,
|
|
can_idle: false,
|
|
can_move: false,
|
|
can_check_quota: false,
|
|
can_condstore: false,
|
|
};
|
|
|
|
let imap = Imap {
|
|
idle_interrupt,
|
|
config,
|
|
session: None,
|
|
should_reconnect: false,
|
|
login_failed_once: false,
|
|
connectivity: Default::default(),
|
|
capabilities_determined: false,
|
|
};
|
|
|
|
Ok(imap)
|
|
}
|
|
|
|
/// Creates new disconnected IMAP client using configured parameters.
|
|
pub async fn new_configured(
|
|
context: &Context,
|
|
idle_interrupt: Receiver<InterruptInfo>,
|
|
) -> Result<Self> {
|
|
if !context.is_configured().await? {
|
|
bail!("IMAP Connect without configured params");
|
|
}
|
|
|
|
let param = LoginParam::from_database(context, "configured_").await?;
|
|
// the trailing underscore is correct
|
|
|
|
let imap = Self::new(
|
|
¶m.imap,
|
|
param.socks5_config.clone(),
|
|
¶m.addr,
|
|
param.server_flags & DC_LP_AUTH_OAUTH2 != 0,
|
|
param
|
|
.provider
|
|
.map_or(param.socks5_config.is_some(), |provider| {
|
|
provider.strict_tls
|
|
}),
|
|
idle_interrupt,
|
|
)
|
|
.await?;
|
|
Ok(imap)
|
|
}
|
|
|
|
/// Connects or reconnects if needed.
|
|
///
|
|
/// It is safe to call this function if already connected, actions are performed only as needed.
|
|
///
|
|
/// 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 async fn connect(&mut self, context: &Context) -> Result<()> {
|
|
if self.config.lp.server.is_empty() {
|
|
bail!("IMAP operation attempted while it is torn down");
|
|
}
|
|
|
|
if self.should_reconnect() {
|
|
self.disconnect(context).await;
|
|
self.should_reconnect = false;
|
|
} else if self.session.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
self.connectivity.set_connecting(context).await;
|
|
|
|
let oauth2 = self.config.oauth2;
|
|
|
|
let connection_res: Result<Client> = if self.config.lp.security == Socket::Starttls
|
|
|| self.config.lp.security == Socket::Plain
|
|
{
|
|
let config = &mut self.config;
|
|
let imap_server: &str = config.lp.server.as_ref();
|
|
let imap_port = config.lp.port;
|
|
|
|
let connection = if let Some(socks5_config) = &config.socks5_config {
|
|
Client::connect_insecure_socks5(
|
|
&ServerAddress {
|
|
host: imap_server.to_string(),
|
|
port: imap_port,
|
|
},
|
|
socks5_config.clone(),
|
|
)
|
|
.await
|
|
} else {
|
|
Client::connect_insecure((imap_server, imap_port)).await
|
|
};
|
|
|
|
match connection {
|
|
Ok(client) => {
|
|
if config.lp.security == Socket::Starttls {
|
|
client.secure(imap_server, config.strict_tls).await
|
|
} else {
|
|
Ok(client)
|
|
}
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
} else {
|
|
let config = &self.config;
|
|
let imap_server: &str = config.lp.server.as_ref();
|
|
let imap_port = config.lp.port;
|
|
|
|
if let Some(socks5_config) = &config.socks5_config {
|
|
Client::connect_secure_socks5(
|
|
&ServerAddress {
|
|
host: imap_server.to_string(),
|
|
port: imap_port,
|
|
},
|
|
config.strict_tls,
|
|
socks5_config.clone(),
|
|
)
|
|
.await
|
|
} else {
|
|
Client::connect_secure((imap_server, imap_port), imap_server, config.strict_tls)
|
|
.await
|
|
}
|
|
};
|
|
|
|
let client = connection_res?;
|
|
let config = &self.config;
|
|
let imap_user: &str = config.lp.user.as_ref();
|
|
let imap_pw: &str = config.lp.password.as_ref();
|
|
|
|
let login_res = if oauth2 {
|
|
let addr: &str = config.addr.as_ref();
|
|
|
|
let token = dc_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 {
|
|
client.login(imap_user, imap_pw).await
|
|
};
|
|
|
|
self.should_reconnect = false;
|
|
|
|
match login_res {
|
|
Ok(session) => {
|
|
// needs to be set here to ensure it is set on reconnects.
|
|
self.session = Some(session);
|
|
self.login_failed_once = false;
|
|
context.emit_event(EventType::ImapConnected(format!(
|
|
"IMAP-LOGIN as {}",
|
|
self.config.lp.user
|
|
)));
|
|
Ok(())
|
|
}
|
|
|
|
Err(err) => {
|
|
let imap_user = self.config.lp.user.to_owned();
|
|
let message = stock_str::cannot_login(context, &imap_user).await;
|
|
|
|
warn!(context, "{} ({})", message, err);
|
|
|
|
let lock = context.wrong_pw_warning_mutex.lock().await;
|
|
if self.login_failed_once
|
|
&& err.to_string().to_lowercase().contains("authentication")
|
|
&& context.get_config_bool(Config::NotifyAboutWrongPw).await?
|
|
{
|
|
if let Err(e) = context.set_config(Config::NotifyAboutWrongPw, None).await {
|
|
warn!(context, "{}", e);
|
|
}
|
|
drop(lock);
|
|
|
|
let mut msg = Message::new(Viewtype::Text);
|
|
msg.text = Some(message.clone());
|
|
if let Err(e) =
|
|
chat::add_device_msg_with_importance(context, None, Some(&mut msg), true)
|
|
.await
|
|
{
|
|
warn!(context, "{}", e);
|
|
}
|
|
} else {
|
|
self.login_failed_once = true;
|
|
}
|
|
|
|
self.trigger_reconnect(context).await;
|
|
Err(format_err!("{}\n\n{}", message, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Determine server capabilities if not done yet.
|
|
async fn determine_capabilities(&mut self) -> Result<()> {
|
|
if self.capabilities_determined {
|
|
return Ok(());
|
|
}
|
|
|
|
let session = self.session.as_mut().context(
|
|
"Can't determine server capabilities because connection was not established",
|
|
)?;
|
|
let caps = session
|
|
.capabilities()
|
|
.await
|
|
.context("CAPABILITY command error")?;
|
|
self.config.can_idle = caps.has_str("IDLE");
|
|
self.config.can_move = caps.has_str("MOVE");
|
|
self.config.can_check_quota = caps.has_str("QUOTA");
|
|
self.config.can_condstore = caps.has_str("CONDSTORE");
|
|
self.capabilities_determined = true;
|
|
Ok(())
|
|
}
|
|
|
|
/// Prepare for IMAP operation.
|
|
///
|
|
/// Ensure that IMAP client is connected, folders are created and IMAP capabilities are
|
|
/// determined.
|
|
pub async fn prepare(&mut self, context: &Context) -> Result<()> {
|
|
if let Err(err) = self.connect(context).await {
|
|
self.connectivity.set_err(context, &err).await;
|
|
return Err(err);
|
|
}
|
|
|
|
self.ensure_configured_folders(context, true).await?;
|
|
self.determine_capabilities().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn disconnect(&mut self, context: &Context) {
|
|
// Close folder if messages should be expunged
|
|
if let Err(err) = self.close_folder(context).await {
|
|
warn!(context, "failed to close folder: {:?}", err);
|
|
}
|
|
|
|
// Logout from the server
|
|
if let Some(mut session) = self.session.take() {
|
|
if let Err(err) = session.logout().await {
|
|
warn!(context, "failed to logout: {:?}", err);
|
|
}
|
|
}
|
|
self.capabilities_determined = false;
|
|
self.config.selected_folder = None;
|
|
self.config.selected_mailbox = None;
|
|
}
|
|
|
|
pub fn should_reconnect(&self) -> bool {
|
|
self.should_reconnect
|
|
}
|
|
|
|
pub async fn trigger_reconnect(&mut self, context: &Context) {
|
|
self.connectivity.set_connecting(context).await;
|
|
self.should_reconnect = true;
|
|
}
|
|
|
|
/// 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, watch_folder: &str) -> Result<()> {
|
|
if !context.sql.is_open().await {
|
|
// probably shutdown
|
|
bail!("IMAP operation attempted while it is torn down");
|
|
}
|
|
self.prepare(context).await?;
|
|
|
|
self.fetch_new_messages(context, watch_folder, false)
|
|
.await
|
|
.context("fetch_new_messages")?;
|
|
|
|
self.move_messages(context, watch_folder)
|
|
.await
|
|
.context("move_messages")?;
|
|
self.delete_messages(context, watch_folder)
|
|
.await
|
|
.context("delete_messages")?;
|
|
self.sync_seen_flags(context, watch_folder)
|
|
.await
|
|
.context("sync_seen_flags")?;
|
|
|
|
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: String,
|
|
) -> Result<()> {
|
|
// Collect pairs of UID and Message-ID.
|
|
let mut msg_ids = BTreeMap::new();
|
|
|
|
self.select_folder(context, Some(&folder)).await?;
|
|
|
|
let session = self
|
|
.session
|
|
.as_mut()
|
|
.context("IMAP No connection established")?;
|
|
|
|
let mut list = session
|
|
.uid_fetch("1:*", RFC724MID_UID)
|
|
.await
|
|
.with_context(|| format!("can't resync folder {}", folder))?;
|
|
while let Some(fetch) = list.next().await {
|
|
let msg = fetch?;
|
|
|
|
// Get Message-ID
|
|
let message_id = get_fetch_headers(&msg)
|
|
.and_then(|headers| prefetch_get_message_id(&headers))
|
|
.ok();
|
|
|
|
if let (Some(uid), Some(rfc724_mid)) = (msg.uid, message_id) {
|
|
msg_ids.insert(uid, rfc724_mid);
|
|
}
|
|
}
|
|
|
|
info!(
|
|
context,
|
|
"Resync: collected {} message IDs in folder {}",
|
|
msg_ids.len(),
|
|
&folder
|
|
);
|
|
|
|
let uid_validity = get_uidvalidity(context, &folder).await?;
|
|
|
|
// Write collected UIDs to SQLite database.
|
|
context
|
|
.sql
|
|
.transaction(move |transaction| {
|
|
transaction.execute("DELETE FROM imap WHERE folder=?", params![folder])?;
|
|
for (uid, rfc724_mid) in &msg_ids {
|
|
// This may detect previously undetected moved
|
|
// messages, so we update server_folder too.
|
|
transaction.execute(
|
|
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(folder, uid, uidvalidity)
|
|
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
|
|
target=excluded.target",
|
|
params![rfc724_mid, folder, uid, uid_validity, folder],
|
|
)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Select a folder and take care of uidvalidity changes.
|
|
/// Also, when selecting a folder for the first time, sets the uid_next to the current
|
|
/// mailbox.uid_next so that no old emails are fetched.
|
|
/// Returns Result<new_emails> (i.e. whether new emails arrived),
|
|
/// if in doubt, returns new_emails=true so emails are fetched.
|
|
pub(crate) async fn select_with_uidvalidity(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
) -> Result<bool> {
|
|
let newly_selected = self.select_or_create_folder(context, folder).await?;
|
|
|
|
let mailbox = self
|
|
.config
|
|
.selected_mailbox
|
|
.as_mut()
|
|
.with_context(|| format!("No mailbox selected, folder: {}", folder))?;
|
|
|
|
let new_uid_validity = mailbox
|
|
.uid_validity
|
|
.with_context(|| format!("No UIDVALIDITY for folder {}", folder))?;
|
|
|
|
let old_uid_validity = get_uidvalidity(context, folder).await?;
|
|
let old_uid_next = get_uid_next(context, folder).await?;
|
|
|
|
if new_uid_validity == old_uid_validity {
|
|
let new_emails = if newly_selected == NewlySelected::No {
|
|
// The folder was not newly selected i.e. no SELECT command was run. This means that mailbox.uid_next
|
|
// was not updated and may contain an incorrect value. So, just return true so that
|
|
// the caller tries to fetch new messages (we could of course run a SELECT command now, but trying to fetch
|
|
// new messages is only one command, just as a SELECT command)
|
|
true
|
|
} else if let Some(uid_next) = mailbox.uid_next {
|
|
if uid_next < old_uid_next {
|
|
warn!(
|
|
context,
|
|
"The server illegally decreased the uid_next of folder {} from {} to {} without changing validity ({}), resyncing UIDs...",
|
|
folder, old_uid_next, uid_next, new_uid_validity,
|
|
);
|
|
set_uid_next(context, folder, uid_next).await?;
|
|
job::schedule_resync(context).await?;
|
|
}
|
|
uid_next != old_uid_next // If uid_next changed, there are new emails
|
|
} else {
|
|
true // We have no uid_next and if in doubt, return true
|
|
};
|
|
return Ok(new_emails);
|
|
}
|
|
|
|
// UIDVALIDITY is modified, reset highest seen MODSEQ.
|
|
set_modseq(context, folder, 0).await?;
|
|
|
|
if mailbox.exists == 0 {
|
|
info!(context, "Folder \"{}\" is empty.", folder);
|
|
|
|
// set uid_next=1 for empty folders.
|
|
// If we do not do this here, we'll miss the first message
|
|
// as we will get in here again and fetch from uid_next then.
|
|
// Also, the "fall back to fetching" below would need a non-zero mailbox.exists to work.
|
|
set_uid_next(context, folder, 1).await?;
|
|
set_uidvalidity(context, folder, new_uid_validity).await?;
|
|
return Ok(false);
|
|
}
|
|
|
|
// ============== uid_validity has changed or is being set the first time. ==============
|
|
|
|
let new_uid_next = match mailbox.uid_next {
|
|
Some(uid_next) => uid_next,
|
|
None => {
|
|
warn!(
|
|
context,
|
|
"IMAP folder has no uid_next, fall back to fetching"
|
|
);
|
|
let session = self.session.as_mut().context("Get uid_next: Nosession")?;
|
|
// note that we use fetch by sequence number
|
|
// and thus we only need to get exactly the
|
|
// last-index message.
|
|
let set = format!("{}", mailbox.exists);
|
|
let mut list = session
|
|
.fetch(set, JUST_UID)
|
|
.await
|
|
.context("Error fetching UID")?;
|
|
|
|
let mut new_last_seen_uid = None;
|
|
while let Some(fetch) = list.next().await.transpose()? {
|
|
if fetch.message == mailbox.exists && fetch.uid.is_some() {
|
|
new_last_seen_uid = fetch.uid;
|
|
}
|
|
}
|
|
new_last_seen_uid.context("select: failed to fetch")? + 1
|
|
}
|
|
};
|
|
|
|
set_uid_next(context, folder, new_uid_next).await?;
|
|
set_uidvalidity(context, folder, new_uid_validity).await?;
|
|
|
|
// Collect garbage entries in `imap` table.
|
|
context
|
|
.sql
|
|
.execute(
|
|
"DELETE FROM imap WHERE folder=? AND uidvalidity!=?",
|
|
paramsv![folder, new_uid_validity],
|
|
)
|
|
.await?;
|
|
|
|
if old_uid_validity != 0 || old_uid_next != 0 {
|
|
job::schedule_resync(context).await?;
|
|
}
|
|
info!(
|
|
context,
|
|
"uid/validity change folder {}: new {}/{} previous {}/{}",
|
|
folder,
|
|
new_uid_next,
|
|
new_uid_validity,
|
|
old_uid_next,
|
|
old_uid_validity,
|
|
);
|
|
Ok(false)
|
|
}
|
|
|
|
pub(crate) async fn fetch_new_messages(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
fetch_existing_msgs: bool,
|
|
) -> Result<bool> {
|
|
let new_emails = self.select_with_uidvalidity(context, folder).await?;
|
|
|
|
if !new_emails && !fetch_existing_msgs {
|
|
info!(context, "No new emails in folder {}", folder);
|
|
return Ok(false);
|
|
}
|
|
|
|
let uid_validity = get_uidvalidity(context, folder).await?;
|
|
let old_uid_next = get_uid_next(context, folder).await?;
|
|
|
|
let msgs = if fetch_existing_msgs {
|
|
self.prefetch_existing_msgs().await?
|
|
} else {
|
|
self.prefetch(old_uid_next).await?
|
|
};
|
|
let read_cnt = msgs.len();
|
|
|
|
let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
|
|
.unwrap_or_default();
|
|
let download_limit = context.download_limit().await?;
|
|
let mut uids_fetch_fully = Vec::with_capacity(msgs.len());
|
|
let mut uids_fetch_partially = Vec::with_capacity(msgs.len());
|
|
let mut largest_uid_skipped = None;
|
|
|
|
// 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).unwrap_or_default();
|
|
|
|
let target = match target_folder(context, folder, &headers).await? {
|
|
Some(config) => match context.get_config(config).await? {
|
|
Some(target) => target,
|
|
None => folder.to_string(),
|
|
},
|
|
None => folder.to_string(),
|
|
};
|
|
|
|
let duplicate = context
|
|
.sql
|
|
.count(
|
|
"SELECT COUNT(*)
|
|
FROM imap
|
|
WHERE rfc724_mid=?
|
|
AND folder=?
|
|
AND uid<?",
|
|
paramsv![message_id, &target, uid],
|
|
)
|
|
.await?
|
|
> 0;
|
|
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(folder, uid, uidvalidity)
|
|
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
|
|
target=excluded.target",
|
|
paramsv![
|
|
message_id,
|
|
folder,
|
|
uid,
|
|
uid_validity,
|
|
if duplicate { "" } else { &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
|
|
&& prefetch_should_download(
|
|
context,
|
|
&headers,
|
|
&message_id,
|
|
fetch_response.flags(),
|
|
show_emails,
|
|
)
|
|
.await?
|
|
{
|
|
match download_limit {
|
|
Some(download_limit) => {
|
|
if fetch_response.size.unwrap_or_default() > download_limit {
|
|
uids_fetch_partially.push(uid);
|
|
} else {
|
|
uids_fetch_fully.push(uid)
|
|
}
|
|
}
|
|
None => uids_fetch_fully.push(uid),
|
|
}
|
|
} else {
|
|
largest_uid_skipped = Some(uid);
|
|
}
|
|
}
|
|
|
|
if !uids_fetch_fully.is_empty() || !uids_fetch_partially.is_empty() {
|
|
self.connectivity.set_working(context).await;
|
|
}
|
|
|
|
// Actually download messages.
|
|
let (largest_uid_fully_fetched, mut received_msgs) = self
|
|
.fetch_many_msgs(
|
|
context,
|
|
folder,
|
|
uids_fetch_fully,
|
|
false,
|
|
fetch_existing_msgs,
|
|
)
|
|
.await?;
|
|
|
|
let (largest_uid_partially_fetched, received_msgs_2) = self
|
|
.fetch_many_msgs(
|
|
context,
|
|
folder,
|
|
uids_fetch_partially,
|
|
true,
|
|
fetch_existing_msgs,
|
|
)
|
|
.await?;
|
|
received_msgs.extend(received_msgs_2);
|
|
|
|
// determine which uid_next to use to update to
|
|
// dc_receive_imf() returns an `Err` value only on recoverable errors, otherwise it just logs an error.
|
|
// `largest_uid_processed` is the largest uid where dc_receive_imf() did NOT return an error.
|
|
|
|
// So: Update the uid_next to the largest uid that did NOT recoverably fail. Not perfect because if there was
|
|
// another message afterwards that succeeded, we will not retry. The upside is that we will not retry an infinite amount of times.
|
|
let largest_uid_without_errors = max(
|
|
max(
|
|
largest_uid_fully_fetched.unwrap_or(0),
|
|
largest_uid_partially_fetched.unwrap_or(0),
|
|
),
|
|
largest_uid_skipped.unwrap_or(0),
|
|
);
|
|
let new_uid_next = largest_uid_without_errors + 1;
|
|
|
|
if new_uid_next > old_uid_next {
|
|
set_uid_next(context, folder, new_uid_next).await?;
|
|
}
|
|
|
|
info!(context, "{} mails read from \"{}\".", read_cnt, folder);
|
|
|
|
chat::mark_old_messages_as_noticed(context, received_msgs).await?;
|
|
|
|
Ok(read_cnt > 0)
|
|
}
|
|
|
|
/// Moves messages.
|
|
///
|
|
/// This is the only place where messages are moved on the IMAP server.
|
|
async fn move_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
let rows = context
|
|
.sql
|
|
.query_map(
|
|
"SELECT id, uid, target FROM imap
|
|
WHERE folder = ?
|
|
AND target != folder
|
|
AND target != '' -- Not planned for deletion.
|
|
ORDER BY id",
|
|
paramsv![folder],
|
|
|row| {
|
|
let rowid: i64 = row.get(0)?;
|
|
let uid: u32 = row.get(1)?;
|
|
let target: String = row.get(2)?;
|
|
Ok((rowid, uid, target))
|
|
},
|
|
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
|
)
|
|
.await?;
|
|
|
|
self.prepare(context).await?;
|
|
self.select_folder(context, Some(folder)).await?;
|
|
|
|
for (rowid, uid, target) in rows {
|
|
// TODO: batch moves of messages with the same destination.
|
|
let set = uid.to_string();
|
|
|
|
if self.config.can_move {
|
|
if let Some(session) = &mut self.session {
|
|
match session.uid_mv(&set, &target).await {
|
|
Ok(_) => {
|
|
context.emit_event(EventType::ImapMessageMoved(format!(
|
|
"IMAP message {}/{} moved to {}",
|
|
folder, uid, target
|
|
)));
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM imap WHERE id=?", paramsv![rowid])
|
|
.await?;
|
|
continue;
|
|
}
|
|
Err(async_imap::error::Error::No(text)) => {
|
|
// "NO" response, probably the message is moved already.
|
|
info!(
|
|
context,
|
|
"IMAP message {}/{} cannot be moved: {}", folder, uid, text
|
|
);
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM imap WHERE id=?", paramsv![rowid])
|
|
.await?;
|
|
continue;
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Cannot move message, fallback to COPY/DELETE {}/{} to {}: {}",
|
|
folder,
|
|
uid,
|
|
target,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
bail!("No session while attempting to move the message");
|
|
}
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Server does not support MOVE, fallback to COPY/DELETE {}/{} to {}",
|
|
folder,
|
|
uid,
|
|
target
|
|
);
|
|
}
|
|
|
|
// Server does not support MOVE or MOVE failed.
|
|
// Copy the message to the destination folder and mark the record for deletion.
|
|
if let Some(session) = &mut self.session {
|
|
match session.uid_copy(&set, &target).await {
|
|
Ok(_) => {
|
|
context.emit_event(EventType::ImapMessageMoved(format!(
|
|
"IMAP message {}/{} copied to {}",
|
|
folder, uid, target
|
|
)));
|
|
// Plan deletion of the original message.
|
|
context
|
|
.sql
|
|
.execute("UPDATE imap SET target='' WHERE id=?", paramsv![rowid])
|
|
.await?;
|
|
}
|
|
Err(async_imap::error::Error::No(text)) => {
|
|
// "NO" response, probably the message is moved already.
|
|
info!(
|
|
context,
|
|
"IMAP message {}/{} cannot be copied: {}", folder, uid, text
|
|
);
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM imap WHERE id=?", paramsv![rowid])
|
|
.await?;
|
|
continue;
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Could not copy message {}/{}: {}", folder, uid, err
|
|
);
|
|
// Break the loop to avoid moving messages out of order.
|
|
// We can't proceed until this message is moved or copied.
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
bail!("No session while attempting to copy the message");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Deletes messages that are marked as planned for deletion in `imap` table.
|
|
///
|
|
/// This is the only place where messages are deleted from the IMAP server.
|
|
async fn delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
let rows = context
|
|
.sql
|
|
.query_map(
|
|
"SELECT id, uid FROM imap
|
|
WHERE folder=? AND target=''
|
|
ORDER BY uid ASC
|
|
LIMIT 50", // Do not try to delete too many messages at once.
|
|
paramsv![folder],
|
|
|row| {
|
|
let rowid: i64 = row.get(0)?;
|
|
let uid: u32 = row.get(1)?;
|
|
Ok((rowid, uid))
|
|
},
|
|
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|
|
)
|
|
.await?;
|
|
|
|
if rows.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
for (rowid, uid) in rows {
|
|
match self.delete_msg(context, folder, uid).await {
|
|
ImapActionResult::Failed | ImapActionResult::RetryLater => {
|
|
warn!(context, "Deletion of message {}/{} failed", folder, uid);
|
|
break;
|
|
}
|
|
ImapActionResult::Success => {
|
|
context
|
|
.sql
|
|
.execute("DELETE FROM imap WHERE id=?", paramsv![rowid])
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(())
|
|
}
|
|
|
|
/// Synchronizes `\Seen` flags using `CONDSTORE` extension.
|
|
pub(crate) async fn sync_seen_flags(&mut self, context: &Context, folder: &str) -> Result<()> {
|
|
if !self.config.can_condstore {
|
|
info!(
|
|
context,
|
|
"Server does not support CONDSTORE, skipping flag synchronization."
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
self.select_folder(context, Some(folder))
|
|
.await
|
|
.context("failed to select folder")?;
|
|
let session = self
|
|
.session
|
|
.as_mut()
|
|
.with_context(|| format!("No IMAP connection established, folder: {}", folder))?;
|
|
|
|
let mailbox = self
|
|
.config
|
|
.selected_mailbox
|
|
.as_ref()
|
|
.with_context(|| format!("No mailbox selected, folder: {}", folder))?;
|
|
|
|
let remote_highest_modseq = if let Some(remote_highest_modseq) = mailbox.highest_modseq {
|
|
remote_highest_modseq
|
|
} else {
|
|
info!(
|
|
context,
|
|
"Mailbox {} does not support mod-sequences, skipping flag synchronization.", folder
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
let mut highest_modseq = get_modseq(context, folder)
|
|
.await
|
|
.with_context(|| format!("failed to get MODSEQ for folder {}", folder))?;
|
|
if highest_modseq >= remote_highest_modseq {
|
|
info!(
|
|
context,
|
|
"MODSEQ {} is already new, HIGHESTMODSEQ={}, skipping seen flag update",
|
|
highest_modseq,
|
|
remote_highest_modseq
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let mut updated_chat_ids = BTreeSet::new();
|
|
let uid_validity = get_uidvalidity(context, folder)
|
|
.await
|
|
.with_context(|| format!("failed to get UID validity for folder {}", folder))?;
|
|
let mut list = session
|
|
.uid_fetch("1:*", format!("(FLAGS) (CHANGEDSINCE {})", highest_modseq))
|
|
.await
|
|
.context("failed to fetch flags")?;
|
|
|
|
while let Some(fetch) = list.next().await {
|
|
let fetch = fetch.context("failed to get FETCH result")?;
|
|
let uid = if let Some(uid) = fetch.uid {
|
|
uid
|
|
} else {
|
|
info!(context, "FETCH result contains no UID, skipping");
|
|
continue;
|
|
};
|
|
let is_seen = fetch.flags().any(|flag| flag == Flag::Seen);
|
|
if is_seen {
|
|
if 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");
|
|
}
|
|
}
|
|
|
|
if remote_highest_modseq > highest_modseq {
|
|
// We haven't seen the message with the highest MODSEQ, maybe it was deleted already.
|
|
highest_modseq = remote_highest_modseq;
|
|
}
|
|
set_modseq(context, folder, highest_modseq)
|
|
.await
|
|
.with_context(|| format!("failed to set MODSEQ for folder {}", folder))?;
|
|
for updated_chat_id in updated_chat_ids {
|
|
context.emit_event(EventType::MsgsNoticed(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 session = self
|
|
.session
|
|
.as_mut()
|
|
.context("IMAP No Connection established")?;
|
|
let self_addr = context
|
|
.get_config(Config::ConfiguredAddr)
|
|
.await?
|
|
.context("not configured")?;
|
|
|
|
let search_command = format!("FROM \"{}\"", self_addr);
|
|
let uids = session
|
|
.uid_search(search_command)
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let mut result = Vec::new();
|
|
for uid_set in &build_sequence_sets(uids) {
|
|
let mut list = session
|
|
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
|
|
.await
|
|
.context("IMAP Could not fetch")?;
|
|
|
|
while let Some(fetch) = list.next().await {
|
|
let msg = fetch?;
|
|
match get_fetch_headers(&msg) {
|
|
Ok(headers) => {
|
|
if let Some(from) = mimeparser::get_from(&headers).first() {
|
|
if context.is_self_addr(&from.addr).await? {
|
|
result.extend(mimeparser::get_recipients(&headers));
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "{}", err);
|
|
continue;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Prefetch all messages greater than or equal to `uid_next`. Return a list of fetch results.
|
|
async fn prefetch(&mut self, uid_next: u32) -> Result<BTreeMap<u32, async_imap::types::Fetch>> {
|
|
let session = self.session.as_mut();
|
|
let session = session.context("fetch_after(): IMAP No Connection established")?;
|
|
|
|
// fetch messages with larger UID than the last one seen
|
|
let set = format!("{}:*", uid_next);
|
|
let mut list = session
|
|
.uid_fetch(set, PREFETCH_FLAGS)
|
|
.await
|
|
.context("IMAP could not fetch")?;
|
|
|
|
let mut msgs = BTreeMap::new();
|
|
while let Some(fetch) = list.next().await {
|
|
let msg = fetch?;
|
|
if let Some(msg_uid) = msg.uid {
|
|
msgs.insert(msg_uid, msg);
|
|
}
|
|
}
|
|
drop(list);
|
|
|
|
// If the mailbox is not empty, results always include
|
|
// at least one UID, even if last_seen_uid+1 is past
|
|
// the last UID in the mailbox. It happens because
|
|
// uid:* is interpreted the same way as *:uid.
|
|
// See <https://tools.ietf.org/html/rfc3501#page-61> for
|
|
// standard reference. Therefore, sometimes we receive
|
|
// already seen messages and have to filter them out.
|
|
let new_msgs = msgs.split_off(&uid_next);
|
|
|
|
Ok(new_msgs)
|
|
}
|
|
|
|
/// Like fetch_after(), but not for new messages but existing ones (the DC_FETCH_EXISTING_MSGS_COUNT newest messages)
|
|
async fn prefetch_existing_msgs(&mut self) -> Result<BTreeMap<u32, async_imap::types::Fetch>> {
|
|
let exists: i64 = {
|
|
let mailbox = self
|
|
.config
|
|
.selected_mailbox
|
|
.as_ref()
|
|
.context("no mailbox")?;
|
|
mailbox.exists.into()
|
|
};
|
|
let session = self.session.as_mut().context("no IMAP session")?;
|
|
|
|
// Fetch last DC_FETCH_EXISTING_MSGS_COUNT (100) messages.
|
|
// Sequence numbers are sequential. If there are 1000 messages in the inbox,
|
|
// we can fetch the sequence numbers 900-1000 and get the last 100 messages.
|
|
let first = cmp::max(1, exists - DC_FETCH_EXISTING_MSGS_COUNT);
|
|
let set = format!("{}:*", first);
|
|
let mut list = session
|
|
.fetch(&set, PREFETCH_FLAGS)
|
|
.await
|
|
.context("IMAP Could not fetch")?;
|
|
|
|
let mut msgs = BTreeMap::new();
|
|
while let Some(fetch) = list.next().await {
|
|
let msg = fetch?;
|
|
if let Some(msg_uid) = msg.uid {
|
|
msgs.insert(msg_uid, msg);
|
|
}
|
|
}
|
|
|
|
Ok(msgs)
|
|
}
|
|
|
|
/// Fetches a list of messages by server UID.
|
|
///
|
|
/// Returns the last uid fetch successfully and the info about each downloaded message.
|
|
pub(crate) async fn fetch_many_msgs(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
server_uids: Vec<u32>,
|
|
fetch_partially: bool,
|
|
fetching_existing_messages: bool,
|
|
) -> Result<(Option<u32>, Vec<ReceivedMsg>)> {
|
|
let mut received_msgs = Vec::new();
|
|
if server_uids.is_empty() {
|
|
return Ok((None, Vec::new()));
|
|
}
|
|
|
|
let session = self.session.as_mut().context("no IMAP session")?;
|
|
|
|
let sets = build_sequence_sets(server_uids.clone());
|
|
let mut count = 0;
|
|
let mut last_uid = None;
|
|
|
|
for set in sets.iter() {
|
|
let mut msgs = match session
|
|
.uid_fetch(
|
|
&set,
|
|
if fetch_partially {
|
|
BODY_PARTIAL
|
|
} else {
|
|
BODY_FULL
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(msgs) => msgs,
|
|
Err(err) => {
|
|
// TODO: maybe differentiate between IO and input/parsing problems
|
|
// so we don't reconnect if we have a (rare) input/output parsing problem?
|
|
self.should_reconnect = true;
|
|
bail!(
|
|
"Error on fetching messages #{} from folder \"{}\"; error={}.",
|
|
&set,
|
|
folder,
|
|
err
|
|
);
|
|
}
|
|
};
|
|
|
|
let folder = folder.to_string();
|
|
|
|
while let Some(Ok(msg)) = msgs.next().await {
|
|
let server_uid = msg.uid.unwrap_or_default();
|
|
|
|
if !server_uids.contains(&server_uid) {
|
|
warn!(
|
|
context,
|
|
"Got unwanted uid {} not in {:?}, requested {:?}",
|
|
&server_uid,
|
|
server_uids,
|
|
&sets
|
|
);
|
|
continue;
|
|
}
|
|
count += 1;
|
|
|
|
let is_deleted = msg.flags().any(|flag| flag == Flag::Deleted);
|
|
let (body, partial) = if fetch_partially {
|
|
(msg.header(), msg.size) // `BODY.PEEK[HEADER]` goes to header() ...
|
|
} else {
|
|
(msg.body(), None) // ... while `BODY.PEEK[]` goes to body() - and includes header()
|
|
};
|
|
|
|
if is_deleted || body.is_none() {
|
|
info!(
|
|
context,
|
|
"Not processing deleted or empty msg {}", server_uid
|
|
);
|
|
last_uid = Some(server_uid);
|
|
continue;
|
|
}
|
|
|
|
// XXX put flags into a set and pass them to dc_receive_imf
|
|
let context = context.clone();
|
|
let folder = folder.clone();
|
|
|
|
// safe, as we checked above that there is a body.
|
|
let body = body.unwrap();
|
|
let is_seen = msg.flags().any(|flag| flag == Flag::Seen);
|
|
|
|
match dc_receive_imf_inner(
|
|
&context,
|
|
body,
|
|
&folder,
|
|
is_seen,
|
|
partial,
|
|
fetching_existing_messages,
|
|
)
|
|
.await
|
|
{
|
|
Ok(received_msg) => {
|
|
if let Some(m) = received_msg {
|
|
received_msgs.push(m);
|
|
}
|
|
last_uid = Some(server_uid)
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "dc_receive_imf error: {:#}", err);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
if count != server_uids.len() {
|
|
warn!(
|
|
context,
|
|
"failed to fetch all uids: got {}, requested {}, we requested the UIDs {:?} using {:?}",
|
|
count,
|
|
server_uids.len(),
|
|
server_uids,
|
|
sets
|
|
);
|
|
}
|
|
|
|
Ok((last_uid, received_msgs))
|
|
}
|
|
|
|
/// 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 true as well.
|
|
///
|
|
/// Returning error means that the operation can be retried.
|
|
async fn add_flag_finalized(&mut self, server_uid: u32, flag: &str) -> Result<()> {
|
|
let s = server_uid.to_string();
|
|
self.add_flag_finalized_with_set(&s, flag).await
|
|
}
|
|
|
|
async fn add_flag_finalized_with_set(&mut self, uid_set: &str, flag: &str) -> Result<()> {
|
|
if self.should_reconnect() {
|
|
bail!("Can't set flag, should reconnect");
|
|
}
|
|
|
|
let session = self.session.as_mut().context("No session").unwrap();
|
|
let query = format!("+FLAGS ({})", flag);
|
|
let mut responses = session
|
|
.uid_store(uid_set, &query)
|
|
.await
|
|
.with_context(|| format!("IMAP failed to store: ({}, {})", uid_set, query))?;
|
|
while let Some(_response) = responses.next().await {
|
|
// Read all the responses
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn prepare_imap_operation_on_msg(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
uid: u32,
|
|
) -> Option<ImapActionResult> {
|
|
if uid == 0 {
|
|
return Some(ImapActionResult::RetryLater);
|
|
}
|
|
if self.session.is_none() {
|
|
// currently jobs are only performed on the INBOX thread
|
|
// TODO: make INBOX/SENT/MVBOX perform the jobs on their
|
|
// respective folders to avoid select_folder network traffic
|
|
// and the involved error states
|
|
if let Err(err) = self.prepare(context).await {
|
|
warn!(context, "prepare_imap_op failed: {}", err);
|
|
return Some(ImapActionResult::RetryLater);
|
|
}
|
|
}
|
|
match self.select_folder(context, Some(folder)).await {
|
|
Ok(_) => None,
|
|
Err(select_folder::Error::ConnectionLost) => {
|
|
warn!(context, "Lost imap connection");
|
|
Some(ImapActionResult::RetryLater)
|
|
}
|
|
Err(select_folder::Error::NoSession) => {
|
|
warn!(context, "no imap session");
|
|
Some(ImapActionResult::Failed)
|
|
}
|
|
Err(select_folder::Error::BadFolderName(folder_name)) => {
|
|
warn!(context, "invalid folder name: {:?}", folder_name);
|
|
Some(ImapActionResult::Failed)
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "failed to select folder: {:?}: {:?}", folder, err);
|
|
Some(ImapActionResult::RetryLater)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn set_seen(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
uid: u32,
|
|
) -> ImapActionResult {
|
|
if let Some(imapresult) = self
|
|
.prepare_imap_operation_on_msg(context, folder, uid)
|
|
.await
|
|
{
|
|
return imapresult;
|
|
}
|
|
// we are connected, and the folder is selected
|
|
info!(context, "Marking message {}/{} as seen...", folder, uid,);
|
|
|
|
if let Err(err) = self.add_flag_finalized(uid, "\\Seen").await {
|
|
warn!(
|
|
context,
|
|
"Cannot mark message {} in folder {} as seen, ignoring: {}.", uid, folder, err
|
|
);
|
|
ImapActionResult::Failed
|
|
} else {
|
|
ImapActionResult::Success
|
|
}
|
|
}
|
|
|
|
pub async fn delete_msg(
|
|
&mut self,
|
|
context: &Context,
|
|
folder: &str,
|
|
uid: u32,
|
|
) -> ImapActionResult {
|
|
if let Some(imapresult) = self
|
|
.prepare_imap_operation_on_msg(context, folder, uid)
|
|
.await
|
|
{
|
|
return imapresult;
|
|
}
|
|
// we are connected, and the folder is selected
|
|
|
|
let display_imap_id = format!("{}/{}", folder, uid);
|
|
|
|
// mark the message for deletion
|
|
if let Err(err) = self.add_flag_finalized(uid, "\\Deleted").await {
|
|
warn!(
|
|
context,
|
|
"Cannot mark message {} as \"Deleted\": {}.", display_imap_id, err
|
|
);
|
|
ImapActionResult::RetryLater
|
|
} else {
|
|
context.emit_event(EventType::ImapMessageDeleted(format!(
|
|
"IMAP Message {} marked as deleted",
|
|
display_imap_id
|
|
)));
|
|
self.config.selected_folder_needs_expunge = true;
|
|
ImapActionResult::Success
|
|
}
|
|
}
|
|
|
|
pub async fn ensure_configured_folders(
|
|
&mut self,
|
|
context: &Context,
|
|
create_mvbox: bool,
|
|
) -> Result<()> {
|
|
let folders_configured = context.sql.get_raw_config_int("folders_configured").await?;
|
|
if folders_configured.unwrap_or_default() >= DC_FOLDERS_CONFIGURED_VERSION {
|
|
return Ok(());
|
|
}
|
|
|
|
self.configure_folders(context, create_mvbox).await
|
|
}
|
|
|
|
pub async fn configure_folders(&mut self, context: &Context, create_mvbox: bool) -> Result<()> {
|
|
let session = self
|
|
.session
|
|
.as_mut()
|
|
.context("no IMAP connection established")?;
|
|
|
|
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 mvbox_folder = None;
|
|
let mut folder_configs = BTreeMap::new();
|
|
let mut fallback_folder = get_fallback_folder(&delimiter);
|
|
|
|
while let Some(folder) = folders.next().await {
|
|
let folder = folder?;
|
|
info!(context, "Scanning folder: {:?}", folder);
|
|
|
|
// Update the delimiter iff there is a different one, but only once.
|
|
if let Some(d) = folder.delimiter() {
|
|
if delimiter_is_default && !d.is_empty() && delimiter != d {
|
|
delimiter = d.to_string();
|
|
fallback_folder = get_fallback_folder(&delimiter);
|
|
delimiter_is_default = false;
|
|
}
|
|
}
|
|
|
|
let folder_meaning = get_folder_meaning(&folder);
|
|
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
|
|
if folder.name() == "DeltaChat" {
|
|
// Always takes precedence
|
|
mvbox_folder = Some(folder.name().to_string());
|
|
} else if folder.name() == fallback_folder {
|
|
// only set if none has been already set
|
|
if mvbox_folder.is_none() {
|
|
mvbox_folder = Some(folder.name().to_string());
|
|
}
|
|
} else 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);
|
|
|
|
if mvbox_folder.is_none() && create_mvbox {
|
|
info!(context, "Creating MVBOX-folder \"DeltaChat\"...",);
|
|
|
|
match session.create("DeltaChat").await {
|
|
Ok(_) => {
|
|
mvbox_folder = Some("DeltaChat".into());
|
|
info!(context, "MVBOX-folder created.",);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Cannot create MVBOX-folder, trying to create INBOX subfolder. ({})", err
|
|
);
|
|
|
|
match session.create(&fallback_folder).await {
|
|
Ok(_) => {
|
|
mvbox_folder = Some(fallback_folder);
|
|
info!(
|
|
context,
|
|
"MVBOX-folder created as INBOX subfolder. ({})", err
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(context, "Cannot create MVBOX-folder. ({})", err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// SUBSCRIBE is needed to make the folder visible to the LSUB command
|
|
// that may be used by other MUAs to list folders.
|
|
// for the LIST command, the folder is always visible.
|
|
if let Some(ref mvbox) = mvbox_folder {
|
|
if let Err(err) = session.subscribe(mvbox).await {
|
|
warn!(context, "could not subscribe to {:?}: {:?}", mvbox, err);
|
|
}
|
|
}
|
|
}
|
|
context
|
|
.set_config(Config::ConfiguredInboxFolder, Some("INBOX"))
|
|
.await?;
|
|
if let Some(ref mvbox_folder) = mvbox_folder {
|
|
context
|
|
.set_config(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
|
|
.await?;
|
|
}
|
|
for (config, name) in folder_configs {
|
|
context.set_config(config, Some(&name)).await?;
|
|
}
|
|
context
|
|
.sql
|
|
.set_raw_config_int("folders_configured", DC_FOLDERS_CONFIGURED_VERSION)
|
|
.await?;
|
|
|
|
info!(context, "FINISHED configuring IMAP-folders.");
|
|
Ok(())
|
|
}
|
|
|
|
/// Update HIGHESTMODSEQ on selected mailbox.
|
|
///
|
|
/// Should be called when MODSEQ is seen on the response, such as IDLE response.
|
|
pub(crate) fn update_modseq(&mut self, modseq: u64) {
|
|
self.config.selected_mailbox =
|
|
self.config
|
|
.selected_mailbox
|
|
.as_ref()
|
|
.map(|mailbox| Mailbox {
|
|
highest_modseq: Some(std::cmp::max(
|
|
mailbox.highest_modseq.unwrap_or_default(),
|
|
modseq,
|
|
)),
|
|
..mailbox.clone()
|
|
});
|
|
}
|
|
|
|
/// Return whether the server sent an unsolicited EXISTS response.
|
|
/// Drains all responses from `session.unsolicited_responses` in the process.
|
|
/// If this returns `true`, this means that new emails arrived and you should
|
|
/// fetch again, even if you just fetched.
|
|
fn server_sent_unsolicited_exists(&self, context: &Context) -> Result<bool> {
|
|
let session = self.session.as_ref().context("no session")?;
|
|
let mut unsolicited_exists = false;
|
|
while let Ok(response) = session.unsolicited_responses.try_recv() {
|
|
match response {
|
|
UnsolicitedResponse::Exists(_) => {
|
|
info!(
|
|
context,
|
|
"Need to fetch again, got unsolicited EXISTS {:?}", response
|
|
);
|
|
unsolicited_exists = true;
|
|
}
|
|
_ => info!(context, "ignoring unsolicited response {:?}", response),
|
|
}
|
|
}
|
|
Ok(unsolicited_exists)
|
|
}
|
|
|
|
pub fn can_check_quota(&self) -> bool {
|
|
self.config.can_check_quota
|
|
}
|
|
|
|
pub async fn get_quota_roots(
|
|
&mut self,
|
|
mailbox_name: &str,
|
|
) -> Result<(Vec<QuotaRoot>, Vec<Quota>)> {
|
|
if let Some(session) = self.session.as_mut() {
|
|
let quota_roots = session.get_quota_root(mailbox_name).await?;
|
|
Ok(quota_roots)
|
|
} else {
|
|
Err(anyhow!("Not connected to IMAP, no session"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns target folder for a message found in the Spam folder.
|
|
async fn spam_target_folder(
|
|
context: &Context,
|
|
folder: &str,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<Option<Config>> {
|
|
if let Some(chat) = prefetch_get_chat(context, headers).await? {
|
|
if chat.blocked != Blocked::Not {
|
|
// Blocked or contact request message in the spam folder, leave it there.
|
|
return Ok(None);
|
|
}
|
|
} else {
|
|
// No chat found.
|
|
let (from_id, blocked_contact, _origin) =
|
|
from_field_to_contact_id(context, &mimeparser::get_from(headers), true).await?;
|
|
if blocked_contact {
|
|
// Contact is blocked, leave the message in spam.
|
|
return Ok(None);
|
|
}
|
|
|
|
if let Some(chat_id_blocked) = ChatIdBlocked::lookup_by_contact(context, from_id).await? {
|
|
if chat_id_blocked.blocked != Blocked::Not {
|
|
return Ok(None);
|
|
}
|
|
} else if from_id != DC_CONTACT_ID_SELF {
|
|
// No chat with this contact found.
|
|
return Ok(None);
|
|
}
|
|
}
|
|
|
|
if needs_move_to_mvbox(context, headers).await? {
|
|
Ok(Some(Config::ConfiguredMvboxFolder))
|
|
} else if needs_move_to_sentbox(context, folder, headers).await? {
|
|
Ok(Some(Config::ConfiguredSentboxFolder))
|
|
} else {
|
|
Ok(Some(Config::ConfiguredInboxFolder))
|
|
}
|
|
}
|
|
|
|
/// Returns `ConfiguredInboxFolder`, `ConfiguredMvboxFolder` or `ConfiguredSentboxFolder` if
|
|
/// the message needs to be moved from `folder`. Otherwise returns `None`.
|
|
pub async fn target_folder(
|
|
context: &Context,
|
|
folder: &str,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<Option<Config>> {
|
|
if context.is_mvbox(folder).await? {
|
|
return Ok(None);
|
|
}
|
|
|
|
if context.is_spam_folder(folder).await? {
|
|
spam_target_folder(context, folder, headers).await
|
|
} else if needs_move_to_mvbox(context, headers).await? {
|
|
Ok(Some(Config::ConfiguredMvboxFolder))
|
|
} else if needs_move_to_sentbox(context, folder, headers).await? {
|
|
Ok(Some(Config::ConfiguredSentboxFolder))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn needs_move_to_mvbox(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<bool> {
|
|
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 headers.get_header_value(HeaderDef::ChatVersion).is_some() {
|
|
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)
|
|
}
|
|
}
|
|
|
|
async fn prefetch_is_outgoing(
|
|
context: &Context,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<bool> {
|
|
let from_address_list = &mimeparser::get_from(headers);
|
|
|
|
// Only looking at the first address in the `From:` field.
|
|
if let Some(info) = from_address_list.first() {
|
|
if context.is_self_addr(&info.addr).await? {
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
async fn needs_move_to_sentbox(
|
|
context: &Context,
|
|
folder: &str,
|
|
headers: &[mailparse::MailHeader<'_>],
|
|
) -> Result<bool> {
|
|
let needs_move = context.get_config_bool(Config::SentboxMove).await?
|
|
&& context
|
|
.get_config(Config::ConfiguredSentboxFolder)
|
|
.await?
|
|
.is_some()
|
|
&& context.is_inbox(folder).await?
|
|
&& headers.get_header_value(HeaderDef::ChatVersion).is_some()
|
|
&& headers
|
|
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
|
.is_none()
|
|
&& prefetch_is_outgoing(context, headers).await?;
|
|
|
|
Ok(needs_move)
|
|
}
|
|
|
|
/// 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 SENT_NAMES: &[&str] = &[
|
|
"sent",
|
|
"sentmail",
|
|
"sent objects",
|
|
"gesendet",
|
|
"Sent Mail",
|
|
"Sendte e-mails",
|
|
"Enviados",
|
|
"Messages envoyés",
|
|
"Messages envoyes",
|
|
"Posta inviata",
|
|
"Verzonden berichten",
|
|
"Wyslane",
|
|
"E-mails enviados",
|
|
"Correio enviado",
|
|
"Enviada",
|
|
"Enviado",
|
|
"Gönderildi",
|
|
"Inviati",
|
|
"Odeslaná pošta",
|
|
"Sendt",
|
|
"Skickat",
|
|
"Verzonden",
|
|
"Wysłane",
|
|
"Éléments envoyés",
|
|
"Απεσταλμένα",
|
|
"Отправленные",
|
|
"寄件備份",
|
|
"已发送邮件",
|
|
"送信済み",
|
|
"보낸편지함",
|
|
];
|
|
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 DRAFT_NAMES: &[&str] = &[
|
|
"Drafts",
|
|
"Kladder",
|
|
"Entw?rfe",
|
|
"Borradores",
|
|
"Brouillons",
|
|
"Bozze",
|
|
"Concepten",
|
|
"Wersje robocze",
|
|
"Rascunhos",
|
|
"Entwürfe",
|
|
"Koncepty",
|
|
"Kopie robocze",
|
|
"Taslaklar",
|
|
"Utkast",
|
|
"Πρόχειρα",
|
|
"Черновики",
|
|
"下書き",
|
|
"草稿",
|
|
"임시보관함",
|
|
];
|
|
let lower = folder_name.to_lowercase();
|
|
|
|
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
|
|
FolderMeaning::Sent
|
|
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
|
|
FolderMeaning::Spam
|
|
} else if DRAFT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
|
|
FolderMeaning::Drafts
|
|
} else {
|
|
FolderMeaning::Unknown
|
|
}
|
|
}
|
|
|
|
fn get_folder_meaning(folder_name: &Name) -> FolderMeaning {
|
|
for attr in folder_name.attributes() {
|
|
if let NameAttribute::Custom(ref label) = attr {
|
|
match label.as_ref() {
|
|
"\\Trash" => return FolderMeaning::Other,
|
|
"\\Sent" => return FolderMeaning::Sent,
|
|
"\\Spam" | "\\Junk" => return FolderMeaning::Spam,
|
|
"\\Drafts" => return FolderMeaning::Drafts,
|
|
_ => {}
|
|
};
|
|
}
|
|
}
|
|
FolderMeaning::Unknown
|
|
}
|
|
|
|
/// 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()),
|
|
}
|
|
}
|
|
|
|
fn prefetch_get_message_id(headers: &[mailparse::MailHeader]) -> Result<String> {
|
|
if let Some(message_id) = headers.get_header_value(HeaderDef::XMicrosoftOriginalMessageId) {
|
|
Ok(crate::mimeparser::parse_message_id(&message_id)?)
|
|
} else if let Some(message_id) = headers.get_header_value(HeaderDef::MessageId) {
|
|
Ok(crate::mimeparser::parse_message_id(&message_id)?)
|
|
} else {
|
|
bail!("prefetch: No message ID found");
|
|
}
|
|
}
|
|
|
|
/// 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<'_>>,
|
|
show_emails: ShowEmails,
|
|
) -> Result<bool> {
|
|
if let Some(msg_id) = message::rfc724_mid_exists(context, message_id).await? {
|
|
// We know the Message-ID already, it must be a Bcc: to self.
|
|
job::add(
|
|
context,
|
|
job::Job::new(Action::MarkseenMsgOnImap, msg_id.to_u32(), Params::new(), 0),
|
|
)
|
|
.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? {
|
|
if 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_id, blocked_contact, origin) =
|
|
from_field_to_contact_id(context, &mimeparser::get_from(headers), true).await?;
|
|
// 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 = 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)
|
|
}
|
|
|
|
fn get_fallback_folder(delimiter: &str) -> String {
|
|
format!("INBOX{}DeltaChat", delimiter)
|
|
}
|
|
|
|
/// 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 rfc724_mid IN (
|
|
SELECT rfc724_mid FROM imap
|
|
WHERE folder=?1
|
|
AND uidvalidity=?2
|
|
AND uid=?3
|
|
LIMIT 1
|
|
)",
|
|
paramsv![&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",
|
|
paramsv![
|
|
MessageState::InSeen,
|
|
MessageState::InFresh,
|
|
MessageState::InNoticed,
|
|
msg_id
|
|
],
|
|
)
|
|
.await
|
|
.with_context(|| format!("failed to update msg {} state", msg_id))?
|
|
> 0;
|
|
|
|
if updated {
|
|
Ok(Some(chat_id))
|
|
} else {
|
|
// Message state has not chnaged.
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
// There is no message is `msgs` table matchng the given UID.
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// 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, folder: &str, uid_next: u32) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (folder, uid_next) VALUES (?,?)
|
|
ON CONFLICT(folder) DO UPDATE SET uid_next=? WHERE folder=?;",
|
|
paramsv![folder, uid_next, uid_next, folder],
|
|
)
|
|
.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, folder: &str) -> Result<u32> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT uid_next FROM imap_sync WHERE folder=?;",
|
|
paramsv![folder],
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
pub(crate) async fn set_uidvalidity(
|
|
context: &Context,
|
|
folder: &str,
|
|
uidvalidity: u32,
|
|
) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (folder, uidvalidity) VALUES (?,?)
|
|
ON CONFLICT(folder) DO UPDATE SET uidvalidity=? WHERE folder=?;",
|
|
paramsv![folder, uidvalidity, uidvalidity, folder],
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_uidvalidity(context: &Context, folder: &str) -> Result<u32> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT uidvalidity FROM imap_sync WHERE folder=?;",
|
|
paramsv![folder],
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Result<()> {
|
|
context
|
|
.sql
|
|
.execute(
|
|
"INSERT INTO imap_sync (folder, modseq) VALUES (?,?)
|
|
ON CONFLICT(folder) DO UPDATE SET modseq=? WHERE folder=?;",
|
|
paramsv![folder, modseq, modseq, folder],
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_modseq(context: &Context, folder: &str) -> Result<u64> {
|
|
Ok(context
|
|
.sql
|
|
.query_get_value(
|
|
"SELECT modseq FROM imap_sync WHERE folder=?;",
|
|
paramsv![folder],
|
|
)
|
|
.await?
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
/// Deprecated, use get_uid_next() and get_uidvalidity()
|
|
pub async fn get_config_last_seen_uid(context: &Context, folder: &str) -> Result<(u32, u32)> {
|
|
let key = format!("imap.mailbox.{}", folder);
|
|
if let Some(entry) = context.sql.get_raw_config(&key).await? {
|
|
// the entry has the format `imap.mailbox.<folder>=<uidvalidity>:<lastseenuid>`
|
|
let mut parts = entry.split(':');
|
|
Ok((
|
|
parts.next().unwrap_or_default().parse().unwrap_or(0),
|
|
parts.next().unwrap_or_default().parse().unwrap_or(0),
|
|
))
|
|
} else {
|
|
Ok((0, 0))
|
|
}
|
|
}
|
|
|
|
/// 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(mut uids: Vec<u32>) -> Vec<String> {
|
|
uids.sort_unstable();
|
|
|
|
// first, try to find consecutive ranges:
|
|
let mut ranges: Vec<UidRange> = vec![];
|
|
|
|
for current in uids {
|
|
if let Some(last) = ranges.last_mut() {
|
|
if 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![String::new()];
|
|
for range in ranges {
|
|
if let Some(last) = result.last_mut() {
|
|
if !last.is_empty() {
|
|
last.push(',');
|
|
}
|
|
last.push_str(&range.to_string());
|
|
|
|
if last.len() > 990 {
|
|
result.push(String::new()); // Start a new uid set
|
|
}
|
|
}
|
|
}
|
|
|
|
result.retain(|s| !s.is_empty());
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::chat::ChatId;
|
|
use crate::config::Config;
|
|
use crate::contact::Contact;
|
|
use crate::test_utils::TestContext;
|
|
|
|
#[test]
|
|
fn test_get_folder_meaning_by_name() {
|
|
assert_eq!(get_folder_meaning_by_name("Gesendet"), FolderMeaning::Sent);
|
|
assert_eq!(get_folder_meaning_by_name("GESENDET"), FolderMeaning::Sent);
|
|
assert_eq!(get_folder_meaning_by_name("gesendet"), FolderMeaning::Sent);
|
|
assert_eq!(
|
|
get_folder_meaning_by_name("Messages envoyés"),
|
|
FolderMeaning::Sent
|
|
);
|
|
assert_eq!(
|
|
get_folder_meaning_by_name("mEsSaGes envoyÉs"),
|
|
FolderMeaning::Sent
|
|
);
|
|
assert_eq!(get_folder_meaning_by_name("xxx"), FolderMeaning::Unknown);
|
|
assert_eq!(get_folder_meaning_by_name("SPAM"), FolderMeaning::Spam);
|
|
}
|
|
|
|
#[async_std::test]
|
|
async fn test_set_uid_next_validity() {
|
|
let t = TestContext::new_alice().await;
|
|
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
|
|
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0);
|
|
|
|
set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap();
|
|
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7);
|
|
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
|
|
|
|
set_uid_next(&t.ctx, "Inbox", 5).await.unwrap();
|
|
set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap();
|
|
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5);
|
|
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_sequence_sets() {
|
|
let cases = vec![
|
|
(vec![], vec![]),
|
|
(vec![1], vec!["1"]),
|
|
(vec![3291], vec!["3291"]),
|
|
(vec![1, 3, 5, 7, 9, 11], vec!["1,3,5,7,9,11"]),
|
|
(vec![1, 2, 3], vec!["1:3"]),
|
|
(vec![1, 4, 5, 6], vec!["1,4:6"]),
|
|
((1..=500).collect(), vec!["1:500"]),
|
|
(vec![3, 4, 8, 9, 10, 11, 39, 50, 2], vec!["2:4,8:11,39,50"]),
|
|
];
|
|
for (input, output) in cases {
|
|
assert_eq!(build_sequence_sets(input), output);
|
|
}
|
|
|
|
let numbers: Vec<_> = (2..=500).step_by(2).collect();
|
|
let result = build_sequence_sets(numbers.clone());
|
|
for set in &result {
|
|
assert!(set.len() < 1010);
|
|
assert!(!set.ends_with(','));
|
|
assert!(!set.starts_with(','));
|
|
}
|
|
assert!(result.len() == 1); // these UIDs fit in one set
|
|
for number in &numbers {
|
|
assert!(result
|
|
.iter()
|
|
.any(|set| set.split(',').any(|n| n.parse::<u32>().unwrap() == *number)));
|
|
}
|
|
|
|
let numbers: Vec<_> = (1..=1000).step_by(3).collect();
|
|
let result = build_sequence_sets(numbers.clone());
|
|
for set in &result {
|
|
assert!(set.len() < 1010);
|
|
assert!(!set.ends_with(','));
|
|
assert!(!set.starts_with(','));
|
|
}
|
|
assert!(result.last().unwrap().ends_with("997,1000"));
|
|
assert!(result.len() == 2); // This time we need 2 sets
|
|
for number in &numbers {
|
|
assert!(result
|
|
.iter()
|
|
.any(|set| set.split(',').any(|n| n.parse::<u32>().unwrap() == *number)));
|
|
}
|
|
|
|
let numbers: Vec<_> = (30000000..=30002500).step_by(4).collect();
|
|
let result = build_sequence_sets(numbers.clone());
|
|
for set in &result {
|
|
assert!(set.len() < 1010);
|
|
assert!(!set.ends_with(','));
|
|
assert!(!set.starts_with(','));
|
|
}
|
|
assert_eq!(result.len(), 6);
|
|
for number in &numbers {
|
|
assert!(result
|
|
.iter()
|
|
.any(|set| set.split(',').any(|n| n.parse::<u32>().unwrap() == *number)));
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn check_target_folder_combination(
|
|
folder: &str,
|
|
mvbox_move: bool,
|
|
chat_msg: bool,
|
|
expected_destination: &str,
|
|
accepted_chat: bool,
|
|
outgoing: bool,
|
|
setupmessage: bool,
|
|
sentbox_move: bool,
|
|
) -> Result<()> {
|
|
println!("Testing: For folder {}, mvbox_move {}, chat_msg {}, accepted {}, outgoing {}, setupmessage {}",
|
|
folder, mvbox_move, chat_msg, accepted_chat, outgoing, setupmessage);
|
|
|
|
let t = TestContext::new_alice().await;
|
|
t.ctx
|
|
.set_config(Config::ConfiguredSpamFolder, Some("Spam"))
|
|
.await?;
|
|
t.ctx
|
|
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
|
|
.await?;
|
|
t.ctx
|
|
.set_config(Config::ConfiguredSentboxFolder, Some("Sent"))
|
|
.await?;
|
|
t.ctx
|
|
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
|
|
.await?;
|
|
t.ctx.set_config(Config::ShowEmails, Some("2")).await?;
|
|
t.ctx
|
|
.set_config_bool(Config::SentboxMove, sentbox_move)
|
|
.await?;
|
|
|
|
if accepted_chat {
|
|
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
|
|
ChatId::create_for_contact(&t.ctx, contact_id).await?;
|
|
}
|
|
let temp;
|
|
|
|
let bytes = if setupmessage {
|
|
include_bytes!("../test-data/message/AutocryptSetupMessage.eml")
|
|
} else {
|
|
temp = format!(
|
|
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
|
|
{}\
|
|
Subject: foo\n\
|
|
Message-ID: <abc@example.com>\n\
|
|
{}\
|
|
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
|
|
\n\
|
|
hello\n",
|
|
if outgoing {
|
|
"From: alice@example.org\nTo: bob@example.net\n"
|
|
} else {
|
|
"From: bob@example.net\nTo: alice@example.org\n"
|
|
},
|
|
if chat_msg { "Chat-Version: 1.0\n" } else { "" },
|
|
);
|
|
temp.as_bytes()
|
|
};
|
|
|
|
let (headers, _) = mailparse::parse_headers(bytes)?;
|
|
|
|
let actual = if let Some(config) = target_folder(&t, folder, &headers).await? {
|
|
t.get_config(config).await?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let expected = if expected_destination == folder {
|
|
None
|
|
} else {
|
|
Some(expected_destination)
|
|
};
|
|
assert_eq!(expected, actual.as_deref(), "For folder {}, mvbox_move {}, chat_msg {}, accepted {}, outgoing {}, setupmessage {}: expected {:?}, got {:?}",
|
|
folder, mvbox_move, chat_msg, accepted_chat, outgoing, setupmessage, expected, actual);
|
|
Ok(())
|
|
}
|
|
|
|
// chat_msg means that the message was sent by Delta Chat
|
|
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
|
|
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
|
|
("INBOX", false, false, "INBOX"),
|
|
("INBOX", false, true, "INBOX"),
|
|
("INBOX", true, false, "INBOX"),
|
|
("INBOX", true, true, "DeltaChat"),
|
|
("Sent", false, false, "Sent"),
|
|
("Sent", false, true, "Sent"),
|
|
("Sent", true, false, "Sent"),
|
|
("Sent", true, true, "DeltaChat"),
|
|
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
|
|
("Spam", false, true, "INBOX"),
|
|
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
|
|
("Spam", true, true, "DeltaChat"),
|
|
];
|
|
|
|
// These are the same as above, but all messages in Spam stay in Spam
|
|
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
|
|
("INBOX", false, false, "INBOX"),
|
|
("INBOX", false, true, "INBOX"),
|
|
("INBOX", true, false, "INBOX"),
|
|
("INBOX", true, true, "DeltaChat"),
|
|
("Sent", false, false, "Sent"),
|
|
("Sent", false, true, "Sent"),
|
|
("Sent", true, false, "Sent"),
|
|
("Sent", true, true, "DeltaChat"),
|
|
("Spam", false, false, "Spam"),
|
|
("Spam", false, true, "Spam"),
|
|
("Spam", true, false, "Spam"),
|
|
("Spam", true, true, "Spam"),
|
|
];
|
|
|
|
#[async_std::test]
|
|
async fn test_target_folder_incoming_accepted() -> Result<()> {
|
|
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
|
check_target_folder_combination(
|
|
folder,
|
|
*mvbox_move,
|
|
*chat_msg,
|
|
expected_destination,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[async_std::test]
|
|
async fn test_target_folder_incoming_request() -> Result<()> {
|
|
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
|
|
check_target_folder_combination(
|
|
folder,
|
|
*mvbox_move,
|
|
*chat_msg,
|
|
expected_destination,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[async_std::test]
|
|
async fn test_target_folder_outgoing() -> Result<()> {
|
|
for sentbox_move in &[true, false] {
|
|
// Test outgoing emails
|
|
for (folder, mvbox_move, chat_msg, mut expected_destination) in
|
|
COMBINATIONS_ACCEPTED_CHAT
|
|
{
|
|
if *folder == "INBOX" && !mvbox_move && *chat_msg && *sentbox_move {
|
|
expected_destination = "Sent"
|
|
}
|
|
check_target_folder_combination(
|
|
folder,
|
|
*mvbox_move,
|
|
*chat_msg,
|
|
expected_destination,
|
|
true,
|
|
true,
|
|
false,
|
|
*sentbox_move,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[async_std::test]
|
|
async fn test_target_folder_setupmsg() -> Result<()> {
|
|
// Test setupmessages
|
|
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
|
check_target_folder_combination(
|
|
folder,
|
|
*mvbox_move,
|
|
*chat_msg,
|
|
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
|
|
false,
|
|
true,
|
|
true,
|
|
false,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|