order confirmation
All checks were successful
Deploy / build-deploy (push) Successful in 6m14s

This commit is contained in:
Jorijn van der Graaf 2026-08-09 00:14:09 +02:00
commit 2fa6e70af1
10 changed files with 619 additions and 20 deletions

View file

@ -0,0 +1,183 @@
/*
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