simplemailclient 0.2.3

A simple terminal mail client (SMTP send, IMAP fetch) with a TUI.
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
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Local};
use uuid::Uuid;
use anyhow::{Context, Result};
use crate::config::Config;

// ─── Domain Types ─────────────────────────────────────────────────────────────

/// A file attachment, stored separately from the message body.
/// The raw bytes are base64-encoded so binary files (zip, images, …) survive
/// JSON serialization intact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
    pub filename: String,
    pub size: u64,
    pub mime_type: String,
    /// Base64-encoded file content.
    pub data: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
    pub id: String,
    pub from: String,
    pub to: String,
    pub body: String,
    pub timestamp: DateTime<Local>,
    pub read: bool,
    pub starred: bool,
    pub folder: Folder,
    /// IMAP UID of this message (set for received messages; None for sent).
    #[serde(default)]
    pub uid: Option<u32>,
    /// RFC 2822 Message-ID header value — used to detect duplicates on re-sync.
    #[serde(default)]
    pub message_id: Option<String>,
    /// Files attached to this message, kept separate from the body text.
    #[serde(default)]
    pub attachments: Vec<Attachment>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Folder {
    Inbox,
    Sent,
    Starred,
    Trash,
}

impl Folder {
    pub fn label(&self) -> &'static str {
        match self {
            Folder::Inbox   => "Inbox",
            Folder::Sent    => "Sent",
            Folder::Starred => "Starred",
            Folder::Trash   => "Trash",
        }
    }
}

/// A contact entry.  Contacts are keyed by domain+user and assigned a stable
/// numeric ID.  They are created automatically from any address we send to or
/// receive from.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
    /// Stable numeric ID (e.g. "1", "2", …)
    pub id: String,
    /// Full email address
    pub address: String,
    /// The local part  (user)
    pub user: String,
    /// The domain part
    pub domain: String,
    /// Optional display name, populated if the remote sends a friendly name
    pub display_name: Option<String>,
    /// User-defined short alias, e.g. "bob". Used for fast addressing in compose.
    #[serde(default)]
    pub nickname: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Default)]
struct MailData {
    messages: Vec<Message>,
    /// address → Contact
    contacts: HashMap<String, Contact>,
    /// monotonic counter for contact IDs
    next_contact_id: u64,
}

// ─── MailStore ────────────────────────────────────────────────────────────────

pub struct MailStore {
    data_path: PathBuf,
    data: RefCell<MailData>,
}

impl MailStore {
    pub fn new(cfg: &Config) -> Result<Self> {
        let data_dir = dirs::data_local_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("mail-rs");
        fs::create_dir_all(&data_dir)?;
        let data_path = data_dir.join("mailbox.json");

        let data = if data_path.exists() {
            let raw = fs::read_to_string(&data_path)
                .with_context(|| format!("Cannot read {}", data_path.display()))?;
            serde_json::from_str(&raw).unwrap_or_default()
        } else {
            MailData::default()
        };

        // Ensure our own identity is in the contact book
        let store = Self { data_path, data: RefCell::new(data) };
        store.ensure_contact(&cfg.identity)?;
        // Re-parse any messages stored with raw MIME bodies (one-time migration).
        store.migrate_mime_bodies()?;
        Ok(store)
    }

    /// Walk all stored messages and re-parse any whose body looks like a raw
    /// MIME multipart (contains a boundary marker). Fixes messages that were
    /// stored before the mailparse-based extraction was in place.
    fn migrate_mime_bodies(&self) -> Result<()> {
        let needs_fix: Vec<usize> = self
            .data
            .borrow()
            .messages
            .iter()
            .enumerate()
            .filter(|(_, m)| looks_like_raw_mime(&m.body))
            .map(|(i, _)| i)
            .collect();

        if needs_fix.is_empty() {
            return Ok(());
        }

        for i in needs_fix {
            // Re-parse using mailparse to get the clean text.
            let raw_body = self.data.borrow().messages[i].body.clone();
            let cleaned = crate::transport::extract_body_text_pub(raw_body.as_bytes());
            if !cleaned.is_empty() && cleaned != raw_body {
                self.data.borrow_mut().messages[i].body = cleaned;
            }
        }

        self.save()?;
        Ok(())
    }

    // ── Persistence ──────────────────────────────────────────────────────────

    fn save(&self) -> Result<()> {
        let json = serde_json::to_string_pretty(&*self.data.borrow())?;
        fs::write(&self.data_path, json)?;
        Ok(())
    }

    // ── Contact Management ───────────────────────────────────────────────────

    /// Ensure an address is registered; return its contact.
    /// If new, parse user@domain and assign the next numeric ID.
    pub fn ensure_contact(&self, address: &str) -> Result<Contact> {
        {
            let data = self.data.borrow();
            if let Some(c) = data.contacts.get(address) {
                return Ok(c.clone());
            }
        }

        let (user, domain) = split_address(address);
        let mut data = self.data.borrow_mut();
        data.next_contact_id += 1;
        let contact = Contact {
            id: data.next_contact_id.to_string(),
            address: address.to_string(),
            user,
            domain,
            display_name: None,
            nickname: None,
        };
        data.contacts.insert(address.to_string(), contact.clone());
        drop(data);
        self.save()?;
        Ok(contact)
    }

    /// Ensure contact and optionally set / update display name.
    pub fn ensure_contact_with_name(&self, address: &str, name: Option<&str>) -> Result<Contact> {
        let mut contact = self.ensure_contact(address)?;
        if let Some(n) = name {
            if contact.display_name.as_deref() != Some(n) {
                contact.display_name = Some(n.to_string());
                self.data
                    .borrow_mut()
                    .contacts
                    .insert(address.to_string(), contact.clone());
                self.save()?;
            }
        }
        Ok(contact)
    }

    /// Manually add a contact with an optional nickname.
    /// Returns an error if the address is already in the book — use
    /// `set_nickname` to update an existing contact's nickname.
    pub fn add_contact(&self, address: &str, nickname: Option<&str>) -> Result<Contact> {
        let address = address.trim();
        if !address.contains('@') {
            anyhow::bail!("Invalid address — must contain '@': {}", address);
        }
        {
            let data = self.data.borrow();
            if data.contacts.contains_key(address) {
                anyhow::bail!("Contact already exists: {}", address);
            }
        }
        let mut contact = self.ensure_contact(address)?;
        let nick = nickname
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string);
        if nick.is_some() {
            contact.nickname = nick;
            self.data
                .borrow_mut()
                .contacts
                .insert(address.to_string(), contact.clone());
            self.save()?;
        }
        Ok(contact)
    }

    /// Remove a contact from the address book.
    /// Existing messages still reference the address as a plain string — they
    /// are not affected.
    pub fn delete_contact(&self, address: &str) -> Result<()> {
        let removed = self.data.borrow_mut().contacts.remove(address).is_some();
        if !removed {
            anyhow::bail!("Contact not found: {}", address);
        }
        self.save()
    }

    /// Set or clear the nickname for an existing contact.
    /// Pass an empty string to clear the nickname.
    pub fn set_nickname(&self, address: &str, nickname: &str) -> Result<()> {
        let nick = nickname.trim();
        let mut data = self.data.borrow_mut();
        let contact = data
            .contacts
            .get_mut(address)
            .with_context(|| format!("Contact not found: {}", address))?;
        contact.nickname = if nick.is_empty() { None } else { Some(nick.to_string()) };
        drop(data);
        self.save()
    }

    /// Find a contact's full address by its nickname (case-insensitive exact match).
    pub fn find_by_nickname(&self, nickname: &str) -> Option<String> {
        let needle = nickname.trim().to_lowercase();
        self.data
            .borrow()
            .contacts
            .values()
            .find(|c| {
                c.nickname
                    .as_deref()
                    .map(|n| n.to_lowercase() == needle)
                    .unwrap_or(false)
            })
            .map(|c| c.address.clone())
    }

    pub fn resolve_contact_id(&self, id: &str) -> Result<String> {
        self.data
            .borrow()
            .contacts
            .values()
            .find(|c| c.id == id)
            .map(|c| c.address.clone())
            .with_context(|| format!("No contact with ID {}", id))
    }

    pub fn all_contacts(&self) -> Vec<Contact> {
        let mut v: Vec<Contact> = self.data.borrow().contacts.values().cloned().collect();
        v.sort_by(|a, b| {
            a.id.parse::<u64>()
                .unwrap_or(0)
                .cmp(&b.id.parse::<u64>().unwrap_or(0))
        });
        v
    }

    // ── Message Operations ───────────────────────────────────────────────────

    pub fn record_sent(&self, to: &str, body: &str, attachments: Vec<Attachment>) -> Result<()> {
        // Auto-register recipient
        self.ensure_contact(to)?;
        let msg = Message {
            id: Uuid::new_v4().to_string(),
            from: String::new(),
            to: to.to_string(),
            body: body.to_string(),
            timestamp: Local::now(),
            read: true,
            starred: false,
            folder: Folder::Sent,
            uid: None,
            message_id: None,
            attachments,
        };
        self.data.borrow_mut().messages.push(msg);
        self.save()
    }

    /// Called by the IMAP sync to store an incoming message.
    /// Auto-registers the sender as a contact.
    /// Returns false (without storing) if the message-id is already known.
    pub fn deliver(
        &self,
        from: &str,
        body: &str,
        display_name: Option<&str>,
        uid: Option<u32>,
        message_id: Option<String>,
        attachments: Vec<Attachment>,
    ) -> Result<bool> {
        // Deduplicate: skip if we already have this message-id.
        if let Some(ref mid) = message_id {
            if self.has_message_id(mid) {
                return Ok(false);
            }
        }
        self.ensure_contact_with_name(from, display_name)?;
        let msg = Message {
            id: Uuid::new_v4().to_string(),
            from: from.to_string(),
            to: String::new(),
            body: body.to_string(),
            timestamp: Local::now(),
            read: false,
            starred: false,
            folder: Folder::Inbox,
            uid,
            message_id,
            attachments,
        };
        self.data.borrow_mut().messages.push(msg);
        self.save()?;
        Ok(true)
    }

    /// Returns true if any stored message already carries this RFC 2822 Message-ID.
    pub fn has_message_id(&self, mid: &str) -> bool {
        self.data
            .borrow()
            .messages
            .iter()
            .any(|m| m.message_id.as_deref() == Some(mid))
    }

    pub fn messages_in(&self, folder: &Folder) -> Vec<(usize, Message)> {
        self.data
            .borrow()
            .messages
            .iter()
            .enumerate()
            .filter(|(_, m)| {
                if folder == &Folder::Starred {
                    m.starred
                } else {
                    &m.folder == folder
                }
            })
            .map(|(i, m)| (i, m.clone()))
            .collect()
    }

    pub fn get_message(&self, index: usize) -> Option<Message> {
        self.data.borrow().messages.get(index).cloned()
    }

    pub fn mark_read(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.read = true;
        }
        self.save()
    }

    pub fn toggle_star(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.starred = !m.starred;
        }
        self.save()
    }

    pub fn move_to_trash(&self, index: usize) -> Result<()> {
        if let Some(m) = self.data.borrow_mut().messages.get_mut(index) {
            m.folder = Folder::Trash;
        }
        self.save()
    }

    pub fn unread_count(&self) -> usize {
        self.data
            .borrow()
            .messages
            .iter()
            .filter(|m| !m.read && m.folder == Folder::Inbox)
            .count()
    }

    pub fn unread_in(&self, folder: &Folder) -> usize {
        self.data
            .borrow()
            .messages
            .iter()
            .filter(|m| !m.read && &m.folder == folder)
            .count()
    }
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

/// Heuristic: does this string look like an undecoded MIME body?
/// Checks for a MIME boundary line (--<hex-string>) or a Content-Type header
/// embedded in the body text.
fn looks_like_raw_mime(body: &str) -> bool {
    body.contains("Content-Type:") || body.contains("Content-Transfer-Encoding:")
}

fn split_address(addr: &str) -> (String, String) {
    if let Some(at) = addr.rfind('@') {
        (addr[..at].to_string(), addr[at + 1..].to_string())
    } else {
        (addr.to_string(), "unknown".to_string())
    }
}