C12.8: sync now, timed sync, pause, offline folders
This commit is contained in:
23 files changed
+763
-66
No files matched your search
@@ -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};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
";
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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}"),
|
||||
];
|
||||
Reference in new issue
Block a user