Skip to main content

sioul/
mail.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The mail client from the terminal: `sioul mail folders | list | act | send`.
5//! The window offers the same with "Undo" before anything reaches the server;
6//! here, a command is already a deliberate act.
7
8use crate::Session;
9use clap::{Subcommand, ValueEnum};
10use sioul_core::compose::{self, Draft, DraftKind};
11use sioul_core::config::Account;
12use sioul_core::maildir;
13use sioul_sync::mailbox::{self, Action};
14use sioul_sync::{SyncError, secret};
15use std::path::PathBuf;
16
17#[derive(Subcommand)]
18pub(crate) enum MailCommand {
19    /// An account's folders, as last listed (`sioul sync` lists them again).
20    Folders { account: String },
21    /// A folder's messages, newest first: date, sender, subject, flags and file.
22    List {
23        account: String,
24        /// The folder, by its name as listed; the inbox by default.
25        folder: Option<String>,
26    },
27    /// Does something to a message, on the server then here.
28    Act {
29        /// The message's file, as `list` shows it.
30        file: PathBuf,
31        #[arg(value_enum)]
32        action: ActionArg,
33        /// For `move`: the folder, by its name as listed.
34        #[arg(long)]
35        to: Option<String>,
36    },
37    /// Writes and sends a message; the body is Markdown.
38    Send {
39        #[arg(long)]
40        account: String,
41        #[arg(long)]
42        to: Vec<String>,
43        #[arg(long)]
44        cc: Vec<String>,
45        #[arg(long)]
46        subject: Option<String>,
47        /// The text, in Markdown.
48        #[arg(long)]
49        body: String,
50        #[arg(long)]
51        attach: Vec<PathBuf>,
52        /// Answers this message (its file), quoting it below the text.
53        #[arg(long)]
54        reply: Option<PathBuf>,
55        /// Answers this message to everyone it went to.
56        #[arg(long)]
57        reply_all: Option<PathBuf>,
58        /// Forwards this message, with its attachments.
59        #[arg(long)]
60        forward: Option<PathBuf>,
61        /// Signs it with your OpenPGP key.
62        #[arg(long)]
63        sign: bool,
64        /// Encrypts it to every recipient's key.
65        #[arg(long)]
66        encrypt: bool,
67    },
68}
69
70#[derive(Clone, Copy, ValueEnum)]
71pub(crate) enum ActionArg {
72    Read,
73    Unread,
74    Flag,
75    Unflag,
76    Trash,
77    Junk,
78    Archive,
79    NotJunk,
80    /// Not spam: marked `$NotJunk`, it stays where it is (mail set aside as spam goes back to its lane).
81    NotSpam,
82    Move,
83}
84
85pub(crate) fn run(s: &Session, command: MailCommand) -> Result<(), String> {
86    match command {
87        MailCommand::Folders { account } => {
88            for folder in mailbox::folders(&mail_account(s, &account)?.id) {
89                // Folder names are the server's.
90                println!("{} · {} · {}", s.tr.text(folder.role.message_id(), None), crate::one_line(&folder.display), crate::one_line(&folder.name));
91            }
92            Ok(())
93        }
94        MailCommand::List { account, folder } => list(s, &account, folder.as_deref()),
95        MailCommand::Act { file, action, to } => act(s, &file, action, to),
96        MailCommand::Send { account, to, cc, subject, body, attach, reply, reply_all, forward, sign, encrypt } => {
97            let account = mail_account(s, &account)?.clone();
98            let own = own_addresses(s);
99            let answered = [(reply, DraftKind::Reply), (reply_all, DraftKind::ReplyAll), (forward, DraftKind::Forward)]
100                .into_iter()
101                .find_map(|(file, kind)| file.map(|f| (f, kind)));
102            let mut draft = match answered {
103                Some((file, kind)) => compose::answer(&file, kind, &account.id, &own).ok_or_else(|| s.tr.text("mail-message-gone", None))?,
104                None => Draft::new(&account.id),
105            };
106            draft.to.extend(to.iter().flat_map(|t| compose::split_addresses(t)));
107            draft.cc.extend(cc.iter().flat_map(|c| compose::split_addresses(c)));
108            if let Some(subject) = subject {
109                draft.subject = subject;
110            }
111            draft.body = body;
112            draft.add_signature(account.signature.as_deref());
113            draft.attachments = attach;
114            draft.sign = sign;
115            draft.encrypt = encrypt;
116            send_draft(s, &account, &draft)
117        }
118    }
119}
120
121fn mail_account<'a>(s: &'a Session, id: &str) -> Result<&'a Account, String> {
122    s.config.account(id).filter(|a| a.syncs()).ok_or_else(|| s.say("account-unknown", &[("id", id.to_string())]))
123}
124
125/// Your addresses, never answered to.
126pub(crate) fn own_addresses(s: &Session) -> Vec<String> {
127    s.config.accounts.iter().filter_map(|a| a.address.clone()).collect()
128}
129
130fn list(s: &Session, account: &str, folder: Option<&str>) -> Result<(), String> {
131    let account = mail_account(s, account)?;
132    let folders = mailbox::folders(&account.id);
133    let missing = |name: &str| s.say("mail-no-such-folder", &[("folder", name.to_string()), ("account", account.id.clone())]);
134    let chosen = match folder {
135        Some(name) => folders.iter().find(|f| f.name == name || f.display == name).ok_or_else(|| missing(name))?,
136        None => folders.iter().find(|f| f.local.is_empty()).ok_or_else(|| missing("INBOX"))?,
137    };
138    let mut cards = maildir::read_messages(&account.maildir_path().join(&chosen.local));
139    cards.reverse();
140    for card in cards {
141        let path = card.path.clone().unwrap_or_default();
142        let letters = maildir::flags_of(&path);
143        // The flags' letters, then the keywords kept, by their names: "S $NotJunk".
144        let keywords = maildir::KEYWORDS.iter().filter(|(letter, _)| letters.contains(*letter)).map(|(_, name)| name.to_string());
145        let flags = std::iter::once(letters.chars().filter(char::is_ascii_uppercase).collect::<String>()).chain(keywords).filter(|f| !f.is_empty()).collect::<Vec<_>>().join(" ");
146        // Sender and subject as the mail wrote them, never a terminal's escape sequences.
147        println!("{} · {} · {} · [{flags}] · {}", crate::date(s, card.date), crate::one_line(card.sender()), crate::one_line(&card.subject), crate::one_line(&path.display().to_string()));
148    }
149    Ok(())
150}
151
152fn act(s: &Session, file: &std::path::Path, action: ActionArg, to: Option<String>) -> Result<(), String> {
153    let account = s.config.account_of(file).ok_or_else(|| s.say("mail-not-in-accounts", &[("file", file.display().to_string())]))?;
154    let action = match action {
155        ActionArg::Read => Action::Read(true),
156        ActionArg::Unread => Action::Read(false),
157        ActionArg::Flag => Action::Flag(true),
158        ActionArg::Unflag => Action::Flag(false),
159        ActionArg::Trash => Action::Trash,
160        ActionArg::Junk => Action::Junk,
161        ActionArg::Archive => Action::Archive,
162        ActionArg::NotJunk => Action::NotJunk,
163        ActionArg::NotSpam => Action::NotSpam,
164        ActionArg::Move => Action::Move(to.ok_or_else(|| s.tr.text("mail-move-needs-to", None))?),
165    };
166    let password = secret::password(account).map_err(|e| e.sentence(&s.tr, &account.id))?;
167    mailbox::act(account, &password, file, &action).map_err(|e| e.sentence(&s.tr, &account.id))?;
168    println!("{}", s.tr.text("mail-done", None));
169    Ok(())
170}
171
172/// Sends a draft, and forgets it once sent; kept when sending failed.
173pub(crate) fn send_draft(s: &Session, account: &Account, draft: &Draft) -> Result<(), String> {
174    let password = secret::password(account).map_err(|e| e.sentence(&s.tr, &account.id))?;
175    match sioul_sync::send::send_draft(&s.config_path, account, &password, draft, &s.tr) {
176        Ok(()) => {
177            draft.discard();
178            println!("{}", s.tr.text("mail-sent", None));
179            Ok(())
180        }
181        Err(e @ SyncError::NotFiled(_)) => {
182            draft.discard();
183            println!("{}", crate::plain_lines(&e.sentence(&s.tr, &account.id)));
184            Ok(())
185        }
186        Err(e) => {
187            let _ = draft.save();
188            Err(e.sentence(&s.tr, &account.id))
189        }
190    }
191}