Skip to content

Commit c49a1b1

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 afc74b0 commit c49a1b1

File tree

15 files changed

+104
-146
lines changed

15 files changed

+104
-146
lines changed

python/tests/test_1_online.py

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

309309

310310
def test_move_avoids_loop(acfactory):
311-
"""Test that the message is only moved once.
311+
"""Test that the message is only moved from INBOX to DeltaChat.
312312
313313
This is to avoid busy loop if moved message reappears in the Inbox
314314
or some scanned folder later.
@@ -319,27 +319,43 @@ def test_move_avoids_loop(acfactory):
319319
ac1 = acfactory.new_online_configuring_account()
320320
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
321321
acfactory.bring_accounts_online()
322+
323+
# Create INBOX.DeltaChat folder and make sure
324+
# it is detected by full folder scan.
325+
ac2.direct_imap.create_folder("INBOX.DeltaChat")
326+
ac2.stop_io()
327+
ac2.start_io()
328+
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
329+
322330
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
323331
ac1_chat.send_text("Message 1")
324332

325333
# Message is moved to the DeltaChat folder and downloaded.
326334
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
327335
assert ac2_msg1.text == "Message 1"
328336

329-
# Move the message to the INBOX again.
337+
# Move the message to the INBOX.DeltaChat again.
338+
# We assume that test server uses "." as the delimiter.
330339
ac2.direct_imap.select_folder("DeltaChat")
331-
ac2.direct_imap.conn.move(["*"], "INBOX")
340+
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
332341

333342
ac1_chat.send_text("Message 2")
334343
ac2_msg2 = ac2._evtracker.wait_next_incoming_message()
335344
assert ac2_msg2.text == "Message 2"
336345

337-
# Check that Message 1 is still in the INBOX folder
346+
# Stop and start I/O to trigger folder scan.
347+
ac2.stop_io()
348+
ac2.start_io()
349+
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
350+
351+
# Check that Message 1 is still in the INBOX.DeltaChat folder
338352
# and Message 2 is in the DeltaChat folder.
339353
ac2.direct_imap.select_folder("INBOX")
340-
assert len(ac2.direct_imap.get_all_messages()) == 1
354+
assert len(ac2.direct_imap.get_all_messages()) == 0
341355
ac2.direct_imap.select_folder("DeltaChat")
342356
assert len(ac2.direct_imap.get_all_messages()) == 1
357+
ac2.direct_imap.select_folder("INBOX.DeltaChat")
358+
assert len(ac2.direct_imap.get_all_messages()) == 1
343359

344360

345361
def test_move_works_on_self_sent(acfactory):
@@ -450,14 +466,19 @@ def test_resend_message(acfactory, lp):
450466
lp.sec("ac2: receive message")
451467
msg_in = ac2._evtracker.wait_next_incoming_message()
452468
assert msg_in.text == "message"
453-
chat2 = msg_in.chat
454-
chat2_msg_cnt = len(chat2.get_messages())
455469

456470
lp.sec("ac1: resend message")
457471
ac1.resend_messages([msg_in])
458472

459-
lp.sec("ac2: check that message is deleted")
460-
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
473+
lp.sec("ac1: send another message")
474+
chat1.send_text("another message")
475+
476+
lp.sec("ac2: receive another message")
477+
msg_in = ac2._evtracker.wait_next_incoming_message()
478+
assert msg_in.text == "another message"
479+
chat2 = msg_in.chat
480+
chat2_msg_cnt = len(chat2.get_messages())
481+
461482
assert len(chat2.get_messages()) == chat2_msg_cnt
462483

463484

@@ -1759,8 +1780,8 @@ def test_group_quote(acfactory, lp):
17591780
(
17601781
"Spam",
17611782
True,
1762-
"DeltaChat",
1763-
), # ...emails are moved from the spam folder to "DeltaChat"
1783+
"xyz",
1784+
), # ...emails are found in a random folder and downloaded without moving
17641785
(
17651786
"Spam",
17661787
False,

src/config.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -717,9 +717,6 @@ impl Context {
717717
_ => Default::default(),
718718
};
719719
self.set_config_internal(key, value).await?;
720-
if key == Config::SentboxWatch {
721-
self.last_full_folder_scan.lock().await.take();
722-
}
723720
Ok(())
724721
}
725722

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
@@ -473,7 +471,6 @@ impl Context {
473471
server_id: RwLock::new(None),
474472
metadata: RwLock::new(None),
475473
creation_time: tools::Time::now(),
476-
last_full_folder_scan: Mutex::new(None),
477474
last_error: parking_lot::RwLock::new("".to_string()),
478475
migration_error: parking_lot::RwLock::new(None),
479476
debug_logging: std::sync::RwLock::new(None),

src/download.rs

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -157,30 +157,23 @@ pub(crate) async fn download_msg(
157157
let row = context
158158
.sql
159159
.query_row_optional(
160-
"SELECT uid, folder, uidvalidity FROM imap WHERE rfc724_mid=? AND target!=''",
160+
"SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target!=''",
161161
(&msg.rfc724_mid,),
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: 11 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -620,52 +620,26 @@ impl Imap {
620620

621621
// Determine the target folder where the message should be moved to.
622622
//
623-
// If we have seen the message on the IMAP server before, do not move it.
623+
// We only move the messages from the INBOX and Spam folders.
624624
// This is required to avoid infinite MOVE loop on IMAP servers
625625
// that alias `DeltaChat` folder to other names.
626626
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
627-
// In this case Delta Chat configured with `DeltaChat` as the destination folder
628-
// would detect messages in the `INBOX.DeltaChat` folder
629-
// and try to move them to the `DeltaChat` folder.
630-
// Such move to the same folder results in the messages
631-
// getting a new UID, so the messages will be detected as new
627+
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
628+
// results in the messages getting a new UID,
629+
// so the messages will be detected as new
632630
// in the `INBOX.DeltaChat` folder again.
633631
let _target;
634632
let target = if let Some(message_id) = &message_id {
635633
let msg_info =
636634
message::rfc724_mid_exists_ex(context, message_id, "deleted=1").await?;
637-
let delete = if let Some((_, _, true)) = msg_info {
635+
let delete = if let Some((_, true)) = msg_info {
638636
info!(context, "Deleting locally deleted message {message_id}.");
639637
true
640-
} else if let Some((_, ts_sent_old, _)) = msg_info {
641-
let is_chat_msg = headers.get_header_value(HeaderDef::ChatVersion).is_some();
642-
let ts_sent = headers
643-
.get_header_value(HeaderDef::Date)
644-
.and_then(|v| mailparse::dateparse(&v).ok())
645-
.unwrap_or_default();
646-
let is_dup = is_dup_msg(is_chat_msg, ts_sent, ts_sent_old);
647-
if is_dup {
648-
info!(context, "Deleting duplicate message {message_id}.");
649-
}
650-
is_dup
651638
} else {
652639
false
653640
};
654641
if delete {
655642
&delete_target
656-
} else if context
657-
.sql
658-
.exists(
659-
"SELECT COUNT (*) FROM imap WHERE rfc724_mid=?",
660-
(message_id,),
661-
)
662-
.await?
663-
{
664-
info!(
665-
context,
666-
"Not moving the message {} that we have seen before.", &message_id
667-
);
668-
folder
669643
} else {
670644
_target = target_folder(context, folder, folder_meaning, &headers).await?;
671645
&_target
@@ -768,7 +742,6 @@ impl Imap {
768742
.fetch_many_msgs(
769743
context,
770744
folder,
771-
uid_validity,
772745
uids_fetch_in_batch.split_off(0),
773746
&uid_message_ids,
774747
fetch_partially,
@@ -1383,12 +1356,10 @@ impl Session {
13831356
///
13841357
/// If the message is incorrect or there is a failure to write a message to the database,
13851358
/// it is skipped and the error is logged.
1386-
#[expect(clippy::too_many_arguments)]
13871359
pub(crate) async fn fetch_many_msgs(
13881360
&mut self,
13891361
context: &Context,
13901362
folder: &str,
1391-
uidvalidity: u32,
13921363
request_uids: Vec<u32>,
13931364
uid_message_ids: &BTreeMap<u32, String>,
13941365
fetch_partially: bool,
@@ -1514,9 +1485,6 @@ impl Session {
15141485
);
15151486
let res = receive_imf_inner(
15161487
context,
1517-
folder,
1518-
uidvalidity,
1519-
request_uid,
15201488
rfc724_mid,
15211489
body,
15221490
is_seen,
@@ -1530,9 +1498,6 @@ impl Session {
15301498
}
15311499
receive_imf_inner(
15321500
context,
1533-
folder,
1534-
uidvalidity,
1535-
request_uid,
15361501
rfc724_mid,
15371502
body,
15381503
is_seen,
@@ -2112,7 +2077,9 @@ pub async fn target_folder_cfg(
21122077

21132078
if folder_meaning == FolderMeaning::Spam {
21142079
spam_target_folder_cfg(context, headers).await
2115-
} else if needs_move_to_mvbox(context, headers).await? {
2080+
} else if folder_meaning == FolderMeaning::Inbox
2081+
&& needs_move_to_mvbox(context, headers).await?
2082+
{
21162083
Ok(Some(Config::ConfiguredMvboxFolder))
21172084
} else {
21182085
Ok(None)
@@ -2260,7 +2227,9 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
22602227
];
22612228
let lower = folder_name.to_lowercase();
22622229

2263-
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
2230+
if lower == "inbox" {
2231+
FolderMeaning::Inbox
2232+
} else if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
22642233
FolderMeaning::Sent
22652234
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
22662235
FolderMeaning::Spam
@@ -2416,15 +2385,6 @@ pub(crate) async fn prefetch_should_download(
24162385
Ok(should_download)
24172386
}
24182387

2419-
/// Returns whether a message is a duplicate (resent message).
2420-
pub(crate) fn is_dup_msg(is_chat_msg: bool, ts_sent: i64, ts_sent_old: i64) -> bool {
2421-
// If the existing message has timestamp_sent == 0, that means we don't know its actual sent
2422-
// timestamp, so don't delete the new message. E.g. outgoing messages have zero timestamp_sent
2423-
// because they are stored to the db before sending. Also consider as duplicates only messages
2424-
// with greater timestamp to avoid deleting both messages in a multi-device setting.
2425-
is_chat_msg && ts_sent_old != 0 && ts_sent > ts_sent_old
2426-
}
2427-
24282388
/// Marks messages in `msgs` table as seen, searching for them by UID.
24292389
///
24302390
/// 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)