catcrafts.net/server/implementations/Catcrafts.Server-Mail.cpp
Jorijn van der Graaf 2fa6e70af1
All checks were successful
Deploy / build-deploy (push) Successful in 6m14s
order confirmation
2026-08-09 00:14:09 +02:00

183 lines
7.5 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The order confirmation email: one per paid order, invoice attached.
//
// Plain text, because that is what the shop's documents are — the invoice is
// markdown for the same reason. No HTML part, no tracking pixel, no remote
// images; the privacy notice promises none of that and an email is the
// easiest place to break the promise silently.
//
// Delivery shells out to a sendmail-compatible command (MAIL_COMMAND, msmtp
// -t in production) rather than speaking SMTP itself, for the same reason
// invoices shell out to gpg: TLS, AUTH, submission quirks and deliverability
// are exactly the parts a from-scratch client gets subtly wrong, and the
// volume is a handful of messages per week. The command's exit code is the
// contract — zero means the message was accepted, and only then does the
// mailer write the ledger event that stops a resend.
//
// The recipient travels inside the message (To: header, hence -t). That
// header is built HERE, which makes this file the last line of defence
// against header injection — the address is re-checked against the same
// shape rule the checkout enforced, not trusted from history.
module;
#include <cstdlib>
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
namespace Catcrafts::Server {
namespace {
MailConfig gMail;
} // namespace
void ConfigureMail(MailConfig config) {
// The From value becomes a mail header; whatever else the operator puts
// there, a line break must not ride along.
for (const char c : config.from) {
if (c == '\r' || c == '\n') {
std::println(std::cerr, "mail: refusing MAIL_FROM with a line break");
return;
}
}
if (config.from.empty()) {
config.from = std::format("{} <info@{}>", kSellerName, kSellerSite);
}
gMail = std::move(config);
if (!gMail.command.empty()) {
std::println(std::cerr, "mail: order confirmations via '{}'", gMail.command);
}
}
bool MailConfigured() { return !gMail.command.empty(); }
std::string MailFrom() { return gMail.from; }
std::string BuildOrderConfirmationEmail(const OrderRecord& o,
std::string_view productName,
std::string_view colorLabel,
std::string_view from,
std::string_view orderUrl,
std::string_view invoiceAttachment,
std::string_view dateRfc2822) {
// The address goes into the To: header and -t makes that header the
// envelope. It was validated at checkout, but THIS is where a smuggled
// CR/LF would become an extra recipient, so the check repeats here.
if (!Form::LooksLikeEmail(o.buyer.email)) return {};
const std::string item = colorLabel.empty()
? std::string(productName)
: std::format("{} — {}", productName, colorLabel);
// The boundary must never occur in the content. 32 CSPRNG hex chars from
// the order token cannot collide with markdown or an armored signature —
// and being deterministic keeps the builder pure for the self-test.
const std::string boundary = std::format("=_cc_{}", o.token);
// Headers stay pure ASCII so no RFC 2047 encoding is ever needed; the
// bodies declare UTF-8 with 8bit transfer encoding, which every
// submission server this century accepts (msmtp negotiates 8BITMIME).
std::string m;
m.reserve(2048 + invoiceAttachment.size());
m += std::format("From: {}\n", from);
m += std::format("To: {}\n", o.buyer.email);
m += std::format("Subject: Catcrafts order {} confirmed\n", o.reference);
m += std::format("Date: {}\n", dateRfc2822);
m += std::format("Message-ID: <{}@{}>\n", o.token, kSellerSite);
m += "MIME-Version: 1.0\n";
m += std::format("Content-Type: multipart/mixed; boundary=\"{}\"\n\n", boundary);
m += std::format("--{}\n", boundary);
m += "Content-Type: text/plain; charset=utf-8\n";
m += "Content-Transfer-Encoding: 8bit\n\n";
m += std::format("Payment received — order {} is confirmed.\n\n", o.reference);
m += o.quantity > 1
? std::format("* Item: {} × {}\n", item, o.quantity)
: std::format("* Item: {}\n", item);
if (o.vatIncluded) {
m += std::format("* Total paid: {} (incl. 21% NL VAT)\n",
Money::FormatEuro(o.totalMinor));
} else {
m += std::format("* Total paid: {} (VAT 0%, zero-rated export — any "
"import charges are levied by the destination "
"country, not part of this order)\n",
Money::FormatEuro(o.totalMinor));
}
if (!o.paidVia.empty()) {
m += std::format("* Paid via: {}\n", o.paidVia);
}
m += std::format("* Invoice number: {}\n\n", o.invoiceNumber);
m += "The invoice is attached: plain markdown, clearsigned with the "
"shop's GPG key, so it stays readable and verifiable anywhere "
"(gpg --verify) without this shop's help. Keep the file with your "
"records — receipts are not hosted forever.\n\n";
m += "Devices are sourced, flashed and tested to order — allow up to a "
"week between payment and dispatch. The order page tracks it:\n\n";
m += std::format(" {}\n\n", orderUrl);
m += "That link is the key to the order: anyone holding it can read the "
"status page, so treat it like the receipt it is.\n\n";
m += "Questions? Reply to this email.\n\n";
m += std::format("{} · {} · {}\n", kSellerName, kSellerStreet, kSellerCity);
m += std::format("KVK {} · VAT {} · https://{}\n", kSellerKvk, kSellerVat,
kSellerSite);
m += std::format("\n--{}\n", boundary);
m += "Content-Type: text/markdown; charset=utf-8\n";
m += std::format("Content-Disposition: attachment; "
"filename=\"catcrafts-invoice-{}.md\"\n", o.invoiceNumber);
m += "Content-Transfer-Encoding: 8bit\n\n";
m += invoiceAttachment;
if (!invoiceAttachment.ends_with('\n')) m += '\n';
m += std::format("--{}--\n", boundary);
return m;
}
bool SendMailMessage(const std::string& message) {
if (gMail.command.empty()) return false;
std::error_code ec;
const std::filesystem::path dir =
std::filesystem::temp_directory_path(ec) / "catcrafts-mail";
if (ec) return false;
std::filesystem::create_directories(dir, ec);
std::filesystem::permissions(dir, std::filesystem::perms::owner_all, ec);
// Distinct per call, like the invoice signing temp files.
static std::atomic<std::uint64_t> counter{1};
const std::filesystem::path in =
dir / std::format("msg-{}.eml", counter.fetch_add(1));
{
std::ofstream f(in, std::ios::trunc | std::ios::binary);
if (!f) return false;
f << message;
if (!f.flush()) return false;
}
// The command is the OPERATOR's, from the environment — never request
// data. The only thing composed onto the line is our own temp file path.
const std::string cmd = std::format("{} < '{}'", gMail.command, in.string());
const int rc = std::system(cmd.c_str());
std::filesystem::remove(in, ec);
if (rc != 0) {
std::println(std::cerr, "mail: '{}' failed (rc {})", gMail.command, rc);
return false;
}
return true;
}
} // namespace Catcrafts::Server