C12.8: sync now, timed sync, pause, offline folders

This commit is contained in:
雷电芽衣 committed 2026-10-19 22:29:27 -04:00
1 parent 4d0d64ff54
commit 22d913d45e
23 files changed
+763 -66

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ pub use net::{Login, Secret, Security, Server, system_tls};
pub use pop::Pop;
pub use smtp::Smtp;
pub use store::{
AccountId, Cached, Fetched, Held, Key, MailboxId, MessageId, Sizes, Store, Stored, Synced,
AccountId, Cached, Fetched, Held, Key, LocalFolder, MailboxId, MessageId, Sizes, Store, Stored, Synced,
};
pub use tokio_rustls::TlsConnector;
pub use vault::{Backend, Kept, Keyring, Vault};
+1 -1
View File
@@ -21,7 +21,7 @@ mod search;
mod tests;
pub use accounts::Cached;
pub use folders::{Held, Synced};
pub use folders::{Held, LocalFolder, Synced};
pub use messages::{Fetched, Key, Stored};
const DB: &str = "mail.db";
+52 -10
View File
@@ -13,10 +13,22 @@ pub struct Held {
pub id: MessageId,
/// The body is here, perhaps without attachments.
pub body: bool,
/// Every part is here.
pub whole: bool,
/// Sorted.
pub flags: Vec<String>,
}
/// A local mailbox.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalFolder {
pub id: MailboxId,
pub name: String,
pub role: Option<Role>,
/// Every message comes down whole.
pub offline: bool,
}
/// An account's sync, as the window shows it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Synced {
@@ -67,30 +79,58 @@ impl Store {
}
/// The account's mailboxes in name order.
pub fn folders(&self, account: AccountId) -> Result<Vec<(MailboxId, String, Option<Role>)>> {
let mut query = self
.conn
.prepare("SELECT id, name, role FROM mailboxes WHERE account = ?1 ORDER BY name")?;
pub fn folders(&self, account: AccountId) -> Result<Vec<LocalFolder>> {
let mut query = self.conn.prepare(
"SELECT id, name, role, offline FROM mailboxes WHERE account = ?1 ORDER BY name",
)?;
let rows = query.query_map([account.0], |row| {
Ok((
MailboxId(row.get(0)?),
row.get(1)?,
row.get::<_, Option<String>>(2)?,
row.get(3)?,
))
})?;
rows.map(|row| {
let (id, name, role) = row?;
let (id, name, role, offline) = row?;
let role = role
.map(|role| {
Role::from_name(&role)
.ok_or_else(|| Error::Store(format!("an unknown folder role {role:?}")))
})
.transpose()?;
Ok((id, name, role))
Ok(LocalFolder {
id,
name,
role,
offline,
})
})
.collect()
}
/// Whether the mailbox's mail comes down whole.
pub fn offline(&self, mailbox: MailboxId) -> Result<bool> {
Ok(self.conn.query_row(
"SELECT offline FROM mailboxes WHERE id = ?1",
[mailbox.0],
|row| row.get(0),
)?)
}
pub fn set_offline(&self, mailbox: MailboxId, offline: bool) -> Result<()> {
self.writable()?;
let changed = self.conn.execute(
"UPDATE mailboxes SET offline = ?1 WHERE id = ?2",
params![offline, mailbox.0],
)?;
if changed != 1 {
return Err(Error::Store(format!("no mailbox {}", mailbox.0)));
}
log::info!("mail: mailbox {} offline: {offline}", mailbox.0);
Ok(())
}
/// Held messages by UID; mail under another UIDVALIDITY goes.
pub fn open_mailbox(
&mut self,
@@ -116,19 +156,21 @@ impl Store {
}
let mut held: HashMap<u32, Held> = HashMap::new();
let mut query = self.conn.prepare(
"SELECT id, uid, content IS NOT NULL FROM messages
WHERE mailbox = ?1 AND uidvalidity = ?2",
"SELECT id, uid, content IS NOT NULL, content IS NOT NULL AND missing = '[]'
FROM messages WHERE mailbox = ?1 AND uidvalidity = ?2",
)?;
let rows = query.query_map(params![mailbox.0, uidvalidity], |row| {
Ok((row.get::<_, u32>(1)?, MessageId(row.get(0)?), row.get(2)?))
let id = MessageId(row.get(0)?);
Ok((row.get::<_, u32>(1)?, id, row.get(2)?, row.get(3)?))
})?;
for row in rows {
let (uid, id, body) = row?;
let (uid, id, body, whole) = row?;
held.insert(
uid,
Held {
id,
body,
whole,
flags: Vec::new(),
},
);
+6 -1
View File
@@ -1,5 +1,5 @@
/// Step `n` upgrades version `n`.
pub(super) const STEPS: &[&str] = &[V1, V2];
pub(super) const STEPS: &[&str] = &[V1, V2, V3];
const V1: &str = "
CREATE TABLE accounts (
@@ -58,3 +58,8 @@ ALTER TABLE mailboxes ADD COLUMN role TEXT;
ALTER TABLE mailboxes ADD COLUMN uidvalidity INTEGER;
ALTER TABLE messages ADD COLUMN missing TEXT NOT NULL DEFAULT '[]';
";
/// Folders kept whole (C12.8).
const V3: &str = "
ALTER TABLE mailboxes ADD COLUMN offline INTEGER NOT NULL DEFAULT 0;
";
+4 -3
View File
@@ -143,9 +143,10 @@ fn a_v1_store_gains_sync_and_keeps_its_mail() {
store
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap();
let [(inbox, name, role)] = store.folders(ann).unwrap().try_into().unwrap();
assert_eq!((name.as_str(), role), ("INBOX", Some(Role::Inbox)));
let held = store.open_mailbox(inbox, 7).unwrap();
let [inbox] = store.folders(ann).unwrap().try_into().unwrap();
assert_eq!((inbox.name.as_str(), inbox.role), ("INBOX", Some(Role::Inbox)));
assert!(!inbox.offline);
let held = store.open_mailbox(inbox.id, 7).unwrap();
assert_eq!(held.keys().copied().collect::<Vec<_>>(), [1]);
assert!(held[&1].body);
store.set_synced(ann, 9).unwrap();
+20 -4
View File
@@ -158,6 +158,26 @@ async fn one(
_ => {}
}
}
let key = |uid| Key::Imap {
uidvalidity: opened.uidvalidity,
uid,
};
if lock(store)?.offline(mailbox)? {
let mut whole: Vec<u32> = server
.keys()
.filter(|uid| !held.get(uid).is_some_and(|held| held.whole))
.copied()
.collect();
whole.sort_unstable();
for batch in whole.chunks(BATCH) {
for got in imap.bodies(batch).await? {
let fetched = Fetched::Whole(&got.bytes);
put(store, mailbox, key(got.uid), &got, fetched)?;
report.bodies += 1;
}
}
return Ok(());
}
let recent: HashSet<u32> = imap.since(day).await?.into_iter().collect();
let mut headers = Vec::new();
let mut bodies = Vec::new();
@@ -170,10 +190,6 @@ async fn one(
}
headers.sort_unstable();
bodies.sort_unstable();
let key = |uid| Key::Imap {
uidvalidity: opened.uidvalidity,
uid,
};
for batch in headers.chunks(BATCH) {
for got in imap.headers(batch).await? {
put(
+4 -4
View File
@@ -51,15 +51,15 @@ async fn run(
fn subjects(store: &Mutex<Store>, id: AccountId) -> Vec<(String, String)> {
let store = store.lock().unwrap();
let [(inbox, name, role)] = store.folders(id).unwrap().try_into().unwrap();
assert_eq!((name.as_str(), role), ("INBOX", Some(Role::Inbox)));
let [inbox] = store.folders(id).unwrap().try_into().unwrap();
assert_eq!((inbox.name.as_str(), inbox.role), ("INBOX", Some(Role::Inbox)));
let mut out: Vec<_> = store
.uidls(inbox)
.uidls(inbox.id)
.unwrap()
.into_iter()
.map(|uidl| {
let key = Key::Pop { uidl: uidl.clone() };
let found = store.find(inbox, &key).unwrap().unwrap();
let found = store.find(inbox.id, &key).unwrap().unwrap();
let stored = store.get(found).unwrap();
assert!(stored.body);
(uidl, stored.subject.unwrap())
+41 -4
View File
@@ -98,7 +98,7 @@ fn key(uidvalidity: u32, uid: u32) -> Key {
}
fn inbox(store: &Store, id: AccountId) -> MailboxId {
store.folders(id).unwrap()[0].0
store.folders(id).unwrap()[0].id
}
fn opened() -> (tempfile::TempDir, Mutex<Store>, AccountId) {
@@ -127,7 +127,7 @@ async fn a_first_sync_takes_headers_and_recent_bodies() {
.folders(id)
.unwrap()
.into_iter()
.map(|(_, name, role)| (name, role))
.map(|folder| (folder.name, folder.role))
.collect();
assert_eq!(
folders,
@@ -319,7 +319,10 @@ async fn inbox_casing_does_not_destroy_cached_mail() {
.unwrap();
let folders = store.folders(id).unwrap();
assert_eq!(folders.len(), 1);
assert_eq!((kept[0], folders[0].1.as_str()), (folders[0].0, "INBOX"));
assert_eq!(
(kept[0], folders[0].name.as_str()),
(folders[0].id, "INBOX")
);
let new = store.find(kept[0], &key(7, 2)).unwrap().unwrap();
assert_eq!(
store.original(new).unwrap().unwrap(),
@@ -345,7 +348,7 @@ async fn roles_are_asked_for_when_the_server_can_list_them() {
.await
.unwrap();
let folders = store.lock().unwrap().folders(id).unwrap();
assert_eq!(folders[0].2, Some(Role::Sent));
assert_eq!(folders[0].role, Some(Role::Sent));
}
/// One FETCH line carrying each section as a literal.
@@ -408,3 +411,37 @@ async fn a_recent_body_sync_leaves_attachments_on_the_server() {
assert!(mail.attachments[0].bytes.is_empty());
assert_eq!(store.synced(id).unwrap().partial, 1);
}
#[tokio::test]
async fn an_offline_folder_takes_every_message_whole() {
let (_dir, store, id) = opened();
run(&store, id, first_sync()).await.unwrap();
let mailbox = inbox(&store.lock().unwrap(), id);
store.lock().unwrap().set_offline(mailbox, true).unwrap();
let steps = vec![
expect("{tag} CAPABILITY"),
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
expect("{tag} LIST \"\" *"),
send("* LIST (\\HasNoChildren) \"/\" INBOX\n"),
send("* LIST (\\HasNoChildren \\Sent) \"/\" Sent\n{tag} OK listed\n"),
expect("{tag} EXAMINE \"INBOX\""),
send("* 2 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK in\n"),
expect("{tag} UID FETCH 1:* (UID FLAGS)"),
send("* 1 FETCH (UID 1 FLAGS (\\Seen))\n* 2 FETCH (UID 2 FLAGS ())\n"),
send("{tag} OK flags\n"),
expect("{tag} UID FETCH 1 (UID FLAGS BODY.PEEK[])"),
send(&literal(1, "\\Seen", "BODY[]", &format!("{OLD}kept\n"))),
send("{tag} OK bodies\n"),
expect("{tag} EXAMINE \"Sent\""),
send("* 0 EXISTS\n* OK [UIDVALIDITY 3] v\n{tag} OK in\n"),
];
let report = run(&store, id, steps).await.unwrap();
assert_eq!((report.headers, report.bodies), (0, 1));
let mut store = store.lock().unwrap();
assert!(store.folders(id).unwrap()[0].offline);
let held = store.open_mailbox(mailbox, 7).unwrap();
assert!(held[&1].whole && held[&2].whole);
let old = store.find(mailbox, &key(7, 1)).unwrap().unwrap();
let bytes = store.original(old).unwrap().unwrap();
assert!(String::from_utf8(bytes).unwrap().ends_with("kept\r\n"));
}
+37
View File
@@ -124,6 +124,16 @@ pub(super) const EN: &[(&str, &str)] = &[
"mail.sync.range_value",
"Headers of all mail; text from the last {days} days; attachments when asked",
),
(
"mail.sync.range_rest",
"Other folders: headers of all mail; text from the last {days} days; attachments when asked",
),
("mail.sync.whole", "Kept whole"),
(
"mail.sync.whole_value",
"Every message in {names}, with attachments",
),
("mail.sync.whole_sep", ", "),
("mail.sync.uncached", "Not downloaded"),
(
"mail.sync.uncached_value",
@@ -147,6 +157,19 @@ pub(super) const EN: &[(&str, &str)] = &[
"mail.sync.unread",
"The sync state could not be read: {error}",
),
("mail.menu.sync", "Sync"),
("mail.sync.now", "Sync Now"),
("mail.sync.pause", "Pause Sync"),
("mail.sync.resume", "Resume Sync"),
(
"mail.sync.paused",
"Sync is paused. Nothing downloads until you resume.",
),
("mail.sync.offline", "Keep {name} whole offline"),
(
"mail.sync.offline_failed",
"The folder setting was not saved: {error}",
),
];
pub(super) const ZH_CN: &[(&str, &str)] = &[
@@ -261,6 +284,13 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
"mail.sync.range_value",
"全部邮件的邮件头;最近 {days} 天的正文;附件按需下载",
),
(
"mail.sync.range_rest",
"其他文件夹:全部邮件的邮件头;最近 {days} 天的正文;附件按需下载",
),
("mail.sync.whole", "完整保留"),
("mail.sync.whole_value", "{names} 的每封邮件,连同附件"),
("mail.sync.whole_sep", "、"),
("mail.sync.uncached", "未下载"),
("mail.sync.uncached_value", "{n} 封邮件只有邮件头"),
("mail.sync.uncached_value.one", "1 封邮件只有邮件头"),
@@ -269,4 +299,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("mail.sync.partial_value.one", "1 封邮件的附件未下载"),
("mail.sync.failed", "上次同步失败:{error}"),
("mail.sync.unread", "读不出同步状态:{error}"),
("mail.menu.sync", "同步"),
("mail.sync.now", "立即同步"),
("mail.sync.pause", "暂停同步"),
("mail.sync.resume", "恢复同步"),
("mail.sync.paused", "同步已暂停。恢复之前不下载任何内容。"),
("mail.sync.offline", "离线完整保留 {name}"),
("mail.sync.offline_failed", "文件夹设置未存下:{error}"),
];