Skip to content

Commit 10f3e09

Browse files
committed
feat: protect the Date header
We do not try to delete resent messages anymore. Previously resent messages were distinguised by having duplicate Message-ID, but future Date, but now we need to download the message before we even see the Date. We now move the message to the destination folder but do not fetch it. It may not be a good idea to delete the duplicate in multi-device setups anyway, because the device which has a message may delete the duplicate of a message the other device missed. To avoid triggering IMAP move loop described in the comments we now only move the messages from INBOX and Spam folders.
1 parent fd3e48d commit 10f3e09

File tree

15 files changed

+87
-155
lines changed

15 files changed

+87
-155
lines changed

python/tests/test_1_online.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ def test_move_works(acfactory):
333333

334334

335335
def test_move_avoids_loop(acfactory):
336-
"""Test that the message is only moved once.
336+
"""Test that the message is only moved from INBOX to DeltaChat.
337337
338338
This is to avoid busy loop if moved message reappears in the Inbox
339339
or some scanned folder later.
@@ -344,27 +344,43 @@ def test_move_avoids_loop(acfactory):
344344
ac1 = acfactory.new_online_configuring_account()
345345
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
346346
acfactory.bring_accounts_online()
347+
348+
# Create INBOX.DeltaChat folder and make sure
349+
# it is detected by full folder scan.
350+
ac2.direct_imap.create_folder("INBOX.DeltaChat")
351+
ac2.stop_io()
352+
ac2.start_io()
353+
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
354+
347355
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
348356
ac1_chat.send_text("Message 1")
349357

350358
# Message is moved to the DeltaChat folder and downloaded.
351359
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
352360
assert ac2_msg1.text == "Message 1"
353361

354-
# Move the message to the INBOX again.
362+
# Move the message to the INBOX.DeltaChat again.
363+
# We assume that test server uses "." as the delimiter.
355364
ac2.direct_imap.select_folder("DeltaChat")
356-
ac2.direct_imap.conn.move(["*"], "INBOX")
365+
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
357366

358367
ac1_chat.send_text("Message 2")
359368
ac2_msg2 = ac2._evtracker.wait_next_incoming_message()
360369
assert ac2_msg2.text == "Message 2"
361370

362-
# Check that Message 1 is still in the INBOX folder
371+
# Stop and start I/O to trigger folder scan.
372+
ac2.stop_io()
373+
ac2.start_io()
374+
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
375+
376+
# Check that Message 1 is still in the INBOX.DeltaChat folder
363377
# and Message 2 is in the DeltaChat folder.
364378
ac2.direct_imap.select_folder("INBOX")
365-
assert len(ac2.direct_imap.get_all_messages()) == 1
379+
assert len(ac2.direct_imap.get_all_messages()) == 0
366380
ac2.direct_imap.select_folder("DeltaChat")
367381
assert len(ac2.direct_imap.get_all_messages()) == 1
382+
ac2.direct_imap.select_folder("INBOX.DeltaChat")
383+
assert len(ac2.direct_imap.get_all_messages()) == 1
368384

369385

370386
def test_move_works_on_self_sent(acfactory):
@@ -471,14 +487,19 @@ def test_resend_message(acfactory, lp):
471487
lp.sec("ac2: receive message")
472488
msg_in = ac2._evtracker.wait_next_incoming_message()
473489
assert msg_in.text == "message"
474-
chat2 = msg_in.chat
475-
chat2_msg_cnt = len(chat2.get_messages())
476490

477491
lp.sec("ac1: resend message")
478492
ac1.resend_messages([msg_in])
479493

480-
lp.sec("ac2: check that message is deleted")
481-
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
494+
lp.sec("ac1: send another message")
495+
chat1.send_text("another message")
496+
497+
lp.sec("ac2: receive another message")
498+
msg_in = ac2._evtracker.wait_next_incoming_message()
499+
assert msg_in.text == "another message"
500+
chat2 = msg_in.chat
501+
chat2_msg_cnt = len(chat2.get_messages())
502+
482503
assert len(chat2.get_messages()) == chat2_msg_cnt
483504

484505

@@ -1882,8 +1903,8 @@ def test_group_quote(acfactory, lp):
18821903
(
18831904
"xyz",
18841905
True,
1885-
"DeltaChat",
1886-
), # ...emails are found in a random folder and moved to DeltaChat
1906+
"xyz",
1907+
), # ...emails are found in a random folder and downloaded without moving
18871908
(
18881909
"Spam",
18891910
False,

src/config.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -735,9 +735,6 @@ impl Context {
735735
_ => Default::default(),
736736
};
737737
self.set_config_internal(key, value).await?;
738-
if key == Config::SentboxWatch {
739-
self.last_full_folder_scan.lock().await.take();
740-
}
741738
Ok(())
742739
}
743740

src/context.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,6 @@ pub struct InnerContext {
263263
/// IMAP METADATA.
264264
pub(crate) metadata: RwLock<Option<ServerMetadata>>,
265265

266-
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
267-
268266
/// ID for this `Context` in the current process.
269267
///
270268
/// This allows for multiple `Context`s open in a single process where each context can
@@ -445,7 +443,6 @@ impl Context {
445443
server_id: RwLock::new(None),
446444
metadata: RwLock::new(None),
447445
creation_time: tools::Time::now(),
448-
last_full_folder_scan: Mutex::new(None),
449446
last_error: parking_lot::RwLock::new("".to_string()),
450447
debug_logging: std::sync::RwLock::new(None),
451448
push_subscriber,

src/download.rs

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -161,25 +161,18 @@ pub(crate) async fn download_msg(
161161
|row| {
162162
let server_uid: u32 = row.get(0)?;
163163
let server_folder: String = row.get(1)?;
164-
let uidvalidity: u32 = row.get(2)?;
165-
Ok((server_uid, server_folder, uidvalidity))
164+
Ok((server_uid, server_folder))
166165
},
167166
)
168167
.await?;
169168

170-
let Some((server_uid, server_folder, uidvalidity)) = row else {
169+
let Some((server_uid, server_folder)) = row else {
171170
// No IMAP record found, we don't know the UID and folder.
172171
return Err(anyhow!("Call download_full() again to try over."));
173172
};
174173

175174
session
176-
.fetch_single_msg(
177-
context,
178-
&server_folder,
179-
uidvalidity,
180-
server_uid,
181-
msg.rfc724_mid.clone(),
182-
)
175+
.fetch_single_msg(context, &server_folder, server_uid, msg.rfc724_mid.clone())
183176
.await?;
184177
Ok(())
185178
}
@@ -193,7 +186,6 @@ impl Session {
193186
&mut self,
194187
context: &Context,
195188
folder: &str,
196-
uidvalidity: u32,
197189
uid: u32,
198190
rfc724_mid: String,
199191
) -> Result<()> {
@@ -213,14 +205,7 @@ impl Session {
213205
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
214206
uid_message_ids.insert(uid, rfc724_mid);
215207
let (last_uid, _received) = self
216-
.fetch_many_msgs(
217-
context,
218-
folder,
219-
uidvalidity,
220-
vec![uid],
221-
&uid_message_ids,
222-
false,
223-
)
208+
.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, false)
224209
.await?;
225210
if last_uid.is_none() {
226211
bail!("Failed to fetch UID {uid}");

src/imap.rs

Lines changed: 12 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -581,52 +581,26 @@ impl Imap {
581581

582582
// Determine the target folder where the message should be moved to.
583583
//
584-
// If we have seen the message on the IMAP server before, do not move it.
584+
// We only move the messages from the INBOX folder.
585585
// This is required to avoid infinite MOVE loop on IMAP servers
586586
// that alias `DeltaChat` folder to other names.
587587
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
588-
// In this case Delta Chat configured with `DeltaChat` as the destination folder
589-
// would detect messages in the `INBOX.DeltaChat` folder
590-
// and try to move them to the `DeltaChat` folder.
591-
// Such move to the same folder results in the messages
592-
// getting a new UID, so the messages will be detected as new
588+
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
589+
// results in the messages getting a new UID,
590+
// so the messages will be detected as new
593591
// in the `INBOX.DeltaChat` folder again.
594592
let _target;
595593
let target = if let Some(message_id) = &message_id {
596594
let msg_info =
597595
message::rfc724_mid_exists_ex(context, message_id, "deleted=1").await?;
598-
let delete = if let Some((_, _, true)) = msg_info {
596+
let delete = if let Some((_, true)) = msg_info {
599597
info!(context, "Deleting locally deleted message {message_id}.");
600598
true
601-
} else if let Some((_, ts_sent_old, _)) = msg_info {
602-
let is_chat_msg = headers.get_header_value(HeaderDef::ChatVersion).is_some();
603-
let ts_sent = headers
604-
.get_header_value(HeaderDef::Date)
605-
.and_then(|v| mailparse::dateparse(&v).ok())
606-
.unwrap_or_default();
607-
let is_dup = is_dup_msg(is_chat_msg, ts_sent, ts_sent_old);
608-
if is_dup {
609-
info!(context, "Deleting duplicate message {message_id}.");
610-
}
611-
is_dup
612599
} else {
613600
false
614601
};
615602
if delete {
616603
&delete_target
617-
} else if context
618-
.sql
619-
.exists(
620-
"SELECT COUNT (*) FROM imap WHERE rfc724_mid=?",
621-
(message_id,),
622-
)
623-
.await?
624-
{
625-
info!(
626-
context,
627-
"Not moving the message {} that we have seen before.", &message_id
628-
);
629-
folder
630604
} else {
631605
_target = target_folder(context, folder, folder_meaning, &headers).await?;
632606
&_target
@@ -707,7 +681,6 @@ impl Imap {
707681
.fetch_many_msgs(
708682
context,
709683
folder,
710-
uid_validity,
711684
uids_fetch_in_batch.split_off(0),
712685
&uid_message_ids,
713686
fetch_partially,
@@ -1305,7 +1278,6 @@ impl Session {
13051278
&mut self,
13061279
context: &Context,
13071280
folder: &str,
1308-
uidvalidity: u32,
13091281
request_uids: Vec<u32>,
13101282
uid_message_ids: &BTreeMap<u32, String>,
13111283
fetch_partially: bool,
@@ -1433,18 +1405,7 @@ impl Session {
14331405
context,
14341406
"Passing message UID {} to receive_imf().", request_uid
14351407
);
1436-
match receive_imf_inner(
1437-
context,
1438-
folder,
1439-
uidvalidity,
1440-
request_uid,
1441-
rfc724_mid,
1442-
body,
1443-
is_seen,
1444-
partial,
1445-
)
1446-
.await
1447-
{
1408+
match receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await {
14481409
Ok(received_msg) => {
14491410
if let Some(m) = received_msg {
14501411
received_msgs.push(m);
@@ -1952,7 +1913,9 @@ pub async fn target_folder_cfg(
19521913

19531914
if folder_meaning == FolderMeaning::Spam {
19541915
spam_target_folder_cfg(context, headers).await
1955-
} else if needs_move_to_mvbox(context, headers).await? {
1916+
} else if folder_meaning == FolderMeaning::Inbox
1917+
&& needs_move_to_mvbox(context, headers).await?
1918+
{
19561919
Ok(Some(Config::ConfiguredMvboxFolder))
19571920
} else {
19581921
Ok(None)
@@ -2121,7 +2084,9 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
21212084
];
21222085
let lower = folder_name.to_lowercase();
21232086

2124-
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
2087+
if lower == "inbox" {
2088+
FolderMeaning::Inbox
2089+
} else if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21252090
FolderMeaning::Sent
21262091
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21272092
FolderMeaning::Spam
@@ -2280,15 +2245,6 @@ pub(crate) async fn prefetch_should_download(
22802245
Ok(should_download)
22812246
}
22822247

2283-
/// Returns whether a message is a duplicate (resent message).
2284-
pub(crate) fn is_dup_msg(is_chat_msg: bool, ts_sent: i64, ts_sent_old: i64) -> bool {
2285-
// If the existing message has timestamp_sent == 0, that means we don't know its actual sent
2286-
// timestamp, so don't delete the new message. E.g. outgoing messages have zero timestamp_sent
2287-
// because they are stored to the db before sending. Also consider as duplicates only messages
2288-
// with greater timestamp to avoid deleting both messages in a multi-device setting.
2289-
is_chat_msg && ts_sent_old != 0 && ts_sent > ts_sent_old
2290-
}
2291-
22922248
/// Marks messages in `msgs` table as seen, searching for them by UID.
22932249
///
22942250
/// Returns updated chat ID if any message was marked as seen.

src/imap/imap_tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
180180
("Sent", false, false, "Sent"),
181181
("Sent", false, true, "Sent"),
182182
("Sent", true, false, "Sent"),
183-
("Sent", true, true, "DeltaChat"),
183+
("Sent", true, true, "Sent"),
184184
("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
185185
("Spam", false, true, "INBOX"),
186186
("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
@@ -196,7 +196,7 @@ const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
196196
("Sent", false, false, "Sent"),
197197
("Sent", false, true, "Sent"),
198198
("Sent", true, false, "Sent"),
199-
("Sent", true, true, "DeltaChat"),
199+
("Sent", true, true, "Sent"),
200200
("Spam", false, false, "Spam"),
201201
("Spam", false, true, "INBOX"),
202202
("Spam", true, false, "Spam"),

src/imap/scan_folders.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ impl Imap {
1818
) -> Result<bool> {
1919
// First of all, debounce to once per minute:
2020
{
21-
let mut last_scan = context.last_full_folder_scan.lock().await;
21+
let mut last_scan = session.last_full_folder_scan.lock().await;
2222
if let Some(last_scan) = *last_scan {
2323
let elapsed_secs = time_elapsed(&last_scan).as_secs();
2424
let debounce_secs = context

src/imap/session.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ use anyhow::{Context as _, Result};
55
use async_imap::types::Mailbox;
66
use async_imap::Session as ImapSession;
77
use futures::TryStreamExt;
8+
use tokio::sync::Mutex;
89

910
use crate::imap::capabilities::Capabilities;
1011
use crate::net::session::SessionStream;
12+
use crate::tools;
1113

1214
/// Prefetch:
1315
/// - Message-ID to check if we already have the message.
@@ -40,6 +42,8 @@ pub(crate) struct Session {
4042

4143
pub selected_folder_needs_expunge: bool,
4244

45+
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
46+
4347
/// True if currently selected folder has new messages.
4448
///
4549
/// Should be false if no folder is currently selected.
@@ -71,6 +75,7 @@ impl Session {
7175
selected_folder: None,
7276
selected_mailbox: None,
7377
selected_folder_needs_expunge: false,
78+
last_full_folder_scan: Mutex::new(None),
7479
new_mail: false,
7580
}
7681
}

0 commit comments

Comments
 (0)