Add smtp client support for sending mail in UI

`mailer_command` was removed, and a new setting `send_mail` was added.

Its possible values are a string, consisting of a shell command to
execute, or settings to configure an smtp server connection. The
configuration I used for testing this is:

  [composing]
  send_mail = { hostname = "smtp.mail.tld", port = 587, auth = { type = "auto", username = "yoshi", password = { type = "command_eval", value = "gpg2 --no-tty -q -d ~/.passwords/msmtp/yoshi.gpg" } }, security = { type = "STARTTLS" } }

For local smtp server:
  [composing]
  send_mail = { hostname = "localhost", port = 25, auth = { type = "none" }, security = { type = "none" } }
async
Manos Pitsidianakis 2020-07-15 14:38:43 +03:00
parent ddafde7b37
commit 77dc1d74bf
Signed by: Manos Pitsidianakis
GPG Key ID: 73627C2F690DF710
9 changed files with 312 additions and 141 deletions

View File

@ -69,10 +69,11 @@ debug = false
members = ["melib", "testing", ]
[features]
default = ["sqlite3", "notmuch", "regexp"]
default = ["sqlite3", "notmuch", "regexp", "smtp"]
notmuch = ["melib/notmuch_backend", ]
jmap = ["melib/jmap_backend",]
sqlite3 = ["melib/sqlite3"]
smtp = ["melib/smtp"]
regexp = ["pcre2"]
cli-docs = []
svgscreenshot = ["svg_crate"]

View File

@ -79,7 +79,7 @@ thread_local!(static LOG: Arc<Mutex<LoggingBackend>> = Arc::new(Mutex::new({
}}))
);
pub fn log(val: String, level: LoggingLevel) {
pub fn log<S: AsRef<str>>(val: S, level: LoggingLevel) {
LOG.with(|f| {
let mut b = f.lock().unwrap();
if level <= b.level {
@ -91,7 +91,7 @@ pub fn log(val: String, level: LoggingLevel) {
b.dest.write_all(b" [").unwrap();
b.dest.write_all(level.to_string().as_bytes()).unwrap();
b.dest.write_all(b"]: ").unwrap();
b.dest.write_all(val.as_bytes()).unwrap();
b.dest.write_all(val.as_ref().as_bytes()).unwrap();
b.dest.write_all(b"\n").unwrap();
b.dest.flush().unwrap();
}

View File

@ -24,7 +24,7 @@ use melib::list_management;
use melib::Draft;
use crate::conf::accounts::JobRequest;
use crate::jobs::{oneshot, JobId};
use crate::jobs::{JobChannel, JobId, JoinHandle};
use crate::terminal::embed::EmbedGrid;
use nix::sys::wait::WaitStatus;
use std::str::FromStr;
@ -66,7 +66,7 @@ impl std::ops::DerefMut for EmbedStatus {
#[derive(Debug)]
pub struct Composer {
reply_context: Option<(MailboxHash, EnvelopeHash)>,
reply_bytes_request: Option<(JobId, oneshot::Receiver<Result<Vec<u8>>>)>,
reply_bytes_request: Option<(JobId, JobChannel<Vec<u8>>)>,
account_cursor: usize,
cursor: Cursor,
@ -120,6 +120,7 @@ enum ViewMode {
Embed,
SelectRecipients(UIDialog<Address>),
Send(UIConfirmationDialog),
WaitingForSendResult(UIDialog<char>, JoinHandle, JobId, JobChannel<()>),
}
impl ViewMode {
@ -612,6 +613,10 @@ impl Component for Composer {
/* Let user choose whether to quit with/without saving or cancel */
s.draw(grid, center_area(area, s.content.size()), context);
}
ViewMode::WaitingForSendResult(ref mut s, _, _, _) => {
/* Let user choose whether to wait for success or cancel */
s.draw(grid, center_area(area, s.content.size()), context);
}
}
self.dirty = false;
self.draw_attachments(grid, attachment_area, context);
@ -673,28 +678,61 @@ impl Component for Composer {
{
if let Some(true) = result.downcast_ref::<bool>() {
self.update_draft();
if send_draft(
match send_draft(
self.sign_mail,
context,
self.account_cursor,
self.draft.clone(),
SpecialUsageMailbox::Sent,
Flag::SEEN,
false,
) {
context
.replies
.push_back(UIEvent::Action(Tab(Kill(self.id))));
} else {
save_draft(
self.draft.clone().finalise().unwrap().as_bytes(),
context,
SpecialUsageMailbox::Drafts,
Flag::SEEN | Flag::DRAFT,
self.account_cursor,
);
Ok(Some((job_id, handle, chan))) => {
self.mode = ViewMode::WaitingForSendResult(
UIDialog::new(
"Waiting for confirmation.. The tab will close automatically on successful submission.",
vec![
('c', "force close tab".to_string()),
('n', "close this message and return to edit mode".to_string()),
],
true,
Some(Box::new(move |id: ComponentId, results: &[char]| {
Some(UIEvent::FinishedUIDialog(
id,
Box::new(results.get(0).map(|c| *c).unwrap_or('c')),
))
})),
context,
), handle, job_id, chan);
}
Ok(None) => {
context.replies.push_back(UIEvent::Notification(
Some("Sent.".into()),
String::new(),
Some(NotificationType::INFO),
));
context
.replies
.push_back(UIEvent::Action(Tab(Kill(self.id))));
}
Err(err) => {
context.replies.push_back(UIEvent::Notification(
None,
err.to_string(),
Some(NotificationType::ERROR),
));
save_draft(
self.draft.clone().finalise().unwrap().as_bytes(),
context,
SpecialUsageMailbox::Drafts,
Flag::SEEN | Flag::DRAFT,
self.account_cursor,
);
self.mode = ViewMode::Edit;
}
}
}
self.mode = ViewMode::Edit;
self.set_dirty(true);
return true;
}
(ViewMode::Send(ref mut selector), _) => {
@ -753,6 +791,59 @@ impl Component for Composer {
return true;
}
}
(
ViewMode::WaitingForSendResult(ref selector, _, _, _),
UIEvent::FinishedUIDialog(id, result),
) if selector.id() == *id => {
if let Some(key) = result.downcast_mut::<char>() {
match key {
'c' => {
context
.replies
.push_back(UIEvent::Action(Tab(Kill(self.id))));
return true;
}
'n' => {
self.set_dirty(true);
if let ViewMode::WaitingForSendResult(_, handle, job_id, chan) =
std::mem::replace(&mut self.mode, ViewMode::Edit)
{
context.accounts[self.account_cursor].active_jobs.insert(
job_id,
JobRequest::SendMessageBackground(handle, chan),
);
}
}
_ => {}
}
}
return true;
}
(
ViewMode::WaitingForSendResult(_, _, ref our_job_id, ref mut chan),
UIEvent::StatusEvent(StatusEvent::JobFinished(ref job_id)),
) if *our_job_id == *job_id => {
let result = chan.try_recv().unwrap();
if let Some(Err(err)) = result {
self.mode = ViewMode::Edit;
context.replies.push_back(UIEvent::Notification(
None,
err.to_string(),
Some(NotificationType::ERROR),
));
self.set_dirty(true);
} else {
context
.replies
.push_back(UIEvent::Action(Tab(Kill(self.id))));
}
return true;
}
(ViewMode::WaitingForSendResult(ref mut selector, _, _, _), _) => {
if selector.process_event(event, context) {
return true;
}
}
_ => {}
}
if self.cursor == Cursor::Headers
@ -1279,76 +1370,58 @@ pub fn send_draft(
mut draft: Draft,
mailbox_type: SpecialUsageMailbox,
flags: Flag,
) -> bool {
use std::io::Write;
use std::process::{Command, Stdio};
complete_in_background: bool,
) -> Result<Option<(JobId, JoinHandle, JobChannel<()>)>> {
let format_flowed = *mailbox_acc_settings!(context[account_cursor].composing.format_flowed);
let command = mailbox_acc_settings!(context[account_cursor].composing.mailer_command);
if command.is_empty() {
context.replies.push_back(UIEvent::Notification(
None,
String::from("mailer_command configuration value is empty"),
Some(NotificationType::ERROR),
));
return false;
}
let bytes;
let mut msmtp = Command::new("sh")
.args(&["-c", command])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to start mailer command");
{
let stdin = msmtp.stdin.as_mut().expect("failed to open stdin");
if sign_mail.is_true() {
let mut content_type = ContentType::default();
if format_flowed {
if let ContentType::Text {
ref mut parameters, ..
} = content_type
{
parameters.push((b"format".to_vec(), b"flowed".to_vec()));
}
if sign_mail.is_true() {
let mut content_type = ContentType::default();
if format_flowed {
if let ContentType::Text {
ref mut parameters, ..
} = content_type
{
parameters.push((b"format".to_vec(), b"flowed".to_vec()));
}
}
let mut body: AttachmentBuilder = Attachment::new(
content_type,
let mut body: AttachmentBuilder = Attachment::new(
content_type,
Default::default(),
std::mem::replace(&mut draft.body, String::new()).into_bytes(),
)
.into();
if !draft.attachments.is_empty() {
let mut parts = std::mem::replace(&mut draft.attachments, Vec::new());
parts.insert(0, body);
let boundary = ContentType::make_boundary(&parts);
body = Attachment::new(
ContentType::Multipart {
boundary: boundary.into_bytes(),
kind: MultipartType::Mixed,
parts: parts.into_iter().map(|a| a.into()).collect::<Vec<_>>(),
},
Default::default(),
std::mem::replace(&mut draft.body, String::new()).into_bytes(),
Vec::new(),
)
.into();
if !draft.attachments.is_empty() {
let mut parts = std::mem::replace(&mut draft.attachments, Vec::new());
parts.insert(0, body);
let boundary = ContentType::make_boundary(&parts);
body = Attachment::new(
ContentType::Multipart {
boundary: boundary.into_bytes(),
kind: MultipartType::Mixed,
parts: parts.into_iter().map(|a| a.into()).collect::<Vec<_>>(),
},
Default::default(),
Vec::new(),
)
.into();
}
let output = crate::components::mail::pgp::sign(
body.into(),
mailbox_acc_settings!(context[account_cursor].pgp.gpg_binary)
.as_ref()
.map(|s| s.as_str()),
mailbox_acc_settings!(context[account_cursor].pgp.key)
.as_ref()
.map(|s| s.as_str()),
);
if let Err(e) = &output {
debug!("{:?} could not sign draft msg", e);
}
let output = crate::components::mail::pgp::sign(
body.into(),
mailbox_acc_settings!(context[account_cursor].pgp.gpg_binary)
.as_ref()
.map(|s| s.as_str()),
mailbox_acc_settings!(context[account_cursor].pgp.key)
.as_ref()
.map(|s| s.as_str()),
);
match output {
Err(err) => {
debug!("{:?} could not sign draft msg", err);
log(
format!(
"Could not sign draft in account `{}`: {}.",
context.accounts[account_cursor].name(),
e.to_string()
err.to_string()
),
ERROR,
);
@ -1357,62 +1430,38 @@ pub fn send_draft(
"Could not sign draft in account `{}`.",
context.accounts[account_cursor].name()
)),
e.to_string(),
err.to_string(),
Some(NotificationType::ERROR),
));
return false;
return Err(err);
}
draft.attachments.push(output.unwrap());
} else {
let mut content_type = ContentType::default();
if format_flowed {
if let ContentType::Text {
ref mut parameters, ..
} = content_type
{
parameters.push((b"format".to_vec(), b"flowed".to_vec()));
}
let body: AttachmentBuilder = Attachment::new(
content_type,
Default::default(),
std::mem::replace(&mut draft.body, String::new()).into_bytes(),
)
.into();
draft.attachments.insert(0, body);
Ok(output) => {
draft.attachments.push(output);
}
}
bytes = draft.finalise().unwrap();
stdin
.write_all(bytes.as_bytes())
.expect("Failed to write to stdin");
}
let output = msmtp.wait().expect("Failed to wait on mailer");
if output.success() {
context.replies.push_back(UIEvent::Notification(
Some("Sent.".into()),
String::new(),
None,
));
} else {
let error_message = if let Some(exit_code) = output.code() {
format!(
"Could not send e-mail using `{}`: Process exited with {}",
command, exit_code
let mut content_type = ContentType::default();
if format_flowed {
if let ContentType::Text {
ref mut parameters, ..
} = content_type
{
parameters.push((b"format".to_vec(), b"flowed".to_vec()));
}
let body: AttachmentBuilder = Attachment::new(
content_type,
Default::default(),
std::mem::replace(&mut draft.body, String::new()).into_bytes(),
)
} else {
format!(
"Could not send e-mail using `{}`: Process was killed by signal",
command
)
};
context.replies.push_back(UIEvent::Notification(
Some("Message not sent.".into()),
error_message.clone(),
Some(NotificationType::ERROR),
));
log(error_message, ERROR);
.into();
draft.attachments.insert(0, body);
}
}
let bytes = draft.finalise().unwrap();
let send_mail = mailbox_acc_settings!(context[account_cursor].composing.send_mail).clone();
let ret =
context.accounts[account_cursor].send(bytes.clone(), send_mail, complete_in_background);
save_draft(
bytes.as_bytes(),
context,
@ -1420,7 +1469,7 @@ pub fn send_draft(
flags,
account_cursor,
);
true
ret
}
pub fn save_draft(

View File

@ -1495,14 +1495,23 @@ impl Component for MailView {
* on its own */
drop(detect);
drop(envelope);
return super::compose::send_draft(
if let Err(err) = super::compose::send_draft(
ToggleFlag::False,
context,
self.coordinates.0,
draft,
SpecialUsageMailbox::Sent,
Flag::SEEN,
);
true,
) {
context.replies.push_back(UIEvent::StatusEvent(
StatusEvent::DisplayMessage(format!(
"Couldn't send unsubscribe e-mail: {}",
err
)),
));
}
return true;
}
}
list_management::ListAction::Url(url) => {

View File

@ -24,7 +24,7 @@
*/
use super::{AccountConf, FileMailboxConf};
use crate::jobs::{JobExecutor, JobId, JoinHandle};
use crate::jobs::{JobChannel, JobExecutor, JobId, JoinHandle};
use melib::async_workers::{Async, AsyncBuilder, AsyncStatus, WorkContext};
use melib::backends::{
AccountHash, BackendOp, Backends, MailBackend, Mailbox, MailboxHash, NotifyFn, ReadOnlyOp,
@ -173,7 +173,8 @@ pub enum JobRequest {
Refresh(MailboxHash, JoinHandle, oneshot::Receiver<Result<()>>),
SetFlags(EnvelopeHash, JoinHandle, oneshot::Receiver<Result<()>>),
SaveMessage(MailboxHash, JoinHandle, oneshot::Receiver<Result<()>>),
SendMessage(JoinHandle, oneshot::Receiver<Result<()>>),
SendMessage,
SendMessageBackground(JoinHandle, JobChannel<()>),
CopyTo(MailboxHash, JoinHandle, oneshot::Receiver<Result<Vec<u8>>>),
DeleteMessage(EnvelopeHash, JoinHandle, oneshot::Receiver<Result<()>>),
CreateMailbox(
@ -215,7 +216,10 @@ impl core::fmt::Debug for JobRequest {
write!(f, "JobRequest::SetMailboxSubscription")
}
JobRequest::Watch(_) => write!(f, "JobRequest::Watch"),
JobRequest::SendMessage(_, _) => write!(f, "JobRequest::SendMessage"),
JobRequest::SendMessage => write!(f, "JobRequest::SendMessage"),
JobRequest::SendMessageBackground(_, _) => {
write!(f, "JobRequest::SendMessageBackground")
}
}
}
}
@ -1221,6 +1225,81 @@ impl Account {
Ok(())
}
pub fn send(
&mut self,
message: String,
send_mail: crate::conf::composing::SendMail,
complete_in_background: bool,
) -> Result<Option<(JobId, JoinHandle, JobChannel<()>)>> {
use crate::conf::composing::SendMail;
use std::io::Write;
use std::process::{Command, Stdio};
debug!(&send_mail);
match send_mail {
SendMail::ShellCommand(ref command) => {
if command.is_empty() {
return Err(MeliError::new(
"send_mail shell command configuration value is empty",
));
}
let mut msmtp = Command::new("sh")
.args(&["-c", command])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to start mailer command");
{
let stdin = msmtp.stdin.as_mut().expect("failed to open stdin");
stdin
.write_all(message.as_bytes())
.expect("Failed to write to stdin");
}
let output = msmtp.wait().expect("Failed to wait on mailer");
if output.success() {
melib::log("Message sent.", melib::LoggingLevel::TRACE);
} else {
let error_message = if let Some(exit_code) = output.code() {
format!(
"Could not send e-mail using `{}`: Process exited with {}",
command, exit_code
)
} else {
format!(
"Could not send e-mail using `{}`: Process was killed by signal",
command
)
};
melib::log(&error_message, melib::LoggingLevel::ERROR);
return Err(
MeliError::new(error_message.clone()).set_summary("Message not sent.")
);
}
Ok(None)
}
#[cfg(feature = "smtp")]
SendMail::Smtp(conf) => {
let (chan, handle, job_id) = self.job_executor.spawn_specialized(async move {
let mut smtp_connection =
melib::smtp::SmtpConnection::new_connection(conf).await?;
smtp_connection.mail_transaction(&message).await
});
self.sender
.send(ThreadEvent::UIEvent(UIEvent::StatusEvent(
StatusEvent::NewJob(job_id),
)))
.unwrap();
if complete_in_background {
self.active_jobs
.insert(job_id, JobRequest::SendMessageBackground(handle, chan));
return Ok(None);
} else {
self.active_jobs.insert(job_id, JobRequest::SendMessage);
}
Ok(Some((job_id, handle, chan)))
}
}
}
pub fn delete(
&mut self,
env_hash: EnvelopeHash,
@ -1678,6 +1757,30 @@ impl Account {
.expect("Could not send event on main channel");
}
}
JobRequest::SendMessage => {
self.sender
.send(ThreadEvent::UIEvent(UIEvent::StatusEvent(
StatusEvent::JobFinished(*job_id),
)))
.unwrap();
}
JobRequest::SendMessageBackground(_, mut chan) => {
let r = chan.try_recv().unwrap();
if let Some(Err(err)) = r {
self.sender
.send(ThreadEvent::UIEvent(UIEvent::Notification(
Some("Could not send message".to_string()),
err.to_string(),
Some(crate::types::NotificationType::ERROR),
)))
.expect("Could not send event on main channel");
}
self.sender
.send(ThreadEvent::UIEvent(UIEvent::StatusEvent(
StatusEvent::JobFinished(*job_id),
)))
.unwrap();
}
JobRequest::CopyTo(mailbox_hash, _, mut chan) => {
if let Err(err) = chan
.try_recv()

View File

@ -28,8 +28,7 @@ use std::collections::HashMap;
pub struct ComposingSettings {
/// A command to pipe new emails to
/// Required
#[serde(alias = "mailer-command", alias = "mailer-cmd", alias = "mailer_cmd")]
pub mailer_command: String,
pub send_mail: SendMail,
/// Command to launch editor. Can have arguments. Draft filename is given as the last argument. If it's missing, the environment variable $EDITOR is looked up.
#[serde(
default = "none",
@ -54,7 +53,7 @@ pub struct ComposingSettings {
impl Default for ComposingSettings {
fn default() -> Self {
ComposingSettings {
mailer_command: String::new(),
send_mail: SendMail::ShellCommand("/bin/false".into()),
editor_command: None,
embed: false,
format_flowed: true,
@ -62,3 +61,11 @@ impl Default for ComposingSettings {
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum SendMail {
#[cfg(feature = "smtp")]
Smtp(melib::smtp::SmtpServerConf),
ShellCommand(String),
}

View File

@ -202,9 +202,8 @@ impl Default for ShortcutsOverride {
pub struct ComposingSettingsOverride {
#[doc = " A command to pipe new emails to"]
#[doc = " Required"]
#[serde(alias = "mailer-command", alias = "mailer-cmd", alias = "mailer_cmd")]
#[serde(default)]
pub mailer_command: Option<String>,
pub send_mail: Option<SendMail>,
#[doc = " Command to launch editor. Can have arguments. Draft filename is given as the last argument. If it's missing, the environment variable $EDITOR is looked up."]
#[serde(alias = "editor-command", alias = "editor-cmd", alias = "editor_cmd")]
#[serde(default)]
@ -226,7 +225,7 @@ pub struct ComposingSettingsOverride {
impl Default for ComposingSettingsOverride {
fn default() -> Self {
ComposingSettingsOverride {
mailer_command: None,
send_mail: None,
editor_command: None,
embed: None,
format_flowed: None,

View File

@ -77,7 +77,7 @@ macro_rules! uuid_hash_type {
}
impl $n {
fn new() -> Self {
pub fn new() -> Self {
$n(Uuid::new_v4())
}
pub fn null() -> Self {
@ -216,6 +216,8 @@ impl JobExecutor {
}
}
pub type JobChannel<T> = oneshot::Receiver<Result<T>>;
///// Spawns a future on the executor.
//fn spawn<F, R>(future: F) -> JoinHandle<R>
//where

View File

@ -38,6 +38,7 @@ pub use self::helpers::*;
use super::execute::Action;
use super::jobs::JobId;
use super::terminal::*;
use crate::components::{Component, ComponentId};
use melib::backends::{AccountHash, MailboxHash};
use melib::{EnvelopeHash, RefreshEvent, ThreadHash};
@ -125,8 +126,8 @@ pub enum UIEvent {
EnvelopeRemove(EnvelopeHash, ThreadHash),
Contacts(ContactEvent),
Compose(ComposeEvent),
FinishedUIDialog(crate::components::ComponentId, UIMessage),
GlobalUIDialog(Box<dyn crate::components::Component>),
FinishedUIDialog(ComponentId, UIMessage),
GlobalUIDialog(Box<dyn Component>),
Timer(u8),
}