Skip to main content

max / goingson

25.0 KB · 698 lines History Blame Raw
1 //! Email accounts.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The account is not host-bound: `create_email_account`,
6 //! `update_email_account` and `delete_email_account` take
7 //! `State<Arc<AppState>>` and nothing else, no `AppHandle` appears in
8 //! `commands/email_account.rs`, the repository calls are synchronous, and
9 //! `CredentialStore::store_password` is a process-level call rather than
10 //! something asked of a Tauri handle. **OAuth** is the host-bound half. A
11 //! section is not undescribable because its loudest feature is: the test is
12 //! what the *data* needs rather than what the busiest control does.
13 //!
14 //! # What is here
15 //!
16 //! - `GET /settings/email` — the accounts, as a settings section.
17 //! - `GET /settings/email/new` — the manual IMAP/SMTP form.
18 //! - `POST /settings/email` — create one.
19 //! - `GET /settings/email/{id}/edit` — the same form, filled in.
20 //! - `POST /settings/email/{id}` — save one.
21 //! - `POST /settings/email/{id}/delete` — delete one.
22 //!
23 //! The advanced block is `?advanced=1` rather than a toggle button, which is
24 //! decision 2: the form remembers whether it is open because the address does.
25 //!
26 //! # What refused
27 //!
28 //! - **The OAuth handshake.** The system browser, a localhost callback poll and
29 //! a code exchange: three host interactions in sequence, where a route
30 //! handler is a function from a request to an answer. An OAuth account still
31 //! lists and still deletes; adding and reconnecting one has nowhere to go.
32 //! - **Test Connection and Sync Now.** Both are network round-trips whose
33 //! result is the screen's content (the folder list, a progress count). A
34 //! handler is synchronous, so neither can be awaited inside one.
35 //! - **Auto-detect.** Typing a domain filling the four server fields is one
36 //! field's value writing another's, which is quasicoherent `f35aafee`:
37 //! nothing can say put this chosen value into that other field. So the form
38 //! asks for the servers plainly, and the provider defaults are what the
39 //! placeholders show.
40 //! - **The provider note.** Each detected domain carries an app-password
41 //! instruction with a link to where you generate one. `Field::hint` is plain
42 //! text, and the instruction without the link is the useful half, so the hint
43 //! carries the instruction and the link has nowhere to be. This is the one
44 //! refusal here that loses something a user actually needs.
45
46 #![allow(clippy::needless_pass_by_value)]
47
48 use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType, NewEmailAccount};
49 use quasi_declare::declare;
50 use quasi_router::screen::Choice;
51 use quasi_router::{Response, RouteError, Router};
52
53 use crate::state::{AppState, DESKTOP_USER_ID};
54
55 #[cfg(test)]
56 mod tests;
57
58 /// One auto-sync interval on offer.
59 ///
60 /// A struct rather than a pair, because a description names what it draws and
61 /// `.1` is not a name.
62 struct Interval {
63 /// The minutes, as the field submits them. Empty means manual only.
64 value: &'static str,
65 label: &'static str,
66 }
67
68 /// The intervals the JS offers, as its own `SYNC_INTERVAL_OPTIONS`.
69 const SYNC_INTERVALS: [Interval; 5] = [
70 Interval {
71 value: "",
72 label: "Manual only",
73 },
74 Interval {
75 value: "5",
76 label: "Every 5 minutes",
77 },
78 Interval {
79 value: "15",
80 label: "Every 15 minutes",
81 },
82 Interval {
83 value: "30",
84 label: "Every 30 minutes",
85 },
86 Interval {
87 value: "60",
88 label: "Hourly",
89 },
90 ];
91
92 /// How an account authenticates, for the row that says so.
93 fn auth_label(auth: &EmailAuthType) -> &'static str {
94 match auth {
95 EmailAuthType::Password => "Password",
96 EmailAuthType::OAuth2Fastmail => "Fastmail (OAuth)",
97 EmailAuthType::OAuth2Google => "Google (OAuth)",
98 EmailAuthType::OAuth2Microsoft => "Microsoft (OAuth)",
99 EmailAuthType::OAuth2Yahoo => "Yahoo (OAuth)",
100 }
101 }
102
103 declare! {
104 /// One account.
105 ///
106 /// Edit is offered on a password account and withheld on an OAuth one,
107 /// because the form below is the IMAP/SMTP form and an OAuth account has no
108 /// servers, username or password to edit. Delete is offered on both:
109 /// removing an account is the same act either way.
110 shape row_for(account: &EmailAccount) -> Row;
111
112 row &account.account_name {
113 secondary &account.email_address;
114 meta auth_label(&account.auth_type);
115
116 act "Edit" to get "/settings/email/{account.id}/edit"
117 when account.auth_type is EmailAuthType::Password;
118
119 act "Delete" to post "/settings/email/{account.id}/delete" {
120 tone Danger;
121 }
122 }
123 }
124
125 /// Every account this user has.
126 ///
127 /// The read is the parent screen's, which is what converting this section
128 /// meant: a description says what is on the screen rather than fetching it.
129 pub(super) fn accounts(state: &AppState) -> Result<Vec<EmailAccount>, RouteError> {
130 state
131 .email_accounts
132 .list_by_user(DESKTOP_USER_ID)
133 .map_err(|error| RouteError::internal(error.to_string()))
134 }
135
136 declare! {
137 /// The section's body: the accounts, and the way to add one.
138 pub(super) shape pane(accounts: &[EmailAccount]) -> Vec<Node>;
139
140 section "Email accounts";
141
142 empty "No accounts yet." when accounts.is_empty();
143
144 list {
145 for account in accounts.iter() {
146 include row_for(account);
147 }
148 } unless accounts.is_empty();
149
150 act "Add an account" to get "/settings/email/new";
151 }
152
153 /// What the form is filling in, and what it is answering.
154 ///
155 /// `existing` is the account being edited, or `None` for a new one. `advanced`
156 /// is the disclosure's state, which is an address rather than a toggle so that a
157 /// form reopened after a refusal is open to the same depth it was.
158 struct Editing<'a> {
159 existing: Option<&'a EmailAccount>,
160 advanced: bool,
161 errors: &'a [(&'a str, String)],
162 submitted: Option<&'a quasi_router::Params>,
163 }
164
165 impl<'a> Editing<'a> {
166 /// A fresh form.
167 ///
168 /// Advanced opens by default on a new account, because the server fields
169 /// are required and a form whose required fields are hidden cannot be
170 /// submitted. The JS reaches the same state by a different route: it opens
171 /// the block once autodetect has filled those fields in.
172 const fn fresh() -> Self {
173 Self {
174 existing: None,
175 advanced: true,
176 errors: &[],
177 submitted: None,
178 }
179 }
180
181 /// The form over an existing account.
182 const fn of(account: &'a EmailAccount, advanced: bool) -> Self {
183 Self {
184 existing: Some(account),
185 advanced,
186 errors: &[],
187 submitted: None,
188 }
189 }
190 }
191
192 /// Whether the form is editing rather than adding.
193 fn is_edit(editing: &Editing) -> bool {
194 editing.existing.is_some()
195 }
196
197 /// What each question falls back to when nothing has been submitted.
198 ///
199 /// One table by name rather than a default spelled at each field, which is the
200 /// same argument the parent screen's `default_for` makes: `archive_folder_name`
201 /// and the two ports had their fallbacks written where the field was built, and
202 /// the field is not where a default belongs.
203 fn stored(editing: &Editing, name: &str) -> String {
204 let Some(account) = editing.existing else {
205 return match name {
206 "archive_folder_name" => "Archive".to_owned(),
207 "imap_port" => "993".to_owned(),
208 "smtp_port" => "587".to_owned(),
209 "sync_interval_minutes" => "15".to_owned(),
210 _ => String::new(),
211 };
212 };
213 match name {
214 "account_name" => account.account_name.clone(),
215 "email_address" => account.email_address.clone(),
216 "username" => account.username.clone(),
217 "archive_folder_name" => account
218 .archive_folder_name
219 .clone()
220 .unwrap_or_else(|| "Archive".to_owned()),
221 "email_signature" => account.email_signature.clone().unwrap_or_default(),
222 "imap_server" => account.imap_server.clone(),
223 "smtp_server" => account.smtp_server.clone(),
224 "imap_port" => account.imap_port.to_string(),
225 "smtp_port" => account.smtp_port.to_string(),
226 "notify_new_emails" => {
227 if account.notify_new_emails {
228 "1".to_owned()
229 } else {
230 String::new()
231 }
232 }
233 "sync_interval_minutes" => account
234 .sync_interval_minutes
235 .map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()),
236 _ => String::new(),
237 }
238 }
239
240 /// What one question holds.
241 ///
242 /// A refused submission beats the stored value, which beats the default.
243 fn value_of(editing: &Editing, name: &str) -> String {
244 editing
245 .submitted
246 .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
247 .unwrap_or_else(|| stored(editing, name))
248 }
249
250 /// Whether a named question was refused.
251 fn has_error(editing: &Editing, name: &str) -> bool {
252 editing.errors.iter().any(|(field, _)| *field == name)
253 }
254
255 /// Why it was refused, or nothing.
256 fn error_for(editing: &Editing, name: &str) -> String {
257 editing
258 .errors
259 .iter()
260 .find(|(field, _)| *field == name)
261 .map(|(_, message)| message.clone())
262 .unwrap_or_default()
263 }
264
265 /// What the password question is called.
266 ///
267 /// The one place adding and editing differ: it is required when there is no
268 /// stored secret and optional when leaving it empty means keep the current one.
269 /// `buildAccountFormHtml` differs them the same way and says so in the label.
270 fn password_label(editing: &Editing) -> &'static str {
271 if is_edit(editing) {
272 "Password (leave empty to keep current)"
273 } else {
274 "Password"
275 }
276 }
277
278 /// What the password box shows before anything is typed.
279 fn password_placeholder(editing: &Editing) -> &'static str {
280 if is_edit(editing) {
281 "Enter new password or leave empty"
282 } else {
283 "your password"
284 }
285 }
286
287 /// What the page is called.
288 fn form_title(editing: &Editing) -> String {
289 match editing.existing {
290 Some(account) => format!("Edit {}", account.account_name),
291 None => "Add an email account".to_owned(),
292 }
293 }
294
295 /// Where the form writes.
296 fn form_path(editing: &Editing) -> String {
297 match editing.existing {
298 Some(account) => format!("/settings/email/{}", account.id),
299 None => "/settings/email".to_owned(),
300 }
301 }
302
303 /// What the form's button reads.
304 fn submit_label(editing: &Editing) -> &'static str {
305 if is_edit(editing) {
306 "Save account"
307 } else {
308 "Add account"
309 }
310 }
311
312 /// This form's own address, which the disclosure toggles by going back to it.
313 fn here(editing: &Editing) -> String {
314 match editing.existing {
315 Some(account) => format!("/settings/email/{}/edit", account.id),
316 None => "/settings/email/new".to_owned(),
317 }
318 }
319
320 declare! {
321 /// The form as a screen of its own, on the shape every other form here
322 /// takes.
323 ///
324 /// The advanced questions are guarded rather than appended by a second
325 /// shape, which is the production this screen earned: six server questions
326 /// on the form only while the disclosure is open.
327 shape form(editing: &Editing) -> Screen;
328
329 screen list_detail "Email account" false {
330 at_place crate::quasi::shell::SETTINGS;
331
332 region "email-band" as Band {
333 page form_title(editing);
334 act "Cancel" to get "/settings/email";
335 }
336
337 region "email-form" as Pane {
338 form post form_path(editing) {
339 submit submit_label(editing);
340
341 field Text "account_name" "Account Name" {
342 required;
343 placeholder "Personal, Work, etc.";
344 value value_of(editing, "account_name");
345 error error_for(editing, "account_name")
346 when has_error(editing, "account_name");
347 }
348
349 field Email "email_address" "Email Address" {
350 required;
351 placeholder "you@example.com";
352 value value_of(editing, "email_address");
353 error error_for(editing, "email_address")
354 when has_error(editing, "email_address");
355 }
356
357 field Text "username" "Username" {
358 required;
359 placeholder "Usually your email address";
360 value value_of(editing, "username");
361 error error_for(editing, "username") when has_error(editing, "username");
362 }
363
364 // Never `Text`. `FieldKind::Secret` is the kind whose contract
365 // is that the value is not echoed or round-tripped, which is
366 // exactly what a password typed into a form wants, and it is
367 // never given a `value` here: the stored secret lives in the OS
368 // keychain and the form has no business carrying it back out
369 // even when it could.
370 field Secret "password" password_label(editing) {
371 required unless is_edit(editing);
372 placeholder password_placeholder(editing);
373 error error_for(editing, "password") when has_error(editing, "password");
374 }
375
376 field Text "archive_folder_name" "Archive Folder Name" {
377 placeholder "Archive";
378 hint "Gmail: [Gmail]/All Mail, Fastmail: Archive.";
379 value value_of(editing, "archive_folder_name");
380 error error_for(editing, "archive_folder_name")
381 when has_error(editing, "archive_folder_name");
382 }
383
384 field Textarea "email_signature" "Email Signature" {
385 placeholder "-- \nYour Name";
386 hint "Appended to outbound emails. Plain text only.";
387 value value_of(editing, "email_signature");
388 error error_for(editing, "email_signature")
389 when has_error(editing, "email_signature");
390 }
391
392 field Text "imap_server" "IMAP Server" when editing.advanced {
393 required;
394 placeholder "imap.example.com";
395 value value_of(editing, "imap_server");
396 error error_for(editing, "imap_server")
397 when has_error(editing, "imap_server");
398 }
399
400 field Number "imap_port" "IMAP Port" when editing.advanced {
401 required;
402 value value_of(editing, "imap_port");
403 error error_for(editing, "imap_port") when has_error(editing, "imap_port");
404 }
405
406 field Text "smtp_server" "SMTP Server" when editing.advanced {
407 required;
408 placeholder "smtp.example.com";
409 value value_of(editing, "smtp_server");
410 error error_for(editing, "smtp_server")
411 when has_error(editing, "smtp_server");
412 }
413
414 field Number "smtp_port" "SMTP Port" when editing.advanced {
415 required;
416 value value_of(editing, "smtp_port");
417 error error_for(editing, "smtp_port") when has_error(editing, "smtp_port");
418 }
419
420 field Checkbox "notify_new_emails" "Notify on new emails" when editing.advanced {
421 hint "A system notification when new mail arrives during auto-sync. \
422 Off by default.";
423 value value_of(editing, "notify_new_emails");
424 error error_for(editing, "notify_new_emails")
425 when has_error(editing, "notify_new_emails");
426 }
427
428 field Select "sync_interval_minutes" "Auto-sync Interval"
429 when editing.advanced {
430 for offered in SYNC_INTERVALS {
431 option Choice::new(offered.value, offered.label);
432 }
433 hint "Check for new email at this interval.";
434 value value_of(editing, "sync_interval_minutes");
435 error error_for(editing, "sync_interval_minutes")
436 when has_error(editing, "sync_interval_minutes");
437 }
438 }
439
440 act "Hide advanced settings" to get "{here(editing)}" when editing.advanced;
441 act "Advanced settings" to get "{here(editing)}" carrying "advanced" "1"
442 unless editing.advanced;
443 }
444 }
445 }
446
447 /// Whether the advanced block is open, from whichever half carries it.
448 fn advanced_on(request: &quasi_router::Request) -> bool {
449 let set = |params: &quasi_router::Params| params.get("advanced").is_some_and(|v| v == "1");
450 set(&request.carried) || set(&request.payload)
451 }
452
453 /// The account a route was addressed at.
454 fn account_id(request: &quasi_router::Request) -> Result<EmailAccountId, RouteError> {
455 let raw = request
456 .captures
457 .get("id")
458 .ok_or_else(|| RouteError::not_found("no account id"))?;
459 Ok(EmailAccountId::from(
460 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an account id"))?,
461 ))
462 }
463
464 fn load(state: &AppState, id: EmailAccountId) -> Result<EmailAccount, RouteError> {
465 state
466 .email_accounts
467 .get_by_id(id, DESKTOP_USER_ID)
468 .map_err(|error| RouteError::internal(error.to_string()))?
469 .ok_or_else(|| RouteError::not_found("no such account"))
470 }
471
472 /// What `create_email_account` refuses, refused here.
473 ///
474 /// The same four checks in the same order, so a described submission and a
475 /// commanded one are refused for the same reasons. The control-character check
476 /// on the folder name is not decoration: it is what stops a folder name from
477 /// carrying a second IMAP command.
478 fn validate(
479 name: &str,
480 address: &str,
481 imap: &str,
482 smtp: &str,
483 archive: &str,
484 ) -> Vec<(&'static str, String)> {
485 let mut errors = Vec::new();
486 if name.is_empty() {
487 errors.push(("account_name", "An account needs a name.".to_owned()));
488 }
489 if !address.contains('@') || address.starts_with('@') || address.ends_with('@') {
490 errors.push(("email_address", "That is not an email address.".to_owned()));
491 }
492 if imap.is_empty() {
493 errors.push(("imap_server", "An IMAP server is required.".to_owned()));
494 }
495 if smtp.is_empty() {
496 errors.push(("smtp_server", "An SMTP server is required.".to_owned()));
497 }
498 if archive.contains(['\r', '\n']) || archive.chars().any(char::is_control) {
499 errors.push((
500 "archive_folder_name",
501 "A folder name cannot carry control characters.".to_owned(),
502 ));
503 }
504 errors
505 }
506
507 /// A port, defaulted rather than refused when the field is blank.
508 fn port(raw: &str, fallback: i32) -> i32 {
509 raw.parse().unwrap_or(fallback)
510 }
511
512 /// The add form.
513 fn new_account(_state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
514 Ok(form(&Editing::fresh()).into())
515 }
516
517 /// Create it, or answer with the form saying why not.
518 fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
519 let field = |name: &str| {
520 request
521 .payload
522 .get(name)
523 .unwrap_or_default()
524 .trim()
525 .to_owned()
526 };
527 let name = field("account_name");
528 let address = field("email_address");
529 let imap = field("imap_server");
530 let smtp = field("smtp_server");
531 let archive = field("archive_folder_name");
532 let password = field("password");
533
534 let mut errors = validate(&name, &address, &imap, &smtp, &archive);
535 if password.is_empty() {
536 errors.push(("password", "A new account needs a password.".to_owned()));
537 }
538 if !errors.is_empty() {
539 return Ok(form(&Editing {
540 existing: None,
541 advanced: true,
542 errors: &errors,
543 submitted: Some(&request.payload),
544 })
545 .into());
546 }
547
548 // The password never reaches the row. `create` writes an empty column and
549 // the secret goes to the OS keychain, which is what the command does and is
550 // not this screen's invention.
551 let account = state
552 .email_accounts
553 .create(
554 DESKTOP_USER_ID,
555 NewEmailAccount {
556 account_name: &name,
557 email_address: &address,
558 imap_server: &imap,
559 imap_port: port(&field("imap_port"), 993),
560 smtp_server: &smtp,
561 smtp_port: port(&field("smtp_port"), 587),
562 username: &field("username"),
563 password: "",
564 use_tls: true,
565 archive_folder_name: Some(archive.as_str()).filter(|a| !a.is_empty()),
566 },
567 )
568 .map_err(|error| RouteError::internal(error.to_string()))?;
569
570 crate::oauth::credentials::CredentialStore::store_password(account.id.into(), &password)
571 .map_err(RouteError::internal)?;
572
573 settings_screen(state)
574 }
575
576 /// The edit form.
577 fn edit(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
578 let account = load(state, account_id(&request)?)?;
579 if account.auth_type != EmailAuthType::Password {
580 return Err(RouteError::not_found(
581 "an OAuth account has no servers to edit",
582 ));
583 }
584 Ok(form(&Editing::of(&account, advanced_on(&request))).into())
585 }
586
587 /// Save it, or answer with the form saying why not.
588 fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
589 let account = load(state, account_id(&request)?)?;
590 let advanced = advanced_on(&request);
591
592 let field = |name: &str| {
593 request
594 .payload
595 .get(name)
596 .unwrap_or_default()
597 .trim()
598 .to_owned()
599 };
600 let name = field("account_name");
601 let address = field("email_address");
602 // The advanced block may be closed, in which case the submission carries no
603 // server fields and the stored ones stand. Reading the form's own state is
604 // what keeps a closed block from blanking four required columns.
605 let imap = if advanced {
606 field("imap_server")
607 } else {
608 account.imap_server.clone()
609 };
610 let smtp = if advanced {
611 field("smtp_server")
612 } else {
613 account.smtp_server.clone()
614 };
615 let archive = field("archive_folder_name");
616
617 let errors = validate(&name, &address, &imap, &smtp, &archive);
618 if !errors.is_empty() {
619 return Ok(form(&Editing {
620 existing: Some(&account),
621 advanced,
622 errors: &errors,
623 submitted: Some(&request.payload),
624 })
625 .into());
626 }
627
628 let password = field("password");
629 state
630 .email_accounts
631 .update(
632 account.id,
633 DESKTOP_USER_ID,
634 goingson_core::UpdateEmailAccount {
635 account_name: &name,
636 email_address: &address,
637 imap_server: &imap,
638 imap_port: if advanced {
639 port(&field("imap_port"), account.imap_port)
640 } else {
641 account.imap_port
642 },
643 smtp_server: &smtp,
644 smtp_port: if advanced {
645 port(&field("smtp_port"), account.smtp_port)
646 } else {
647 account.smtp_port
648 },
649 username: &field("username"),
650 password: None,
651 use_tls: account.use_tls,
652 archive_folder_name: Some(archive.as_str()).filter(|a| !a.is_empty()),
653 },
654 )
655 .map_err(|error| RouteError::internal(error.to_string()))?
656 .ok_or_else(|| RouteError::not_found("no such account"))?;
657
658 // Empty means keep the current one, which is what the label promises.
659 if !password.is_empty() {
660 crate::oauth::credentials::CredentialStore::store_password(account.id.into(), &password)
661 .map_err(RouteError::internal)?;
662 }
663
664 settings_screen(state)
665 }
666
667 /// Delete one, and answer with the section.
668 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
669 let id = account_id(&request)?;
670 let deleted = state
671 .email_accounts
672 .delete(id, DESKTOP_USER_ID)
673 .map_err(|error| RouteError::internal(error.to_string()))?;
674 if !deleted {
675 return Err(RouteError::not_found("no such account"));
676 }
677 settings_screen(state)
678 }
679
680 /// The settings screen showing this section, which every write answers with.
681 fn settings_screen(state: &AppState) -> Result<Response, RouteError> {
682 super::showing(state, "email")
683 }
684
685 /// The section's routes.
686 ///
687 /// Mounted above `/settings/{section}`, so `email` is read as the literal
688 /// segment it is rather than captured as a section name by the parent.
689 #[must_use]
690 pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
691 router
692 .get("/settings/email/new", new_account)
693 .post("/settings/email", create)
694 .get("/settings/email/{id}/edit", edit)
695 .post("/settings/email/{id}", update)
696 .post("/settings/email/{id}/delete", remove)
697 }
698