/* 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