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

@ -315,6 +315,72 @@ unattended; the keyring lives in the 0700 StateDirectory. With a key configured,
a signing failure is a 500 — an unsigned invoice is never served by accident. a signing failure is a 500 — an unsigned invoice is never served by accident.
Without one (dev), invoices carry a visible UNSIGNED marker. Without one (dev), invoices carry a visible UNSIGNED marker.
## Order email (confirmation + invoice)
A paid order gets one confirmation email with the clearsigned invoice
attached — plain text plus a markdown attachment, no HTML part, no remote
resources, nothing the privacy notice would have to explain. The mailer
watches the ledger, so every path to paid (reconciler, arrival poll, a manual
`--mark-paid` even on a later restart) results in exactly one email: the
`notified` event, appended only after the mail command accepts the message,
is what stops a resend.
Delivery shells out to a **sendmail-compatible command** rather than speaking
SMTP itself, for the same reason invoices shell out to gpg: TLS, AUTH and
deliverability are exactly what msmtp already does well, and the volume is a
handful of messages per week. Deliverability stays the mailbox provider's
problem (SPF/DKIM are theirs), and no third party beyond the provider that
already handles `info@catcrafts.net` ever sees order data — which is what the
privacy page implies.
```sh
apt install msmtp
cat > /etc/msmtprc <<'CONF'
defaults
auth on
tls on
tls_starttls on
account catcrafts
host smtp.your-mail-provider.example
port 587
from info@catcrafts.net
user info@catcrafts.net
passwordeval cat /etc/catcrafts/smtp-password
account default : catcrafts
CONF
chmod 0644 /etc/msmtprc
# msmtp runs as the service user, so the password file must be readable by
# it — unlike payments.env, which only root (systemd) reads.
install -o catcrafts -g catcrafts -m 0600 /dev/null /etc/catcrafts/smtp-password
# ...then put the SMTP password in that file.
```
Then in `/etc/catcrafts/payments.env`:
```
MAIL_COMMAND=msmtp -t
MAIL_FROM=Catcrafts <info@catcrafts.net>
```
and `systemctl restart catcrafts-server` — the journal should say
`mail: order confirmations via 'msmtp -t'`. Unset, no email is sent and the
order page plus the invoice download remain the buyer's receipt: degraded,
not down, like every optional integration here.
Worth knowing:
* A failed handoff retries with exponential backoff (1 min doubling to a cap
of ~an hour), forever — a broken relay delays the email, it never eats it.
Watch `journalctl -u catcrafts-server | grep 'mail:'` after changing config.
* With a signing key configured, a gpg failure means the email WAITS — an
unsigned invoice never leaves by accident, same rule as the download.
* Send a real test: `--rail=fake` locally with `MAIL_COMMAND` pointing at
msmtp and your own address in the order form, or just run `tools/e2e.sh`,
which captures the messages with a fake sendmail and verifies the attached
signature.
## Reading the orders ledger ## Reading the orders ledger
```sh ```sh
@ -435,10 +501,11 @@ split working as designed.
tools/fetch-posts.sh # pull posts from the allowed communities tools/fetch-posts.sh # pull posts from the allowed communities
tools/fetch-media.sh [dir] # mirror their media locally (run after the above) tools/fetch-media.sh [dir] # mirror their media locally (run after the above)
tools/fetch-rates.sh # ECB reference rates for the indicative prices tools/fetch-rates.sh # ECB reference rates for the indicative prices
tools/e2e.sh # 143 HTTP checks against a real server; the CI gate tools/e2e.sh # ~200 HTTP checks against a real server (135 while
# coming-soon; the rest re-arm at launch); the CI gate
crafter-build --local -r # the wasm app alone, no backend, on :8080 crafter-build --local -r # the wasm app alone, no backend, on :8080
<server>/catcrafts-server --selftest # ~140 in-process assertions <server>/catcrafts-server --selftest # ~260 in-process assertions
<server>/catcrafts-server --routes # status + title for every route <server>/catcrafts-server --routes # status + title for every route
<server>/catcrafts-server --render /projects # dump one page's HTML <server>/catcrafts-server --render /projects # dump one page's HTML
<server>/catcrafts-server --orders FILE # the orders ledger + manual transitions <server>/catcrafts-server --orders FILE # the orders ledger + manual transitions

View file

@ -79,6 +79,9 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail # MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional, # SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional,
# live shipping rates; zone table without them # live shipping rates; zone table without them
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)
# MAIL_COMMAND=msmtp -t order confirmation email (see deploy/README.md,
# MAIL_FROM=... "Order email"); unset = no email is sent
# BUNQ_API_KEY=... legacy: only used when no Mollie key is set # BUNQ_API_KEY=... legacy: only used when no Mollie key is set
# The '-' prefix makes the file optional: without it the server starts with # The '-' prefix makes the file optional: without it the server starts with
# payments off and the shop renders but refuses checkout — degraded, not down. # payments off and the shop renders but refuses checkout — degraded, not down.

View file

@ -117,7 +117,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
std::array<fs::path, 1> ifaces = { std::array<fs::path, 1> ifaces = {
"server/interfaces/Catcrafts.Server", "server/interfaces/Catcrafts.Server",
}; };
std::array<fs::path, 7> impls = { std::array<fs::path, 8> impls = {
"server/implementations/main", "server/implementations/main",
"server/implementations/Catcrafts.Server-Http", "server/implementations/Catcrafts.Server-Http",
"server/implementations/Catcrafts.Server-Orders", "server/implementations/Catcrafts.Server-Orders",
@ -125,6 +125,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"server/implementations/Catcrafts.Server-Invoice", "server/implementations/Catcrafts.Server-Invoice",
"server/implementations/Catcrafts.Server-Bunq", "server/implementations/Catcrafts.Server-Bunq",
"server/implementations/Catcrafts.Server-Shipping", "server/implementations/Catcrafts.Server-Shipping",
"server/implementations/Catcrafts.Server-Mail",
}; };
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);

View file

@ -358,6 +358,15 @@ std::string NowIso8601() {
std::chrono::system_clock::now())); 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. // POST /shop/<slug> — create an order.
// //
// The sequence is: validate -> compute the amount SERVER-SIDE -> get a payment // 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 } // namespace
int Serve(std::uint16_t port) { int Serve(std::uint16_t port) {
@ -717,6 +828,14 @@ int Serve(std::uint16_t port) {
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); }); 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 // Shipping rates: one fetch at startup, then daily. RefreshShippingTable
// is a no-op without Sendcloud credentials, and every failure mode leaves // is a no-op without Sendcloud credentials, and every failure mode leaves
// the previous table (cached or zone fallback) in charge. // the previous table (cached or zone fallback) in charge.

View file

@ -36,14 +36,9 @@ namespace {
std::string gGpgKeyId; std::string gGpgKeyId;
// The registered business identity. On every invoice — these are the fields // The registered business identity lives in the module interface
// a Dutch invoice must carry along with the sequential number and amounts. // (kSeller...) — the order email's footer states the same facts, and two
constexpr std::string_view kSellerName = "Catcrafts"; // copies of a KVK number is one copy too many.
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";
} // namespace } // 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. // 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":"order", ...full record...} written once, at checkout
// {"type":"status", "id":..,"status":..} one per transition // {"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 // 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, // 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; if (!r) continue;
r->invoiceNumber = std::string(doc->Str("number")); r->invoiceNumber = std::string(doc->Str("number"));
r->invoicedAt = std::string(doc->Str("at")); 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") { } else if (type == "status") {
OrderRecord* r = find(doc->Str("id")); OrderRecord* r = find(doc->Str("id"));
if (!r) continue; // status for an unknown order: skip, keep folding 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; 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::optional<OrderRecord> FindOrder(std::string_view token) {
std::lock_guard lock(gOrdersMutex); std::lock_guard lock(gOrdersMutex);
for (OrderRecord& r : FoldLocked()) { 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("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis"); Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total"); 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 ────────────────────────────────────────────────── // ── rates loader ──────────────────────────────────────────────────
@ -966,6 +1032,17 @@ int main(int argc, char** argv) {
Server::ConfigureInvoicing(v); 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 // Sendcloud is optional: without credentials the compiled-in zone
// table prices all shipping, which is exactly how dev and e2e run. // table prices all shipping, which is exactly how dev and e2e run.
// With credentials the refresh thread fetches per-country rates. // 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" " --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
"\n" "\n"
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\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; return 0;
} }

View file

@ -38,11 +38,12 @@ export namespace Catcrafts::Server {
// ── orders ──────────────────────────────────────────────────────── // ── orders ────────────────────────────────────────────────────────
// //
// An append-only JSON-lines EVENT LOG, not a database. Two event types: // An append-only JSON-lines EVENT LOG, not a database. Four event types:
// "order" (the full record, written once) and "status" (a transition). // "order" (the full record, written once), "status" (a transition),
// Current state is a fold over the file — later events win. Nothing is // "invoice" (the number assignment) and "notified" (the confirmation
// ever rewritten in place, so the file is also the audit trail, and a // email left). Current state is a fold over the file — later events win.
// crash mid-append costs at most the line being written. // 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 // 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 // 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 paidVia; // method that settled it ("ideal", "creditcard")
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
std::string invoicedAt; // ISO 8601 of the invoice event 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); 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::optional<std::string> AssignInvoiceNumber(std::string_view token,
std::string_view isoTimestamp); 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 ────────────────────────────────────────────────────── // ── invoices ──────────────────────────────────────────────────────
// //
// A paid order's invoice: plain markdown, clearsigned with the shop's // A paid order's invoice: plain markdown, clearsigned with the shop's
// GPG key so its authenticity outlives this server. The page invites the // GPG key so its authenticity outlives this server. The page invites the
// buyer to download it rather than promising to host receipts forever. // 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 — // Pure and exported for the self-test: everything on a Dutch invoice —
// seller identity (KVK/VAT), sequential number, dates, buyer address, // seller identity (KVK/VAT), sequential number, dates, buyer address,
// per-line amounts, VAT treatment for EU and export. // per-line amounts, VAT treatment for EU and export.
@ -125,6 +144,44 @@ export namespace Catcrafts::Server {
std::string NewOrderToken(); std::string NewOrderToken();
std::string ReferenceFromToken(std::string_view token); 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 ────────────────────────────────────────────────────── // ── payments ──────────────────────────────────────────────────────
// //
// A rail turns "this order wants €X" into a URL a buyer can pay at, and // A rail turns "this order wants €X" into a URL a buyer can pay at, and

View file

@ -56,6 +56,20 @@ gpg --batch --passphrase '' --quick-gen-key 'Catcrafts e2e <invoices@e2e.invalid
|| { echo "e2e: could not create a GPG key (is gnupg installed?)" >&2; exit 1; } || { echo "e2e: could not create a GPG key (is gnupg installed?)" >&2; exit 1; }
export INVOICE_GPG_KEY='invoices@e2e.invalid' export INVOICE_GPG_KEY='invoices@e2e.invalid'
# A fake sendmail, so the mailer's REAL path — build the MIME message, attach
# the signed invoice, shell out — runs with zero network. Each accepted
# message lands as its own mail-<n>.eml; the mailer sends sequentially from
# one thread, so the count-up cannot race itself.
cat > "$WORK/sendmail" <<EOF
#!/bin/sh
n=1
while [ -e "$WORK/mail-\$n.eml" ]; do n=\$((n + 1)); done
cat > "$WORK/mail-\$n.eml"
EOF
chmod +x "$WORK/sendmail"
export MAIL_COMMAND="$WORK/sendmail"
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 & "$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 &
SRV_PID=$! SRV_PID=$!
@ -714,6 +728,71 @@ else
bad "customer series" "every invoice got its own customer uuid — series not shared" bad "customer series" "every invoice got its own customer uuid — series not shared"
fi fi
echo "== the confirmation email =="
# Every paid order gets exactly one confirmation with the signed invoice
# attached. Four orders were paid above; the mailer sweeps the ledger every
# 2 s, so all four messages should exist within a few sweeps.
i=0
until [ "$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)" -ge 4 ]; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then break; fi
sleep 0.25
done
n_mail=$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)
if [ "$n_mail" -eq 4 ]; then
ok "one confirmation email per paid order ($n_mail sent)"
else
bad "confirmation email count" "expected 4, got $n_mail"
fi
# The NL order's message, found by its own order link (the same email address
# placed two orders, so the address alone would be ambiguous).
MAIL=$(grep -l "/order/$TOKEN" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
if [ -n "$MAIL" ]; then
for probe in 'To: e2e@example.org' 'Subject: Catcrafts order CC-' \
'From: Catcrafts <info@catcrafts.net>' 'MIME-Version: 1.0' \
'€578.30' 'incl. 21% NL VAT' 'KVK 78437059' \
'BEGIN PGP SIGNED MESSAGE' 'filename="catcrafts-invoice-'; do
if grep -qF -- "$probe" "$MAIL"; then
ok "email has $probe"
else
bad "email content" "missing: $probe"
fi
done
# The attached invoice must verify offline exactly like the download.
sed -n '/BEGIN PGP SIGNED MESSAGE/,/END PGP SIGNATURE/p' "$MAIL" > "$WORK/mail-invoice.asc"
if gpg --verify "$WORK/mail-invoice.asc" >/dev/null 2>&1; then
ok "emailed invoice signature verifies with gpg"
else
bad "emailed invoice signature" "gpg --verify failed"
fi
else
bad "confirmation email" "no message links /order/$TOKEN"
fi
# The export order's message states the VAT treatment its invoice carries.
MAIL_CA=$(grep -l "/order/$TOKEN_CA" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
if [ -n "$MAIL_CA" ] && grep -qF 'zero-rated export' "$MAIL_CA"; then
ok "export confirmation states the zero-rated treatment"
else
bad "export confirmation" "no message for the CA order, or no VAT note in it"
fi
# Idempotency comes from the ledger's notified event, not from luck in
# timing — sit out two more sweeps and expect no fifth message.
sleep 5
n_after=$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)
if [ "$n_after" = "$n_mail" ]; then
ok "no order was emailed twice"
else
bad "email idempotency" "message count grew from $n_mail to $n_after"
fi
if grep -q '"type":"notified"' "$ORDERS"; then
ok "notified events recorded in the ledger"
else
bad "notified event" "no notified event in $ORDERS"
fi
else else
echo "== checkout (coming soon) ==" echo "== checkout (coming soon) =="
# A perfectly valid order must be refused while the shop is closed: after # A perfectly valid order must be refused while the shop is closed: after