C12.7: POP3 fetch by UIDL

This commit is contained in:
雷电芽衣 committed 2026-10-19 21:40:25 -04:00
1 parent 890bd80d4d
commit 4d0d64ff54
18 files changed
+394 -47

No files matched your search

+5 -2
View File
@@ -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,
}
}
}
+9
View File
@@ -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(())
+10 -1
View File
@@ -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(
+51 -3
View File
@@ -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>,
+1 -1
View File
@@ -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,
+194
View File
@@ -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}");
}
+2 -1
View File
@@ -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();
+18 -2
View File
@@ -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 封邮件只有邮件头"),