|
1 |
+ |
//! Email accounts, the settings section this port said was not describable.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! # The parent module's claim, corrected
|
|
6 |
+ |
//!
|
|
7 |
+ |
//! [`super`]'s header lists Email among five sections "left out rather than
|
|
8 |
+ |
//! half-described", under one cause: "a route handler is `fn(&AppState,
|
|
9 |
+ |
//! Params)`, and those sections are about the host rather than about the app."
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! That is right about Sync, Sharing and About, and it is wrong about Email —
|
|
12 |
+ |
//! measured 2026-08-21 by reading the commands rather than the screen.
|
|
13 |
+ |
//! `create_email_account`, `update_email_account` and `delete_email_account`
|
|
14 |
+ |
//! take `State<Arc<AppState>>` and nothing else. No `AppHandle` appears in
|
|
15 |
+ |
//! `commands/email_account.rs` at all. The repository calls are synchronous,
|
|
16 |
+ |
//! and `CredentialStore::store_password` is a process-level call rather than
|
|
17 |
+ |
//! something asked of a Tauri handle.
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! What is host-bound here is **OAuth**, not the account. 82 of
|
|
20 |
+ |
//! `email-accounts.js`'s lines mention it, and that volume is what made the
|
|
21 |
+ |
//! whole section read as unreachable. The distinction matters beyond this file:
|
|
22 |
+ |
//! a section is not undescribable because its loudest feature is, and the test
|
|
23 |
+ |
//! is what the *data* needs rather than what the busiest control does.
|
|
24 |
+ |
//!
|
|
25 |
+ |
//! # What is here
|
|
26 |
+ |
//!
|
|
27 |
+ |
//! - `GET /settings/email` — the accounts, as a settings section.
|
|
28 |
+ |
//! - `GET /settings/email/new` — the manual IMAP/SMTP form.
|
|
29 |
+ |
//! - `POST /settings/email` — create one.
|
|
30 |
+ |
//! - `GET /settings/email/{id}/edit` — the same form, filled in.
|
|
31 |
+ |
//! - `POST /settings/email/{id}` — save one.
|
|
32 |
+ |
//! - `POST /settings/email/{id}/delete` — delete one.
|
|
33 |
+ |
//!
|
|
34 |
+ |
//! The advanced block is `?advanced=1` rather than a toggle button, which is
|
|
35 |
+ |
//! decision 2 again and the same answer the project dashboard's completed
|
|
36 |
+ |
//! milestones got: the JS opens it when the server fields already have values
|
|
37 |
+ |
//! and closes it otherwise, so which state you are looking at depends on
|
|
38 |
+ |
//! module-scope facts and has no address. Here the form remembers it because
|
|
39 |
+ |
//! the address does.
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! # What refused
|
|
42 |
+ |
//!
|
|
43 |
+ |
//! - **The OAuth handshake.** `startOAuth` opens the system browser, then
|
|
44 |
+ |
//! `listenForOAuthCallback(port)` polls a localhost callback and
|
|
45 |
+ |
//! `completeOAuth` exchanges the code. Three host interactions in sequence
|
|
46 |
+ |
//! with a modal in between; a route handler is a function from a request to
|
|
47 |
+ |
//! an answer. An OAuth account still lists and still deletes — it is adding
|
|
48 |
+ |
//! and reconnecting one that has nowhere to go.
|
|
49 |
+ |
//! - **Test Connection and Sync Now.** Both are network round-trips whose
|
|
50 |
+ |
//! result is the screen's content (the folder list, a progress count). A
|
|
51 |
+ |
//! handler is synchronous, so neither can be awaited inside one.
|
|
52 |
+ |
//! - **Auto-detect.** Typing a domain fills the four server fields from
|
|
53 |
+ |
//! `PROVIDER_SETTINGS`. That is one field's value writing another's, which is
|
|
54 |
+ |
//! quasicoherent `f35aafee` — "nothing can say put this chosen value into
|
|
55 |
+ |
//! that other field" — and the reason the form here asks for the servers
|
|
56 |
+ |
//! plainly instead. The provider defaults are not lost: they are what the
|
|
57 |
+ |
//! placeholders show.
|
|
58 |
+ |
//! - **The provider note.** Each detected domain carries an app-password
|
|
59 |
+ |
//! instruction with a link to where you generate one. `Field::hint` is plain
|
|
60 |
+ |
//! text, and the instruction without the link is the useful half, so the hint
|
|
61 |
+ |
//! carries the instruction and the link has nowhere to be. Worth knowing that
|
|
62 |
+ |
//! this is the one refusal here that loses something a user actually needs.
|
|
63 |
+ |
|
|
64 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
65 |
+ |
|
|
66 |
+ |
use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType, NewEmailAccount};
|
|
67 |
+ |
use quasi_router::screen::{Act, Choice, Field, Row};
|
|
68 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
69 |
+ |
|
|
70 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
|
71 |
+ |
|
|
72 |
+ |
#[cfg(test)]
|
|
73 |
+ |
mod tests;
|
|
74 |
+ |
|
|
75 |
+ |
/// The intervals the JS offers, as its own `SYNC_INTERVAL_OPTIONS`.
|
|
76 |
+ |
const SYNC_INTERVALS: [(&str, &str); 5] = [
|
|
77 |
+ |
("", "Manual only"),
|
|
78 |
+ |
("5", "Every 5 minutes"),
|
|
79 |
+ |
("15", "Every 15 minutes"),
|
|
80 |
+ |
("30", "Every 30 minutes"),
|
|
81 |
+ |
("60", "Hourly"),
|
|
82 |
+ |
];
|
|
83 |
+ |
|
|
84 |
+ |
/// How an account authenticates, for the row that says so.
|
|
85 |
+ |
fn auth_label(auth: &EmailAuthType) -> &'static str {
|
|
86 |
+ |
match auth {
|
|
87 |
+ |
EmailAuthType::Password => "Password",
|
|
88 |
+ |
EmailAuthType::OAuth2Fastmail => "Fastmail (OAuth)",
|
|
89 |
+ |
EmailAuthType::OAuth2Google => "Google (OAuth)",
|
|
90 |
+ |
EmailAuthType::OAuth2Microsoft => "Microsoft (OAuth)",
|
|
91 |
+ |
EmailAuthType::OAuth2Yahoo => "Yahoo (OAuth)",
|
|
92 |
+ |
}
|
|
93 |
+ |
}
|
|
94 |
+ |
|
|
95 |
+ |
/// One account.
|
|
96 |
+ |
///
|
|
97 |
+ |
/// Edit is offered on a password account and withheld on an OAuth one, because
|
|
98 |
+ |
/// the form below is the IMAP/SMTP form and an OAuth account has no servers,
|
|
99 |
+ |
/// username or password to edit. Delete is offered on both: removing an account
|
|
100 |
+ |
/// is the same act either way.
|
|
101 |
+ |
fn row_for(account: &EmailAccount) -> Row {
|
|
102 |
+ |
let mut row = Row::new(&account.account_name)
|
|
103 |
+ |
.secondary(&account.email_address)
|
|
104 |
+ |
.meta(auth_label(&account.auth_type));
|
|
105 |
+ |
|
|
106 |
+ |
if account.auth_type == EmailAuthType::Password {
|
|
107 |
+ |
row = row.act(Act::new(
|
|
108 |
+ |
"Edit",
|
|
109 |
+ |
Action::get(format!("/settings/email/{}/edit", account.id)),
|
|
110 |
+ |
));
|
|
111 |
+ |
}
|
|
112 |
+ |
|
|
113 |
+ |
row.act(
|
|
114 |
+ |
Act::new(
|
|
115 |
+ |
"Delete",
|
|
116 |
+ |
Action::post(format!("/settings/email/{}/delete", account.id)),
|
|
117 |
+ |
)
|
|
118 |
+ |
.tone(makeover_layout::Tone::Danger),
|
|
119 |
+ |
)
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
/// The section's body: the accounts, and the way to add one.
|
|
123 |
+ |
pub(super) fn pane(state: &AppState) -> Result<Vec<Node>, RouteError> {
|
|
124 |
+ |
let accounts = state
|
|
125 |
+ |
.email_accounts
|
|
126 |
+ |
.list_by_user(DESKTOP_USER_ID)
|
|
127 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
128 |
+ |
|
|
129 |
+ |
let mut out = vec![Node::section("Email accounts")];
|
|
130 |
+ |
if accounts.is_empty() {
|
|
131 |
+ |
out.push(Node::empty("No accounts yet."));
|
|
132 |
+ |
} else {
|
|
133 |
+ |
out.push(Node::list(accounts.iter().map(row_for)));
|
|
134 |
+ |
}
|
|
135 |
+ |
out.push(Node::act(
|
|
136 |
+ |
"Add an account".to_owned(),
|
|
137 |
+ |
Action::get("/settings/email/new"),
|
|
138 |
+ |
));
|
|
139 |
+ |
Ok(out)
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
/// The account form, for adding and for editing.
|
|
143 |
+ |
///
|
|
144 |
+ |
/// `existing` is the account being edited, or `None` for a new one. The two
|
|
145 |
+ |
/// differ in exactly one place — the password field, which is required when
|
|
146 |
+ |
/// there is no stored secret and optional when leaving it empty means keep the
|
|
147 |
+ |
/// current one. `buildAccountFormHtml` differs them the same way and says so in
|
|
148 |
+ |
/// the label.
|
|
149 |
+ |
fn fields(
|
|
150 |
+ |
existing: Option<&EmailAccount>,
|
|
151 |
+ |
advanced: bool,
|
|
152 |
+ |
errors: &[(&str, String)],
|
|
153 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
154 |
+ |
) -> Vec<Field> {
|
|
155 |
+ |
let error_for = |name: &str| {
|
|
156 |
+ |
errors
|
|
157 |
+ |
.iter()
|
|
158 |
+ |
.find(|(field, _)| *field == name)
|
|
159 |
+ |
.map(|(_, message)| message.clone())
|
|
160 |
+ |
};
|
|
161 |
+ |
let apply = |field: Field, name: &str| match error_for(name) {
|
|
162 |
+ |
Some(message) => field.error(message),
|
|
163 |
+ |
None => field,
|
|
164 |
+ |
};
|
|
165 |
+ |
// A refused submission beats the stored value, which beats the default.
|
|
166 |
+ |
let value_of = |name: &str, stored: String| {
|
|
167 |
+ |
submitted
|
|
168 |
+ |
.and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
|
|
169 |
+ |
.unwrap_or(stored)
|
|
170 |
+ |
};
|
|
171 |
+ |
|
|
172 |
+ |
let mut name = Field::new(
|
|
173 |
+ |
makeover_layout::FieldKind::Text,
|
|
174 |
+ |
"account_name",
|
|
175 |
+ |
"Account Name",
|
|
176 |
+ |
)
|
|
177 |
+ |
.required()
|
|
178 |
+ |
.value(value_of(
|
|
179 |
+ |
"account_name",
|
|
180 |
+ |
existing.map(|a| a.account_name.clone()).unwrap_or_default(),
|
|
181 |
+ |
));
|
|
182 |
+ |
name.placeholder = Some("Personal, Work, etc.".to_owned());
|
|
183 |
+ |
|
|
184 |
+ |
let mut address = Field::new(
|
|
185 |
+ |
makeover_layout::FieldKind::Email,
|
|
186 |
+ |
"email_address",
|
|
187 |
+ |
"Email Address",
|
|
188 |
+ |
)
|
|
189 |
+ |
.required()
|
|
190 |
+ |
.value(value_of(
|
|
191 |
+ |
"email_address",
|
|
192 |
+ |
existing
|
|
193 |
+ |
.map(|a| a.email_address.clone())
|
|
194 |
+ |
.unwrap_or_default(),
|
|
195 |
+ |
));
|
|
196 |
+ |
address.placeholder = Some("you@example.com".to_owned());
|
|
197 |
+ |
|
|
198 |
+ |
let mut username = Field::new(makeover_layout::FieldKind::Text, "username", "Username")
|
|
199 |
+ |
.required()
|
|
200 |
+ |
.value(value_of(
|
|
201 |
+ |
"username",
|
|
202 |
+ |
existing.map(|a| a.username.clone()).unwrap_or_default(),
|
|
203 |
+ |
));
|
|
204 |
+ |
username.placeholder = Some("Usually your email address".to_owned());
|
|
205 |
+ |
|
|
206 |
+ |
// Never `Text`. `FieldKind::Secret` is the kind whose contract is that the
|
|
207 |
+ |
// value is not echoed or round-tripped, which is exactly what a password
|
|
208 |
+ |
// typed into a form wants, and it is never given a `value` here: the stored
|
|
209 |
+ |
// secret lives in the OS keychain and the form has no business carrying it
|
|
210 |
+ |
// back out even when it could.
|
|
211 |
+ |
let mut password = Field::new(makeover_layout::FieldKind::Secret, "password", {
|
|
212 |
+ |
if existing.is_some() {
|
|
213 |
+ |
"Password (leave empty to keep current)"
|
|
214 |
+ |
} else {
|
|
215 |
+ |
"Password"
|
|
216 |
+ |
}
|
|
217 |
+ |
});
|
|
218 |
+ |
if existing.is_none() {
|
|
219 |
+ |
password = password.required();
|
|
220 |
+ |
}
|
|
221 |
+ |
password.placeholder = Some(
|
|
222 |
+ |
if existing.is_some() {
|
|
223 |
+ |
"Enter new password or leave empty"
|
|
224 |
+ |
} else {
|
|
225 |
+ |
"your password"
|
|
226 |
+ |
}
|
|
227 |
+ |
.to_owned(),
|
|
228 |
+ |
);
|
|
229 |
+ |
|
|
230 |
+ |
let mut archive = Field::new(
|
|
231 |
+ |
makeover_layout::FieldKind::Text,
|
|
232 |
+ |
"archive_folder_name",
|
|
233 |
+ |
"Archive Folder Name",
|
|
234 |
+ |
)
|
|
235 |
+ |
.hint("Gmail: [Gmail]/All Mail, Fastmail: Archive.")
|
|
236 |
+ |
.value(value_of(
|
|
237 |
+ |
"archive_folder_name",
|
|
238 |
+ |
existing
|
|
239 |
+ |
.and_then(|a| a.archive_folder_name.clone())
|
|
240 |
+ |
.unwrap_or_else(|| "Archive".to_owned()),
|
|
241 |
+ |
));
|
|
242 |
+ |
archive.placeholder = Some("Archive".to_owned());
|
|
243 |
+ |
|
|
244 |
+ |
let mut signature = Field::new(
|
|
245 |
+ |
makeover_layout::FieldKind::Textarea,
|
|
246 |
+ |
"email_signature",
|
|
247 |
+ |
"Email Signature",
|
|
248 |
+ |
)
|
|
249 |
+ |
.hint("Appended to outbound emails. Plain text only.")
|
|
250 |
+ |
.value(value_of(
|
|
251 |
+ |
"email_signature",
|
|
252 |
+ |
existing
|
|
253 |
+ |
.and_then(|a| a.email_signature.clone())
|
|
254 |
+ |
.unwrap_or_default(),
|
|
255 |
+ |
));
|
|
256 |
+ |
signature.placeholder = Some("-- \nYour Name".to_owned());
|
|
257 |
+ |
|
|
258 |
+ |
let mut out = vec![
|
|
259 |
+ |
apply(name, "account_name"),
|
|
260 |
+ |
apply(address, "email_address"),
|
|
261 |
+ |
apply(username, "username"),
|
|
262 |
+ |
apply(password, "password"),
|
|
263 |
+ |
apply(archive, "archive_folder_name"),
|
|
264 |
+ |
apply(signature, "email_signature"),
|
|
265 |
+ |
];
|
|
266 |
+ |
|
|
267 |
+ |
if !advanced {
|
|
268 |
+ |
return out;
|
|
269 |
+ |
}
|
|
270 |
+ |
|
|
271 |
+ |
let mut imap = Field::new(
|
|
272 |
+ |
makeover_layout::FieldKind::Text,
|
|
273 |
+ |
"imap_server",
|
|
274 |
+ |
"IMAP Server",
|
|
275 |
+ |
)
|
|
276 |
+ |
.required()
|
|
277 |
+ |
.value(value_of(
|
|
278 |
+ |
"imap_server",
|
|
279 |
+ |
existing.map(|a| a.imap_server.clone()).unwrap_or_default(),
|
|
280 |
+ |
));
|
|
281 |
+ |
imap.placeholder = Some("imap.example.com".to_owned());
|
|
282 |
+ |
|
|
283 |
+ |
let mut smtp = Field::new(
|
|
284 |
+ |
makeover_layout::FieldKind::Text,
|
|
285 |
+ |
"smtp_server",
|
|
286 |
+ |
"SMTP Server",
|
|
287 |
+ |
)
|
|
288 |
+ |
.required()
|
|
289 |
+ |
.value(value_of(
|
|
290 |
+ |
"smtp_server",
|
|
291 |
+ |
existing.map(|a| a.smtp_server.clone()).unwrap_or_default(),
|
|
292 |
+ |
));
|
|
293 |
+ |
smtp.placeholder = Some("smtp.example.com".to_owned());
|
|
294 |
+ |
|
|
295 |
+ |
out.extend([
|
|
296 |
+ |
apply(imap, "imap_server"),
|
|
297 |
+ |
apply(
|
|
298 |
+ |
Field::new(makeover_layout::FieldKind::Number, "imap_port", "IMAP Port")
|
|
299 |
+ |
.required()
|
|
300 |
+ |
.value(value_of(
|
|
301 |
+ |
"imap_port",
|
|
302 |
+ |
existing.map_or_else(|| "993".to_owned(), |a| a.imap_port.to_string()),
|
|
303 |
+ |
)),
|
|
304 |
+ |
"imap_port",
|
|
305 |
+ |
),
|
|
306 |
+ |
apply(smtp, "smtp_server"),
|
|
307 |
+ |
apply(
|
|
308 |
+ |
Field::new(makeover_layout::FieldKind::Number, "smtp_port", "SMTP Port")
|
|
309 |
+ |
.required()
|
|
310 |
+ |
.value(value_of(
|
|
311 |
+ |
"smtp_port",
|
|
312 |
+ |
existing.map_or_else(|| "587".to_owned(), |a| a.smtp_port.to_string()),
|
|
313 |
+ |
)),
|
|
314 |
+ |
"smtp_port",
|
|
315 |
+ |
),
|
|
316 |
+ |
apply(
|
|
317 |
+ |
Field::new(
|
|
318 |
+ |
makeover_layout::FieldKind::Checkbox,
|
|
319 |
+ |
"notify_new_emails",
|
|
320 |
+ |
"Notify on new emails",
|
|
321 |
+ |
)
|
|
322 |
+ |
.hint("A system notification when new mail arrives during auto-sync. Off by default.")
|
|
323 |
+ |
.value(match existing {
|
|
324 |
+ |
Some(account) if account.notify_new_emails => "1",
|
|
325 |
+ |
_ => "",
|
|
326 |
+ |
}),
|
|
327 |
+ |
"notify_new_emails",
|
|
328 |
+ |
),
|
|
329 |
+ |
apply(
|
|
330 |
+ |
Field::select(
|
|
331 |
+ |
"sync_interval_minutes",
|
|
332 |
+ |
"Auto-sync Interval",
|
|
333 |
+ |
SYNC_INTERVALS
|
|
334 |
+ |
.iter()
|
|
335 |
+ |
.map(|(value, label)| Choice::new(*value, *label))
|
|
336 |
+ |
.collect(),
|
|
337 |
+ |
)
|
|
338 |
+ |
.hint("Check for new email at this interval.")
|
|
339 |
+ |
.value(value_of(
|
|
340 |
+ |
"sync_interval_minutes",
|
|
341 |
+ |
existing
|
|
342 |
+ |
.and_then(|a| a.sync_interval_minutes)
|
|
343 |
+ |
.map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()),
|
|
344 |
+ |
)),
|
|
345 |
+ |
"sync_interval_minutes",
|
|
346 |
+ |
),
|
|
347 |
+ |
]);
|
|
348 |
+ |
|
|
349 |
+ |
out
|
|
350 |
+ |
}
|
|
351 |
+ |
|
|
352 |
+ |
/// The form as a screen of its own, on the shape every other form here takes.
|
|
353 |
+ |
fn form(
|
|
354 |
+ |
existing: Option<&EmailAccount>,
|
|
355 |
+ |
advanced: bool,
|
|
356 |
+ |
errors: &[(&str, String)],
|
|
357 |
+ |
submitted: Option<&quasi_router::Params>,
|
|
358 |
+ |
) -> Screen {
|
|
359 |
+ |
let (title, action) = match existing {
|
|
360 |
+ |
Some(account) => (
|
|
361 |
+ |
format!("Edit {}", account.account_name),
|
|
362 |
+ |
Action::post(format!("/settings/email/{}", account.id)),
|
|
363 |
+ |
),
|
|
364 |
+ |
None => (
|
|
365 |
+ |
"Add an email account".to_owned(),
|
|
366 |
+ |
Action::post("/settings/email"),
|
|
367 |
+ |
),
|
|
368 |
+ |
};
|
|
369 |
+ |
|
|
370 |
+ |
// The disclosure is an address, so a form reopened after a refusal is open
|
|
371 |
+ |
// to the same depth it was.
|
|
372 |
+ |
let toggle = {
|
|
373 |
+ |
let here = match existing {
|
|
374 |
+ |
Some(account) => format!("/settings/email/{}/edit", account.id),
|
|
375 |
+ |
None => "/settings/email/new".to_owned(),
|
|
376 |
+ |
};
|
|
377 |
+ |
let action = Action::get(here);
|
|
378 |
+ |
if advanced {
|
|
379 |
+ |
Node::act("Hide advanced settings".to_owned(), action)
|
|
380 |
+ |
} else {
|
|
381 |
+ |
Node::act(
|
|
382 |
+ |
"Advanced settings".to_owned(),
|
|
383 |
+ |
action.carrying("advanced", "1"),
|
|
384 |
+ |
)
|
|
385 |
+ |
}
|
|
386 |
+ |
};
|
|
387 |
+ |
|
|
388 |
+ |
let band = Slot::new("email-band", RegionKind::Band)
|
|
389 |
+ |
.with(Node::page(title))
|
|
390 |
+ |
.with(Node::act("Cancel", Action::get("/settings/email")));
|
|
391 |
+ |
|
|
392 |
+ |
let pane = Slot::new("email-form", RegionKind::Pane)
|
|
393 |
+ |
.with(Node::Form {
|
|
394 |
+ |
action,
|
|
395 |
+ |
submit: if existing.is_some() {
|
|
396 |
+ |
"Save account".to_owned()
|
|
397 |
+ |
} else {
|
|
398 |
+ |
"Add account".to_owned()
|
|
399 |
+ |
},
|
|
400 |
+ |
fields: fields(existing, advanced, errors, submitted),
|
|
401 |
+ |
})
|
|
402 |
+ |
.with(toggle);
|
|
403 |
+ |
|
|
404 |
+ |
Screen::list_detail("Email account", false)
|
|
405 |
+ |
.with(band)
|
|
406 |
+ |
.with(pane)
|
|
407 |
+ |
}
|
|
408 |
+ |
|
|
409 |
+ |
/// Whether the advanced block is open, from whichever half carries it.
|
|
410 |
+ |
fn advanced_on(request: &quasi_router::Request) -> bool {
|
|
411 |
+ |
let set = |params: &quasi_router::Params| params.get("advanced").is_some_and(|v| v == "1");
|
|
412 |
+ |
set(&request.carried) || set(&request.payload)
|
|
413 |
+ |
}
|
|
414 |
+ |
|
|
415 |
+ |
/// The account a route was addressed at.
|
|
416 |
+ |
fn account_id(request: &quasi_router::Request) -> Result<EmailAccountId, RouteError> {
|
|
417 |
+ |
let raw = request
|
|
418 |
+ |
.captures
|
|
419 |
+ |
.get("id")
|
|
420 |
+ |
.ok_or_else(|| RouteError::not_found("no account id"))?;
|
|
421 |
+ |
Ok(EmailAccountId::from(
|
|
422 |
+ |
uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an account id"))?,
|
|
423 |
+ |
))
|
|
424 |
+ |
}
|
|
425 |
+ |
|
|
426 |
+ |
fn load(state: &AppState, id: EmailAccountId) -> Result<EmailAccount, RouteError> {
|
|
427 |
+ |
state
|
|
428 |
+ |
.email_accounts
|
|
429 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
430 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
431 |
+ |
.ok_or_else(|| RouteError::not_found("no such account"))
|
|
432 |
+ |
}
|
|
433 |
+ |
|
|
434 |
+ |
/// What `create_email_account` refuses, refused here.
|
|
435 |
+ |
///
|
|
436 |
+ |
/// The same four checks in the same order, so a described submission and a
|
|
437 |
+ |
/// commanded one are refused for the same reasons. The control-character check
|
|
438 |
+ |
/// on the folder name is not decoration: it is what stops a folder name from
|
|
439 |
+ |
/// carrying a second IMAP command.
|
|
440 |
+ |
fn validate(
|
|
441 |
+ |
name: &str,
|
|
442 |
+ |
address: &str,
|
|
443 |
+ |
imap: &str,
|
|
444 |
+ |
smtp: &str,
|
|
445 |
+ |
archive: &str,
|
|
446 |
+ |
) -> Vec<(&'static str, String)> {
|
|
447 |
+ |
let mut errors = Vec::new();
|
|
448 |
+ |
if name.is_empty() {
|
|
449 |
+ |
errors.push(("account_name", "An account needs a name.".to_owned()));
|
|
450 |
+ |
}
|
|
451 |
+ |
if !address.contains('@') || address.starts_with('@') || address.ends_with('@') {
|
|
452 |
+ |
errors.push(("email_address", "That is not an email address.".to_owned()));
|
|
453 |
+ |
}
|
|
454 |
+ |
if imap.is_empty() {
|
|
455 |
+ |
errors.push(("imap_server", "An IMAP server is required.".to_owned()));
|
|
456 |
+ |
}
|
|
457 |
+ |
if smtp.is_empty() {
|
|
458 |
+ |
errors.push(("smtp_server", "An SMTP server is required.".to_owned()));
|
|
459 |
+ |
}
|
|
460 |
+ |
if archive.contains(['\r', '\n']) || archive.chars().any(char::is_control) {
|
|
461 |
+ |
errors.push((
|
|
462 |
+ |
"archive_folder_name",
|
|
463 |
+ |
"A folder name cannot carry control characters.".to_owned(),
|
|
464 |
+ |
));
|
|
465 |
+ |
}
|
|
466 |
+ |
errors
|
|
467 |
+ |
}
|
|
468 |
+ |
|
|
469 |
+ |
/// A port, defaulted rather than refused when the field is blank.
|
|
470 |
+ |
fn port(raw: &str, fallback: i32) -> i32 {
|
|
471 |
+ |
raw.parse().unwrap_or(fallback)
|
|
472 |
+ |
}
|
|
473 |
+ |
|
|
474 |
+ |
/// The add form.
|
|
475 |
+ |
fn new_account(_state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
476 |
+ |
// Advanced opens by default on a new account, because the server fields are
|
|
477 |
+ |
// required and a form whose required fields are hidden cannot be submitted.
|
|
478 |
+ |
// The JS reaches the same state by a different route: it opens the block
|
|
479 |
+ |
// once autodetect has filled those fields in.
|
|
480 |
+ |
Ok(form(None, true, &[], None).into())
|
|
481 |
+ |
}
|
|
482 |
+ |
|
|
483 |
+ |
/// Create it, or answer with the form saying why not.
|
|
484 |
+ |
fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
485 |
+ |
let field = |name: &str| {
|
|
486 |
+ |
request
|
|
487 |
+ |
.payload
|
|
488 |
+ |
.get(name)
|
|
489 |
+ |
.unwrap_or_default()
|
|
490 |
+ |
.trim()
|
|
491 |
+ |
.to_owned()
|
|
492 |
+ |
};
|
|
493 |
+ |
let name = field("account_name");
|
|
494 |
+ |
let address = field("email_address");
|
|
495 |
+ |
let imap = field("imap_server");
|
|
496 |
+ |
let smtp = field("smtp_server");
|
|
497 |
+ |
let archive = field("archive_folder_name");
|
|
498 |
+ |
let password = field("password");
|
|
499 |
+ |
|
|
500 |
+ |
let mut errors = validate(&name, &address, &imap, &smtp, &archive);
|