|
1 |
+ |
//! Writing a message, and the Out box it goes to.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! Max, 2026-08-22: "let's have the design of the compose be inspired by
|
|
6 |
+ |
//! Eudora." Three of that design's choices are load-bearing here, and each one
|
|
7 |
+ |
//! removes a problem the port had rather than adding a look.
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! # The header is a grid, and `Attached:` is a row of it
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! Eudora drew a right-aligned label column with the fields beside it: From,
|
|
12 |
+ |
//! To, Cc, Bcc, Subject, Attached. `3fb2526a` recorded that goingson's modal and
|
|
13 |
+ |
//! its standalone window differed in five ways, and two of them stop existing
|
|
14 |
+ |
//! under that shape:
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! - **The attachments bar.** It sat inside the form in the modal and outside it
|
|
17 |
+ |
//! in the window, which is the divergence the task named first. As a header
|
|
18 |
+ |
//! row there is one arrangement and nowhere else to put it.
|
|
19 |
+ |
//! - **Progressive disclosure.** `toggleCcBcc` hid Cc and Bcc behind a link, and
|
|
20 |
+ |
//! nothing in the vocabulary names "hidden until asked for". Eudora never hid
|
|
21 |
+ |
//! them. The gap goes away rather than being worked around, and if it ever
|
|
22 |
+ |
//! comes back the answer is an address, the way [`super::settings::email`]
|
|
23 |
+ |
//! says its advanced block with `?advanced=1`.
|
|
24 |
+ |
//!
|
|
25 |
+ |
//! # Queue, not Send, and that is the whole reason this screen exists
|
|
26 |
+ |
//!
|
|
27 |
+ |
//! `send_email` is async and a route handler is synchronous, so a described
|
|
28 |
+ |
//! compose could not send. Eudora's answer was already the right one: you Queue,
|
|
29 |
+ |
//! and something else drains the Out box. Queueing is a local write.
|
|
30 |
+ |
//!
|
|
31 |
+ |
//! So the outbox is not the workaround, it is the feature — a message you can
|
|
32 |
+ |
//! see before it goes, schedule, and take back. See [`crate::outbox`] for the
|
|
33 |
+ |
//! drainer and goingson `a3c76a24` for the ruling.
|
|
34 |
+ |
//!
|
|
35 |
+ |
//! # The draft saves as you write it, which is why there is no Save button
|
|
36 |
+ |
//!
|
|
37 |
+ |
//! Every field here carries [`Field::changes`], so leaving a field writes it to
|
|
38 |
+ |
//! the draft. That is what a mail client does anyway, and it is also what gets
|
|
39 |
+ |
//! this screen past a known gap: quasicoherent `7da72faf` records that a
|
|
40 |
+ |
//! [`Node::Form`] has one submit, and compose wanted three verbs over one set of
|
|
41 |
+ |
//! values. It does not, once the values are already saved. Queue and Discard act
|
|
42 |
+ |
//! on a draft that is already on disk, and there is no form at all.
|
|
43 |
+ |
//!
|
|
44 |
+ |
//! `Field::changes` is the DOM `change` event in a webview, which fires when a
|
|
45 |
+ |
//! field settles rather than per keystroke. On a terminal it is per keystroke,
|
|
46 |
+ |
//! which is quasicoherent `8032fe61` and is that renderer's to answer; a draft
|
|
47 |
+ |
//! written more often than necessary is the harmless end of that finding.
|
|
48 |
+ |
//!
|
|
49 |
+ |
//! # A new message is a POST
|
|
50 |
+ |
//!
|
|
51 |
+ |
//! `GET /compose` would have to create a draft to have somewhere to write to,
|
|
52 |
+ |
//! and a GET that writes is a GET that a reload repeats. So `POST /compose`
|
|
53 |
+ |
//! makes the draft and goes to it, and the screen itself is
|
|
54 |
+ |
//! `GET /compose/{id}` — an address that can be reloaded, linked and reopened.
|
|
55 |
+ |
//!
|
|
56 |
+ |
//! An abandoned blank draft is the cost, and it is Eudora's cost too: an empty
|
|
57 |
+ |
//! message in Out that you throw away. Discard is one press.
|
|
58 |
+ |
//!
|
|
59 |
+ |
//! # What is not here
|
|
60 |
+ |
//!
|
|
61 |
+ |
//! **Attachments.** `Attached:` is drawn and says what the message carries, and
|
|
62 |
+ |
//! there is no control to add one. `send_email_inner` takes
|
|
63 |
+ |
//! `attachment_paths: Vec<String>` — paths on this machine, read at send time —
|
|
64 |
+ |
//! and a described file field carries bytes. Bridging those is a real piece of
|
|
65 |
+ |
//! work rather than a line, and it is filed rather than half-done.
|
|
66 |
+ |
//!
|
|
67 |
+ |
//! **A window of its own.** [`Frame`](quasi_router::Frame) is what a mount puts
|
|
68 |
+ |
//! around a screen, and a mount is a `Webview`; this app builds one. A compose
|
|
69 |
+ |
//! window is a second mount carrying a frame whose verbs are these verbs, which
|
|
70 |
+ |
//! is the other half of `3fb2526a`.
|
|
71 |
+ |
|
|
72 |
+ |
// Handlers take their request by value because `quasi_router::Handler` is a
|
|
73 |
+ |
// plain `fn(&S, Request)` pointer, so the signature is the router's.
|
|
74 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
75 |
+ |
|
|
76 |
+ |
use chrono::{DateTime, Utc};
|
|
77 |
+ |
use goingson_core::EmailId;
|
|
78 |
+ |
use quasi_router::layout::Tone;
|
|
79 |
+ |
use quasi_router::screen::{Act, Choice, Field, Row, Tag};
|
|
80 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
81 |
+ |
|
|
82 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
|
83 |
+ |
|
|
84 |
+ |
#[cfg(test)]
|
|
85 |
+ |
mod tests;
|
|
86 |
+ |
|
|
87 |
+ |
/// The region the header and body live in.
|
|
88 |
+ |
const BODY: &str = "compose";
|
|
89 |
+ |
/// Where a saved field's acknowledgement lands.
|
|
90 |
+ |
///
|
|
91 |
+ |
/// Its own region rather than the header's, because answering into the header
|
|
92 |
+ |
/// would redraw the control the reader is still in.
|
|
93 |
+ |
const SAVED: &str = "compose-saved";
|
|
94 |
+ |
|
|
95 |
+ |
/// The field names, which are also what a write reads out of the payload.
|
|
96 |
+ |
const FROM: &str = "from";
|
|
97 |
+ |
const TO: &str = "to";
|
|
98 |
+ |
const CC: &str = "cc";
|
|
99 |
+ |
const BCC: &str = "bcc";
|
|
100 |
+ |
const SUBJECT: &str = "subject";
|
|
101 |
+ |
const MESSAGE: &str = "body";
|
|
102 |
+ |
/// The instant Queue Later asks for.
|
|
103 |
+ |
const SEND_AFTER: &str = "send_after";
|
|
104 |
+ |
|
|
105 |
+ |
/// A new message: make the draft, then go to it.
|
|
106 |
+ |
fn start(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
107 |
+ |
let draft = save(state, EmailId::new(), Fields::default())?;
|
|
108 |
+ |
Ok(Response::goto(Action::get(format!(
|
|
109 |
+ |
"/compose/{}",
|
|
110 |
+ |
draft.id
|
|
111 |
+ |
))))
|
|
112 |
+ |
}
|
|
113 |
+ |
|
|
114 |
+ |
/// What the screen writes, all of it optional so one field can be saved alone.
|
|
115 |
+ |
#[derive(Default)]
|
|
116 |
+ |
struct Fields {
|
|
117 |
+ |
from: Option<EmailId>,
|
|
118 |
+ |
to: Option<String>,
|
|
119 |
+ |
cc: Option<String>,
|
|
120 |
+ |
bcc: Option<String>,
|
|
121 |
+ |
subject: Option<String>,
|
|
122 |
+ |
body: Option<String>,
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
/// Write a draft, keeping whatever this call did not name.
|
|
126 |
+ |
///
|
|
127 |
+ |
/// `save_draft` replaces rather than patches, so a single-field write has to
|
|
128 |
+ |
/// read the row first or it blanks the other five. That is the repository's
|
|
129 |
+ |
/// shape and not worth changing for this: a draft is small and the read is
|
|
130 |
+ |
/// local.
|
|
131 |
+ |
fn save(state: &AppState, id: EmailId, fields: Fields) -> Result<goingson_core::Email, RouteError> {
|
|
132 |
+ |
let existing = state
|
|
133 |
+ |
.emails
|
|
134 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
135 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
136 |
+ |
|
|
137 |
+ |
let held = existing.as_ref();
|
|
138 |
+ |
let account = fields
|
|
139 |
+ |
.from
|
|
140 |
+ |
.map(|id| goingson_core::EmailAccountId::from(uuid::Uuid::from(id)))
|
|
141 |
+ |
.or_else(|| held.and_then(|email| email.draft_account_id));
|
|
142 |
+ |
|
|
143 |
+ |
let from = match account {
|
|
144 |
+ |
Some(id) => state
|
|
145 |
+ |
.email_accounts
|
|
146 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
147 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
148 |
+ |
.map(|account| account.email_address)
|
|
149 |
+ |
.unwrap_or_default(),
|
|
150 |
+ |
None => held.map(|email| email.from.clone()).unwrap_or_default(),
|
|
151 |
+ |
};
|
|
152 |
+ |
|
|
153 |
+ |
let pick = |given: Option<String>, kept: Option<String>| given.or(kept).unwrap_or_default();
|
|
154 |
+ |
|
|
155 |
+ |
state
|
|
156 |
+ |
.emails
|
|
157 |
+ |
.save_draft(
|
|
158 |
+ |
id,
|
|
159 |
+ |
DESKTOP_USER_ID,
|
|
160 |
+ |
&from,
|
|
161 |
+ |
&pick(fields.to, held.map(|email| email.to.clone())),
|
|
162 |
+ |
Some(&pick(
|
|
163 |
+ |
fields.cc,
|
|
164 |
+ |
held.and_then(|email| email.cc_address.clone()),
|
|
165 |
+ |
)),
|
|
166 |
+ |
Some(&pick(
|
|
167 |
+ |
fields.bcc,
|
|
168 |
+ |
held.and_then(|email| email.bcc_address.clone()),
|
|
169 |
+ |
)),
|
|
170 |
+ |
&pick(fields.subject, held.map(|email| email.subject.clone())),
|
|
171 |
+ |
&pick(fields.body, held.map(|email| email.body.clone())),
|
|
172 |
+ |
account,
|
|
173 |
+ |
held.and_then(|email| email.in_reply_to.clone()).as_deref(),
|
|
174 |
+ |
None,
|
|
175 |
+ |
held.and_then(|email| email.thread_id.clone()).as_deref(),
|
|
176 |
+ |
)
|
|
177 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))
|
|
178 |
+ |
}
|
|
179 |
+ |
|
|
180 |
+ |
/// The screen.
|
|
181 |
+ |
fn screen(state: &AppState, id: EmailId) -> Result<Screen, RouteError> {
|
|
182 |
+ |
let draft = state
|
|
183 |
+ |
.emails
|
|
184 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
185 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
186 |
+ |
.filter(|email| email.is_draft)
|
|
187 |
+ |
.ok_or_else(|| RouteError::not_found("no such draft"))?;
|
|
188 |
+ |
|
|
189 |
+ |
let accounts = state
|
|
190 |
+ |
.email_accounts
|
|
191 |
+ |
.list_by_user(DESKTOP_USER_ID)
|
|
192 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
193 |
+ |
|
|
194 |
+ |
let writes = |name: &str| Action::post(format!("/compose/{id}/field")).with("field", name);
|
|
195 |
+ |
let text = |name: &'static str, label: &str, value: String| {
|
|
196 |
+ |
Node::field(
|
|
197 |
+ |
Field {
|
|
198 |
+ |
value: Some(value),
|
|
199 |
+ |
..Field::new(makeover_layout::FieldKind::Text, name, label)
|
|
200 |
+ |
}
|
|
201 |
+ |
.changes(writes(name)),
|
|
202 |
+ |
)
|
|
203 |
+ |
};
|
|
204 |
+ |
|
|
205 |
+ |
let mut header =
|
|
206 |
+ |
Slot::new(BODY, RegionKind::Pane).with(Node::page(if draft.subject.is_empty() {
|
|
207 |
+ |
"New message".to_owned()
|
|
208 |
+ |
} else {
|
|
209 |
+ |
draft.subject.clone()
|
|
210 |
+ |
}));
|
|
211 |
+ |
|
|
212 |
+ |
// The header grid, in Eudora's order. From first because it is the one
|
|
213 |
+ |
// choice rather than a thing typed, and because a message with no account
|
|
214 |
+ |
// cannot leave the outbox.
|
|
215 |
+ |
header = header.with(Node::field(
|
|
216 |
+ |
Field {
|
|
217 |
+ |
options: accounts
|
|
218 |
+ |
.iter()
|
|
219 |
+ |
.map(|account| Choice::new(account.id.to_string(), account.email_address.clone()))
|
|
220 |
+ |
.collect(),
|
|
221 |
+ |
value: draft.draft_account_id.map(|id| id.to_string()),
|
|
222 |
+ |
..Field::new(makeover_layout::FieldKind::Select, FROM, "From")
|
|
223 |
+ |
}
|
|
224 |
+ |
.changes(writes(FROM)),
|
|
225 |
+ |
));
|
|
226 |
+ |
header = header
|
|
227 |
+ |
.with(text(TO, "To", draft.to.clone()))
|
|
228 |
+ |
// Always drawn. Eudora never hid them, which is why this screen needs
|
|
229 |
+ |
// no word for progressive disclosure.
|
|
230 |
+ |
.with(text(CC, "Cc", draft.cc_address.clone().unwrap_or_default()))
|
|
231 |
+ |
.with(text(
|
|
232 |
+ |
BCC,
|
|
233 |
+ |
"Bcc",
|
|
234 |
+ |
draft.bcc_address.clone().unwrap_or_default(),
|
|
235 |
+ |
))
|
|
236 |
+ |
.with(text(SUBJECT, "Subject", draft.subject.clone()))
|
|
237 |
+ |
.with(attached(&draft))
|
|
238 |
+ |
.with(Node::field(
|
|
239 |
+ |
Field {
|
|
240 |
+ |
value: Some(draft.body.clone()),
|
|
241 |
+ |
..Field::new(makeover_layout::FieldKind::Textarea, MESSAGE, "Message")
|
|
242 |
+ |
}
|
|
243 |
+ |
.changes(writes(MESSAGE)),
|
|
244 |
+ |
));
|
|
245 |
+ |
|
|
246 |
+ |
// The verbs. Not a form's submit: the draft is already saved, so these act
|
|
247 |
+ |
// on a thing that exists. See the module header.
|
|
248 |
+ |
let queued = draft.is_queued();
|
|
249 |
+ |
if queued {
|
|
250 |
+ |
header = header
|
|
251 |
+ |
.with(Node::text(
|
|
252 |
+ |
"This message is in the Out box. Take it back to edit it.",
|
|
253 |
+ |
))
|
|
254 |
+ |
.with(Node::Act(Act::new(
|
|
255 |
+ |
"Take it back",
|
|
256 |
+ |
Action::post(format!("/compose/{id}/unqueue")),
|
|
257 |
+ |
)));
|
|
258 |
+ |
} else {
|
|
259 |
+ |
header = header
|
|
260 |
+ |
.with(Node::Act(
|
|
261 |
+ |
Act::new("Queue", Action::post(format!("/compose/{id}/queue"))).tone(Tone::Success),
|
|
262 |
+ |
))
|
|
263 |
+ |
.with(Node::Act(
|
|
264 |
+ |
Act::new("Queue later", Action::post(format!("/compose/{id}/queue"))).asking(
|
|
265 |
+ |
Field::new(
|
|
266 |
+ |
makeover_layout::FieldKind::DateTime,
|
|
267 |
+ |
SEND_AFTER,
|
|
268 |
+ |
"Send after",
|
|
269 |
+ |
),
|
|
270 |
+ |
),
|
|
271 |
+ |
));
|
|
272 |
+ |
}
|
|
273 |
+ |
|
|
274 |
+ |
Ok(Screen::list_detail("Compose", false)
|
|
275 |
+ |
.at_place(super::shell::EMAILS)
|
|
276 |
+ |
.with(header)
|
|
277 |
+ |
.with(
|
|
278 |
+ |
Slot::new("compose-aside", RegionKind::Pane).with(Node::Act(
|
|
279 |
+ |
Act::new("Discard", Action::post(format!("/compose/{id}/discard")))
|
|
280 |
+ |
.tone(Tone::Danger)
|
|
281 |
+ |
.confirm("Throw this message away?"),
|
|
282 |
+ |
)),
|
|
283 |
+ |
))
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
/// The `Attached:` row.
|
|
287 |
+ |
///
|
|
288 |
+ |
/// Drawn even when empty, because it is a header row rather than a bar that
|
|
289 |
+ |
/// appears: the point of Eudora's shape is that the message says what it
|
|
290 |
+ |
/// carries in the same place every time. There is no control to add one; see
|
|
291 |
+ |
/// the module header.
|
|
292 |
+ |
fn attached(draft: &goingson_core::Email) -> Node {
|
|
293 |
+ |
let names = draft.attachment_meta.as_deref().unwrap_or_default();
|
|
294 |
+ |
Node::list([Row::new("Attached").meta(if names.is_empty() {
|
|
295 |
+ |
"Nothing".to_owned()
|
|
296 |
+ |
} else {
|
|
297 |
+ |
names.to_owned()
|
|
298 |
+ |
})])
|
|
299 |
+ |
}
|
|
300 |
+ |
|
|
301 |
+ |
/// The screen, as an answer.
|
|
302 |
+ |
fn show(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
303 |
+ |
Ok(screen(state, asked_for(&request)?)?.into())
|
|
304 |
+ |
}
|
|
305 |
+ |
|
|
306 |
+ |
/// The id in the address.
|
|
307 |
+ |
fn asked_for(request: &quasi_router::Request) -> Result<EmailId, RouteError> {
|
|
308 |
+ |
request
|
|
309 |
+ |
.captures
|
|
310 |
+ |
.get("id")
|
|
311 |
+ |
.unwrap_or_default()
|
|
312 |
+ |
.parse::<uuid::Uuid>()
|
|
313 |
+ |
.map(Into::into)
|
|
314 |
+ |
.map_err(|_| RouteError::not_found("not a message id"))
|
|
315 |
+ |
}
|
|
316 |
+ |
|
|
317 |
+ |
/// One field, written because it settled.
|
|
318 |
+ |
fn write_field(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
319 |
+ |
let id = asked_for(&request)?;
|
|
320 |
+ |
// Read from either, the way `time_tracking::View::of` does: a control's own
|
|
321 |
+ |
// params and what a caller sends arrive in different halves of the request,
|
|
322 |
+ |
// and which half is the renderer's business rather than this handler's.
|
|
323 |
+ |
let which = request
|
|
324 |
+ |
.payload
|
|
325 |
+ |
.get("field")
|
|
326 |
+ |
.or_else(|| request.carried.get("field"))
|
|
327 |
+ |
.unwrap_or_default()
|
|
328 |
+ |
.to_owned();
|
|
329 |
+ |
let value = request.payload.get(&which).unwrap_or_default().to_owned();
|
|
330 |
+ |
|
|
331 |
+ |
let mut fields = Fields::default();
|
|
332 |
+ |
match which.as_str() {
|
|
333 |
+ |
FROM => {
|
|
334 |
+ |
fields.from = value.parse::<uuid::Uuid>().ok().map(Into::into);
|
|
335 |
+ |
}
|
|
336 |
+ |
TO => fields.to = Some(value),
|
|
337 |
+ |
CC => fields.cc = Some(value),
|
|
338 |
+ |
BCC => fields.bcc = Some(value),
|
|
339 |
+ |
SUBJECT => fields.subject = Some(value),
|
|
340 |
+ |
MESSAGE => fields.body = Some(value),
|
|
341 |
+ |
// A name this screen does not draw. Refused rather than ignored: it did
|
|
342 |
+ |
// not come from a control here.
|
|
343 |
+ |
_ => return Err(RouteError::not_found("no such field")),
|
|
344 |
+ |
}
|
|
345 |
+ |
|
|
346 |
+ |
save(state, id, fields)?;
|
|
347 |
+ |
// Nothing is answered back. The value is already on screen, it is what the
|
|
348 |
+ |
// reader typed, and replacing the field under a caret is the failure
|
|
349 |
+ |
// quasicoherent `a135f898` records.
|
|
350 |
+ |
// Nothing is answered back into the field. The value is already on screen,
|
|
351 |
+ |
// it is what the reader typed, and replacing a control under a caret is the
|
|
352 |
+ |
// failure quasicoherent `a135f898` records. The empty region is the
|
|
353 |
+ |
// acknowledgement.
|
|
354 |
+ |
Ok(Response::fragment(SAVED, Node::text("")))
|
|
355 |
+ |
}
|
|
356 |
+ |
|
|
357 |
+ |
/// Put the message in the Out box.
|
|
358 |
+ |
fn queue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
359 |
+ |
let id = asked_for(&request)?;
|
|
360 |
+ |
let send_after = read_instant(&request)?;
|
|
361 |
+ |
|
|
362 |
+ |
// Refused here rather than left to fail once a minute in the drainer: a
|
|
363 |
+ |
// message with nobody to send to, or nothing to send it from, is a mistake
|
|
364 |
+ |
// to catch while the person is still looking at it.
|
|
365 |
+ |
let draft = state
|
|
366 |
+ |
.emails
|
|
367 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
368 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
369 |
+ |
.filter(|email| email.is_draft)
|
|
370 |
+ |
.ok_or_else(|| RouteError::not_found("no such draft"))?;
|
|
371 |
+ |
|
|
372 |
+ |
if draft.draft_account_id.is_none() {
|
|
373 |
+ |
return Err(
|
|
374 |
+ |
RouteError::conflict("Choose which account this is from before queueing it.")
|
|
375 |
+ |
.as_toast(),
|
|
376 |
+ |
);
|
|
377 |
+ |
}
|
|
378 |
+ |
if draft.to.trim().is_empty() {
|
|
379 |
+ |
return Err(RouteError::conflict("Say who it is going to.").as_toast());
|
|
380 |
+ |
}
|
|
381 |
+ |
|
|
382 |
+ |
state
|
|
383 |
+ |
.emails
|
|
384 |
+ |
.queue_draft(id, DESKTOP_USER_ID, send_after)
|
|
385 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
386 |
+ |
.ok_or_else(|| RouteError::not_found("no such draft"))?;
|
|
387 |
+ |
|
|
388 |
+ |
Ok(Response::goto(Action::get("/outbox")).toast(
|
|
389 |
+ |
Tone::Success,
|
|
390 |
+ |
match send_after {
|
|
391 |
+ |
Some(at) => format!(
|
|
392 |
+ |
"In the Out box, going after {}.",
|
|
393 |
+ |
at.format("%b %-d, %H:%M")
|
|
394 |
+ |
),
|
|
395 |
+ |
None => "In the Out box.".to_owned(),
|
|
396 |
+ |
},
|
|
397 |
+ |
))
|
|
398 |
+ |
}
|
|
399 |
+ |
|
|
400 |
+ |
/// The instant Queue Later asked for, if it asked.
|
|
401 |
+ |
fn read_instant(request: &quasi_router::Request) -> Result<Option<DateTime<Utc>>, RouteError> {
|
|
402 |
+ |
let raw = request.payload.get(SEND_AFTER).unwrap_or_default();
|
|
403 |
+ |
if raw.trim().is_empty() {
|
|
404 |
+ |
return Ok(None);
|
|
405 |
+ |
}
|
|
406 |
+ |
// A local wall-clock instant, which is what a datetime control gives and
|
|
407 |
+ |
// what the person meant. Stored as UTC, compared against UTC by the
|
|
408 |
+ |
// drainer.
|
|
409 |
+ |
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")
|
|
410 |
+ |
.ok()
|
|
411 |
+ |
.and_then(|naive| naive.and_local_timezone(chrono::Local).single())
|
|
412 |
+ |
.map(|local| local.with_timezone(&Utc))
|
|
413 |
+ |
.map(Some)
|
|
414 |
+ |
.ok_or_else(|| RouteError::conflict("That is not a time this understands.").as_toast())
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
/// Take a message back out of the Out box.
|
|
418 |
+ |
fn unqueue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
419 |
+ |
let id = asked_for(&request)?;
|
|
420 |
+ |
state
|
|
421 |
+ |
.emails
|
|
422 |
+ |
.unqueue_draft(id, DESKTOP_USER_ID)
|
|
423 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
424 |
+ |
.ok_or_else(|| RouteError::not_found("not in the Out box"))?;
|
|
425 |
+ |
Ok(Response::screen(screen(state, id)?).toast(
|
|
426 |
+ |
Tone::Success,
|
|
427 |
+ |
"Taken back. It will not go until you queue it.",
|
|
428 |
+ |
))
|
|
429 |
+ |
}
|
|
430 |
+ |
|
|
431 |
+ |
/// Throw the message away.
|
|
432 |
+ |
fn discard(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
433 |
+ |
let id = asked_for(&request)?;
|
|
434 |
+ |
state
|
|
435 |
+ |
.emails
|
|
436 |
+ |
.delete(id, DESKTOP_USER_ID)
|
|
437 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
438 |
+ |
Ok(Response::goto(Action::get("/emails")).toast(Tone::Success, "Thrown away."))
|
|
439 |
+ |
}
|
|
440 |
+ |
|
|
441 |
+ |
/// The Out box: what is waiting, when it goes, and why one is stuck.
|
|
442 |
+ |
fn outbox(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
443 |
+ |
let waiting = state
|
|
444 |
+ |
.emails
|
|
445 |
+ |
.list_outbox(DESKTOP_USER_ID)
|
|
446 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
447 |
+ |
|
|
448 |
+ |
let mut pane = Slot::new("outbox", RegionKind::Pane).with(Node::page("Out box"));
|
|
449 |
+ |
|
|
450 |
+ |
if waiting.is_empty() {
|
|
451 |
+ |
pane = pane.with(Node::empty("Nothing waiting to go."));
|
|
452 |
+ |
} else {
|
|
453 |
+ |
pane = pane.with(Node::list(waiting.iter().map(|email| {
|
|
454 |
+ |
// One part per role: `Row::meta` sets rather than appends, so three
|
|
455 |
+ |
// calls to it would be one fact and two discarded.
|
|
456 |
+ |
let mut row = Row::new(if email.subject.is_empty() {
|
|
457 |
+ |
"(no subject)"
|
|
458 |
+ |
} else {
|
|
459 |
+ |
&email.subject
|
|
460 |
+ |
})
|
|
461 |
+ |
.secondary(email.to.clone());
|
|
462 |
+ |
|
|
463 |
+ |
// Why it is stuck if it is, and when it goes if it is not. A stuck
|
|
464 |
+ |
// message's timing is not the interesting fact about it.
|
|
465 |
+ |
row = match &email.send_error {
|
|
466 |
+ |
Some(error) => row
|
|
467 |
+ |
.token(
|
|
468 |
+ |
Tag::badge(format!("Stuck after {}", email.send_attempts))
|
|
469 |
+ |
.tone(Tone::Danger),
|
|
470 |
+ |
)
|
|
471 |
+ |
.meta(error.clone()),
|
|
472 |
+ |
None => row.meta(match email.send_after {
|
|
473 |
+ |
Some(at) => format!("after {}", at.format("%b %-d, %H:%M")),
|
|
474 |
+ |
None => "next pass".to_owned(),
|
|
475 |
+ |
}),
|
|
476 |
+ |
};
|
|
477 |
+ |
|
|
478 |
+ |
row.act(Act::new(
|
|
479 |
+ |
"Take it back",
|
|
480 |
+ |
Action::post(format!("/compose/{}/unqueue", email.id)),
|
|
481 |
+ |
))
|
|
482 |
+ |
.activate(Action::get(format!("/compose/{}", email.id)))
|
|
483 |
+ |
})));
|
|
484 |
+ |
}
|
|
485 |
+ |
|
|
486 |
+ |
Ok(Screen::list_detail("Out box", false)
|
|
487 |
+ |
.at_place(super::shell::OUTBOX)
|
|
488 |
+ |
.with(pane)
|
|
489 |
+ |
.into())
|
|
490 |
+ |
}
|
|
491 |
+ |
|
|
492 |
+ |
/// This module's routes.
|
|
493 |
+ |
#[must_use]
|
|
494 |
+ |
pub fn routes(router: Router<AppState>) -> Router<AppState> {
|
|
495 |
+ |
router
|
|
496 |
+ |
.post("/compose", start)
|
|
497 |
+ |
.get("/outbox", outbox)
|
|
498 |
+ |
.get("/compose/{id}", show)
|
|
499 |
+ |
.post("/compose/{id}/field", write_field)
|
|
500 |
+ |
.post("/compose/{id}/queue", queue)
|