elma-tui 0.2.1

A modern terminal-based email client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Backend integration layer.
//!
//! The backend module defines the abstraction that the TUI uses to communicate with
//! different mail providers.  Backends expose a push-based event stream for mailbox
//! updates and a pull-based channel for action completion status.  The contract is
//! intentionally asynchronous so the UI thread never blocks on network or disk I/O.
//! See [`MailBackend`] for details about the required behaviour.

use crate::model::{Action, MailboxKind, Message, MessageContent, MessageId};
use anyhow::Result;
use lettre::message::{
    Attachment as LettreAttachment, MultiPart, SinglePart,
    header::ContentType as LettreContentType, header::HeaderName as LettreHeaderName,
    header::HeaderValue as LettreHeaderValue,
};
use std::sync::mpsc::Receiver;

#[cfg(debug_assertions)]
mod debug_log;
pub mod gmail;
pub mod jmap;
pub mod mock;

/// Notifications that backends emit when something about the mailbox changes.
///
/// The UI subscribes to the channel returned by [`MailBackend::load_mailbox`] and keeps
/// its local state in sync with the events that arrive.
#[derive(Clone, Debug)]
pub enum BackendEvent {
    NewMessage(Message),
    MessageFlagsChanged(Message),
    MessageDeleted(MessageId),
}

/// Result information for a committed action.
///
/// Backends must send one result per action passed to [`MailBackend::apply_actions`].
/// Successful operations report `Ok(())`; failures should capture the backend-specific
/// error text so the UI can surface it to the user.
#[derive(Clone, Debug)]
pub struct ActionStatus {
    pub action: Action,
    pub result: std::result::Result<(), String>,
}

/// Snapshot of a mailbox returned by [`MailBackend::load_mailbox`].
#[derive(Clone, Debug)]
pub struct MailboxSnapshot {
    /// Total number of messages currently present in the mailbox.
    pub total: usize,
    /// Messages that have been populated so far. Implementations may return a
    /// partial slice so the UI can start rendering immediately.
    pub messages: Vec<Message>,
}

/// Data associated with an outgoing message created from the compose view.
#[derive(Clone, Debug, Default)]
pub struct OutgoingMessage {
    pub to: Vec<String>,
    pub cc: Vec<String>,
    pub bcc: Vec<String>,
    pub subject: String,
    pub text_body: String,
    pub html_body: String,
    pub attachments: Vec<OutgoingAttachment>,
}

/// A file attached to an outgoing message.
#[derive(Clone, Debug)]
pub struct OutgoingAttachment {
    pub filename: String,
    pub mime_type: String,
    pub data: Vec<u8>,
}

impl OutgoingAttachment {
    pub fn size(&self) -> usize {
        self.data.len()
    }
}

/// What outgoing messages name as their mailer.
const MAILER: &str = concat!("Elma ", env!("CARGO_PKG_VERSION"));

/// The `X-Mailer` every backend stamps on what it sends.
///
/// Leaving the header off does not mean the message goes out without one: a
/// JMAP server is free to fill the gap with its own name, and FastMail files
/// everything submitted through its API as `MessagingEngine.com Webmail
/// Interface` -- so a message written in Elma arrived claiming to come from a
/// webmail nobody had opened.  Naming ourselves keeps the two send paths
/// saying the same thing, since SMTP added no header at all.
pub(crate) fn mailer_header() -> LettreHeaderValue {
    LettreHeaderValue::new(
        LettreHeaderName::new_from_ascii_str("X-Mailer"),
        MAILER.to_string(),
    )
}

/// Assemble the MIME body of an outgoing message.
///
/// The two bodies always go into a `multipart/alternative`; attachments wrap
/// that in a `multipart/mixed`.  Every SMTP-based backend builds the same
/// structure, so the code lives here rather than being copied per backend.
pub(crate) fn build_compose_body(
    text_body: String,
    html_body: String,
    attachments: Vec<OutgoingAttachment>,
) -> Result<MultiPart> {
    let alternative = MultiPart::alternative()
        .singlepart(SinglePart::plain(text_body))
        .singlepart(SinglePart::html(html_body));

    if attachments.is_empty() {
        return Ok(alternative);
    }

    let mut mixed = MultiPart::mixed().multipart(alternative);
    for attachment in attachments {
        let content_type: LettreContentType = attachment
            .mime_type
            .parse()
            .unwrap_or_else(|_| LettreContentType::parse("application/octet-stream").unwrap());
        let part = LettreAttachment::new(attachment.filename).body(attachment.data, content_type);
        mixed = mixed.singlepart(part);
    }
    Ok(mixed)
}

/// The MIME headers of a leaf body part that decide how to treat it.
///
/// Filled in from whatever the backend has at hand: an IMAP `BODYSTRUCTURE`,
/// a parsed MIME tree, or a JMAP `EmailBodyPart`.
pub(crate) struct LeafPart<'a> {
    /// Major type only — `text`, `image`, `application`, …
    pub(crate) major_type: &'a str,
    /// Whether the part names a file, in the disposition or the content type.
    pub(crate) has_filename: bool,
    /// `Content-Disposition`, without its parameters.
    pub(crate) disposition: Option<&'a str>,
    /// Whether the part carries a `Content-ID` the body could point at.
    pub(crate) has_content_id: bool,
}

/// What a leaf part is to the reader.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PartRole {
    /// Body text or markup: the viewer renders it, and there is nothing here to
    /// save that the reader is not already looking at.
    Body,
    /// Content the body points at with `cid:…`, an embedded image being the
    /// usual case. Part of how the message reads rather than something the
    /// sender attached — but still a file, and still worth being able to keep.
    Inline,
    /// A file the sender attached.
    Attachment,
}

impl LeafPart<'_> {
    /// What this part is, by the one rule every backend follows.
    ///
    /// Each backend asks twice: once for the message list, from what the server
    /// says about a message it has not fetched yet, and once for the opened
    /// message, from the parts it actually got. If the two disagree the `@`
    /// marker in the list flips as soon as the user opens the message, so the
    /// rule lives here rather than being written out per backend.
    pub(crate) fn role(&self) -> PartRole {
        // An explicit `attachment` disposition settles it, even for parts that
        // also carry a Content-ID.
        if self
            .disposition
            .is_some_and(|value| value.eq_ignore_ascii_case("attachment"))
        {
            return PartRole::Attachment;
        }

        // A Content-ID on a part the sender did not mark as an attachment is
        // there for the body to reference as `cid:…` — the logo in an HTML
        // mail. Whether the body *does* reference it cannot be checked from the
        // message list, where there is no body yet, so anything that could be
        // referenced counts as inline and the marker stays put on open.
        if self.has_content_id
            && self
                .disposition
                .is_none_or(|value| value.eq_ignore_ascii_case("inline"))
        {
            return PartRole::Inline;
        }

        if self.has_filename {
            return PartRole::Attachment;
        }

        // What is left is either body text or something the reader cannot see
        // any other way.
        if self.major_type.eq_ignore_ascii_case("multipart")
            || self.major_type.eq_ignore_ascii_case("text")
        {
            PartRole::Body
        } else {
            PartRole::Attachment
        }
    }

    /// Whether this part earns the message an attachment marker in the list.
    ///
    /// Deliberately narrower than "is a file": a signature logo would otherwise
    /// mark every newsletter as carrying an attachment.
    pub(crate) fn is_attachment(&self) -> bool {
        matches!(self.role(), PartRole::Attachment)
    }
}

/// Abstraction over a mail provider implementation.
///
/// The trait is purposely synchronous from the caller's perspective while the
/// implementation is free to spawn its own async tasks.  `apply_actions` returns an
/// `mpsc::Receiver` for streaming status updates; the UI polls that receiver every
/// frame so the terminal stays responsive during long-running commits.
///
/// # Design Notes
///
/// * `load_mailbox` produces both the initial message list and a channel that streams
///   [`BackendEvent`] updates.  This ensures there is a single source of truth for
///   mailbox mutations.  It is called from a worker thread and may block for as
///   long as connecting and authenticating take.
/// * `load_message`, `send_message`, `save_draft` and `fetch_attachment_blob` are
///   blocking calls that the UI always makes from a worker thread, never from the
///   event loop.  They may take as long as the network does, and two of them (or
///   one of them plus an `apply_actions` batch) can be in flight at the same time,
///   so implementations have to serialise their own connection state.
/// * `apply_actions` accepts the full action batch and must never block the caller.
///   Implementations should spawn work (e.g. on a thread or async runtime) and send
///   [`ActionStatus`] entries as each action completes.
///
/// # Examples
///
/// ```
/// use elma_rs::backend::{ActionStatus, MailBackend, MailboxSnapshot};
/// use elma_rs::model::{Action, ActionType, MessageId};
/// # struct DemoBackend;
/// # impl MailBackend for DemoBackend {
/// #     fn load_mailbox(&self, _kind: elma_rs::model::MailboxKind) -> anyhow::Result<(MailboxSnapshot, std::sync::mpsc::Receiver<_>)> {
/// #         unimplemented!()
/// #     }
/// #     fn load_message(&self, _id: MessageId) -> anyhow::Result<_> { unimplemented!() }
/// #     fn apply_actions(&self, actions: Vec<Action>) -> anyhow::Result<std::sync::mpsc::Receiver<ActionStatus>> {
/// #         // start background work here
/// #         unimplemented!()
/// #     }
/// # }
/// let backend = DemoBackend;
/// let statuses = backend.apply_actions(vec![Action::new(ActionType::Archive, MessageId(42))])?;
/// // UI polls `statuses` until every ActionStatus has been received.
/// # Ok::<_, anyhow::Error>(())
/// ```
pub trait MailBackend: Send + Sync {
    /// Load `mailbox` and return a channel that streams [`BackendEvent`] updates.
    fn load_mailbox(
        &self,
        mailbox: MailboxKind,
    ) -> Result<(MailboxSnapshot, Receiver<BackendEvent>)>;

    /// Load the full content for a single message.
    ///
    /// Called from a worker thread, so blocking here is expected.
    fn load_message(&self, message_id: MessageId) -> Result<MessageContent>;
    /// Begin processing a batch of actions and return a channel with completion updates.
    fn apply_actions(&self, actions: Vec<Action>) -> Result<Receiver<ActionStatus>>;
    /// Submit actions that should be executed ahead of any pending queued work.
    ///
    /// Backends with an internal work queue should insert these at the front.
    /// The default implementation simply delegates to [`apply_actions`](MailBackend::apply_actions).
    fn apply_immediate_actions(&self, actions: Vec<Action>) -> Result<Receiver<ActionStatus>> {
        self.apply_actions(actions)
    }
    /// Send a fully composed message.
    ///
    /// Called from a worker thread; uploading megabytes of attachments here is
    /// expected and does not stall the UI.
    fn send_message(&self, message: OutgoingMessage) -> Result<()>;
    /// Store a draft message.
    ///
    /// Called from a worker thread, like [`send_message`](MailBackend::send_message).
    fn save_draft(&self, message: OutgoingMessage) -> Result<()>;
    /// Download the raw bytes for an attachment identified by its backend blob id.
    ///
    /// Called from a worker thread, once per attachment that needs it.
    /// Backends that deliver the full message payload up-front populate
    /// [`MessageAttachment::data`](crate::model::MessageAttachment::data) directly
    /// and can leave this method unimplemented.  Backends that only return a
    /// pointer (for example JMAP) must override this to issue the blob download.
    fn fetch_attachment_blob(&self, _blob_id: &str) -> Result<Vec<u8>> {
        Err(anyhow::anyhow!(
            "this backend does not support on-demand attachment download"
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::{LeafPart, PartRole, mailer_header};

    /// A message that names no mailer is one the server is free to name: it is
    /// FastMail's stamp, not ours, that reached recipients before this header
    /// existed.
    #[test]
    fn an_outgoing_message_names_elma_as_its_mailer() {
        let message = lettre::Message::builder()
            .from("her@example.com".parse().expect("the sender parses"))
            .to("him@example.com".parse().expect("the recipient parses"))
            .subject("Lunch?")
            .raw_header(mailer_header())
            .body(String::from("Half twelve?"))
            .expect("the message builds");

        let formatted = String::from_utf8(message.formatted()).expect("the message is text");
        assert!(
            formatted.contains(&format!("X-Mailer: Elma {}\r\n", env!("CARGO_PKG_VERSION"))),
            "{formatted}"
        );
    }

    fn part(major_type: &str, has_filename: bool) -> LeafPart<'_> {
        LeafPart {
            major_type,
            has_filename,
            disposition: None,
            has_content_id: false,
        }
    }

    #[test]
    fn body_text_is_not_an_attachment() {
        assert!(!part("text", false).is_attachment());
        assert!(!part("multipart", false).is_attachment());
    }

    #[test]
    fn a_named_part_is_an_attachment_whatever_its_type() {
        // Older mailers send `text/csv; name="report.csv"` with no
        // Content-Disposition at all.
        assert!(part("text", true).is_attachment());
        assert!(part("application", true).is_attachment());
    }

    #[test]
    fn a_part_the_reader_cannot_see_otherwise_is_an_attachment() {
        // No name, no disposition — but a PDF is not something the body pane
        // can render, so it has to be offered for download.
        assert!(part("application", false).is_attachment());
        assert!(part("image", false).is_attachment());
    }

    /// The three roles are distinct: body text has nothing to save, an embedded
    /// image has, and only the third earns a marker.
    #[test]
    fn a_part_the_body_references_is_a_file_without_being_an_attachment() {
        let referenced = LeafPart {
            major_type: "image",
            has_filename: true,
            disposition: Some("inline"),
            has_content_id: true,
        };

        assert_eq!(referenced.role(), PartRole::Inline);
        assert_eq!(part("text", false).role(), PartRole::Body);
        assert_eq!(part("application", true).role(), PartRole::Attachment);
    }

    #[test]
    fn an_image_the_body_can_reference_is_not_an_attachment() {
        // The logo in an HTML mail: the body points at it as `cid:…`, so it is
        // part of the message, not something to save.  Senders write this both
        // with an explicit `inline` disposition and with none at all.
        let referenced = LeafPart {
            major_type: "image",
            has_filename: true,
            disposition: Some("inline"),
            has_content_id: true,
        };
        assert!(!referenced.is_attachment());
        assert!(
            !LeafPart {
                disposition: None,
                ..referenced
            }
            .is_attachment()
        );

        // Without a Content-ID nothing can reference it, so it stays an
        // attachment however the sender labelled it.
        assert!(
            LeafPart {
                has_content_id: false,
                ..referenced
            }
            .is_attachment()
        );

        // And a sender who says `attachment` outright is taken at their word,
        // Content-ID or not.
        assert!(
            LeafPart {
                disposition: Some("attachment"),
                ..referenced
            }
            .is_attachment()
        );
    }
}