C12.5: mail accounts, add and remove
ElyMail's window lists accounts and adds Gmail, Microsoft, iCloud (app-specific password), IMAP and POP3 ones; a check logs in to both servers and reports each. Accounts live in the store as JSON with a case-insensitive unique address; secrets go to the keychain. Removal names the cache it deletes, takes the secret first and puts it back when local removal fails. Originals are set aside until commit and restored after a crash. Requests are numbered so late results stay with their own dialog. The account list is keyboard-navigable.
This commit is contained in:
39 files changed
+3152
-105
No files matched your search
@@ -0,0 +1,270 @@
|
||||
//! The add-account dialog (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, InlineMessage},
|
||||
forms::{Input, PasswordInput},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_mail_engine::{Auth, Error, Security};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _,
|
||||
WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::{APPLE_ACCOUNT, KINDS, Kind},
|
||||
mail::{Mail, text},
|
||||
view::auth,
|
||||
};
|
||||
|
||||
type Edit = fn(&mut Adding, &str, &mut App);
|
||||
|
||||
/// A segmented choice whose change edits the dialog.
|
||||
fn choice(
|
||||
this: &WeakEntity<Mail>,
|
||||
id: &'static str,
|
||||
picked: &str,
|
||||
options: impl IntoIterator<Item = (&'static str, SharedString)>,
|
||||
edit: Edit,
|
||||
) -> SegmentedControl {
|
||||
let this = this.clone();
|
||||
options
|
||||
.into_iter()
|
||||
.fold(
|
||||
SegmentedControl::new(id, picked.to_owned()),
|
||||
|control, (key, label)| control.segment(key, label, None),
|
||||
)
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |mail, cx| {
|
||||
if let Some(adding) = &mut mail.adding {
|
||||
edit(adding, key, cx);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn security_key(security: Security) -> &'static str {
|
||||
match security {
|
||||
Security::Tls => "tls",
|
||||
Security::StartTls => "starttls",
|
||||
}
|
||||
}
|
||||
|
||||
fn security_of(key: &str) -> Security {
|
||||
match key {
|
||||
"tls" => Security::Tls,
|
||||
_ => Security::StartTls,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_key(auth: Auth) -> &'static str {
|
||||
match auth {
|
||||
Auth::Password => "password",
|
||||
Auth::Google => "google",
|
||||
Auth::Microsoft => "microsoft",
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn add_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let adding = self.adding.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| mail.close_adding(cx)).ok();
|
||||
}
|
||||
};
|
||||
let kinds = KINDS.map(|kind| {
|
||||
(
|
||||
kind.key(),
|
||||
text(cx, &format!("mail.kind.{}", kind.key()), &[]),
|
||||
)
|
||||
});
|
||||
let kinds = choice(
|
||||
&this,
|
||||
"mail-add-kind",
|
||||
adding.kind.key(),
|
||||
kinds,
|
||||
|adding, key, cx| {
|
||||
if let Some(kind) = Kind::from_key(key) {
|
||||
adding.set_kind(kind, cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
let oauth = adding.auth() != Auth::Password;
|
||||
let unavailable = match adding.auth().provider() {
|
||||
Some(Err(Error::Unavailable(name))) => {
|
||||
Some(text(cx, &format!("mail.add.unavailable.{name}"), &[]))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let cancel = text(cx, "mail.cancel", &[]);
|
||||
let (go_key, go): (&str, fn(&mut Mail, &mut Context<Mail>)) = match &adding.step {
|
||||
Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save),
|
||||
_ if oauth => ("mail.add.sign_in", Mail::sign_in),
|
||||
_ => ("mail.add.check", Mail::check),
|
||||
};
|
||||
let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]);
|
||||
let busy = adding.busy();
|
||||
let blocked = busy || (unavailable.is_some() && oauth);
|
||||
let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss)
|
||||
.child(kinds)
|
||||
.children(
|
||||
unavailable
|
||||
.map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)),
|
||||
)
|
||||
.child(self.add_fields(adding, &this, cx))
|
||||
.children(status(adding, cx))
|
||||
.action(move |close| {
|
||||
Button::new("mail-add-cancel", cancel)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action(move |_| {
|
||||
Button::new("mail-add-go", go_label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.loading(busy)
|
||||
.disabled(blocked)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, go).ok();
|
||||
})
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
|
||||
fn add_fields(&self, adding: &Adding, this: &WeakEntity<Mail>, cx: &App) -> AnyElement {
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let field = |input| div().w_full().child(Input::new(input));
|
||||
let fields = &adding.fields;
|
||||
let password = div().w_full().child(PasswordInput::new(&fields.password));
|
||||
match adding.kind {
|
||||
Kind::Gmail | Kind::Microsoft => DescriptionList::new()
|
||||
.item(label("mail.add.address_hint"), field(&fields.address))
|
||||
.into_any_element(),
|
||||
Kind::ICloud => {
|
||||
let open = Button::new("mail-add-apple", label("mail.icloud.open"))
|
||||
.on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT));
|
||||
let rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.icloud.password"), password);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(label("mail.icloud.steps"))
|
||||
.child(div().child(open))
|
||||
.child(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
Kind::Imap | Kind::Pop => {
|
||||
let auths = [Auth::Password, Auth::Google, Auth::Microsoft]
|
||||
.map(|each| (auth_key(each), auth(each, cx)));
|
||||
let methods = choice(
|
||||
this,
|
||||
"mail-add-auth",
|
||||
auth_key(adding.auth),
|
||||
auths,
|
||||
|adding, key, _| {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
adding.auth = match key {
|
||||
"google" => Auth::Google,
|
||||
"microsoft" => Auth::Microsoft,
|
||||
_ => Auth::Password,
|
||||
};
|
||||
},
|
||||
);
|
||||
let securities = |outgoing: bool, picked: Security| {
|
||||
let options = [Security::Tls, Security::StartTls].map(|each| {
|
||||
(
|
||||
security_key(each),
|
||||
label(&format!("mail.security.{}", security_key(each))),
|
||||
)
|
||||
});
|
||||
let id = if outgoing {
|
||||
"mail-add-out-security"
|
||||
} else {
|
||||
"mail-add-in-security"
|
||||
};
|
||||
let edit: Edit = if outgoing {
|
||||
|adding, key, cx| adding.set_security(true, security_of(key), cx)
|
||||
} else {
|
||||
|adding, key, cx| adding.set_security(false, security_of(key), cx)
|
||||
};
|
||||
choice(this, id, security_key(picked), options, edit)
|
||||
};
|
||||
let mut rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.auth.title"), methods);
|
||||
if adding.auth == Auth::Password {
|
||||
rows = rows
|
||||
.item(label("mail.add.user"), field(&fields.user))
|
||||
.item(label("mail.add.password"), password);
|
||||
}
|
||||
rows.item(label("mail.add.incoming"), field(&fields.incoming))
|
||||
.item(label("mail.add.port"), field(&fields.incoming_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(false, adding.incoming_security),
|
||||
)
|
||||
.item(label("mail.add.outgoing"), field(&fields.outgoing))
|
||||
.item(label("mail.add.port"), field(&fields.outgoing_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(true, adding.outgoing_security),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each server's answer, or what is under way.
|
||||
fn status(adding: &Adding, cx: &App) -> Option<AnyElement> {
|
||||
let line =
|
||||
|severity, words: SharedString| InlineMessage::new(severity, words).into_any_element();
|
||||
let said = |key: &str, result: &ely_mail_engine::Result<()>| match result {
|
||||
Ok(()) => line(
|
||||
Severity::Success,
|
||||
text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]),
|
||||
),
|
||||
Err(error) => line(
|
||||
Severity::Danger,
|
||||
text(cx, key, &[("result", &failure(error, cx))]),
|
||||
),
|
||||
};
|
||||
Some(match &adding.step {
|
||||
Step::Editing => return None,
|
||||
Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])),
|
||||
Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])),
|
||||
Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])),
|
||||
Step::Failed(words) => line(Severity::Danger, words.clone()),
|
||||
Step::Checked { checked, .. } => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(said("mail.check.incoming", &checked.incoming))
|
||||
.child(said("mail.check.outgoing", &checked.outgoing))
|
||||
.into_any_element(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A server's refusal in the reader's language, its words kept.
|
||||
pub fn failure(error: &Error, cx: &App) -> SharedString {
|
||||
let (key, detail) = match error {
|
||||
Error::Connect(err) => ("mail.error.connect", err.to_string()),
|
||||
Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()),
|
||||
Error::Rejected(detail) => ("mail.error.rejected", detail.clone()),
|
||||
Error::Server(detail) => ("mail.error.server", detail.clone()),
|
||||
Error::Protocol(detail) => ("mail.error.protocol", detail.clone()),
|
||||
Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()),
|
||||
other => ("mail.error.other", other.to_string()),
|
||||
};
|
||||
text(cx, key, &[("detail", &detail)])
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The add-account dialog's fields and steps (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::forms::{InputEvent, TextInput};
|
||||
use ely_mail_engine::{Account, Auth, Checked, Security, Server};
|
||||
use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window};
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::{
|
||||
kinds::{self, Kind},
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
pub struct Fields {
|
||||
pub address: Entity<TextInput>,
|
||||
pub user: Entity<TextInput>,
|
||||
pub password: Entity<TextInput>,
|
||||
pub incoming: Entity<TextInput>,
|
||||
pub incoming_port: Entity<TextInput>,
|
||||
pub outgoing: Entity<TextInput>,
|
||||
pub outgoing_port: Entity<TextInput>,
|
||||
}
|
||||
|
||||
pub enum Step {
|
||||
Editing,
|
||||
/// The browser holds the provider's page.
|
||||
Signing,
|
||||
Checking,
|
||||
/// `kept` goes to the vault: a password or refresh token.
|
||||
Checked {
|
||||
account: Account,
|
||||
kept: String,
|
||||
checked: Checked,
|
||||
},
|
||||
Failed(SharedString),
|
||||
Saving,
|
||||
}
|
||||
|
||||
pub struct Adding {
|
||||
pub kind: Kind,
|
||||
pub fields: Fields,
|
||||
pub incoming_security: Security,
|
||||
pub outgoing_security: Security,
|
||||
/// Typed-in accounts choose; providers fix it.
|
||||
pub auth: Auth,
|
||||
pub step: Step,
|
||||
/// The ports last filled in; any other was typed.
|
||||
filled: [String; 2],
|
||||
/// The request awaited; any other answer is dropped.
|
||||
pub asked: Option<u64>,
|
||||
pub task: Option<AbortHandle>,
|
||||
_edits: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Drop for Adding {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Adding {
|
||||
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
|
||||
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
|
||||
let words = text(cx, key, &[]);
|
||||
cx.new(|cx| {
|
||||
let input = TextInput::new(window, cx)
|
||||
.placeholder(words.clone())
|
||||
.label(words);
|
||||
if masked { input.masked() } else { input }
|
||||
})
|
||||
};
|
||||
let address = field("mail.add.address", false, cx);
|
||||
let user = field("mail.add.user", false, cx);
|
||||
let password = field("mail.add.password", true, cx);
|
||||
let incoming = field("mail.add.incoming", false, cx);
|
||||
let incoming_port = field("mail.add.port", false, cx);
|
||||
let outgoing = field("mail.add.outgoing", false, cx);
|
||||
let outgoing_port = field("mail.add.port", false, cx);
|
||||
let fields = Fields {
|
||||
address,
|
||||
user,
|
||||
password,
|
||||
incoming,
|
||||
incoming_port,
|
||||
outgoing,
|
||||
outgoing_port,
|
||||
};
|
||||
let edits = [
|
||||
&fields.address,
|
||||
&fields.user,
|
||||
&fields.password,
|
||||
&fields.incoming,
|
||||
&fields.incoming_port,
|
||||
&fields.outgoing,
|
||||
&fields.outgoing_port,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Changed {
|
||||
mail.edited(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut adding = Adding {
|
||||
kind,
|
||||
fields,
|
||||
incoming_security: Security::Tls,
|
||||
outgoing_security: Security::StartTls,
|
||||
auth: Auth::Password,
|
||||
step: Step::Editing,
|
||||
filled: Default::default(),
|
||||
asked: None,
|
||||
task: None,
|
||||
_edits: edits,
|
||||
};
|
||||
adding.set_ports(cx);
|
||||
adding
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
self.asked = None;
|
||||
}
|
||||
|
||||
/// A new kind keeps what was typed.
|
||||
pub fn set_kind(&mut self, kind: Kind, cx: &mut App) {
|
||||
self.stop();
|
||||
self.kind = kind;
|
||||
self.step = Step::Editing;
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) {
|
||||
self.stop();
|
||||
self.step = Step::Editing;
|
||||
if outgoing {
|
||||
self.outgoing_security = security;
|
||||
} else {
|
||||
self.incoming_security = security;
|
||||
}
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
/// The usual ports, unless one was typed.
|
||||
fn set_ports(&mut self, cx: &mut App) {
|
||||
let ports = [
|
||||
kinds::port(Some(self.kind.protocol()), self.incoming_security),
|
||||
kinds::port(None, self.outgoing_security),
|
||||
];
|
||||
let fields = [&self.fields.incoming_port, &self.fields.outgoing_port];
|
||||
for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) {
|
||||
let typed = field.read(cx).text().trim().to_owned();
|
||||
if typed.is_empty() || typed == *filled {
|
||||
*filled = port.to_string();
|
||||
field.update(cx, |field, cx| field.set_text(filled.clone(), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-in this kind uses.
|
||||
pub fn auth(&self) -> Auth {
|
||||
match self.kind.known() {
|
||||
Some((auth, ..)) => auth,
|
||||
None => self.auth,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self, field: &Entity<TextInput>, cx: &App) -> String {
|
||||
field.read(cx).text().trim().to_owned()
|
||||
}
|
||||
|
||||
/// The servers as known or typed.
|
||||
pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> {
|
||||
if let Some((_, incoming, outgoing)) = self.kind.known() {
|
||||
return Ok((incoming, outgoing));
|
||||
}
|
||||
let server = |host: &Entity<TextInput>, port: &Entity<TextInput>, security| {
|
||||
let host = self.read(host, cx);
|
||||
if host.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_host", &[]));
|
||||
}
|
||||
let port = self.read(port, cx);
|
||||
let port = port
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|port| *port > 0)
|
||||
.ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?;
|
||||
Ok(Server {
|
||||
host,
|
||||
port,
|
||||
security,
|
||||
})
|
||||
};
|
||||
Ok((
|
||||
server(
|
||||
&self.fields.incoming,
|
||||
&self.fields.incoming_port,
|
||||
self.incoming_security,
|
||||
)?,
|
||||
server(
|
||||
&self.fields.outgoing,
|
||||
&self.fields.outgoing_port,
|
||||
self.outgoing_security,
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The account the fields name; `signed` is an OAuth address.
|
||||
pub fn account(&self, signed: Option<&str>, cx: &App) -> Result<Account, SharedString> {
|
||||
let address = match signed {
|
||||
Some(address) => address.to_owned(),
|
||||
None => self.read(&self.fields.address, cx),
|
||||
};
|
||||
if address.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_address", &[]));
|
||||
}
|
||||
let user = match (signed, self.kind) {
|
||||
(None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx),
|
||||
_ => address.clone(),
|
||||
};
|
||||
if user.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_user", &[]));
|
||||
}
|
||||
let (incoming, outgoing) = self.servers(cx)?;
|
||||
let account = Account {
|
||||
address,
|
||||
user,
|
||||
auth: self.auth(),
|
||||
protocol: self.kind.protocol(),
|
||||
incoming,
|
||||
outgoing,
|
||||
};
|
||||
account
|
||||
.valid()
|
||||
.map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub fn password(&self, cx: &App) -> Result<String, SharedString> {
|
||||
let password = self.fields.password.read(cx).text().to_owned();
|
||||
if password.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_password", &[]));
|
||||
}
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
matches!(self.step, Step::Signing | Step::Checking | Step::Saving)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Checking, signing in and adding an account (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth};
|
||||
use gpui::{Context, SharedString, Window};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::Kind,
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
impl Mail {
|
||||
pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.adding.is_none() && self.removing.is_none() {
|
||||
self.adding = Some(Adding::new(Kind::Gmail, window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close_adding(&mut self, cx: &mut Context<Self>) {
|
||||
self.adding = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A field changed: an earlier check no longer holds.
|
||||
pub fn edited(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &mut self.adding else {
|
||||
return;
|
||||
};
|
||||
if !matches!(adding.step, Step::Editing | Step::Saving) {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a request for the open dialog.
|
||||
fn ask(&mut self) -> Option<u64> {
|
||||
let adding = self.adding.as_mut()?;
|
||||
adding.stop();
|
||||
self.asked += 1;
|
||||
adding.asked = Some(self.asked);
|
||||
Some(self.asked)
|
||||
}
|
||||
|
||||
/// The open dialog, if it awaits `asked`.
|
||||
fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> {
|
||||
self.adding
|
||||
.as_mut()
|
||||
.filter(|adding| adding.asked == Some(asked))
|
||||
}
|
||||
|
||||
fn halt(&mut self, words: SharedString, cx: &mut Context<Self>) {
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.stop();
|
||||
adding.step = Step::Failed(words);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The words when the address is already here.
|
||||
fn taken(&self, address: &str, cx: &Context<Self>) -> Option<SharedString> {
|
||||
self.accounts()
|
||||
.iter()
|
||||
.any(|(_, account)| account.address.eq_ignore_ascii_case(address))
|
||||
.then(|| text(cx, "mail.add.taken", &[("address", address)]))
|
||||
}
|
||||
|
||||
/// Checks a typed-in password with both servers.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let ready = adding
|
||||
.account(None, cx)
|
||||
.and_then(|account| Ok((account, adding.password(cx)?)));
|
||||
match ready {
|
||||
Ok((account, password)) => {
|
||||
self.probe(account, password.clone(), Secret::Password(password), cx)
|
||||
}
|
||||
Err(words) => self.halt(words, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs in with `secret`; `kept` goes to the vault after.
|
||||
fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context<Self>) {
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let tls = match &self.tls {
|
||||
Ok(tls) => tls.clone(),
|
||||
Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx),
|
||||
};
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Checking;
|
||||
}
|
||||
log::info!("mail: checking a {:?} account", account.protocol);
|
||||
let probe = account.clone();
|
||||
let work = async move { probe.check(secret, &tls).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
mail.checked(asked, account, kept, done, cx)
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn checked(
|
||||
&mut self,
|
||||
asked: u64,
|
||||
account: Account,
|
||||
kept: String,
|
||||
done: Result<Checked, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(adding) = self.awaiting(asked) else {
|
||||
return log::info!("mail: a stale check dropped");
|
||||
};
|
||||
adding.task = None;
|
||||
adding.step = match done {
|
||||
Ok(checked) => Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])),
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the provider's page, then checks what it grants.
|
||||
pub fn sign_in(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let provider = match adding.auth().provider() {
|
||||
Some(Ok(provider)) => provider,
|
||||
Some(Err(error)) => return self.halt(unsigned(&error, cx), cx),
|
||||
None => return log::error!("mail: sign-in asked of a password account"),
|
||||
};
|
||||
if let Err(words) = adding.servers(cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let hint = adding.fields.address.read(cx).text().trim().to_owned();
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Signing;
|
||||
}
|
||||
let work = async move { oauth::SignIn::start(provider, &hint).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, started, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) {
|
||||
Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx),
|
||||
Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx),
|
||||
Err(words) => mail.halt(words, cx),
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context<Self>) {
|
||||
cx.open_url(sign_in.url());
|
||||
log::info!("mail: the browser has the sign-in page");
|
||||
let work = async move {
|
||||
let tokens = sign_in.finish().await?;
|
||||
let email = tokens
|
||||
.email
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("the provider named no address".into()))?;
|
||||
let refresh = tokens
|
||||
.refresh
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("no refresh token granted".into()))?;
|
||||
Ok::<_, Error>((tokens.access, refresh, email))
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
let account = match done {
|
||||
Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| {
|
||||
adding
|
||||
.account(Some(&email), cx)
|
||||
.map(|account| (account, access, refresh))
|
||||
}),
|
||||
Ok(Err(error)) => Some(Err(unsigned(&error, cx))),
|
||||
Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))),
|
||||
};
|
||||
match account {
|
||||
Some(Ok((account, access, refresh))) => {
|
||||
mail.probe(account, refresh, Secret::Token(access), cx)
|
||||
}
|
||||
Some(Err(words)) => mail.halt(words, cx),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the checked account, then its secret.
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Adding {
|
||||
step:
|
||||
Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
..
|
||||
}) = &self.adding
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !checked.passed() {
|
||||
return;
|
||||
}
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let (account, kept) = (account.clone(), kept.clone());
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Saving;
|
||||
}
|
||||
let vault = self.vault.clone();
|
||||
let work = move |store: &mut ely_mail_engine::Store| {
|
||||
let id = match store.add_account(&account) {
|
||||
Ok(id) => id,
|
||||
Err(Error::Taken(address)) => return Ok(Err(address)),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let held = vault.put(&account.secret_name(), &kept);
|
||||
Ok(Ok((id, account, held)))
|
||||
};
|
||||
self.with_store(cx, work, move |mail, saved, cx| match saved {
|
||||
Ok(Err(address)) => {
|
||||
let words = text(cx, "mail.add.taken", &[("address", &address)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
Ok(Ok((id, account, held))) => {
|
||||
log::info!("mail: account {} added", id.0);
|
||||
let words = text(cx, "mail.add.added", &[("address", &account.address)]);
|
||||
if let Some(Ok(accounts)) = &mut mail.accounts {
|
||||
accounts.push((id, account));
|
||||
}
|
||||
mail.picked = Some(id);
|
||||
if mail.awaiting(asked).is_some() {
|
||||
mail.adding = None;
|
||||
}
|
||||
mail.toast(words, Severity::Success, cx);
|
||||
if let Kept::Session(reason) = held {
|
||||
mail.toast(
|
||||
text(cx, "mail.add.session_only", &[("error", &reason)]),
|
||||
Severity::Warning,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let words = text(cx, "mail.add.save_failed", &[("error", &error)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign-in failures, with this build's missing client named.
|
||||
fn unsigned(error: &Error, cx: &gpui::App) -> SharedString {
|
||||
match error {
|
||||
Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]),
|
||||
other => text(
|
||||
cx,
|
||||
"mail.add.sign_in_failed",
|
||||
&[("error", &other.to_string())],
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! The kinds of account and their known servers.
|
||||
|
||||
use ely_mail_engine::{Auth, Protocol, Security, Server};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Gmail,
|
||||
Microsoft,
|
||||
ICloud,
|
||||
Imap,
|
||||
Pop,
|
||||
}
|
||||
|
||||
pub const KINDS: [Kind; 5] = [
|
||||
Kind::Gmail,
|
||||
Kind::Microsoft,
|
||||
Kind::ICloud,
|
||||
Kind::Imap,
|
||||
Kind::Pop,
|
||||
];
|
||||
|
||||
/// Where iCloud makes app-specific passwords.
|
||||
pub const APPLE_ACCOUNT: &str = "https://account.apple.com";
|
||||
|
||||
impl Kind {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Gmail => "gmail",
|
||||
Kind::Microsoft => "microsoft",
|
||||
Kind::ICloud => "icloud",
|
||||
Kind::Imap => "imap",
|
||||
Kind::Pop => "pop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> Option<Kind> {
|
||||
KINDS.into_iter().find(|kind| kind.key() == key)
|
||||
}
|
||||
|
||||
pub fn protocol(self) -> Protocol {
|
||||
match self {
|
||||
Kind::Pop => Protocol::Pop,
|
||||
_ => Protocol::Imap,
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider's servers and sign-in; None when typed in.
|
||||
pub fn known(self) -> Option<(Auth, Server, Server)> {
|
||||
let server = |host: &str, port, security| Server {
|
||||
host: host.into(),
|
||||
port,
|
||||
security,
|
||||
};
|
||||
match self {
|
||||
Kind::Gmail => Some((
|
||||
Auth::Google,
|
||||
server("imap.gmail.com", 993, Security::Tls),
|
||||
server("smtp.gmail.com", 465, Security::Tls),
|
||||
)),
|
||||
Kind::Microsoft => Some((
|
||||
Auth::Microsoft,
|
||||
server("outlook.office365.com", 993, Security::Tls),
|
||||
server("smtp.office365.com", 587, Security::StartTls),
|
||||
)),
|
||||
// Apple: IMAP takes the full address too.
|
||||
Kind::ICloud => Some((
|
||||
Auth::Password,
|
||||
server("imap.mail.me.com", 993, Security::Tls),
|
||||
server("smtp.mail.me.com", 587, Security::StartTls),
|
||||
)),
|
||||
Kind::Imap | Kind::Pop => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual port for a protocol under a security.
|
||||
pub fn port(protocol: Option<Protocol>, security: Security) -> u16 {
|
||||
match (protocol, security) {
|
||||
(Some(Protocol::Imap), Security::Tls) => 993,
|
||||
(Some(Protocol::Imap), Security::StartTls) => 143,
|
||||
(Some(Protocol::Pop), Security::Tls) => 995,
|
||||
(Some(Protocol::Pop), Security::StartTls) => 110,
|
||||
(None, Security::Tls) => 465,
|
||||
(None, Security::StartTls) => 587,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The window: accounts and the dialogs that change them.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, AccountId, Store, TlsConnector, Vault};
|
||||
use ely_platform::AppId;
|
||||
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};
|
||||
|
||||
/// What the engine runs on and keeps to.
|
||||
pub struct Parts {
|
||||
pub store: Result<Store, String>,
|
||||
pub vault: Vault,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
}
|
||||
|
||||
impl Parts {
|
||||
pub fn system(dir: &Path) -> Self {
|
||||
Self {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::system(),
|
||||
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mail {
|
||||
pub store: Result<Arc<Mutex<Store>>, String>,
|
||||
pub read_only: Option<String>,
|
||||
pub vault: Arc<Vault>,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
runtime: Arc<Runtime>,
|
||||
/// None until read.
|
||||
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
|
||||
pub picked: Option<AccountId>,
|
||||
pub adding: Option<Adding>,
|
||||
pub removing: Option<Removing>,
|
||||
/// Numbers every request across dialogs.
|
||||
pub asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
pub root: WeakEntity<Root>,
|
||||
pub side: Entity<Side>,
|
||||
}
|
||||
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
}
|
||||
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let dir = match ely_platform::data_dir(app) {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
|
||||
};
|
||||
let parts = Parts::system(&dir);
|
||||
let mail = cx.new(|cx| Mail::new(parts, cx));
|
||||
let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx);
|
||||
mail.update(cx, |mail, cx| {
|
||||
mail.root = root.downgrade();
|
||||
window.focus(&mail.focus, cx);
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn new(parts: Parts, cx: &mut Context<Self>) -> Self {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("mail")
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|error| {
|
||||
ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}"))
|
||||
});
|
||||
let read_only = match &parts.store {
|
||||
Ok(store) => store.read_only().map(str::to_owned),
|
||||
Err(error) => {
|
||||
log::error!("mail: the store did not open: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Err(error) = &parts.tls {
|
||||
log::error!("mail: no system TLS: {error}");
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let mut mail = Mail {
|
||||
store: parts.store.map(|store| Arc::new(Mutex::new(store))),
|
||||
read_only,
|
||||
vault: Arc::new(parts.vault),
|
||||
tls: parts.tls,
|
||||
runtime: Arc::new(runtime),
|
||||
accounts: None,
|
||||
picked: None,
|
||||
adding: None,
|
||||
removing: None,
|
||||
asked: 0,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
side: cx.new(|_| Side::new(this)),
|
||||
};
|
||||
mail.read_accounts(cx);
|
||||
mail
|
||||
}
|
||||
|
||||
pub fn read_accounts(&mut self, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
|store| store.accounts(),
|
||||
|mail, read, _| {
|
||||
if let Ok(accounts) = &read {
|
||||
let still = |id| accounts.iter().any(|(each, _)| *each == id);
|
||||
mail.picked = mail
|
||||
.picked
|
||||
.filter(|id| still(*id))
|
||||
.or_else(|| accounts.first().map(|(id, _)| *id));
|
||||
}
|
||||
mail.accounts = Some(read);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn accounts(&self) -> &[(AccountId, Account)] {
|
||||
match &self.accounts {
|
||||
Some(Ok(accounts)) => accounts,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pick(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
self.picked = Some(id);
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
/// Redraws the window and its account list.
|
||||
pub fn notify(&self, cx: &mut Context<Self>) {
|
||||
self.side.update(cx, |_, cx| cx.notify());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Store work off the window's thread.
|
||||
pub fn with_store<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) {
|
||||
let store = match &self.store {
|
||||
Ok(store) => store.clone(),
|
||||
Err(error) => {
|
||||
self.failed("mail.error.store", error, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let read = cx.background_spawn(async move {
|
||||
let mut store = store
|
||||
.lock()
|
||||
.map_err(|_| "a store task panicked".to_owned())?;
|
||||
work(&mut store).map_err(|err| err.to_string())
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let read = read.await;
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, read, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Network work on the mail runtime; abort stops it.
|
||||
pub fn on_runtime<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl Future<Output = R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) -> tokio::task::AbortHandle {
|
||||
let task = self.runtime.spawn(work);
|
||||
let abort = task.abort_handle();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let done = task.await;
|
||||
if done.as_ref().is_err_and(|err| err.is_cancelled()) {
|
||||
return;
|
||||
}
|
||||
let done = done.map_err(|err| format!("a mail task failed: {err}"));
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, done, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
abort
|
||||
}
|
||||
|
||||
/// Not logged: the words may name an address.
|
||||
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
|
||||
if let Some(root) = self.root.upgrade() {
|
||||
root.update(cx, |root, cx| root.toast(text, severity, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
|
||||
log::warn!("mail: {key}");
|
||||
let words = text(cx, key, &[("error", error)]);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
// Release builds open no console on Windows.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod add_view;
|
||||
mod adding;
|
||||
mod flow;
|
||||
mod kinds;
|
||||
mod mail;
|
||||
mod menu;
|
||||
mod removing;
|
||||
mod side;
|
||||
mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Mail, ely_ui::blank);
|
||||
ely_ui::run(AppId::Mail, mail::open);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! The Account menu.
|
||||
|
||||
use gpui::{App, Menu, MenuItem, actions};
|
||||
|
||||
use crate::mail::text;
|
||||
|
||||
actions!(mail, [AddAccount, RemoveAccount]);
|
||||
|
||||
pub const CONTEXT: &str = "mail";
|
||||
|
||||
pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
vec![Menu::new(text(cx, "mail.menu.account", &[])).items([
|
||||
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
|
||||
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
|
||||
])]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Removing an account: its local data and secret only.
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{AccountId, Cached, Error, Kept};
|
||||
use gpui::Context;
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Removing {
|
||||
pub id: AccountId,
|
||||
pub address: String,
|
||||
pub cached: Cached,
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
/// Measures what goes, then asks.
|
||||
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.picked else { return };
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.cached(id),
|
||||
move |mail, cached, cx| {
|
||||
let address = mail
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == id)
|
||||
.map(|(_, account)| account.address.clone());
|
||||
match (cached, address) {
|
||||
(Ok(cached), Some(address))
|
||||
if mail.adding.is_none() && mail.removing.is_none() =>
|
||||
{
|
||||
mail.removing = Some(Removing {
|
||||
id,
|
||||
address,
|
||||
cached,
|
||||
});
|
||||
}
|
||||
(Ok(_), _) => {
|
||||
log::info!("mail: removal asked of a gone account or over a dialog")
|
||||
}
|
||||
(Err(error), _) => mail.failed("mail.remove.failed", &error, cx),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The secret goes first, and back if the rest fails.
|
||||
pub fn remove(&mut self, removing: Removing, cx: &mut Context<Self>) {
|
||||
let Some((_, account)) = self
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == removing.id)
|
||||
else {
|
||||
return log::info!("mail: the account to remove is already gone");
|
||||
};
|
||||
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
|
||||
let address = removing.address;
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| {
|
||||
let secret = vault.get(&name)?;
|
||||
vault.remove(&name)?;
|
||||
store.remove_account(id).map_err(|error| {
|
||||
match secret.map(|secret| vault.put(&name, &secret)) {
|
||||
Some(Kept::Session(reason)) => Error::Vault(format!(
|
||||
"{error}; its secret is back for this session only: {reason}"
|
||||
)),
|
||||
_ => error,
|
||||
}
|
||||
})
|
||||
},
|
||||
move |mail, removed, cx| {
|
||||
match removed {
|
||||
Ok(()) => {
|
||||
log::info!("mail: account {} removed", id.0);
|
||||
mail.toast(
|
||||
text(cx, "mail.remove.done", &[("address", &address)]),
|
||||
Severity::Success,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(error) => mail.failed("mail.remove.failed", &error, cx),
|
||||
}
|
||||
mail.read_accounts(cx);
|
||||
},
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! The account list down the left.
|
||||
|
||||
use ely_gpui_component::lists::{ListItem, SelectableList};
|
||||
use ely_mail_engine::AccountId;
|
||||
use gpui::{
|
||||
Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
mail::{Mail, text},
|
||||
view::protocol,
|
||||
};
|
||||
|
||||
pub struct Side {
|
||||
mail: WeakEntity<Mail>,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn new(mail: WeakEntity<Mail>) -> Self {
|
||||
Self { mail }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Side {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(mail) = self.mail.upgrade() else {
|
||||
return div();
|
||||
};
|
||||
let mail = mail.read(cx);
|
||||
let list = mail.accounts().iter().fold(
|
||||
SelectableList::new("mail-accounts").label(text(cx, "mail.accounts", &[])),
|
||||
|list, (id, account)| {
|
||||
let item = ListItem::new(("mail-account", id.0 as u64), account.address.clone())
|
||||
.description(protocol(account, cx));
|
||||
list.row(id.0.to_string(), item)
|
||||
},
|
||||
);
|
||||
let pick = self.mail.clone();
|
||||
let list = list
|
||||
.selected(mail.picked.map(|id| id.0.to_string()))
|
||||
.on_change(move |keys, _, cx| {
|
||||
let Some(id) = keys.first().and_then(|key| key.parse().ok()) else {
|
||||
return log::error!("mail: the account list sent no account");
|
||||
};
|
||||
pick.update(cx, |mail, cx| mail.pick(AccountId(id), cx))
|
||||
.ok();
|
||||
})
|
||||
.size_full();
|
||||
div().size_full().p_2().child(list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Accounts in the window: add, check, keep, remove (C12.5).
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{Account, Auth, Backend, Checked, Protocol, Security, Server, Store, Vault};
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use crate::{
|
||||
adding::Step,
|
||||
kinds::Kind,
|
||||
mail::{Mail, Parts},
|
||||
};
|
||||
|
||||
mod flows;
|
||||
|
||||
/// A credential store that keeps or refuses.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Keys {
|
||||
pub kept: Arc<Mutex<HashMap<String, String>>>,
|
||||
pub refuse: Option<&'static str>,
|
||||
pub deletes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Backend for Keys {
|
||||
fn get(&self, name: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.kept.lock().unwrap().get(name).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().insert(name.into(), secret.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, name: &str) -> Result<(), String> {
|
||||
self.deletes.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
if cx.update(|_, app| done(app)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
pub(crate) fn window<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
ely_ui::settings::install_defaults(cx);
|
||||
ely_ui::i18n::install_as("en-US", cx);
|
||||
// As tool_root does: menus before the window.
|
||||
let menus = crate::menu::menus(cx);
|
||||
let menus = ely_ui::commands::menus(
|
||||
ely_platform::AppId::Mail,
|
||||
&ely_ui::commands::OPENING,
|
||||
false,
|
||||
menus,
|
||||
cx,
|
||||
);
|
||||
cx.set_menus(menus);
|
||||
});
|
||||
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()),
|
||||
};
|
||||
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
|
||||
settle(cx, |app| mail.read(app).accounts.is_some());
|
||||
(mail, cx)
|
||||
}
|
||||
|
||||
/// A port nothing listens on.
|
||||
pub(crate) fn closed_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
pub(crate) fn account(address: &str) -> Account {
|
||||
let server = |host: &str, port| Server {
|
||||
host: host.into(),
|
||||
port,
|
||||
security: Security::Tls,
|
||||
};
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server("imap.example.com", 993),
|
||||
outgoing: server("smtp.example.com", 465),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the dialog on `kind`.
|
||||
pub(crate) fn adding(mail: &Entity<Mail>, kind: Kind, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.start_adding(window, cx);
|
||||
mail.adding.as_mut().unwrap().set_kind(kind, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn type_in(
|
||||
mail: &Entity<Mail>,
|
||||
cx: &mut VisualTestContext,
|
||||
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
|
||||
words: &str,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone();
|
||||
field.update(app, |field, cx| field.set_text(words.to_owned(), cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A passed check, as servers would answer.
|
||||
pub(crate) fn passed(
|
||||
mail: &Entity<Mail>,
|
||||
account: Account,
|
||||
kept: &str,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.adding.as_mut().unwrap().step = Step::Checked {
|
||||
account,
|
||||
kept: kept.into(),
|
||||
checked: Checked {
|
||||
incoming: Ok(()),
|
||||
outgoing: Ok(()),
|
||||
},
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn step_words(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(
|
||||
|_, app| match &mail.read(app).adding.as_ref().unwrap().step {
|
||||
Step::Failed(words) => words.to_string(),
|
||||
Step::Editing => "editing".into(),
|
||||
Step::Signing => "signing".into(),
|
||||
Step::Checking => "checking".into(),
|
||||
Step::Saving => "saving".into(),
|
||||
Step::Checked { .. } => "checked".into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth};
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
|
||||
use crate::{adding::Step, kinds::Kind, mail::Mail};
|
||||
|
||||
#[test]
|
||||
fn an_imap_form_says_what_is_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
let check = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)))
|
||||
};
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the email address.");
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the user name.");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type both server names.");
|
||||
type_in(&mail, cx, |f| &f.incoming, "imap.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "70000");
|
||||
check(cx);
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"70000 is not a port; use 1 to 65535."
|
||||
);
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "587");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the password.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_follow_security_but_a_typed_one_stays() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Pop, cx);
|
||||
let ports = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let fields = &mail.read(app).adding.as_ref().unwrap().fields;
|
||||
(
|
||||
fields.incoming_port.read(app).text().to_owned(),
|
||||
fields.outgoing_port.read(app).text().to_owned(),
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(ports(cx), ("995".into(), "587".into()));
|
||||
let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.adding
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_security(outgoing, security, cx)
|
||||
})
|
||||
});
|
||||
};
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("110".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "2525");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("110".into(), "2525".into()));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx)));
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
assert_eq!(ports(cx), ("993".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.incoming_port, "995");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_reports_each_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
type_in(&mail, cx, |f| &f.password, "pw");
|
||||
type_in(&mail, cx, |f| &f.incoming, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string());
|
||||
type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "checking");
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
mail.read(app).adding.as_ref().unwrap().step,
|
||||
Step::Checked { .. }
|
||||
)
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let Step::Checked {
|
||||
checked,
|
||||
account,
|
||||
kept,
|
||||
} = &mail.read(app).adding.as_ref().unwrap().step
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
matches!(checked.incoming, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.incoming
|
||||
);
|
||||
assert!(
|
||||
matches!(checked.outgoing, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.outgoing
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.protocol, kept.as_str()),
|
||||
("ann", Protocol::Imap, "pw")
|
||||
);
|
||||
let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app);
|
||||
assert!(said.starts_with("cannot connect ("), "{said}");
|
||||
});
|
||||
type_in(&mail, cx, |f| &f.password, "pw2");
|
||||
assert_eq!(step_words(&mail, cx), "editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icloud_account_takes_apples_servers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::ICloud, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@icloud.com");
|
||||
let account = cx
|
||||
.update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(account.incoming.host.as_str(), account.incoming.port),
|
||||
("imap.mail.me.com", 993)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
account.outgoing.host.as_str(),
|
||||
account.outgoing.port,
|
||||
account.outgoing.security
|
||||
),
|
||||
("smtp.mail.me.com", 587, Security::StartTls)
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.auth),
|
||||
("ann@icloud.com", Auth::Password)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_without_a_client_id_says_so() {
|
||||
if oauth::google().is_ok() {
|
||||
return eprintln!("SKIP: this build has a Google client ID");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Gmail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx)));
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"Gmail sign-in is not in this build: it was built without a Google client ID."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_keychain_keeps_the_account_and_says_why() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(mail.picked, Some(mail.accounts()[0].0));
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_is_kept_and_removed_with_its_secret() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
{
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ANN@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
}
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
cx.update(|_, app| {
|
||||
let removing = mail.read(app).removing.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(removing.address.as_str(), removing.cached.messages),
|
||||
("ann@example.com", 0)
|
||||
);
|
||||
});
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |app| mail.read(app).accounts().is_empty());
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_secret_removal_keeps_the_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
assert_eq!(
|
||||
Store::open(dir.path()).unwrap().accounts().unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_removal_puts_the_secret_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let inbox = store.mailbox(ann, "INBOX").unwrap();
|
||||
let raw: &[u8] = b"Subject: Kept
|
||||
|
||||
still here
|
||||
";
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid: 1,
|
||||
};
|
||||
let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).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.clone());
|
||||
// A folder where the original would be set aside.
|
||||
let shard = std::fs::read_dir(dir.path().join("content"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let name = std::fs::read_dir(&shard)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.file_name();
|
||||
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let store = store.lock().unwrap();
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
assert_eq!(store.original(kept).unwrap().unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_saves_in_flight_add_an_address_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
})
|
||||
});
|
||||
passed(&mail, account("ANN@example.com"), "pw2", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
mail.read(app).adding.as_ref().map(|adding| &adding.step),
|
||||
Some(Step::Failed(_))
|
||||
)
|
||||
});
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).accounts().len(), 1));
|
||||
assert_eq!(
|
||||
Store::open(dir.path()).unwrap().accounts().unwrap()[0].1,
|
||||
account("ann@example.com")
|
||||
);
|
||||
let kept = keys.kept.lock().unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept.get("account:ann@example.com").map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyboard_picks_another_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bo = store.add_account(&account("bo@example.com")).unwrap();
|
||||
drop(store);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
// As `mail::open` does.
|
||||
cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app));
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
cx.simulate_keystrokes("tab down");
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| mail.read(app).picked) == Some(bo) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("no Tab stop let Down reach the second account");
|
||||
}
|
||||
|
||||
/// Through the dialog: back to its last stop, Remove.
|
||||
fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.simulate_keystrokes("shift-tab enter");
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").unwrap(),
|
||||
});
|
||||
settle(cx, |app| mail.read(app).removing.is_none());
|
||||
}
|
||||
|
||||
/// The save lands after a second dialog opened.
|
||||
fn save_then_reopen(fail: bool) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
let added = store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"));
|
||||
added.unwrap();
|
||||
}
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
let adding = mail.adding.as_mut().unwrap();
|
||||
adding.set_kind(Kind::Pop, cx);
|
||||
let address = adding.fields.address.clone();
|
||||
address.update(cx, |field, cx| {
|
||||
field.set_text("bob@example.com".to_owned(), cx)
|
||||
});
|
||||
})
|
||||
});
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| assert!(mail.read(app).accounts().is_empty()));
|
||||
} else {
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
}
|
||||
cx.update(|_, app| {
|
||||
let adding = mail.read(app).adding.as_ref().unwrap();
|
||||
assert_eq!(adding.kind, Kind::Pop);
|
||||
assert!(matches!(adding.step, Step::Editing));
|
||||
assert_eq!(adding.fields.address.read(app).text(), "bob@example.com");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_failed_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(true);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! What the window draws.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, EmptyState, ErrorView},
|
||||
overlays::ConfirmDialog,
|
||||
primitives::{IconName, Severity},
|
||||
typography::format,
|
||||
};
|
||||
use ely_mail_engine::{Account, Auth, Protocol, Security, Server};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Shell;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
mail::{Mail, text},
|
||||
menu,
|
||||
};
|
||||
|
||||
/// "IMAP · imap.example.com"
|
||||
pub fn protocol(account: &Account, cx: &App) -> SharedString {
|
||||
let name = protocol_name(account.protocol);
|
||||
text(
|
||||
cx,
|
||||
"mail.account.line",
|
||||
&[("protocol", name), ("host", &account.incoming.host)],
|
||||
)
|
||||
}
|
||||
|
||||
fn server(server: &Server, cx: &App) -> SharedString {
|
||||
let security = match server.security {
|
||||
Security::Tls => "TLS",
|
||||
Security::StartTls => "STARTTLS",
|
||||
};
|
||||
text(
|
||||
cx,
|
||||
"mail.account.server",
|
||||
&[
|
||||
("host", &server.host),
|
||||
("port", &server.port.to_string()),
|
||||
("security", security),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn auth(auth: Auth, cx: &App) -> SharedString {
|
||||
let key = match auth {
|
||||
Auth::Password => "mail.auth.password",
|
||||
Auth::Google => "mail.auth.google",
|
||||
Auth::Microsoft => "mail.auth.microsoft",
|
||||
};
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
fn add_button(&self, cx: &App) -> Button {
|
||||
Button::new("mail-add", text(cx, "mail.add.open", &[]))
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
|
||||
}
|
||||
|
||||
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let error = |key: &str, detail: &str, cx: &App| {
|
||||
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
|
||||
.detail(detail.to_owned())
|
||||
.into_any_element()
|
||||
};
|
||||
if let Err(detail) = &self.store {
|
||||
return error("mail.store.unopened", detail, cx);
|
||||
}
|
||||
let accounts = match &self.accounts {
|
||||
None => return div().into_any_element(),
|
||||
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 {
|
||||
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
|
||||
.action(self.add_button(cx))
|
||||
.into_any_element();
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let incoming = format!(
|
||||
"{} · {}",
|
||||
protocol_name(account.protocol),
|
||||
server(&account.incoming, cx)
|
||||
);
|
||||
let details = DescriptionList::new()
|
||||
.text(label("mail.account.address"), account.address.clone())
|
||||
.text(label("mail.account.user"), account.user.clone())
|
||||
.text(label("mail.account.incoming"), incoming)
|
||||
.text(
|
||||
label("mail.account.outgoing"),
|
||||
server(&account.outgoing, cx),
|
||||
)
|
||||
.text(label("mail.account.auth"), auth(account.auth, cx));
|
||||
let remove = Button::new("mail-remove", label("mail.remove.open"))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
|
||||
div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.max_w_128()
|
||||
.child(details)
|
||||
.child(div().child(remove))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let removing = self.removing.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| {
|
||||
mail.removing = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
let size = format::file_size(removing.cached.bytes, false);
|
||||
let count = removing.cached.messages;
|
||||
let args = [
|
||||
("address", removing.address.as_str()),
|
||||
("n", &count.to_string()),
|
||||
("size", &size),
|
||||
];
|
||||
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
|
||||
let dialog = ConfirmDialog::new(
|
||||
"mail-remove",
|
||||
text(cx, "mail.remove.title", &args),
|
||||
message,
|
||||
dismiss,
|
||||
)
|
||||
.confirm(text(cx, "mail.remove.go", &[]))
|
||||
.destructive()
|
||||
.on_confirm({
|
||||
let removing = removing.clone();
|
||||
move |_, cx| {
|
||||
let removing = removing.clone();
|
||||
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
|
||||
}
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_name(protocol: Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Imap => "IMAP",
|
||||
Protocol::Pop => "POP3",
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Mail {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
|
||||
let read_only = self.read_only.as_ref().map(|reason| {
|
||||
Banner::new(
|
||||
"mail-read-only",
|
||||
Severity::Warning,
|
||||
text(cx, "mail.store.read_only", &[("error", reason)]),
|
||||
)
|
||||
});
|
||||
let shell = Shell::new(AppId::Mail.name(), &self.focus)
|
||||
.left(left)
|
||||
.children(read_only)
|
||||
.child(self.body(cx));
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(menu::CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(
|
||||
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
|
||||
.child(shell)
|
||||
.children(self.add_dialog(cx))
|
||||
.children(self.remove_dialog(cx))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user