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

@ -358,6 +358,15 @@ std::string NowIso8601() {
std::chrono::system_clock::now()));
}
// RFC 5322 date for the email header, always UTC. Without the L flag
// std::format's %a/%b are locale-independent English — exactly what a mail
// header needs, whatever locale the host booted with.
std::string NowRfc2822() {
return std::format("{:%a, %d %b %Y %H:%M:%S} +0000",
std::chrono::floor<std::chrono::seconds>(
std::chrono::system_clock::now()));
}
// POST /shop/<slug> — create an order.
//
// The sequence is: validate -> compute the amount SERVER-SIDE -> get a payment
@ -667,6 +676,108 @@ void ReconcilerLoop(const std::stop_token& stop) {
}
}
// Build and hand ONE order's confirmation to the mail command. False means
// "not sent" in every failure mode — the caller retries later, and the
// notified event that stops a resend is only written on success.
bool SendConfirmationEmail(const OrderRecord& order) {
// The invoice rides along, so its number must exist. It normally does
// from the paid transition; an order paid before invoicing existed gets
// its number here, exactly as the download route grants one.
OrderRecord o = order;
if (o.invoiceNumber.empty()) {
if (!AssignInvoiceNumber(o.token, NowIso8601())) return false;
const auto reread = FindOrder(o.token);
if (!reread || reread->invoiceNumber.empty()) return false;
o = *reread;
}
std::string productName = o.product;
std::string colorLabel = o.color;
if (const Product* pr = gContent.FindProduct(o.product)) {
productName = pr->name;
if (const Variant* v = pr->FindVariant(o.color)) colorLabel = v->label;
}
// Same signature rule as the download: with a key configured, a signing
// failure means no email now (retry later), never an unsigned invoice.
std::string invoice = BuildInvoiceMarkdown(o, productName, colorLabel);
if (InvoiceSigningConfigured()) {
const auto signedText = ClearsignInvoice(invoice);
if (!signedText) {
std::println(std::cerr, "mail: invoice signing failed for {} — retrying later",
o.reference);
return false;
}
invoice = *signedText;
} else {
invoice = "UNSIGNED — development copy; production invoices are "
"GPG-clearsigned.\n\n" + invoice;
}
const std::string message = BuildOrderConfirmationEmail(
o, productName, colorLabel, MailFrom(),
std::format("{}/order/{}", gRedirectBase, o.token), invoice, NowRfc2822());
if (message.empty()) {
// The address failed the envelope shape check. That cannot heal by
// waiting, but the ledger stays honest: no notified event is written
// for an email that never left, and the backoff caps the log noise.
std::println(std::cerr, "mail: order {} has an unmailable address", o.reference);
return false;
}
if (!SendMailMessage(message)) return false;
AppendOrderNotified(o.token, NowIso8601());
std::println(std::cerr, "order {} confirmation emailed", o.reference);
return true;
}
// The mailer: watches the ledger for paid orders that were never emailed.
//
// A sweep rather than a hook inside PollAndAdvance, deliberately: every path
// to paid — the reconciler, the buyer's arrival poll, the manual CLI even on
// a later restart — funnels into the same ledger, so the ledger is the one
// thing worth watching. It also keeps the SMTP handoff off the request
// thread: the arrival poll renders the buyer's order page, and that page
// must not wait on a mail server. Crash-safety errs toward a duplicate
// email (send, then append the notified event), never a missing one.
void MailerLoop(const std::stop_token& stop) {
struct Attempt {
int failures = 0;
std::chrono::steady_clock::time_point next;
};
std::unordered_map<std::string, Attempt> attempts;
while (!stop.stop_requested()) {
// Seconds after the paid transition, not milliseconds — nobody
// watches their inbox harder than that, and the fold is cheap at
// this volume.
std::this_thread::sleep_for(std::chrono::seconds(2));
if (stop.stop_requested()) break;
for (const OrderRecord& order : ListOrders()) {
if (order.status != "paid" && order.status != "shipped") continue;
if (!order.confirmationSentAt.empty()) {
attempts.erase(order.token);
continue;
}
const auto now = std::chrono::steady_clock::now();
Attempt& att = attempts[order.token];
if (att.failures > 0 && now < att.next) continue;
if (SendConfirmationEmail(order)) {
attempts.erase(order.token);
} else {
// 1, 2, 4 … 64 minutes: a broken mail command must not turn
// the journal into a metronome, but recovery is still found
// within the hour without a restart.
++att.failures;
att.next = now + std::chrono::minutes(
1 << std::min(att.failures - 1, 6));
}
}
}
}
} // namespace
int Serve(std::uint16_t port) {
@ -717,6 +828,14 @@ int Serve(std::uint16_t port) {
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
}
// The mailer only exists when a mail command is configured. Without one
// the order page and the invoice download remain the buyer's receipt —
// degraded, not broken, like every other optional integration here.
std::optional<std::jthread> mailer;
if (MailConfigured()) {
mailer.emplace([](std::stop_token st) { MailerLoop(st); });
}
// Shipping rates: one fetch at startup, then daily. RefreshShippingTable
// is a no-op without Sendcloud credentials, and every failure mode leaves
// the previous table (cached or zone fallback) in charge.

View file

@ -36,14 +36,9 @@ namespace {
std::string gGpgKeyId;
// The registered business identity. On every invoice — these are the fields
// a Dutch invoice must carry along with the sequential number and amounts.
constexpr std::string_view kSellerName = "Catcrafts";
constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
constexpr std::string_view kSellerCity = "3315NN Dordrecht";
constexpr std::string_view kSellerKvk = "78437059";
constexpr std::string_view kSellerVat = "NL003329281B38";
constexpr std::string_view kSellerSite = "catcrafts.net";
// The registered business identity lives in the module interface
// (kSeller...) — the order email's footer states the same facts, and two
// copies of a KVK number is one copy too many.
} // namespace

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

View file

@ -8,10 +8,12 @@ No permission is granted to copy, modify, distribute, or create derivative works
// Order storage: an append-only JSON-lines event log.
//
// Two event types share the file:
// Four event types share the file:
//
// {"type":"order", ...full record...} written once, at checkout
// {"type":"status", "id":..,"status":..} one per transition
// {"type":"order", ...full record...} written once, at checkout
// {"type":"status", "id":..,"status":..} one per transition
// {"type":"invoice", "id":..,"number":..} the number assignment, at paid
// {"type":"notified","id":..,"what":..} the confirmation email left
//
// Current state is a left fold over the file; later events win. Nothing is
// ever rewritten, so the log doubles as the audit trail the tax records need,
@ -126,6 +128,14 @@ std::vector<OrderRecord> FoldLocked() {
if (!r) continue;
r->invoiceNumber = std::string(doc->Str("number"));
r->invoicedAt = std::string(doc->Str("at"));
} else if (type == "notified") {
OrderRecord* r = find(doc->Str("id"));
if (!r) continue;
// "what" names the message so a future shipped-notice can share
// the event type without re-marking the confirmation as sent.
if (doc->Str("what") == "confirmation") {
r->confirmationSentAt = std::string(doc->Str("at"));
}
} else if (type == "status") {
OrderRecord* r = find(doc->Str("id"));
if (!r) continue; // status for an unknown order: skip, keep folding
@ -257,6 +267,13 @@ std::optional<std::string> AssignInvoiceNumber(std::string_view token,
return number;
}
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp) {
std::lock_guard lock(gOrdersMutex);
return AppendLine(std::format(
R"({{"type":"notified","at":"{}","id":"{}","what":"confirmation"}})",
JsonEscape(isoTimestamp), JsonEscape(token)));
}
std::optional<OrderRecord> FindOrder(std::string_view token) {
std::lock_guard lock(gOrdersMutex);
for (OrderRecord& r : FoldLocked()) {

View file

@ -691,6 +691,72 @@ void RunMoneySelfTest() {
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// ── the order confirmation email ──────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
o.vatIncluded = true;
o.buyer.country = "NL";
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
const std::string mail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
"email: From header");
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
"email: subject carries the reference");
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
"email: date header");
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
!= std::string::npos,
"email: message id from the token");
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
"email: item names colour and quantity");
Check(mail.find("€1135.23") != std::string::npos, "email: total");
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
!= std::string::npos,
"email: order page link");
Check(mail.find("filename=\"catcrafts-invoice-"
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
"email: attachment filename is the invoice number");
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
"email: attachment body verbatim");
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
"email: multipart closes");
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "CA";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(exMail.find("zero-rated export") != std::string::npos,
"email: export VAT wording");
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
// A single unit does not advertise a quantity.
o.quantity = 1;
const std::string one = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
// The last line of defence: an address that could smuggle a header
// yields NO message at all, however it got into the record.
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
}
// ── rates loader ──────────────────────────────────────────────────
@ -966,6 +1032,17 @@ int main(int argc, char** argv) {
Server::ConfigureInvoicing(v);
}
// Order email: a sendmail-compatible command ("msmtp -t" on the
// server) that reads the message on stdin and takes the recipient
// from its headers. Unset means no email is sent — the order page
// and invoice download remain the buyer's receipt.
{
Server::MailConfig mailCfg;
if (const char* v = std::getenv("MAIL_COMMAND"); v && *v) mailCfg.command = v;
if (const char* v = std::getenv("MAIL_FROM"); v && *v) mailCfg.from = v;
Server::ConfigureMail(std::move(mailCfg));
}
// Sendcloud is optional: without credentials the compiled-in zone
// table prices all shipping, which is exactly how dev and e2e run.
// With credentials the refresh thread fetches per-country rates.
@ -1042,6 +1119,7 @@ int main(int argc, char** argv) {
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
"\n"
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD");
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
return 0;
}

View file

@ -38,11 +38,12 @@ export namespace Catcrafts::Server {
// ── orders ────────────────────────────────────────────────────────
//
// An append-only JSON-lines EVENT LOG, not a database. Two event types:
// "order" (the full record, written once) and "status" (a transition).
// Current state is a fold over the file — later events win. Nothing is
// ever rewritten in place, so the file is also the audit trail, and a
// crash mid-append costs at most the line being written.
// An append-only JSON-lines EVENT LOG, not a database. Four event types:
// "order" (the full record, written once), "status" (a transition),
// "invoice" (the number assignment) and "notified" (the confirmation
// email left). Current state is a fold over the file — later events win.
// Nothing is ever rewritten in place, so the file is also the audit
// trail, and a crash mid-append costs at most the line being written.
//
// The volume argument: this sells single-digit units per week. When that
// is wrong by two orders of magnitude, the log imports into SQLite in one
@ -69,6 +70,8 @@ export namespace Catcrafts::Server {
std::string paidVia; // method that settled it ("ideal", "creditcard")
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
std::string invoicedAt; // ISO 8601 of the invoice event
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
// event; empty = not (yet) emailed
};
void SetOrdersPath(const std::filesystem::path& path);
@ -95,12 +98,28 @@ export namespace Catcrafts::Server {
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
std::string_view isoTimestamp);
// Appends the notified event: this order's confirmation email was
// accepted by the mail command. Written AFTER the handoff succeeds, so a
// crash between send and append errs toward a duplicate email — an
// apology — never toward a buyer who paid and heard nothing.
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
// ── invoices ──────────────────────────────────────────────────────
//
// A paid order's invoice: plain markdown, clearsigned with the shop's
// GPG key so its authenticity outlives this server. The page invites the
// buyer to download it rather than promising to host receipts forever.
// The registered business identity — on every invoice and at the foot of
// every order email. One definition, like the rest of the compiled-in
// authored content; the selftest pins the values.
inline constexpr std::string_view kSellerName = "Catcrafts";
inline constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
inline constexpr std::string_view kSellerCity = "3315NN Dordrecht";
inline constexpr std::string_view kSellerKvk = "78437059";
inline constexpr std::string_view kSellerVat = "NL003329281B38";
inline constexpr std::string_view kSellerSite = "catcrafts.net";
// Pure and exported for the self-test: everything on a Dutch invoice —
// seller identity (KVK/VAT), sequential number, dates, buyer address,
// per-line amounts, VAT treatment for EU and export.
@ -125,6 +144,44 @@ export namespace Catcrafts::Server {
std::string NewOrderToken();
std::string ReferenceFromToken(std::string_view token);
// ── the order confirmation email ──────────────────────────────────
//
// A paid order gets ONE email: the confirmation, with the clearsigned
// invoice attached. Delivery shells out to a sendmail-compatible command
// (msmtp -t on the server) exactly as signing shells out to gpg — TLS,
// AUTH and deliverability are the parts a hand-rolled SMTP client
// reimplements badly, and this sends a handful of messages per week.
// The command reads the complete RFC 5322 message on stdin and takes the
// recipient from its headers; the buyer's address was validated at
// checkout to be header-safe (Form::LooksLikeEmail rejects CR, LF,
// commas and angle brackets for exactly this moment).
struct MailConfig {
std::string command; // MAIL_COMMAND, e.g. "msmtp -t"; empty = no email
std::string from; // MAIL_FROM header; defaults to the shop inbox
};
void ConfigureMail(MailConfig config);
bool MailConfigured();
// The configured From header — the mailer passes it to the builder.
std::string MailFrom();
// Pure and exported for the self-test: the complete MIME message, a
// plain-text confirmation plus the invoice as a markdown attachment.
// Returns an empty string when the buyer's address fails the envelope
// shape check — the last line of defence sits where the envelope is
// built, not in the history of the record.
std::string BuildOrderConfirmationEmail(const OrderRecord& order,
std::string_view productName,
std::string_view colorLabel,
std::string_view from,
std::string_view orderUrl,
std::string_view invoiceAttachment,
std::string_view dateRfc2822);
// Pipe one message into the configured command. False means "not sent,
// keep it queued": the mailer never writes a notified event on failure.
bool SendMailMessage(const std::string& message);
// ── payments ──────────────────────────────────────────────────────
//
// A rail turns "this order wants €X" into a URL a buyer can pay at, and