//! Email accounts. //! //! //! //! The account is not host-bound: `create_email_account`, //! `update_email_account` and `delete_email_account` take //! `State>` and nothing else, no `AppHandle` appears in //! `commands/email_account.rs`, the repository calls are synchronous, and //! `CredentialStore::store_password` is a process-level call rather than //! something asked of a Tauri handle. **OAuth** is the host-bound half. A //! section is not undescribable because its loudest feature is: the test is //! what the *data* needs rather than what the busiest control does. //! //! # What is here //! //! - `GET /settings/email` — the accounts, as a settings section. //! - `GET /settings/email/new` — the manual IMAP/SMTP form. //! - `POST /settings/email` — create one. //! - `GET /settings/email/{id}/edit` — the same form, filled in. //! - `POST /settings/email/{id}` — save one. //! - `POST /settings/email/{id}/delete` — delete one. //! //! The advanced block is `?advanced=1` rather than a toggle button, which is //! decision 2: the form remembers whether it is open because the address does. //! //! # What refused //! //! - **The OAuth handshake.** The system browser, a localhost callback poll and //! a code exchange: three host interactions in sequence, where a route //! handler is a function from a request to an answer. An OAuth account still //! lists and still deletes; adding and reconnecting one has nowhere to go. //! - **Test Connection and Sync Now.** Both are network round-trips whose //! result is the screen's content (the folder list, a progress count). A //! handler is synchronous, so neither can be awaited inside one. //! - **Auto-detect.** Typing a domain filling the four server fields is one //! field's value writing another's, which is quasicoherent `f35aafee`: //! nothing can say put this chosen value into that other field. So the form //! asks for the servers plainly, and the provider defaults are what the //! placeholders show. //! - **The provider note.** Each detected domain carries an app-password //! instruction with a link to where you generate one. `Field::hint` is plain //! text, and the instruction without the link is the useful half, so the hint //! carries the instruction and the link has nowhere to be. This is the one //! refusal here that loses something a user actually needs. #![allow(clippy::needless_pass_by_value)] use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType, NewEmailAccount}; use quasi_declare::declare; use quasi_router::screen::Choice; use quasi_router::{Response, RouteError, Router}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// One auto-sync interval on offer. /// /// A struct rather than a pair, because a description names what it draws and /// `.1` is not a name. struct Interval { /// The minutes, as the field submits them. Empty means manual only. value: &'static str, label: &'static str, } /// The intervals the JS offers, as its own `SYNC_INTERVAL_OPTIONS`. const SYNC_INTERVALS: [Interval; 5] = [ Interval { value: "", label: "Manual only", }, Interval { value: "5", label: "Every 5 minutes", }, Interval { value: "15", label: "Every 15 minutes", }, Interval { value: "30", label: "Every 30 minutes", }, Interval { value: "60", label: "Hourly", }, ]; /// How an account authenticates, for the row that says so. fn auth_label(auth: &EmailAuthType) -> &'static str { match auth { EmailAuthType::Password => "Password", EmailAuthType::OAuth2Fastmail => "Fastmail (OAuth)", EmailAuthType::OAuth2Google => "Google (OAuth)", EmailAuthType::OAuth2Microsoft => "Microsoft (OAuth)", EmailAuthType::OAuth2Yahoo => "Yahoo (OAuth)", } } declare! { /// One account. /// /// Edit is offered on a password account and withheld on an OAuth one, /// because the form below is the IMAP/SMTP form and an OAuth account has no /// servers, username or password to edit. Delete is offered on both: /// removing an account is the same act either way. shape row_for(account: &EmailAccount) -> Row; row &account.account_name { secondary &account.email_address; meta auth_label(&account.auth_type); act "Edit" to get "/settings/email/{account.id}/edit" when account.auth_type is EmailAuthType::Password; act "Delete" to post "/settings/email/{account.id}/delete" { tone Danger; } } } /// Every account this user has. /// /// The read is the parent screen's, which is what converting this section /// meant: a description says what is on the screen rather than fetching it. pub(super) fn accounts(state: &AppState) -> Result, RouteError> { state .email_accounts .list_by_user(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string())) } declare! { /// The section's body: the accounts, and the way to add one. pub(super) shape pane(accounts: &[EmailAccount]) -> Vec; section "Email accounts"; empty "No accounts yet." when accounts.is_empty(); list { for account in accounts.iter() { include row_for(account); } } unless accounts.is_empty(); act "Add an account" to get "/settings/email/new"; } /// What the form is filling in, and what it is answering. /// /// `existing` is the account being edited, or `None` for a new one. `advanced` /// is the disclosure's state, which is an address rather than a toggle so that a /// form reopened after a refusal is open to the same depth it was. struct Editing<'a> { existing: Option<&'a EmailAccount>, advanced: bool, errors: &'a [(&'a str, String)], submitted: Option<&'a quasi_router::Params>, } impl<'a> Editing<'a> { /// A fresh form. /// /// Advanced opens by default on a new account, because the server fields /// are required and a form whose required fields are hidden cannot be /// submitted. The JS reaches the same state by a different route: it opens /// the block once autodetect has filled those fields in. const fn fresh() -> Self { Self { existing: None, advanced: true, errors: &[], submitted: None, } } /// The form over an existing account. const fn of(account: &'a EmailAccount, advanced: bool) -> Self { Self { existing: Some(account), advanced, errors: &[], submitted: None, } } } /// Whether the form is editing rather than adding. fn is_edit(editing: &Editing) -> bool { editing.existing.is_some() } /// What each question falls back to when nothing has been submitted. /// /// One table by name rather than a default spelled at each field, which is the /// same argument the parent screen's `default_for` makes: `archive_folder_name` /// and the two ports had their fallbacks written where the field was built, and /// the field is not where a default belongs. fn stored(editing: &Editing, name: &str) -> String { let Some(account) = editing.existing else { return match name { "archive_folder_name" => "Archive".to_owned(), "imap_port" => "993".to_owned(), "smtp_port" => "587".to_owned(), "sync_interval_minutes" => "15".to_owned(), _ => String::new(), }; }; match name { "account_name" => account.account_name.clone(), "email_address" => account.email_address.clone(), "username" => account.username.clone(), "archive_folder_name" => account .archive_folder_name .clone() .unwrap_or_else(|| "Archive".to_owned()), "email_signature" => account.email_signature.clone().unwrap_or_default(), "imap_server" => account.imap_server.clone(), "smtp_server" => account.smtp_server.clone(), "imap_port" => account.imap_port.to_string(), "smtp_port" => account.smtp_port.to_string(), "notify_new_emails" => { if account.notify_new_emails { "1".to_owned() } else { String::new() } } "sync_interval_minutes" => account .sync_interval_minutes .map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()), _ => String::new(), } } /// What one question holds. /// /// A refused submission beats the stored value, which beats the default. fn value_of(editing: &Editing, name: &str) -> String { editing .submitted .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned)) .unwrap_or_else(|| stored(editing, name)) } /// Whether a named question was refused. fn has_error(editing: &Editing, name: &str) -> bool { editing.errors.iter().any(|(field, _)| *field == name) } /// Why it was refused, or nothing. fn error_for(editing: &Editing, name: &str) -> String { editing .errors .iter() .find(|(field, _)| *field == name) .map(|(_, message)| message.clone()) .unwrap_or_default() } /// What the password question is called. /// /// The one place adding and editing differ: it is required when there is no /// stored secret and optional when leaving it empty means keep the current one. /// `buildAccountFormHtml` differs them the same way and says so in the label. fn password_label(editing: &Editing) -> &'static str { if is_edit(editing) { "Password (leave empty to keep current)" } else { "Password" } } /// What the password box shows before anything is typed. fn password_placeholder(editing: &Editing) -> &'static str { if is_edit(editing) { "Enter new password or leave empty" } else { "your password" } } /// What the page is called. fn form_title(editing: &Editing) -> String { match editing.existing { Some(account) => format!("Edit {}", account.account_name), None => "Add an email account".to_owned(), } } /// Where the form writes. fn form_path(editing: &Editing) -> String { match editing.existing { Some(account) => format!("/settings/email/{}", account.id), None => "/settings/email".to_owned(), } } /// What the form's button reads. fn submit_label(editing: &Editing) -> &'static str { if is_edit(editing) { "Save account" } else { "Add account" } } /// This form's own address, which the disclosure toggles by going back to it. fn here(editing: &Editing) -> String { match editing.existing { Some(account) => format!("/settings/email/{}/edit", account.id), None => "/settings/email/new".to_owned(), } } declare! { /// The form as a screen of its own, on the shape every other form here /// takes. /// /// The advanced questions are guarded rather than appended by a second /// shape, which is the production this screen earned: six server questions /// on the form only while the disclosure is open. shape form(editing: &Editing) -> Screen; screen list_detail "Email account" false { at_place crate::quasi::shell::SETTINGS; region "email-band" as Band { page form_title(editing); act "Cancel" to get "/settings/email"; } region "email-form" as Pane { form post form_path(editing) { submit submit_label(editing); field Text "account_name" "Account Name" { required; placeholder "Personal, Work, etc."; value value_of(editing, "account_name"); error error_for(editing, "account_name") when has_error(editing, "account_name"); } field Email "email_address" "Email Address" { required; placeholder "you@example.com"; value value_of(editing, "email_address"); error error_for(editing, "email_address") when has_error(editing, "email_address"); } field Text "username" "Username" { required; placeholder "Usually your email address"; value value_of(editing, "username"); error error_for(editing, "username") when has_error(editing, "username"); } // Never `Text`. `FieldKind::Secret` is the kind whose contract // is that the value is not echoed or round-tripped, which is // exactly what a password typed into a form wants, and it is // never given a `value` here: the stored secret lives in the OS // keychain and the form has no business carrying it back out // even when it could. field Secret "password" password_label(editing) { required unless is_edit(editing); placeholder password_placeholder(editing); error error_for(editing, "password") when has_error(editing, "password"); } field Text "archive_folder_name" "Archive Folder Name" { placeholder "Archive"; hint "Gmail: [Gmail]/All Mail, Fastmail: Archive."; value value_of(editing, "archive_folder_name"); error error_for(editing, "archive_folder_name") when has_error(editing, "archive_folder_name"); } field Textarea "email_signature" "Email Signature" { placeholder "-- \nYour Name"; hint "Appended to outbound emails. Plain text only."; value value_of(editing, "email_signature"); error error_for(editing, "email_signature") when has_error(editing, "email_signature"); } field Text "imap_server" "IMAP Server" when editing.advanced { required; placeholder "imap.example.com"; value value_of(editing, "imap_server"); error error_for(editing, "imap_server") when has_error(editing, "imap_server"); } field Number "imap_port" "IMAP Port" when editing.advanced { required; value value_of(editing, "imap_port"); error error_for(editing, "imap_port") when has_error(editing, "imap_port"); } field Text "smtp_server" "SMTP Server" when editing.advanced { required; placeholder "smtp.example.com"; value value_of(editing, "smtp_server"); error error_for(editing, "smtp_server") when has_error(editing, "smtp_server"); } field Number "smtp_port" "SMTP Port" when editing.advanced { required; value value_of(editing, "smtp_port"); error error_for(editing, "smtp_port") when has_error(editing, "smtp_port"); } field Checkbox "notify_new_emails" "Notify on new emails" when editing.advanced { hint "A system notification when new mail arrives during auto-sync. \ Off by default."; value value_of(editing, "notify_new_emails"); error error_for(editing, "notify_new_emails") when has_error(editing, "notify_new_emails"); } field Select "sync_interval_minutes" "Auto-sync Interval" when editing.advanced { for offered in SYNC_INTERVALS { option Choice::new(offered.value, offered.label); } hint "Check for new email at this interval."; value value_of(editing, "sync_interval_minutes"); error error_for(editing, "sync_interval_minutes") when has_error(editing, "sync_interval_minutes"); } } act "Hide advanced settings" to get "{here(editing)}" when editing.advanced; act "Advanced settings" to get "{here(editing)}" carrying "advanced" "1" unless editing.advanced; } } } /// Whether the advanced block is open, from whichever half carries it. fn advanced_on(request: &quasi_router::Request) -> bool { let set = |params: &quasi_router::Params| params.get("advanced").is_some_and(|v| v == "1"); set(&request.carried) || set(&request.payload) } /// The account a route was addressed at. fn account_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no account id"))?; Ok(EmailAccountId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an account id"))?, )) } fn load(state: &AppState, id: EmailAccountId) -> Result { state .email_accounts .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such account")) } /// What `create_email_account` refuses, refused here. /// /// The same four checks in the same order, so a described submission and a /// commanded one are refused for the same reasons. The control-character check /// on the folder name is not decoration: it is what stops a folder name from /// carrying a second IMAP command. fn validate( name: &str, address: &str, imap: &str, smtp: &str, archive: &str, ) -> Vec<(&'static str, String)> { let mut errors = Vec::new(); if name.is_empty() { errors.push(("account_name", "An account needs a name.".to_owned())); } if !address.contains('@') || address.starts_with('@') || address.ends_with('@') { errors.push(("email_address", "That is not an email address.".to_owned())); } if imap.is_empty() { errors.push(("imap_server", "An IMAP server is required.".to_owned())); } if smtp.is_empty() { errors.push(("smtp_server", "An SMTP server is required.".to_owned())); } if archive.contains(['\r', '\n']) || archive.chars().any(char::is_control) { errors.push(( "archive_folder_name", "A folder name cannot carry control characters.".to_owned(), )); } errors } /// A port, defaulted rather than refused when the field is blank. fn port(raw: &str, fallback: i32) -> i32 { raw.parse().unwrap_or(fallback) } /// The add form. fn new_account(_state: &AppState, _request: quasi_router::Request) -> Result { Ok(form(&Editing::fresh()).into()) } /// Create it, or answer with the form saying why not. fn create(state: &AppState, request: quasi_router::Request) -> Result { let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let name = field("account_name"); let address = field("email_address"); let imap = field("imap_server"); let smtp = field("smtp_server"); let archive = field("archive_folder_name"); let password = field("password"); let mut errors = validate(&name, &address, &imap, &smtp, &archive); if password.is_empty() { errors.push(("password", "A new account needs a password.".to_owned())); } if !errors.is_empty() { return Ok(form(&Editing { existing: None, advanced: true, errors: &errors, submitted: Some(&request.payload), }) .into()); } // The password never reaches the row. `create` writes an empty column and // the secret goes to the OS keychain, which is what the command does and is // not this screen's invention. let account = state .email_accounts .create( DESKTOP_USER_ID, NewEmailAccount { account_name: &name, email_address: &address, imap_server: &imap, imap_port: port(&field("imap_port"), 993), smtp_server: &smtp, smtp_port: port(&field("smtp_port"), 587), username: &field("username"), password: "", use_tls: true, archive_folder_name: Some(archive.as_str()).filter(|a| !a.is_empty()), }, ) .map_err(|error| RouteError::internal(error.to_string()))?; crate::oauth::credentials::CredentialStore::store_password(account.id.into(), &password) .map_err(RouteError::internal)?; settings_screen(state) } /// The edit form. fn edit(state: &AppState, request: quasi_router::Request) -> Result { let account = load(state, account_id(&request)?)?; if account.auth_type != EmailAuthType::Password { return Err(RouteError::not_found( "an OAuth account has no servers to edit", )); } Ok(form(&Editing::of(&account, advanced_on(&request))).into()) } /// Save it, or answer with the form saying why not. fn update(state: &AppState, request: quasi_router::Request) -> Result { let account = load(state, account_id(&request)?)?; let advanced = advanced_on(&request); let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let name = field("account_name"); let address = field("email_address"); // The advanced block may be closed, in which case the submission carries no // server fields and the stored ones stand. Reading the form's own state is // what keeps a closed block from blanking four required columns. let imap = if advanced { field("imap_server") } else { account.imap_server.clone() }; let smtp = if advanced { field("smtp_server") } else { account.smtp_server.clone() }; let archive = field("archive_folder_name"); let errors = validate(&name, &address, &imap, &smtp, &archive); if !errors.is_empty() { return Ok(form(&Editing { existing: Some(&account), advanced, errors: &errors, submitted: Some(&request.payload), }) .into()); } let password = field("password"); state .email_accounts .update( account.id, DESKTOP_USER_ID, goingson_core::UpdateEmailAccount { account_name: &name, email_address: &address, imap_server: &imap, imap_port: if advanced { port(&field("imap_port"), account.imap_port) } else { account.imap_port }, smtp_server: &smtp, smtp_port: if advanced { port(&field("smtp_port"), account.smtp_port) } else { account.smtp_port }, username: &field("username"), password: None, use_tls: account.use_tls, archive_folder_name: Some(archive.as_str()).filter(|a| !a.is_empty()), }, ) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such account"))?; // Empty means keep the current one, which is what the label promises. if !password.is_empty() { crate::oauth::credentials::CredentialStore::store_password(account.id.into(), &password) .map_err(RouteError::internal)?; } settings_screen(state) } /// Delete one, and answer with the section. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = account_id(&request)?; let deleted = state .email_accounts .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such account")); } settings_screen(state) } /// The settings screen showing this section, which every write answers with. fn settings_screen(state: &AppState) -> Result { super::showing(state, "email") } /// The section's routes. /// /// Mounted above `/settings/{section}`, so `email` is read as the literal /// segment it is rather than captured as a section name by the parent. #[must_use] pub(super) fn routes(router: Router) -> Router { router .get("/settings/email/new", new_account) .post("/settings/email", create) .get("/settings/email/{id}/edit", edit) .post("/settings/email/{id}", update) .post("/settings/email/{id}/delete", remove) }