Skip to content

Commit ce0a681

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 a955cb5 commit ce0a681

File tree

15 files changed

+105
-158
lines changed

15 files changed

+105
-158
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
@@ -465,7 +463,6 @@ impl Context {
465463
server_id: RwLock::new(None),
466464
metadata: RwLock::new(None),
467465
creation_time: tools::Time::now(),
468-
last_full_folder_scan: Mutex::new(None),
469466
last_error: parking_lot::RwLock::new("".to_string()),
470467
migration_error: parking_lot::RwLock::new(None),
471468
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 & 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
@@ -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,
@@ -1342,7 +1315,6 @@ impl Session {
13421315
&mut self,
13431316
context: &Context,
13441317
folder: &str,
1345-
uidvalidity: u32,
13461318
request_uids: Vec<u32>,
13471319
uid_message_ids: &BTreeMap<u32, String>,
13481320
fetch_partially: bool,
@@ -1466,18 +1438,7 @@ impl Session {
14661438
context,
14671439
"Passing message UID {} to receive_imf().", request_uid
14681440
);
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-
{
1441+
match receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await {
14811442
Ok(received_msg) => {
14821443
received_msgs_channel
14831444
.send((request_uid, received_msg))
@@ -1994,7 +1955,9 @@ pub async fn target_folder_cfg(
19941955

19951956
if folder_meaning == FolderMeaning::Spam {
19961957
spam_target_folder_cfg(context, headers).await
1997-
} else if needs_move_to_mvbox(context, headers).await? {
1958+
} else if folder_meaning == FolderMeaning::Inbox
1959+
&& needs_move_to_mvbox(context, headers).await?
1960+
{
19981961
Ok(Some(Config::ConfiguredMvboxFolder))
19991962
} else {
20001963
Ok(None)
@@ -2163,7 +2126,9 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
21632126
];
21642127
let lower = folder_name.to_lowercase();
21652128

2166-
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
2129+
if lower == "inbox" {
2130+
FolderMeaning::Inbox
2131+
} else if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21672132
FolderMeaning::Sent
21682133
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
21692134
FolderMeaning::Spam
@@ -2322,15 +2287,6 @@ pub(crate) async fn prefetch_should_download(
23222287
Ok(should_download)
23232288
}
23242289

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-
23342290
/// Marks messages in `msgs` table as seen, searching for them by UID.
23352291
///
23362292
/// 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)