C12.7: POP3 fetch by UIDL
This commit is contained in:
18 files changed
+394
-47
No files matched your search
@@ -8,7 +8,10 @@ use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector,
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
Imap,
|
||||
Pop,
|
||||
/// `keep` leaves each message on the server (§12.3).
|
||||
Pop {
|
||||
keep: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// A password, or OAuth with one provider.
|
||||
@@ -106,7 +109,7 @@ impl Account {
|
||||
.logout()
|
||||
.await
|
||||
}
|
||||
Protocol::Pop => Pop::connect(&self.incoming, login, tls).await?.quit().await,
|
||||
Protocol::Pop { .. } => Pop::connect(&self.incoming, login, tls).await?.quit().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ impl Pop {
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Marks it deleted; QUIT carries that out.
|
||||
pub async fn delete(&mut self, number: usize) -> Result<()> {
|
||||
within(
|
||||
"deleting the message",
|
||||
ask(&mut self.io, &format!("DELE {number}")),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
pub async fn quit(mut self) -> Result<()> {
|
||||
within("logging out", ask(&mut self.io, "QUIT")).await??;
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! IMAP folders as mailboxes, and how far they are synced.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{Transaction, params};
|
||||
|
||||
@@ -150,6 +150,15 @@ impl Store {
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// The UIDLs a POP3 mailbox holds.
|
||||
pub fn uidls(&self, mailbox: MailboxId) -> Result<HashSet<String>> {
|
||||
let mut query = self
|
||||
.conn
|
||||
.prepare("SELECT uidl FROM messages WHERE mailbox = ?1 AND uidl IS NOT NULL")?;
|
||||
let rows = query.query_map([mailbox.0], |row| row.get(0))?;
|
||||
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||
}
|
||||
|
||||
pub fn set_synced(&self, account: AccountId, at: i64) -> Result<()> {
|
||||
self.writable()?;
|
||||
let changed = self.conn.execute(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! IMAP sync: folders, flags, deletions and the 90-day window (§12.3).
|
||||
//! IMAP and POP3 sync (§12.3).
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -6,8 +6,8 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Result, Secret, Store,
|
||||
TlsConnector, Vault, oauth,
|
||||
Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Pop, Protocol, Result,
|
||||
Role, Secret, Store, TlsConnector, Vault, oauth,
|
||||
};
|
||||
|
||||
/// Bodies come down for mail this recent.
|
||||
@@ -23,6 +23,8 @@ pub struct Report {
|
||||
pub headers: usize,
|
||||
pub bodies: usize,
|
||||
pub removed: usize,
|
||||
/// POP3 messages deleted from the server.
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
/// The password, or an access token from the refresh token.
|
||||
@@ -83,6 +85,52 @@ pub async fn imap(
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// New mail by UIDL; no folders, local copies stay.
|
||||
pub async fn pop(
|
||||
account: &Account,
|
||||
id: AccountId,
|
||||
secret: Secret,
|
||||
tls: &TlsConnector,
|
||||
store: &Mutex<Store>,
|
||||
now: i64,
|
||||
) -> Result<Report> {
|
||||
let Protocol::Pop { keep } = account.protocol else {
|
||||
return Err(Error::Invalid("a POP3 sync of an IMAP account".into()));
|
||||
};
|
||||
let mut pop = Pop::connect(&account.incoming, &account.login(secret), tls).await?;
|
||||
let inbox = match lock(store)?.set_folders(id, &[("INBOX", Some(Role::Inbox))])?[..] {
|
||||
[inbox] => inbox,
|
||||
_ => return Err(Error::Store("a POP3 account has one inbox".into())),
|
||||
};
|
||||
let mut held = lock(store)?.uidls(inbox)?;
|
||||
let mut report = Report {
|
||||
folders: 1,
|
||||
..Report::default()
|
||||
};
|
||||
for (number, uidl) in pop.uids().await? {
|
||||
if !held.contains(&uidl) {
|
||||
let bytes = pop.fetch(number).await?;
|
||||
let key = Key::Pop { uidl: uidl.clone() };
|
||||
lock(store)?.put(inbox, &key, &[], Fetched::Whole(&bytes))?;
|
||||
held.insert(uidl);
|
||||
report.bodies += 1;
|
||||
}
|
||||
if !keep {
|
||||
pop.delete(number).await?;
|
||||
report.deleted += 1;
|
||||
}
|
||||
}
|
||||
pop.quit().await?;
|
||||
lock(store)?.set_synced(id, now)?;
|
||||
log::info!(
|
||||
"mail: account {} synced: {} new, {} deleted on the server",
|
||||
id.0,
|
||||
report.bodies,
|
||||
report.deleted
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn one(
|
||||
imap: &mut Imap,
|
||||
store: &Mutex<Store>,
|
||||
|
||||
@@ -11,7 +11,7 @@ fn pop_oauth(address: &str) -> Account {
|
||||
address: address.into(),
|
||||
user: "bo".into(),
|
||||
auth: Auth::Microsoft,
|
||||
protocol: Protocol::Pop,
|
||||
protocol: Protocol::Pop { keep: true },
|
||||
incoming: Server {
|
||||
host: "pop.example.com".into(),
|
||||
port: 995,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
mod fake;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ely_mail_engine::{
|
||||
Account, AccountId, Error, Key, Protocol, Role, Secret, Security, Store, sync,
|
||||
};
|
||||
use fake::{account, certs, expect, send, serve};
|
||||
|
||||
const NOW: i64 = 1_792_411_200;
|
||||
|
||||
fn opened(keep: bool) -> (tempfile::TempDir, Mutex<Store>, Account, AccountId) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
let account = Account {
|
||||
protocol: Protocol::Pop { keep },
|
||||
..account("ann@example.com")
|
||||
};
|
||||
let id = store.add_account(&account).unwrap();
|
||||
(dir, Mutex::new(store), account, id)
|
||||
}
|
||||
|
||||
/// A login, then `steps`.
|
||||
async fn run(
|
||||
store: &Mutex<Store>,
|
||||
account: &Account,
|
||||
id: AccountId,
|
||||
steps: Vec<fake::Step>,
|
||||
) -> Result<sync::Report, Error> {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("+OK ready\n"),
|
||||
expect("USER ann@example.com"),
|
||||
send("+OK\n"),
|
||||
expect("PASS pw"),
|
||||
send("+OK in\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (incoming, task) = serve(&certs, Security::Tls, script).await;
|
||||
let account = Account {
|
||||
incoming,
|
||||
..account.clone()
|
||||
};
|
||||
let secret = Secret::Password("pw".into());
|
||||
let done = sync::pop(&account, id, secret, &certs.trusted, store, NOW).await;
|
||||
if done.is_ok() {
|
||||
task.await.unwrap().unwrap();
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
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 mut out: Vec<_> = store
|
||||
.uidls(inbox)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|uidl| {
|
||||
let key = Key::Pop { uidl: uidl.clone() };
|
||||
let found = store.find(inbox, &key).unwrap().unwrap();
|
||||
let stored = store.get(found).unwrap();
|
||||
assert!(stored.body);
|
||||
(uidl, stored.subject.unwrap())
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn quit() -> [fake::Step; 2] {
|
||||
[expect("QUIT"), send("+OK bye\n")]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_mail_comes_down_once_and_stays_on_the_server() {
|
||||
let (_dir, store, account, id) = opened(true);
|
||||
let mut first = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n2 bb2\n.\n"),
|
||||
expect("RETR 1"),
|
||||
send("+OK\nSubject: One\n\nhi\n.\n"),
|
||||
expect("RETR 2"),
|
||||
send("+OK\nSubject: Two\n\nhi\n.\n"),
|
||||
];
|
||||
first.extend(quit());
|
||||
let report = run(&store, &account, id, first).await.unwrap();
|
||||
assert_eq!((report.bodies, report.deleted), (2, 0));
|
||||
assert_eq!(store.lock().unwrap().synced(id).unwrap().at, Some(NOW));
|
||||
let mut second = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n2 bb2\n3 cc3\n.\n"),
|
||||
expect("RETR 3"),
|
||||
send("+OK\nSubject: Three\n\nhi\n.\n"),
|
||||
];
|
||||
second.extend(quit());
|
||||
let report = run(&store, &account, id, second).await.unwrap();
|
||||
assert_eq!((report.bodies, report.deleted), (1, 0));
|
||||
let held = subjects(&store, id);
|
||||
assert_eq!(
|
||||
held,
|
||||
[("aa1", "One"), ("bb2", "Two"), ("cc3", "Three")]
|
||||
.map(|(uidl, subject)| (uidl.to_owned(), subject.to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_keep_each_stored_message_is_deleted() {
|
||||
let (_dir, store, account, id) = opened(false);
|
||||
let mut first = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n.\n"),
|
||||
expect("RETR 1"),
|
||||
send("+OK\nSubject: One\n\nhi\n.\n"),
|
||||
expect("DELE 1"),
|
||||
send("+OK deleted\n"),
|
||||
];
|
||||
first.extend(quit());
|
||||
run(&store, &account, id, first).await.unwrap();
|
||||
// A held message the server still lists goes too.
|
||||
let mut second = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n2 bb2\n.\n"),
|
||||
expect("DELE 1"),
|
||||
send("+OK deleted\n"),
|
||||
expect("RETR 2"),
|
||||
send("+OK\nSubject: Two\n\nhi\n.\n"),
|
||||
expect("DELE 2"),
|
||||
send("+OK deleted\n"),
|
||||
];
|
||||
second.extend(quit());
|
||||
let report = run(&store, &account, id, second).await.unwrap();
|
||||
assert_eq!((report.bodies, report.deleted), (1, 2));
|
||||
assert_eq!(subjects(&store, id).len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_uidl_listed_twice_comes_down_once() {
|
||||
for keep in [true, false] {
|
||||
let (_dir, store, account, id) = opened(keep);
|
||||
let mut steps = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n2 aa1\n.\n"),
|
||||
expect("RETR 1"),
|
||||
send("+OK\nSubject: One\n\nhi\n.\n"),
|
||||
];
|
||||
if !keep {
|
||||
steps.extend([
|
||||
expect("DELE 1"),
|
||||
send("+OK deleted\n"),
|
||||
expect("DELE 2"),
|
||||
send("+OK deleted\n"),
|
||||
]);
|
||||
}
|
||||
steps.extend(quit());
|
||||
let report = run(&store, &account, id, steps).await.unwrap();
|
||||
let deleted = if keep { 0 } else { 2 };
|
||||
assert_eq!((report.bodies, report.deleted), (1, deleted));
|
||||
assert_eq!(subjects(&store, id).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_message_stops_the_sync_before_any_delete() {
|
||||
let (_dir, store, account, id) = opened(false);
|
||||
let steps = vec![
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n.\n"),
|
||||
expect("RETR 1"),
|
||||
send("-ERR gone\n"),
|
||||
];
|
||||
let error = run(&store, &account, id, steps).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, Error::Server(ref text) if text == "gone"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(store.lock().unwrap().synced(id).unwrap().at, None);
|
||||
assert!(subjects(&store, id).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_imap_account_is_refused() {
|
||||
let (_dir, store, account, id) = opened(true);
|
||||
let account = Account {
|
||||
protocol: Protocol::Imap,
|
||||
..account
|
||||
};
|
||||
let secret = Secret::Password("pw".into());
|
||||
let error = sync::pop(&account, id, secret, &certs().trusted, &store, NOW)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, Error::Invalid(_)), "{error}");
|
||||
}
|
||||
@@ -118,7 +118,8 @@ async fn a_first_sync_takes_headers_and_recent_bodies() {
|
||||
folders: 2,
|
||||
headers: 1,
|
||||
bodies: 1,
|
||||
removed: 0
|
||||
removed: 0,
|
||||
deleted: 0
|
||||
}
|
||||
);
|
||||
let store = store.lock().unwrap();
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mail.add.outgoing", "Outgoing server (SMTP)"),
|
||||
("mail.add.port", "Port"),
|
||||
("mail.add.security", "Security"),
|
||||
("mail.add.keep", "Keep a copy on the server"),
|
||||
("mail.security.tls", "TLS"),
|
||||
("mail.security.starttls", "STARTTLS"),
|
||||
("mail.auth.title", "Sign-in"),
|
||||
@@ -115,6 +116,10 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mail.sync.running", "Syncing…"),
|
||||
("mail.sync.never", "Not yet"),
|
||||
("mail.sync.range", "Downloaded"),
|
||||
("mail.sync.range_pop", "Every new message, whole"),
|
||||
("mail.sync.server", "On the server"),
|
||||
("mail.sync.kept", "A copy stays"),
|
||||
("mail.sync.deleted", "Deleted once downloaded"),
|
||||
(
|
||||
"mail.sync.range_value",
|
||||
"Headers of all mail; text from the last {days} days; attachments when asked",
|
||||
@@ -138,7 +143,10 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"1 message has attachments not downloaded",
|
||||
),
|
||||
("mail.sync.failed", "The last sync failed: {error}"),
|
||||
("mail.sync.unread", "The sync state could not be read: {error}"),
|
||||
(
|
||||
"mail.sync.unread",
|
||||
"The sync state could not be read: {error}",
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
@@ -162,6 +170,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mail.add.outgoing", "发信服务器(SMTP)"),
|
||||
("mail.add.port", "端口"),
|
||||
("mail.add.security", "加密"),
|
||||
("mail.add.keep", "在服务器上保留副本"),
|
||||
("mail.security.tls", "TLS"),
|
||||
("mail.security.starttls", "STARTTLS"),
|
||||
("mail.auth.title", "登录方式"),
|
||||
@@ -244,7 +253,14 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mail.sync.running", "正在同步…"),
|
||||
("mail.sync.never", "尚未同步"),
|
||||
("mail.sync.range", "已下载"),
|
||||
("mail.sync.range_value", "全部邮件的邮件头;最近 {days} 天的正文;附件按需下载"),
|
||||
("mail.sync.range_pop", "每封新邮件整封下载"),
|
||||
("mail.sync.server", "服务器上"),
|
||||
("mail.sync.kept", "保留副本"),
|
||||
("mail.sync.deleted", "下载后删除"),
|
||||
(
|
||||
"mail.sync.range_value",
|
||||
"全部邮件的邮件头;最近 {days} 天的正文;附件按需下载",
|
||||
),
|
||||
("mail.sync.uncached", "未下载"),
|
||||
("mail.sync.uncached_value", "{n} 封邮件只有邮件头"),
|
||||
("mail.sync.uncached_value.one", "1 封邮件只有邮件头"),
|
||||
|
||||
Reference in new issue
Block a user