C12.6: IMAP sync
This commit is contained in:
32 files changed
+2343
-276
No files matched your search
@@ -16,6 +16,7 @@ ely-mail-engine.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui.workspace = true
|
||||
jiff.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ impl Mail {
|
||||
accounts.push((id, account));
|
||||
}
|
||||
mail.picked = Some(id);
|
||||
mail.sync(id, cx);
|
||||
if mail.awaiting(asked).is_some() {
|
||||
mail.adding = None;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use ely_ui::Root;
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{adding::Adding, removing::Removing, side::Side};
|
||||
use crate::{adding::Adding, removing::Removing, side::Side, syncing::Syncs};
|
||||
|
||||
/// What the engine runs on and keeps to.
|
||||
pub struct Parts {
|
||||
@@ -43,6 +43,7 @@ pub struct Mail {
|
||||
pub picked: Option<AccountId>,
|
||||
pub adding: Option<Adding>,
|
||||
pub removing: Option<Removing>,
|
||||
pub syncs: Syncs,
|
||||
/// Numbers every request across dialogs.
|
||||
pub asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
@@ -100,6 +101,7 @@ impl Mail {
|
||||
picked: None,
|
||||
adding: None,
|
||||
removing: None,
|
||||
syncs: Syncs::new(),
|
||||
asked: 0,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
@@ -113,7 +115,8 @@ impl Mail {
|
||||
self.with_store(
|
||||
cx,
|
||||
|store| store.accounts(),
|
||||
|mail, read, _| {
|
||||
|mail, read, cx| {
|
||||
let first = mail.accounts.is_none();
|
||||
if let Ok(accounts) = &read {
|
||||
let still = |id| accounts.iter().any(|(each, _)| *each == id);
|
||||
mail.picked = mail
|
||||
@@ -122,6 +125,12 @@ impl Mail {
|
||||
.or_else(|| accounts.first().map(|(id, _)| *id));
|
||||
}
|
||||
mail.accounts = Some(read);
|
||||
if first {
|
||||
let ids: Vec<_> = mail.accounts().iter().map(|(id, _)| *id).collect();
|
||||
for id in ids {
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod mail;
|
||||
mod menu;
|
||||
mod removing;
|
||||
mod side;
|
||||
mod syncing;
|
||||
mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -55,6 +55,7 @@ impl Mail {
|
||||
return log::info!("mail: the account to remove is already gone");
|
||||
};
|
||||
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
|
||||
self.stop_sync(id);
|
||||
let address = removing.address;
|
||||
self.with_store(
|
||||
cx,
|
||||
@@ -80,7 +81,10 @@ impl Mail {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(error) => mail.failed("mail.remove.failed", &error, cx),
|
||||
Err(error) => {
|
||||
mail.failed("mail.remove.failed", &error, cx);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
}
|
||||
mail.read_accounts(cx);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! IMAP accounts sync on open and once added.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, Protocol, Synced, sync};
|
||||
use gpui::Context;
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::mail::Mail;
|
||||
|
||||
/// One account's sync as the window shows it.
|
||||
#[derive(Default)]
|
||||
pub struct Syncing {
|
||||
pub task: Option<AbortHandle>,
|
||||
pub synced: Option<Synced>,
|
||||
pub failed: Option<String>,
|
||||
}
|
||||
|
||||
pub type Syncs = HashMap<AccountId, Syncing>;
|
||||
|
||||
impl Mail {
|
||||
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
|
||||
return log::info!("mail: sync asked of a gone account");
|
||||
};
|
||||
if account.protocol != Protocol::Imap {
|
||||
return;
|
||||
}
|
||||
if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) {
|
||||
return log::info!("mail: account {} is already syncing", id.0);
|
||||
}
|
||||
let Ok(store) = self.store.clone() else {
|
||||
return log::error!("mail: no store to sync with");
|
||||
};
|
||||
let tls = match self.tls.clone() {
|
||||
Ok(tls) => tls,
|
||||
Err(error) => {
|
||||
log::warn!("mail: account {} cannot sync without TLS", id.0);
|
||||
self.syncs.entry(id).or_default().failed = Some(error);
|
||||
return self.read_synced(id, cx);
|
||||
}
|
||||
};
|
||||
let (account, vault) = (account.clone(), self.vault.clone());
|
||||
let work = async move {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))?
|
||||
.as_secs() as i64;
|
||||
let secret = sync::secret(&account, &vault).await?;
|
||||
sync::imap(&account, id, secret, &tls, &store, now).await
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
let failed = match done {
|
||||
Ok(Ok(_)) => None,
|
||||
Ok(Err(error)) => Some(error.to_string()),
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
if failed.is_some() {
|
||||
log::warn!("mail: account {} did not sync", id.0);
|
||||
}
|
||||
let Some(syncing) = mail.syncs.get_mut(&id) else {
|
||||
return log::info!("mail: a sync ended for a removed account");
|
||||
};
|
||||
syncing.task = None;
|
||||
syncing.failed = failed;
|
||||
mail.read_synced(id, cx);
|
||||
});
|
||||
self.syncs.entry(id).or_default().task = Some(task);
|
||||
self.read_synced(id, cx);
|
||||
}
|
||||
|
||||
/// Stops the account's sync and forgets it.
|
||||
pub fn stop_sync(&mut self, id: AccountId) {
|
||||
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.synced(id),
|
||||
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
|
||||
(Ok(synced), Some(syncing)) => syncing.synced = Some(synced),
|
||||
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
|
||||
(_, None) => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{Account, Auth, Backend, Checked, Protocol, Security, Server, Store, Vault};
|
||||
use ely_mail_engine::{
|
||||
Account, Auth, Backend, Checked, Protocol, Security, Server, Store, TlsConnector, Vault,
|
||||
};
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use crate::{
|
||||
@@ -20,6 +22,7 @@ use crate::{
|
||||
};
|
||||
|
||||
mod flows;
|
||||
mod syncs;
|
||||
|
||||
/// A credential store that keeps or refuses.
|
||||
#[derive(Clone, Default)]
|
||||
@@ -69,6 +72,16 @@ pub(crate) fn window<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
let tls = ely_mail_engine::system_tls().map_err(|err| err.to_string());
|
||||
window_with(cx, dir, keys, tls)
|
||||
}
|
||||
|
||||
pub(crate) fn window_with<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
tls: Result<TlsConnector, String>,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
@@ -89,7 +102,7 @@ pub(crate) fn window<'a>(
|
||||
let parts = Parts {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::new(Box::new(keys)),
|
||||
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
|
||||
tls,
|
||||
};
|
||||
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
|
||||
settle(cx, |app| mail.read(app).accounts.is_some());
|
||||
@@ -102,9 +115,10 @@ pub(crate) fn closed_port() -> u16 {
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
/// On this machine's discard port, so syncs end at once.
|
||||
pub(crate) fn account(address: &str) -> Account {
|
||||
let server = |host: &str, port| Server {
|
||||
host: host.into(),
|
||||
let server = |port| Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security: Security::Tls,
|
||||
};
|
||||
@@ -113,8 +127,8 @@ pub(crate) fn account(address: &str) -> Account {
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server("imap.example.com", 993),
|
||||
outgoing: server("smtp.example.com", 465),
|
||||
incoming: server(9),
|
||||
outgoing: server(9),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use ely_mail_engine::{Protocol, Store};
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{Keys, account, closed_port, settle, window, window_with};
|
||||
|
||||
#[test]
|
||||
fn an_imap_account_syncs_on_open_and_says_why_it_failed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = closed_port();
|
||||
let ann = store.add_account(&ann).unwrap();
|
||||
let pop = ely_mail_engine::Account {
|
||||
protocol: Protocol::Pop,
|
||||
..account("bo@example.com")
|
||||
};
|
||||
let bo = store.add_account(&pop).unwrap();
|
||||
drop(store);
|
||||
let keys = Keys::default();
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys);
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.task.is_none() && each.synced.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
let syncing = &mail.syncs[&ann];
|
||||
let failed = syncing.failed.as_deref().unwrap();
|
||||
assert!(failed.starts_with("cannot connect"), "{failed}");
|
||||
assert_eq!(syncing.synced.unwrap().at, None);
|
||||
assert!(!mail.syncs.contains_key(&bo), "POP3 is collected apart");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_without_its_secret_says_so() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let failed = mail.read(app).syncs[&ann].failed.clone().unwrap();
|
||||
assert_eq!(
|
||||
failed,
|
||||
"credential store failed: no saved secret for account:ann@example.com"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_tls_is_the_sync_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let tls = Err("platform trust store unavailable".to_owned());
|
||||
let (mail, cx) = window_with(&mut cx, dir.path(), Keys::default(), tls);
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.synced.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
assert_eq!(
|
||||
syncing.failed.as_deref(),
|
||||
Some("platform trust store unavailable")
|
||||
);
|
||||
assert!(syncing.task.is_none());
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use ely_gpui_component::{
|
||||
primitives::{IconName, Severity},
|
||||
typography::format,
|
||||
};
|
||||
use ely_mail_engine::{Account, Auth, Protocol, Security, Server};
|
||||
use ely_mail_engine::{Account, AccountId, Auth, Protocol, Security, Server, sync};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Shell;
|
||||
use gpui::{
|
||||
@@ -77,7 +77,7 @@ impl Mail {
|
||||
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
|
||||
Some(Ok(accounts)) => accounts,
|
||||
};
|
||||
let Some((_, account)) = accounts.iter().find(|(id, _)| Some(*id) == self.picked) else {
|
||||
let Some((id, account)) = accounts.iter().find(|(id, _)| Some(*id) == self.picked) else {
|
||||
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
|
||||
.action(self.add_button(cx))
|
||||
.into_any_element();
|
||||
@@ -97,6 +97,7 @@ impl Mail {
|
||||
server(&account.outgoing, cx),
|
||||
)
|
||||
.text(label("mail.account.auth"), auth(account.auth, cx));
|
||||
let (details, failed) = self.sync_rows(*id, details, cx);
|
||||
let remove = Button::new("mail-remove", label("mail.remove.open"))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
|
||||
div()
|
||||
@@ -105,11 +106,53 @@ impl Mail {
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.max_w_128()
|
||||
.children(failed)
|
||||
.child(details)
|
||||
.child(div().child(remove))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// When it last synced, what is here, why it failed.
|
||||
fn sync_rows(
|
||||
&self,
|
||||
id: AccountId,
|
||||
details: DescriptionList,
|
||||
cx: &App,
|
||||
) -> (DescriptionList, Option<Banner>) {
|
||||
let Some(syncing) = self.syncs.get(&id) else {
|
||||
return (details, None);
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
|
||||
(Some(_), _) => label("mail.sync.running"),
|
||||
(None, Some(at)) => local_time(at).into(),
|
||||
(None, None) => label("mail.sync.never"),
|
||||
};
|
||||
let days = sync::BODY_DAYS.to_string();
|
||||
let mut details = details.text(label("mail.sync.last"), last).text(
|
||||
label("mail.sync.range"),
|
||||
text(cx, "mail.sync.range_value", &[("days", &days)]),
|
||||
);
|
||||
if let Some(synced) = syncing.synced {
|
||||
for (key, n) in [
|
||||
("mail.sync.uncached", synced.uncached),
|
||||
("mail.sync.partial", synced.partial),
|
||||
] {
|
||||
let value = format!("{key}_value");
|
||||
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
|
||||
details = details.text(label(key), words);
|
||||
}
|
||||
}
|
||||
let failed = syncing.failed.as_ref().map(|error| {
|
||||
Banner::new(
|
||||
"mail-sync-failed",
|
||||
Severity::Danger,
|
||||
text(cx, "mail.sync.failed", &[("error", error)]),
|
||||
)
|
||||
});
|
||||
(details, failed)
|
||||
}
|
||||
|
||||
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let removing = self.removing.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
@@ -184,3 +227,17 @@ impl Render for Mail {
|
||||
.children(self.remove_dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Local time to the minute.
|
||||
fn local_time(at: i64) -> String {
|
||||
match jiff::Timestamp::from_second(at) {
|
||||
Ok(at) => at
|
||||
.to_zoned(jiff::tz::TimeZone::system())
|
||||
.strftime("%Y-%m-%d %H:%M")
|
||||
.to_string(),
|
||||
Err(error) => {
|
||||
log::error!("mail: a sync time out of range: {error}");
|
||||
at.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user