Skip to content

Commit 5a694aa

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 c34ccaf commit 5a694aa

File tree

15 files changed

+105
-159
lines changed

15 files changed

+105
-159
lines changed

python/tests/test_1_online.py

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

335335

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

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

355-
# Move the message to the INBOX again.
363+
# Move the message to the INBOX.DeltaChat again.
364+
# We assume that test server uses "." as the delimiter.
356365
ac2.direct_imap.select_folder("DeltaChat")
357-
ac2.direct_imap.conn.move(["*"], "INBOX")
366+
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
358367

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

363-
# Check that Message 1 is still in the INBOX folder
372+
# Stop and start I/O to trigger folder scan.
373+
ac2.stop_io()
374+
ac2.start_io()
375+
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
376+
377+
# Check that Message 1 is still in the INBOX.DeltaChat folder
364378
# and Message 2 is in the DeltaChat folder.
365379
ac2.direct_imap.select_folder("INBOX")
366-
assert len(ac2.direct_imap.get_all_messages()) == 1
380+
assert len(ac2.direct_imap.get_all_messages()) == 0
367381
ac2.direct_imap.select_folder("DeltaChat")
368382
assert len(ac2.direct_imap.get_all_messages()) == 1
383+
ac2.direct_imap.select_folder("INBOX.DeltaChat")
384+
assert len(ac2.direct_imap.get_all_messages()) == 1
369385

370386

371387
def test_move_works_on_self_sent(acfactory):
@@ -476,14 +492,19 @@ def test_resend_message(acfactory, lp):
476492
lp.sec("ac2: receive message")
477493
msg_in = ac2._evtracker.wait_next_incoming_message()
478494
assert msg_in.text == "message"
479-
chat2 = msg_in.chat
480-
chat2_msg_cnt = len(chat2.get_messages())
481495

482496
lp.sec("ac1: resend message")
483497
ac1.resend_messages([msg_in])
484498

485-
lp.sec("ac2: check that message is deleted")
486-
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
499+
lp.sec("ac1: send another message")
500+
chat1.send_text("another message")
501+
502+
lp.sec("ac2: receive another message")
503+
msg_in = ac2._evtracker.wait_next_incoming_message()
504+
assert msg_in.text == "another message"
505+
chat2 = msg_in.chat
506+
chat2_msg_cnt = len(chat2.get_messages())
507+
487508
assert len(chat2.get_messages()) == chat2_msg_cnt
488509

489510

@@ -1785,8 +1806,8 @@ def test_group_quote(acfactory, lp):
17851806
(
17861807
"xyz",
17871808
True,
1788-
"DeltaChat",
1789-
), # ...emails are found in a random folder and moved to DeltaChat
1809+
"xyz",
1810+
), # ...emails are found in a random folder and downloaded without moving
17901811
(
17911812
"Spam",
17921813
False,

src/config.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -738,9 +738,6 @@ impl Context {
738738
_ => Default::default(),
739739
};
740740
self.set_config_internal(key, value).await?;
741-
if key == Config::SentboxWatch {
742-
self.last_full_folder_scan.lock().await.take();
743-
}
744741
Ok(())
745742
}
746743

src/context.rs

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

265-
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
266-
267265
/// ID for this `Context` in the current process.
268266
///
269267
/// This allows for multiple `Context`s open in a single process where each context can
@@ -469,7 +467,6 @@ impl Context {
469467
server_id: RwLock::new(None),
470468
metadata: RwLock::new(None),
471469
creation_time: tools::Time::now(),
472-
last_full_folder_scan: Mutex::new(None),
473470
last_error: parking_lot::RwLock::new("".to_string()),
474471
migration_error: parking_lot::RwLock::new(None),
475472
debug_logging: std::sync::RwLock::new(None),

src/download.rs

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

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

176175
session
177-
.fetch_single_msg(
178-
context,
179-
&server_folder,
180-
uidvalidity,
181-
server_uid,
182-
msg.rfc724_mid.clone(),
183-
)
176+
.fetch_single_msg(context, &server_folder, server_uid, msg.rfc724_mid.clone())
184177
.await?;
185178
Ok(())
186179
}
@@ -194,7 +187,6 @@ impl Session {
194187
&mut self,
195188
context: &Context,
196189
folder: &str,
197-
uidvalidity: u32,
198190
uid: u32,
199191
rfc724_mid: String,
200192
) -> Result<()> {
@@ -214,16 +206,8 @@ impl Session {
214206
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
215207
uid_message_ids.insert(uid, rfc724_mid);
216208
let (sender, receiver) = async_channel::unbounded();
217-
self.fetch_many_msgs(
218-
context,
219-
folder,
220-
uidvalidity,
221-
vec![uid],
222-
&uid_message_ids,
223-
false,
224-
sender,
225-
)
226-
.await?;
209+
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, false, sender)
210+
.await?;
227211
if receiver.recv().await.is_err() {
228212
bail!("Failed to fetch UID {uid}");
229213
}

src/imap.rs

Lines changed: 12 additions & 57 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
@@ -728,7 +702,6 @@ impl Imap {
728702
.fetch_many_msgs(
729703
context,
730704
folder,
731-
uid_validity,
732705
uids_fetch_in_batch.split_off(0),
733706
&uid_message_ids,
734707
fetch_partially,
@@ -1337,12 +1310,10 @@ impl Session {
13371310
///
13381311
/// If the message is incorrect or there is a failure to write a message to the database,
13391312
/// it is skipped and the error is logged.
1340-
#[expect(clippy::too_many_arguments)]
13411313
pub(crate) async fn fetch_many_msgs(
13421314
&mut self,
13431315
context: &Context,
13441316
folder: &str,
1345-
uidvalidity: u32,
13461317
request_uids: Vec<u32>,
13471318
uid_message_ids: &BTreeMap<u32, String>,
13481319
fetch_partially: bool,
@@ -1466,18 +1437,7 @@ impl Session {
14661437
context,
14671438
"Passing message UID {} to receive_imf().", request_uid
14681439
);
1469-
match receive_imf_inner(
1470-
context,
1471-
folder,
1472-
uidvalidity,
1473-
request_uid,
1474-
rfc724_mid,
1475-
body,
1476-
is_seen,
1477-
partial,
1478-
)
1479-
.await
1480-
{
1440+
match receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await {
14811441
Ok(received_msg) => {
14821442
received_msgs_channel
14831443
.send((request_uid, received_msg))
@@ -1994,7 +1954,9 @@ pub async fn target_folder_cfg(
19941954

19951955
if folder_meaning == FolderMeaning::Spam {
19961956
spam_target_folder_cfg(context, headers).await
1997-
} else if needs_move_to_mvbox(context, headers).await? {
1957+
} else if folder_meaning == FolderMeaning::Inbox
1958+
&& needs_move_to_mvbox(context, headers).await?
1959+
{
19981960
Ok(Some(Config::ConfiguredMvboxFolder))
19991961
} else {
20001962
Ok(None)
@@ -2163,7 +2125,9 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
21632125
];
21642126
let lower = folder_name.to_lowercase();
21652127

2166-
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
2128+
if lower == "inbox" {
2129+
FolderMeaning::Inbox
2130+
} else if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21672131
FolderMeaning::Sent
21682132
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21692133
FolderMeaning::Spam
@@ -2322,15 +2286,6 @@ pub(crate) async fn prefetch_should_download(
23222286
Ok(should_download)
23232287
}
23242288

2325-
/// Returns whether a message is a duplicate (resent message).
2326-
pub(crate) fn is_dup_msg(is_chat_msg: bool, ts_sent: i64, ts_sent_old: i64) -> bool {
2327-
// If the existing message has timestamp_sent == 0, that means we don't know its actual sent
2328-
// timestamp, so don't delete the new message. E.g. outgoing messages have zero timestamp_sent
2329-
// because they are stored to the db before sending. Also consider as duplicates only messages
2330-
// with greater timestamp to avoid deleting both messages in a multi-device setting.
2331-
is_chat_msg && ts_sent_old != 0 && ts_sent > ts_sent_old
2332-
}
2333-
23342289
/// Marks messages in `msgs` table as seen, searching for them by UID.
23352290
///
23362291
/// 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
@@ -186,7 +186,7 @@ const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
186186
("Sent", false, false, "Sent"),
187187
("Sent", false, true, "Sent"),
188188
("Sent", true, false, "Sent"),
189-
("Sent", true, true, "DeltaChat"),
189+
("Sent", true, true, "Sent"),
190190
("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
191191
("Spam", false, true, "INBOX"),
192192
("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
@@ -202,7 +202,7 @@ const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
202202
("Sent", false, false, "Sent"),
203203
("Sent", false, true, "Sent"),
204204
("Sent", true, false, "Sent"),
205-
("Sent", true, true, "DeltaChat"),
205+
("Sent", true, true, "Sent"),
206206
("Spam", false, false, "Spam"),
207207
("Spam", false, true, "INBOX"),
208208
("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::Session as ImapSession;
66
use async_imap::types::Mailbox;
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)