├── .gitignore ├── .travis.yml ├── COPYING ├── Cargo.lock ├── Cargo.toml ├── LICENSE-MIT ├── Makefile ├── README.md ├── UNLICENSE ├── ctags.rust ├── main.rs ├── rustfmt.toml └── session.vim /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | doc 3 | tags 4 | build 5 | target 6 | *-out 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - 1.27.0 4 | - stable 5 | - beta 6 | - nightly 7 | script: 8 | - cargo build --verbose 9 | - cargo doc 10 | - cargo test --verbose 11 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | This project is dual-licensed under the Unlicense and MIT licenses. 2 | 3 | You may use this code under the terms of either license. 4 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.95" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 10 | 11 | [[package]] 12 | name = "cmail" 13 | version = "0.4.0" 14 | dependencies = [ 15 | "anyhow", 16 | "crossbeam-channel", 17 | "jiff", 18 | "lexopt", 19 | "libc", 20 | "signal-hook", 21 | ] 22 | 23 | [[package]] 24 | name = "crossbeam-channel" 25 | version = "0.5.14" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" 28 | dependencies = [ 29 | "crossbeam-utils", 30 | ] 31 | 32 | [[package]] 33 | name = "crossbeam-utils" 34 | version = "0.8.21" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 37 | 38 | [[package]] 39 | name = "jiff" 40 | version = "0.1.16" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "24a46169c7a10358cdccfb179910e8a5a392fc291bdb409da9aeece5b19786d8" 43 | dependencies = [ 44 | "jiff-tzdb-platform", 45 | "serde", 46 | "windows-sys", 47 | ] 48 | 49 | [[package]] 50 | name = "jiff-tzdb" 51 | version = "0.1.1" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "91335e575850c5c4c673b9bd467b0e025f164ca59d0564f69d0c2ee0ffad4653" 54 | 55 | [[package]] 56 | name = "jiff-tzdb-platform" 57 | version = "0.1.1" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "9835f0060a626fe59f160437bc725491a6af23133ea906500027d1bd2f8f4329" 60 | dependencies = [ 61 | "jiff-tzdb", 62 | ] 63 | 64 | [[package]] 65 | name = "lexopt" 66 | version = "0.3.0" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" 69 | 70 | [[package]] 71 | name = "libc" 72 | version = "0.2.169" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 75 | 76 | [[package]] 77 | name = "proc-macro2" 78 | version = "1.0.92" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 81 | dependencies = [ 82 | "unicode-ident", 83 | ] 84 | 85 | [[package]] 86 | name = "quote" 87 | version = "1.0.37" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 90 | dependencies = [ 91 | "proc-macro2", 92 | ] 93 | 94 | [[package]] 95 | name = "serde" 96 | version = "1.0.216" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" 99 | dependencies = [ 100 | "serde_derive", 101 | ] 102 | 103 | [[package]] 104 | name = "serde_derive" 105 | version = "1.0.216" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" 108 | dependencies = [ 109 | "proc-macro2", 110 | "quote", 111 | "syn", 112 | ] 113 | 114 | [[package]] 115 | name = "signal-hook" 116 | version = "0.3.17" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 119 | dependencies = [ 120 | "libc", 121 | "signal-hook-registry", 122 | ] 123 | 124 | [[package]] 125 | name = "signal-hook-registry" 126 | version = "1.4.2" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 129 | dependencies = [ 130 | "libc", 131 | ] 132 | 133 | [[package]] 134 | name = "syn" 135 | version = "2.0.91" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" 138 | dependencies = [ 139 | "proc-macro2", 140 | "quote", 141 | "unicode-ident", 142 | ] 143 | 144 | [[package]] 145 | name = "unicode-ident" 146 | version = "1.0.14" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 149 | 150 | [[package]] 151 | name = "windows-sys" 152 | version = "0.59.0" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 155 | dependencies = [ 156 | "windows-targets", 157 | ] 158 | 159 | [[package]] 160 | name = "windows-targets" 161 | version = "0.52.6" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 164 | dependencies = [ 165 | "windows_aarch64_gnullvm", 166 | "windows_aarch64_msvc", 167 | "windows_i686_gnu", 168 | "windows_i686_gnullvm", 169 | "windows_i686_msvc", 170 | "windows_x86_64_gnu", 171 | "windows_x86_64_gnullvm", 172 | "windows_x86_64_msvc", 173 | ] 174 | 175 | [[package]] 176 | name = "windows_aarch64_gnullvm" 177 | version = "0.52.6" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 180 | 181 | [[package]] 182 | name = "windows_aarch64_msvc" 183 | version = "0.52.6" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 186 | 187 | [[package]] 188 | name = "windows_i686_gnu" 189 | version = "0.52.6" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 192 | 193 | [[package]] 194 | name = "windows_i686_gnullvm" 195 | version = "0.52.6" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 198 | 199 | [[package]] 200 | name = "windows_i686_msvc" 201 | version = "0.52.6" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 204 | 205 | [[package]] 206 | name = "windows_x86_64_gnu" 207 | version = "0.52.6" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 210 | 211 | [[package]] 212 | name = "windows_x86_64_gnullvm" 213 | version = "0.52.6" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 216 | 217 | [[package]] 218 | name = "windows_x86_64_msvc" 219 | version = "0.52.6" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 222 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cmail" 3 | version = "0.4.0" #:version 4 | authors = ["Andrew Gallant "] 5 | description = "Send email with the output of long running commands." 6 | homepage = "https://github.com/BurntSushi/rust-cmail" 7 | repository = "https://github.com/BurntSushi/rust-cmail" 8 | readme = "README.md" 9 | keywords = ["email", "cli", "command"] 10 | license = "Unlicense/MIT" 11 | edition = "2021" 12 | 13 | [dependencies] 14 | anyhow = "1.0.95" 15 | crossbeam-channel = "0.5.14" 16 | jiff = "0.1.16" 17 | lexopt = "0.3.0" 18 | libc = "0.2.169" 19 | signal-hook = "0.3.17" 20 | 21 | [[bin]] 22 | name = "cmail" 23 | path = "main.rs" 24 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrew Gallant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | echo Nothing to do... 3 | 4 | ctags: 5 | ctags --recurse --options=ctags.rust --languages=Rust 6 | 7 | docs: 8 | cargo doc 9 | in-dir ./target/doc fix-perms 10 | rscp ./target/doc/* gopher:~/www/burntsushi.net/rustdoc/ 11 | 12 | push: 13 | git push origin master 14 | git push github master 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A command line utility for sending periodic email with the output of long 2 | running commands. 3 | 4 | [![Build status](https://api.travis-ci.org/BurntSushi/rust-cmail.png)](https://travis-ci.org/BurntSushi/rust-cmail) 5 | [![](https://meritbadge.herokuapp.com/cmail)](https://crates.io/crates/cmail) 6 | 7 | Dual-licensed under MIT or the [UNLICENSE](https://unlicense.org/). 8 | 9 | 10 | ### Installation 11 | 12 | Currently, you have to build with Cargo, Rust's package manager. I'll hopefully 13 | release binaries soon. 14 | 15 | ``` 16 | git clone git://github.com/BurntSushi/rust-cmail 17 | cd rust-cmail 18 | cargo build --release 19 | ./target/release/cmail --help 20 | ``` 21 | 22 | ### Usage 23 | 24 | ``` 25 | Usage: cmail [options] [ ...] 26 | 27 | Options: 28 | -h, --help Display this help message. 29 | -p ARG, --period ARG Data is emailed at this period in seconds. 30 | Set to 0 to disable and send only one email 31 | when the command completes. 32 | [default: 900] 33 | -s, --silent Don't pass the command's stdout/stderr to the 34 | terminal. Instead, only send stdout/stderr 35 | in email. 36 | -a, --send-all Send the entire command's output on each email. 37 | N.B. This saves all output in memory. 38 | -t ARG, --to ARG The email address to send to. By default, this 39 | is set to $EMAIL. If neither $EMAIL nor --to 40 | are set, then an error is returned. 41 | ``` 42 | 43 | `cmail` responds gracefully to signals or if the command being run is 44 | terminated unexpectedly. Internally, `cmail` uses `sendmail` to send email. 45 | 46 | 47 | ### Examples 48 | 49 | Send whatever is on stdin: 50 | 51 | ```bash 52 | cmail < 25 | -------------------------------------------------------------------------------- /ctags.rust: -------------------------------------------------------------------------------- 1 | --langdef=Rust 2 | --langmap=Rust:.rs 3 | --regex-Rust=/^[ \t]*(#\[[^\]]\][ \t]*)*(pub[ \t]+)?(extern[ \t]+)?("[^"]+"[ \t]+)?(unsafe[ \t]+)?fn[ \t]+([a-zA-Z0-9_]+)/\6/f,functions,function definitions/ 4 | --regex-Rust=/^[ \t]*(pub[ \t]+)?type[ \t]+([a-zA-Z0-9_]+)/\2/T,types,type definitions/ 5 | --regex-Rust=/^[ \t]*(pub[ \t]+)?enum[ \t]+([a-zA-Z0-9_]+)/\2/g,enum,enumeration names/ 6 | --regex-Rust=/^[ \t]*(pub[ \t]+)?struct[ \t]+([a-zA-Z0-9_]+)/\2/s,structure names/ 7 | --regex-Rust=/^[ \t]*(pub[ \t]+)?mod[ \t]+([a-zA-Z0-9_]+)/\2/m,modules,module names/ 8 | --regex-Rust=/^[ \t]*(pub[ \t]+)?static[ \t]+([a-zA-Z0-9_]+)/\2/c,consts,static constants/ 9 | --regex-Rust=/^[ \t]*(pub[ \t]+)?trait[ \t]+([a-zA-Z0-9_]+)/\2/t,traits,traits/ 10 | --regex-Rust=/^[ \t]*(pub[ \t]+)?impl([ \t\n]+<.*>)?[ \t]+([a-zA-Z0-9_]+)/\3/i,impls,trait implementations/ 11 | --regex-Rust=/^[ \t]*macro_rules![ \t]+([a-zA-Z0-9_]+)/\1/d,macros,macro definitions/ 12 | -------------------------------------------------------------------------------- /main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | ffi::OsString, 4 | io::{self, BufRead, Read, Write}, 5 | process::{self, Command, ExitCode, Stdio}, 6 | thread, 7 | time::Duration, 8 | }; 9 | 10 | use anyhow::Context; 11 | use crossbeam_channel::{self as channel, Receiver, Sender}; 12 | use libc::c_int; 13 | 14 | /// The main starting point for cmail. 15 | /// 16 | /// High level overview: 17 | /// 18 | /// 1. Spawn the command given or read from stdin if no command is given. 19 | /// 2. Start the email sender. 20 | /// 3. Create a channel that ticks every N seconds. 21 | /// 4. Start the main loop which waits on three channels: OS signals, the 22 | /// ticker and lines read from the spawned command (or stdin). 23 | fn main() -> anyhow::Result { 24 | // We must start our signal notifier before spawning any threads! 25 | let signal = signal_notify(&[libc::SIGINT, libc::SIGTERM])?; 26 | let config = Config::parse(std::env::args_os())?; 27 | 28 | // When we don't have any arguments, cmail sends email containing stdin. 29 | let (mut cmd, lines, mut subject) = 30 | if let Some((ref bin, ref args, ref human_readable)) = config.cmd { 31 | let subject = human_readable.clone(); 32 | let mut command = Command::new(bin); 33 | command.args(args); 34 | let (cmd, lines) = Cmd::run(command, !config.silent)?; 35 | (Some(cmd), lines, subject) 36 | } else { 37 | let passthru = Passthru::stdout(!config.silent); 38 | let stdin = passthru.gobble(io::stdin()); 39 | (None, stdin, "".to_string()) 40 | }; 41 | if let Some(ref explicit_subject) = config.subject { 42 | subject = explicit_subject.clone(); 43 | } 44 | 45 | let email = match config.to { 46 | None => env::var("EMAIL").unwrap_or(String::new()), 47 | Some(ref s) => s.to_string(), 48 | }; 49 | anyhow::ensure!( 50 | !email.is_empty(), 51 | "Please specify an email address with --to or by \ 52 | setting the EMAIL environment variable." 53 | ); 54 | let emailer = EmailSender::run(subject, email, config.send_all); 55 | 56 | // If period is zero, then ticker never ticks. 57 | let ticker = if config.period.is_zero() { 58 | channel::never() 59 | } else { 60 | channel::tick(config.period) 61 | }; 62 | // Set to true if either the spawned process or a `sendmail` command 63 | // is interrupted. Setting this to `true` means we've initiated a graceful 64 | // shutdown of cmail that will culminate in one last email send. 65 | let mut killed = false; 66 | // Contains the next batch of lines to email. If the ticker is enabled, 67 | // then this is emptied at every tick. 68 | let mut outlines = Vec::with_capacity(1024); 69 | loop { 70 | crossbeam_channel::select! { 71 | // Respond to an OS signal. Currently, we just listen for 72 | // INT (^C) and TERM (kill). 73 | recv(signal) -> _ => { 74 | killed = true; 75 | if let Some(ref mut cmd) = cmd { 76 | // If we're running a command and receive an interrupt, 77 | // then we don't quit right away and send an email. 78 | // Instead, we *ask* the child process to quit and we'll 79 | // continue reading from its stdout/stderr until EOF. 80 | // 81 | // (Once EOF is hit, the `lines` channel is closed.) 82 | cmd.kill()?; 83 | } else { 84 | // .. on the other hand, if we're reading from stdin, 85 | // then there's really nothing else we can do other than 86 | // send what we've got and quit. 87 | return emailer.last_send(cmd, outlines, killed); 88 | } 89 | } 90 | // When a tick happens, we just want to send the lines we've 91 | // accumulated so far and start over again. 92 | // 93 | // If, during the tick, the `sendmail` process is interrupted, 94 | // then we take this as a sign that we should quit. 95 | // 96 | // Finally, don't respond to ticks if we're shutting down. 97 | recv(ticker) -> _ => { 98 | if !killed { 99 | killed = emailer.send(outlines)?; 100 | outlines = Vec::with_capacity(1024); 101 | match cmd { 102 | Some(ref mut cmd) if killed => { cmd.kill()?; } 103 | _ => {} 104 | } 105 | } 106 | } 107 | // Receive a single line read from the spawned process (or stdin). 108 | // This simply adds the line to our `outlines` buffer. 109 | // Something interesting only happens when the channel is closed: 110 | // we send one last email with the lines we've accumulated. 111 | // 112 | // N.B. This is the main exit point of cmail under normal 113 | // operation. In the absence of ticks, this is usually the only 114 | // channel that gets any activity! 115 | recv(lines) -> line => match line.ok() { 116 | Some(line) => outlines.push(line?), 117 | None => return emailer.last_send(cmd, outlines, killed), 118 | }, 119 | } 120 | } 121 | } 122 | 123 | /// An email sender collects groups of lines and sends emails concurrently. 124 | struct EmailSender { 125 | /// The email sender listens on this channel for sequences of lines. 126 | send_lines: Sender>, 127 | /// When a sequence of lines has been emailed (either successfully or 128 | /// unsuccessfully), the result is sent on this channel. 129 | /// 130 | /// In particular, the next email is not attempted until a consumer 131 | /// receives the corresponding result on this channel. 132 | recv_result: Receiver>, 133 | /// Closed when the email sender shuts down. 134 | recv_done: Receiver<()>, 135 | } 136 | 137 | impl EmailSender { 138 | /// Creates a new email sender. 139 | /// 140 | /// This spawns a thread responsible for sending lines read from the 141 | /// running command to the email address provided. The value returned 142 | /// contains several channels that can be used to interact with this 143 | /// thread. Instead of using the channels explicitly, you should prefer 144 | /// to use the methods defined below. 145 | fn run(subject: String, email: String, send_all: bool) -> EmailSender { 146 | let mut to_send: Vec = Vec::with_capacity(1024); 147 | let (send_lines, recv_lines) = channel::bounded::>(0); 148 | let (send_result, recv_result) = channel::bounded(0); 149 | let (send_done, recv_done) = channel::bounded(1); 150 | thread::spawn(move || { 151 | let mut interrupted = false; 152 | for lines in recv_lines { 153 | if send_all { 154 | to_send.extend(lines); 155 | } else { 156 | if lines.len() == 0 { 157 | to_send = vec!["No output.".to_owned()]; 158 | } else { 159 | to_send = lines; 160 | } 161 | } 162 | let result = if interrupted { 163 | email_lines(&subject, &email, &to_send) 164 | .map(|_| interrupted) 165 | } else { 166 | let r = email_lines_retry(&subject, &email, &to_send); 167 | interrupted = *r.as_ref().unwrap_or(&false); 168 | r 169 | }; 170 | send_result.send(result).unwrap(); 171 | } 172 | // unblock recv_done 173 | drop(send_done); 174 | }); 175 | EmailSender { send_lines, recv_result, recv_done } 176 | } 177 | 178 | /// Consume the email sender and send one last batch of lines. 179 | /// 180 | /// If this method completes successfully, then the email sender thread 181 | /// will have shut down, the last email will have been sent and the spawned 182 | /// child process (if one exists) will be reaped. 183 | /// 184 | /// If cmail was run with a command, then `cmd` should be that command. 185 | /// Otherwise, it should be `None` when cmail reads from stdin. 186 | /// 187 | /// `killed` is a bool indicating whether any of the child processes 188 | /// spawned by cmail were killed by a signal. When `killed` is true, 189 | /// a non-zero exit code is returned in the result. Otherwise, a zero 190 | /// exit code is returned. 191 | fn last_send( 192 | self, 193 | cmd: Option, 194 | mut lines: Vec, 195 | killed: bool, 196 | ) -> anyhow::Result { 197 | let int = match cmd { 198 | None => killed, 199 | Some(mut cmd) => !cmd.wait()?.success() || killed, 200 | }; 201 | let msg = if int { 202 | "Program interrupted." 203 | } else { 204 | "Program completed successfully." 205 | }; 206 | lines.extend(vec!["", "", msg].into_iter().map(str::to_owned)); 207 | self.send(lines)?; 208 | self.done(); 209 | Ok(if killed { ExitCode::FAILURE } else { ExitCode::SUCCESS }) 210 | } 211 | 212 | /// Sends a sequence of lines. 213 | /// 214 | /// If this method completes successfully, then an email will have been 215 | /// sent containing the lines given. 216 | /// 217 | /// If an interrupt occurred when trying to send mail, then `true` is 218 | /// returned in the result. Otherwise, `false` is returned. (This 219 | /// corresponds to the `killed` parameter in `last_send`. It should also 220 | /// be used to start a graceful shutdown of cmail.) 221 | fn send(&self, lines: Vec) -> anyhow::Result { 222 | self.send_lines.send(lines).unwrap(); 223 | Ok(self.recv_result.recv().unwrap()?) 224 | } 225 | 226 | /// Start a graceful shutdown of the emailing thread and wait for all 227 | /// remaining lines to be sent. 228 | fn done(self) { 229 | // Shut down the thread responsible for sending emails. 230 | drop(self.send_lines); 231 | // Wait for it to finished. 232 | let _ = self.recv_done.recv(); 233 | } 234 | } 235 | 236 | /// A simple convenience for handling the command that cmail is watching. 237 | #[derive(Debug)] 238 | struct Cmd { 239 | child: process::Child, 240 | } 241 | 242 | impl Cmd { 243 | /// Run the given command (where each item in `cmd` is a single argument). 244 | /// 245 | /// If `passthru` is true, then the stdout/stderr of the command is printed 246 | /// on the stdout/stderr of cmail. 247 | /// 248 | /// This returns a tuple. The first value is the `Cmd` abstraction, which 249 | /// can be killed and reaped. The second value is a channel that receives 250 | /// line results from the corresponding child process. The channel is 251 | /// closed when the child's stdout and stderr emit EOF. 252 | fn run( 253 | mut command: Command, 254 | passthru: bool, 255 | ) -> anyhow::Result<(Cmd, Receiver>)> { 256 | command.stdout(Stdio::piped()).stderr(Stdio::piped()); 257 | let mut child = command.spawn()?; 258 | 259 | let stdout = child.stdout.take().unwrap(); 260 | let stderr = child.stderr.take().unwrap(); 261 | let stdout = Passthru::stdout(passthru).gobble(stdout); 262 | let stderr = Passthru::stderr(passthru).gobble(stderr); 263 | Ok((Cmd { child }, muxer(vec![stdout, stderr]))) 264 | } 265 | 266 | /// Kill this command and wait to reap it. 267 | fn kill(&mut self) -> anyhow::Result { 268 | // Ignore the error here, in case the child has already died. 269 | // We simply do not care if `kill` fails. 270 | let _ = self.child.kill(); 271 | self.wait() 272 | } 273 | 274 | /// Block until the child is reaped. 275 | fn wait(&mut self) -> anyhow::Result { 276 | Ok(self.child.wait()?) 277 | } 278 | } 279 | 280 | /// Passthru describes how to pass the command's output through the cmail 281 | /// process. 282 | #[derive(Clone, Copy, Debug)] 283 | enum Passthru { 284 | No, 285 | Stdout, 286 | Stderr, 287 | } 288 | 289 | impl Passthru { 290 | /// Pass through on stdout if `yes` is true. 291 | fn stdout(yes: bool) -> Passthru { 292 | if yes { 293 | Passthru::Stdout 294 | } else { 295 | Passthru::No 296 | } 297 | } 298 | 299 | /// Pass through on stderr if `yes` is true. 300 | fn stderr(yes: bool) -> Passthru { 301 | if yes { 302 | Passthru::Stderr 303 | } else { 304 | Passthru::No 305 | } 306 | } 307 | 308 | /// Create a writer corresponding to the pass through settings. 309 | /// 310 | /// If there's no pass through, then a /dev/null-like writer is returned. 311 | fn wtr(self) -> Box { 312 | match self { 313 | Passthru::No => Box::new(io::sink()), 314 | Passthru::Stdout => Box::new(io::stdout()), 315 | Passthru::Stderr => Box::new(io::stderr()), 316 | } 317 | } 318 | 319 | /// Read lines on `rdr` and send the *result* along the channel returned, 320 | /// 321 | /// This will also apply the pass through settings in `self`. 322 | fn gobble(self, rdr: R) -> Receiver> 323 | where 324 | R: Read + Send + 'static, 325 | { 326 | let (s, r) = channel::bounded(0); 327 | thread::spawn(move || { 328 | let mut wtr = self.wtr(); 329 | for line in io::BufReader::new(rdr).lines() { 330 | if let Ok(ref line) = line { 331 | writeln!(&mut wtr, "{}", line).unwrap(); 332 | } 333 | s.send(line).unwrap(); 334 | } 335 | }); 336 | r 337 | } 338 | } 339 | 340 | #[derive(Debug, Default)] 341 | struct Config { 342 | /// If a command to run is given, then we'll get a binary all of its 343 | /// arguments (possibly empty). The `String` is just a human readable 344 | /// (possibly lossily decoded) version of the command. 345 | cmd: Option<(OsString, Vec, String)>, 346 | period: Duration, 347 | silent: bool, 348 | send_all: bool, 349 | to: Option, 350 | subject: Option, 351 | } 352 | 353 | impl Config { 354 | /// Parse the given OS string args into a `Config`. 355 | fn parse(args: I) -> anyhow::Result 356 | where 357 | I: IntoIterator + 'static, 358 | { 359 | use lexopt::{Arg::*, ValueExt}; 360 | 361 | const USAGE: &str = r#" 362 | Usage: cmail [options] [ ...] 363 | 364 | Options: 365 | -h, --help Display this help message. 366 | -p ARG, --period ARG Data is emailed at this period. 367 | Set to `0s` to disable and send only one email 368 | when the command completes. Defaults to 15m. 369 | Example: `2h30m` or `2 hours, 30 minutes`. 370 | -s, --silent Don't pass the command's stdout/stderr to the 371 | terminal. Instead, only send stdout/stderr 372 | in email. 373 | -a, --send-all Send the entire command's output on each email. 374 | N.B. This saves all output in memory. 375 | -t ARG, --to ARG The email address to send to. By default, this 376 | is set to $EMAIL. If neither $EMAIL nor --to 377 | are set, then an error is returned. 378 | --subject ARG Forcefully set the subject of the email. 379 | "#; 380 | 381 | let mut config = Config::default(); 382 | let mut parser = lexopt::Parser::from_iter(args); 383 | let mut cmd_bin_and_args = vec![]; 384 | while let Some(arg) = parser.next()? { 385 | match arg { 386 | Short('h') | Long("help") => { 387 | anyhow::bail!(USAGE); 388 | } 389 | Short('p') | Long("period") => { 390 | let span: jiff::Span = parser.value()?.parse()?; 391 | let sdur = span.to_jiff_duration(&jiff::Zoned::now())?; 392 | config.period = sdur.try_into().with_context(|| { 393 | format!( 394 | "failed to convert `{span:#}` into unsiged \ 395 | duration", 396 | ) 397 | })?; 398 | } 399 | Short('s') | Long("silent") => { 400 | config.silent = true; 401 | } 402 | Short('a') | Long("send-all") => { 403 | config.send_all = true; 404 | } 405 | Short('t') | Long("to") => { 406 | config.to = Some(parser.value()?.parse()?); 407 | } 408 | Long("subject") => { 409 | config.subject = Some(parser.value()?.parse()?); 410 | } 411 | Value(cmd_arg) => { 412 | cmd_bin_and_args.push(cmd_arg); 413 | } 414 | _ => return Err(arg.unexpected().into()), 415 | } 416 | } 417 | if !cmd_bin_and_args.is_empty() { 418 | let bin = cmd_bin_and_args.remove(0); 419 | let mut human_readable = vec![bin.to_string_lossy().into_owned()]; 420 | for arg in cmd_bin_and_args.iter() { 421 | human_readable.push(arg.to_string_lossy().into_owned()); 422 | } 423 | config.cmd = 424 | Some((bin, cmd_bin_and_args, human_readable.join(" "))); 425 | } 426 | Ok(config) 427 | } 428 | } 429 | 430 | /// Given a sequence of receiving channels, multiplex them into one. 431 | /// 432 | /// This spawns a thread for each element in `inps` and sends them all on to 433 | /// a single channel. 434 | /// 435 | /// The resulting channel is closed only when all given channels in `inps` 436 | /// have been closed. 437 | fn muxer(inps: Vec>) -> Receiver { 438 | // If a command sends a lot of output to stdout/stderr in a short time 439 | // period, then setting a large buffer here on the channel gives us a 440 | // little wiggle room to keep up with it. 441 | let (s, r) = channel::bounded(5000); 442 | for inp in inps { 443 | let s = s.clone(); 444 | thread::spawn(move || { 445 | for item in inp { 446 | s.send(item).unwrap(); 447 | } 448 | }); 449 | } 450 | r 451 | } 452 | 453 | /// Sends an email containing `lines` to `email` for the command `cmd`. 454 | /// 455 | /// If the child `sendmail` process was interrupted, then sending the email 456 | /// is retried exactly once. 457 | /// 458 | /// If a retry occurred, then `true` is returned inside the result. Otherwise, 459 | /// `false` is returned. 460 | fn email_lines_retry( 461 | subject: &str, 462 | email: &str, 463 | to_send: &[String], 464 | ) -> io::Result { 465 | // If the first call to email_lines fails because of an 466 | // interruption, then we try to send once more. 467 | // This is to permit the use of ^C in the terminal. The 468 | // intended effect is to stop the running process and email 469 | // whatever has been accumulated. But if `sendmail` is running 470 | // when ^C is sent, then the command fails and no mail is sent. 471 | // So we try once more: if that produces an error, we give up. 472 | match email_lines(subject, email, to_send) { 473 | Ok(()) => Ok(false), 474 | Err(ref e) if e.kind() == io::ErrorKind::Interrupted => { 475 | // If we fail again for any reason, bubble up the 476 | // error and notify the receiver that we should quit. 477 | // This lets the user slam on ^C twice in a row 478 | // to really quit. :] 479 | email_lines(subject, email, to_send).map(|_| true) 480 | } 481 | Err(e) => Err(e), 482 | } 483 | } 484 | 485 | /// Sends an email containing `lines` to `email` for the command `cmd`. 486 | fn email_lines( 487 | subject: &str, 488 | email: &str, 489 | lines: &[String], 490 | ) -> io::Result<()> { 491 | let mut child = 492 | Command::new("sendmail").arg("-t").stdin(Stdio::piped()).spawn()?; 493 | let subject: String = subject.chars().take(50).collect(); 494 | let sep: String = ::std::iter::repeat('-').take(79).collect(); 495 | { 496 | // Open a new scope here since `buf` borrows `child.stdin` mutably. 497 | // We need to drop this borrow before calling `child.wait()`, which 498 | // also borrows `child` mutably. 499 | let mut buf = io::BufWriter::new(child.stdin.as_mut().unwrap()); 500 | writeln!( 501 | &mut buf, 502 | "\ 503 | Subject: [cmail] {subject} 504 | From: {email} 505 | To: {email} 506 | ", 507 | )?; 508 | // Add some extra fluff to make it clear what command is being run. 509 | writeln!(&mut buf, "{sep}\n{subject}\n{sep}")?; 510 | for line in lines { 511 | writeln!(&mut buf, "{line}")?; 512 | } 513 | } 514 | let status = child.wait()?; 515 | if status.success() { 516 | Ok(()) 517 | } else { 518 | // If the exit code is `None`, then we infer that the `sendmail` 519 | // process was killed by a signal. 520 | // In typical usage, this means the user pressed ^C on their terminal, 521 | // rather than it being indicative of some other reason why sendmail 522 | // won't work. 523 | // We use this information to retry the email send (but are careful 524 | // to only retry once). 525 | Err(match status.code() { 526 | None => io::Error::new( 527 | io::ErrorKind::Interrupted, 528 | "email send interrupted", 529 | ), 530 | Some(_) => { 531 | io::Error::new(io::ErrorKind::Other, status.to_string()) 532 | } 533 | }) 534 | } 535 | } 536 | 537 | fn signal_notify(signals: &[c_int]) -> anyhow::Result> { 538 | let (s, r) = channel::bounded(100); 539 | let mut signals = signal_hook::iterator::Signals::new(signals)?; 540 | thread::spawn(move || { 541 | for signal in signals.forever() { 542 | s.send(signal).unwrap(); 543 | } 544 | }); 545 | Ok(r) 546 | } 547 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 79 2 | use_small_heuristics = "max" 3 | -------------------------------------------------------------------------------- /session.vim: -------------------------------------------------------------------------------- 1 | au BufWritePost *.rs silent!make ctags > /dev/null 2>&1 2 | --------------------------------------------------------------------------------