281 lines
7 KiB
D
281 lines
7 KiB
D
/*
|
|
* mal - simple command line mail client
|
|
*
|
|
* Copyright (c) 2024 Jeremy Baxter. All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions
|
|
* are met:
|
|
* 1. Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* 2. Redistributions in binary form must reproduce the above copyright
|
|
* notice, this list of conditions and the following disclaimer in the
|
|
* documentation and/or other materials provided with the distribution.
|
|
* 3. Neither the name of the copyright holder nor the names of its
|
|
* contributors may be used to endorse or promote products derived from
|
|
* this software without specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
* ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE
|
|
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
* POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
module mal;
|
|
|
|
static import std.file;
|
|
|
|
import core.runtime : Runtime;
|
|
|
|
import std.conv : to;
|
|
import std.getopt : getopt, GetOptException;
|
|
import std.path : baseName;
|
|
import std.stdio : stderr, writeln;
|
|
import std.string : fromStringz;
|
|
import std.typecons : No;
|
|
|
|
import std.net.curl : CurlCode, SMTP, ThrowOnError;
|
|
import std.net.isemail : isEmail, EmailStatus;
|
|
|
|
import etc.c.curl : CurlError;
|
|
|
|
string fromAddr; /* [1] */
|
|
string serverAddr; /* -a */
|
|
string fqdn; /* -F */
|
|
string[] mailHeaders; /* -H */
|
|
bool useSmtps; /* -k/-s */
|
|
string messageFile; /* -M */
|
|
string message; /* -m */
|
|
string realName; /* -n */
|
|
string passPhrase; /* -P */
|
|
string serverPort; /* -p */
|
|
string subject; /* -S */
|
|
string[] toAddrs; /* -t */
|
|
string authUser; /* -u */
|
|
bool showVersion; /* -V */
|
|
|
|
int
|
|
main(string[] args)
|
|
{
|
|
SMTP mail;
|
|
EmailStatus fromStatus;
|
|
string messageBuf, protocol;
|
|
CurlCode err;
|
|
|
|
fqdn = hostName() ~ ".local";
|
|
mailHeaders = toAddrs = [];
|
|
subject = "";
|
|
useSmtps = true;
|
|
authUser = message = messageFile = passPhrase =
|
|
realName = serverAddr = serverPort = null;
|
|
|
|
try {
|
|
import std.getopt : config;
|
|
|
|
void
|
|
securityHandler(string opt) @safe
|
|
{
|
|
if (opt == "k") {
|
|
useSmtps = false;
|
|
} else {
|
|
assert(opt == "s");
|
|
useSmtps = true;
|
|
}
|
|
}
|
|
|
|
getopt(args,
|
|
config.bundling,
|
|
config.caseSensitive,
|
|
"a", &serverAddr,
|
|
"F", &fqdn,
|
|
"H", &mailHeaders,
|
|
"k", &securityHandler,
|
|
"M", &messageFile,
|
|
"m", &message,
|
|
"n", &realName,
|
|
"P", &passPhrase,
|
|
"p", &serverPort,
|
|
"S", &subject,
|
|
"s", &securityHandler,
|
|
"t", &toAddrs,
|
|
"u", &authUser,
|
|
"V", &showVersion
|
|
);
|
|
} catch (GetOptException e) {
|
|
import std.algorithm : startsWith;
|
|
|
|
enforceDie(!e.msg.startsWith("Unrecognized option"),
|
|
"unknown option " ~ extractOpt(e));
|
|
enforceDie(!e.msg.startsWith("Missing value for argument"),
|
|
"missing argument for option " ~ extractOpt(e));
|
|
|
|
die(e.msg); /* catch-all */
|
|
}
|
|
|
|
if (showVersion) {
|
|
writeln("mal version 0.0.0");
|
|
return 0;
|
|
}
|
|
|
|
if (args.length != 2) {
|
|
stderr.writeln(
|
|
`usage: mal [-ksV] [-a server] [-F fqdn] [-H header] [-n name]
|
|
[-P passphrase] [-p port] [-S subject] [-t toaddress]
|
|
[-u username] {-M messagefile | -m message} fromaddress`);
|
|
return 1;
|
|
}
|
|
|
|
enforceDie(message || messageFile,
|
|
"a message has to be provided with -M or -m");
|
|
enforceDie(!(message && messageFile),
|
|
"flags -M and -m cannot both be supplied in one invocation");
|
|
|
|
fromAddr = args[1];
|
|
fromStatus = isEmail(fromAddr);
|
|
enforceDie(fromStatus.valid, "invalid email address: %s", fromAddr);
|
|
|
|
if (toAddrs.length == 0) {
|
|
warn("warning: -t not supplied, sending to " ~ fromAddr);
|
|
toAddrs = [fromAddr];
|
|
}
|
|
|
|
foreach (ref string addr; toAddrs) {
|
|
enforceDie(isEmail(addr).valid,
|
|
"invalid email address: %s", addr);
|
|
|
|
addr = "<" ~ addr ~ ">";
|
|
}
|
|
|
|
authUser = authUser ? authUser : fromStatus.localPart;
|
|
protocol = useSmtps ? "smtps" : "smtp";
|
|
serverAddr = serverAddr ? serverAddr : fromStatus.domainPart;
|
|
serverPort = serverPort ? serverPort : useSmtps ? "465" : "587";
|
|
|
|
if (!passPhrase) {
|
|
warn("warning: -P not supplied, using empty passphrase");
|
|
passPhrase = "";
|
|
}
|
|
|
|
try {
|
|
messageBuf =
|
|
messageFile ? std.file.read(messageFile).to!string()
|
|
: message;
|
|
} catch (std.file.FileException e) {
|
|
die(e.msg);
|
|
}
|
|
|
|
mail = SMTP(protocol ~ "://" ~
|
|
serverAddr ~
|
|
":" ~ serverPort ~
|
|
"/" ~ fqdn);
|
|
mail.mailFrom = "<" ~ fromAddr ~ ">";
|
|
mail.mailTo = toAddrs.to!(const(char)[][]);
|
|
mail.message = formHeaders() ~ messageBuf;
|
|
mail.setAuthentication(authUser, passPhrase);
|
|
err = mail.perform(No.throwOnError);
|
|
|
|
if (err != CurlError.ok) {
|
|
import etc.c.curl : curl_easy_strerror;
|
|
|
|
/* known common errors */
|
|
switch (err) {
|
|
case CurlError.send_error:
|
|
warn("Failed sending data to " ~ serverAddr);
|
|
writeln("If the server uses STARTTLS, please try to connect");
|
|
writeln("using regular SSL (-s) or using plain insecure SMTP");
|
|
writeln("(-k). If none of those work, check the SMTP/SMTPS");
|
|
writeln("port for your server and provide it with -p.");
|
|
return 1;
|
|
default:
|
|
die(curl_easy_strerror(err).fromStringz().idup());
|
|
break;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private string
|
|
formHeaders() @safe
|
|
{
|
|
string headerBuf;
|
|
|
|
headerBuf ~= "Content-Type: text/plain;charset=UTF-8\n";
|
|
headerBuf ~= "Subject: " ~ subject ~ "\n";
|
|
headerBuf ~= "From: " ~
|
|
(realName ? `"` ~ realName ~ `" <` : "<") ~
|
|
fromAddr ~ ">\n";
|
|
|
|
headerBuf ~= "To: ";
|
|
foreach (string addr; toAddrs) {
|
|
headerBuf ~= addr ~ ", ";
|
|
}
|
|
headerBuf.length -= 2;
|
|
headerBuf ~= "\n";
|
|
|
|
foreach (string hdr; mailHeaders) {
|
|
headerBuf ~= hdr ~ "\n";
|
|
}
|
|
|
|
return headerBuf ~ "\n";
|
|
}
|
|
|
|
private string
|
|
hostName()
|
|
{
|
|
import core.stdc.stdlib : free, malloc;
|
|
import core.sys.posix.unistd : gethostname;
|
|
|
|
/* according to POSIX */
|
|
enum HOST_NAME_MAX = 256;
|
|
|
|
char *hp;
|
|
string name;
|
|
|
|
hp = cast(char *)malloc(HOST_NAME_MAX * char.sizeof);
|
|
gethostname(hp, HOST_NAME_MAX);
|
|
name = hp.fromStringz().idup();
|
|
free(hp);
|
|
|
|
return name;
|
|
}
|
|
|
|
private void
|
|
warn(string mesg)
|
|
{
|
|
stderr.writeln(baseName(Runtime.args[0]) ~ ": " ~ mesg);
|
|
}
|
|
|
|
private void
|
|
die(string mesg)
|
|
{
|
|
import core.stdc.stdlib : exit;
|
|
|
|
warn(mesg);
|
|
Runtime.terminate();
|
|
exit(1);
|
|
}
|
|
|
|
private void
|
|
enforceDie(A...)(bool cond, string fmt, A a)
|
|
{
|
|
import std.format : format;
|
|
|
|
if (!cond)
|
|
die(format(fmt, a));
|
|
}
|
|
|
|
private string
|
|
extractOpt(in GetOptException e) @safe
|
|
{
|
|
import std.regex : matchFirst;
|
|
|
|
return e.msg.matchFirst("-.")[0];
|
|
}
|