diff --git a/deploy/README.md b/deploy/README.md index fb811f0..6dc8135 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. 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 +``` + +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 ```sh @@ -435,10 +501,11 @@ split working as designed. 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-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 -/catcrafts-server --selftest # ~140 in-process assertions +/catcrafts-server --selftest # ~260 in-process assertions /catcrafts-server --routes # status + title for every route /catcrafts-server --render /projects # dump one page's HTML /catcrafts-server --orders FILE # the orders ledger + manual transitions diff --git a/deploy/catcrafts-server.service b/deploy/catcrafts-server.service index c19e5b8..39552ca 100644 --- a/deploy/catcrafts-server.service +++ b/deploy/catcrafts-server.service @@ -79,6 +79,9 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net # MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail # SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional, # 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 # The '-' prefix makes the file optional: without it the server starts with # payments off and the shop renders but refuses checkout — degraded, not down. diff --git a/project.cpp b/project.cpp index 8f37f5d..34ea76c 100644 --- a/project.cpp +++ b/project.cpp @@ -117,7 +117,7 @@ extern "C" Configuration CrafterBuildProject(std::span a std::array ifaces = { "server/interfaces/Catcrafts.Server", }; - std::array impls = { + std::array impls = { "server/implementations/main", "server/implementations/Catcrafts.Server-Http", "server/implementations/Catcrafts.Server-Orders", @@ -125,6 +125,7 @@ extern "C" Configuration CrafterBuildProject(std::span a "server/implementations/Catcrafts.Server-Invoice", "server/implementations/Catcrafts.Server-Bunq", "server/implementations/Catcrafts.Server-Shipping", + "server/implementations/Catcrafts.Server-Mail", }; cfg.GetInterfacesAndImplementations(ifaces, impls); diff --git a/server/implementations/Catcrafts.Server-Http.cpp b/server/implementations/Catcrafts.Server-Http.cpp index d0a7a28..5395aff 100644 --- a/server/implementations/Catcrafts.Server-Http.cpp +++ b/server/implementations/Catcrafts.Server-Http.cpp @@ -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::system_clock::now())); +} + // POST /shop/ — 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 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 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. diff --git a/server/implementations/Catcrafts.Server-Invoice.cpp b/server/implementations/Catcrafts.Server-Invoice.cpp index 8413f49..51c6e19 100644 --- a/server/implementations/Catcrafts.Server-Invoice.cpp +++ b/server/implementations/Catcrafts.Server-Invoice.cpp @@ -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 diff --git a/server/implementations/Catcrafts.Server-Mail.cpp b/server/implementations/Catcrafts.Server-Mail.cpp new file mode 100644 index 0000000..34fdfaf --- /dev/null +++ b/server/implementations/Catcrafts.Server-Mail.cpp @@ -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 +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("{} ", 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 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 diff --git a/server/implementations/Catcrafts.Server-Orders.cpp b/server/implementations/Catcrafts.Server-Orders.cpp index c5de901..768b8fd 100644 --- a/server/implementations/Catcrafts.Server-Orders.cpp +++ b/server/implementations/Catcrafts.Server-Orders.cpp @@ -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 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 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 FindOrder(std::string_view token) { std::lock_guard lock(gOrdersMutex); for (OrderRecord& r : FoldLocked()) { diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp index 2634889..651dc66 100644 --- a/server/implementations/main.cpp +++ b/server/implementations/main.cpp @@ -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 ", + "https://catcrafts.net/order/0123456789abcdef0123456789abcdef", + "SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000"); + Check(mail.find("From: Catcrafts \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 ", + "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 ", + "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; } diff --git a/server/interfaces/Catcrafts.Server.cppm b/server/interfaces/Catcrafts.Server.cppm index fe86e89..e7e72e4 100644 --- a/server/interfaces/Catcrafts.Server.cppm +++ b/server/interfaces/Catcrafts.Server.cppm @@ -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; // "-", 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 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 diff --git a/tools/e2e.sh b/tools/e2e.sh index 0544bba..566c1c3 100755 --- a/tools/e2e.sh +++ b/tools/e2e.sh @@ -56,6 +56,20 @@ gpg --batch --passphrase '' --quick-gen-key 'Catcrafts e2e &2; exit 1; } 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-.eml; the mailer sends sequentially from +# one thread, so the count-up cannot race itself. +cat > "$WORK/sendmail" < "$WORK/mail-\$n.eml" +EOF +chmod +x "$WORK/sendmail" +export MAIL_COMMAND="$WORK/sendmail" +export MAIL_FROM='Catcrafts ' + "$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 & SRV_PID=$! @@ -714,6 +728,71 @@ else bad "customer series" "every invoice got its own customer uuid — series not shared" 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 ' '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 echo "== checkout (coming soon) ==" # A perfectly valid order must be refused while the shop is closed: after