commit 06df44d51eb0f82c07c6f6df731d8e9e7b7b7bec Author: Jeremy Baxter Date: Fri Feb 9 10:26:06 2024 +1300 add minimal working program to build, run make diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b84426c --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +PREFIX = /usr/local + +DC = ldc2 +CFLAGS = -Oz +#CFLAGS = -O0 -g -d-debug -wi +LDFLAGS = -L-lcurl + +OBJS = mal.o + +all: mal + +mal: ${OBJS} + ${DC} ${LDFLAGS} -of=$@ ${OBJS} + +mal.o: mal.d + ${DC} ${CFLAGS} -c mal.d + +clean: + rm -f mal ${OBJS} + +install: + mkdir -p ${DESTDIR}${PREFIX}/bin + chmod +x mal + cp -f mal ${DESTDIR}${PREFIX}/bin/mal + +.PHONY: all clean install diff --git a/mal.d b/mal.d new file mode 100644 index 0000000..3ffea07 --- /dev/null +++ b/mal.d @@ -0,0 +1,281 @@ +/* + * 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, + "email messsage has to be provided through -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]; +}