2026-08-15 00:54:05 +02:00
|
|
|
|
/*
|
|
|
|
|
|
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 invoice builder and the order confirmation email — the two documents a
|
|
|
|
|
|
// paying customer actually receives, including the VAT treatment on each and
|
|
|
|
|
|
// the header-injection guard on the address that goes into the envelope.
|
|
|
|
|
|
|
|
|
|
|
|
import std;
|
|
|
|
|
|
import Catcrafts.Shared;
|
|
|
|
|
|
import Catcrafts.Server;
|
|
|
|
|
|
|
|
|
|
|
|
using namespace Catcrafts;
|
|
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
int failures = 0;
|
|
|
|
|
|
|
|
|
|
|
|
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
|
|
|
|
|
if (ok) return;
|
|
|
|
|
|
++failures;
|
|
|
|
|
|
std::println(std::cerr, "FAIL: {}{}{}", what,
|
|
|
|
|
|
got.empty() ? "" : " got: ", got);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// The euro amount on the table row starting with `prefix`, in minor units, or
|
|
|
|
|
|
// -1 when the row is missing. Reads the rendered document, not the builder's
|
|
|
|
|
|
// internals: FormatEuro prints "€938.21", or "€580" when the cents are zero.
|
|
|
|
|
|
std::int64_t RowMinor(const std::string& md, std::string_view prefix) {
|
|
|
|
|
|
const std::size_t at = md.find(prefix);
|
|
|
|
|
|
if (at == std::string::npos) return -1;
|
|
|
|
|
|
std::size_t i = at + prefix.size();
|
|
|
|
|
|
std::int64_t euros = 0;
|
|
|
|
|
|
bool any = false;
|
|
|
|
|
|
for (; i < md.size() && md[i] >= '0' && md[i] <= '9'; ++i) {
|
|
|
|
|
|
euros = euros * 10 + (md[i] - '0');
|
|
|
|
|
|
any = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!any) return -1;
|
|
|
|
|
|
std::int64_t cents = 0;
|
|
|
|
|
|
if (i + 2 < md.size() && md[i] == '.') {
|
|
|
|
|
|
cents = (md[i + 1] - '0') * 10 + (md[i + 2] - '0');
|
|
|
|
|
|
}
|
|
|
|
|
|
return euros * 100 + cents;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
|
Server::OrderRecord o;
|
|
|
|
|
|
o.token = "0123456789abcdef0123456789abcdef";
|
|
|
|
|
|
o.reference = "CC-TEST01";
|
|
|
|
|
|
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
|
|
|
|
|
|
o.invoicedAt = "2026-08-05T10:00:00Z";
|
|
|
|
|
|
o.createdAt = "2026-08-05T09:55:00Z";
|
|
|
|
|
|
o.paidVia = "ideal";
|
|
|
|
|
|
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
|
|
|
|
|
|
"Delft", "NL" };
|
|
|
|
|
|
o.quantity = 2;
|
|
|
|
|
|
o.unitMinor = 56330;
|
|
|
|
|
|
o.goodsMinor = 112660;
|
|
|
|
|
|
o.shippingMinor = 863;
|
|
|
|
|
|
o.totalMinor = 113523;
|
|
|
|
|
|
o.vatIncluded = true;
|
|
|
|
|
|
|
|
|
|
|
|
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
|
|
|
|
|
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
|
|
|
|
|
|
"invoice: number heading");
|
|
|
|
|
|
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
|
|
|
|
|
|
"invoice: customer series shown separately");
|
|
|
|
|
|
Check(eu.find("* Invoice number: 7") != std::string::npos,
|
|
|
|
|
|
"invoice: sequence within the series");
|
|
|
|
|
|
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
|
|
|
|
|
|
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
|
|
|
|
|
|
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
|
|
|
|
|
|
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
|
|
|
|
|
|
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
|
|
|
|
|
|
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
|
|
|
|
|
|
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
|
|
|
|
|
|
"invoice: item names the colour");
|
|
|
|
|
|
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
|
|
|
|
|
|
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
|
|
|
|
|
|
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// Every cent of the amounts table, pinned. This is the document a Dutch
|
|
|
|
|
|
// buyer, an accountant and the Belastingdienst read, so a rounding change
|
|
|
|
|
|
// in Money::NetFromGross must break a test rather than ship a wrong VAT
|
|
|
|
|
|
// figure. Derived by hand from net = (gross*10000 + 6050) / 12100:
|
|
|
|
|
|
// goods 112660 -> (1'126'600'000 + 6050) / 12100 = 93107 -> €931.07
|
|
|
|
|
|
// total 113523 -> (1'135'230'000 + 6050) / 12100 = 93821 -> €938.21
|
|
|
|
|
|
// VAT = 113523 - 93821 = 19702 -> €197.02
|
|
|
|
|
|
// shipping = 93821 - 93107 = 714 -> €7.14
|
|
|
|
|
|
// The shipping line is the REMAINDER of the subtotal, not a rounding of
|
|
|
|
|
|
// its own — that is what makes the columns add up. Rounded independently
|
|
|
|
|
|
// it would print €7.13 ((8'630'000 + 6050) / 12100 = 713) and sit a cent
|
|
|
|
|
|
// below the subtotal, which is why the remainder rule exists: shipping
|
|
|
|
|
|
// absorbs the cent so a signed tax document cannot disagree with itself.
|
|
|
|
|
|
Check(eu.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: EU item line is net, not the gross the buyer paid");
|
|
|
|
|
|
Check(eu.find("| Shipping | 1 | €7.14 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: EU shipping line is the subtotal remainder");
|
|
|
|
|
|
Check(eu.find("| Subtotal (ex VAT) | | €938.21 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: EU subtotal is the net of the gross total");
|
|
|
|
|
|
Check(eu.find("| VAT 21% (NL) | | €197.02 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: EU VAT line is the amount actually remitted");
|
|
|
|
|
|
Check(eu.find("| **Total (incl. VAT)** | | **€1135.23** |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: EU gross total is what was charged");
|
|
|
|
|
|
|
|
|
|
|
|
// The property the pinned cents above are one instance of, swept across
|
|
|
|
|
|
// the realistic price grid: the three retail prices × every quantity a
|
|
|
|
|
|
// parcel can carry × the range a shipping rate lives in. Before the
|
|
|
|
|
|
// remainder rule, roughly a quarter of these combinations printed lines
|
|
|
|
|
|
// one cent apart from their own subtotal (three independent half-up
|
|
|
|
|
|
// roundings; two errors uniform on [-½,½) cross a boundary with
|
|
|
|
|
|
// probability ¼). Rendered and re-parsed rather than recomputed, so what
|
|
|
|
|
|
// is being held is the document itself:
|
|
|
|
|
|
// item + shipping == subtotal (the remainder rule, by construction)
|
|
|
|
|
|
// subtotal + VAT == total (what the buyer paid, to the cent)
|
|
|
|
|
|
// |shipping - NetFromGross(shipping gross)| <= 1 (the cent stops here)
|
|
|
|
|
|
{
|
|
|
|
|
|
Server::OrderRecord s = o;
|
|
|
|
|
|
std::string broke;
|
|
|
|
|
|
for (const std::int64_t unit : { 57380, 57980, 66538 }) {
|
|
|
|
|
|
for (std::int64_t qty = 1; qty <= 28; ++qty) {
|
|
|
|
|
|
for (std::int64_t ship = 400; ship <= 6000; ship += 97) {
|
|
|
|
|
|
s.quantity = qty;
|
|
|
|
|
|
s.unitMinor = unit;
|
|
|
|
|
|
s.goodsMinor = unit * qty;
|
|
|
|
|
|
s.shippingMinor = ship;
|
|
|
|
|
|
s.totalMinor = s.goodsMinor + ship;
|
|
|
|
|
|
const std::string md = Server::BuildInvoiceMarkdown(s, "P", "");
|
|
|
|
|
|
const std::int64_t item =
|
|
|
|
|
|
RowMinor(md, std::format("| P | {} | €", qty));
|
|
|
|
|
|
const std::int64_t shipping = RowMinor(md, "| Shipping | 1 | €");
|
|
|
|
|
|
const std::int64_t sub = RowMinor(md, "| Subtotal (ex VAT) | | €");
|
|
|
|
|
|
const std::int64_t vat = RowMinor(md, "| VAT 21% (NL) | | €");
|
|
|
|
|
|
const std::int64_t total =
|
|
|
|
|
|
RowMinor(md, "| **Total (incl. VAT)** | | **€");
|
|
|
|
|
|
const bool ok = item >= 0 && shipping >= 0 && sub >= 0
|
|
|
|
|
|
&& vat >= 0 && total == s.totalMinor
|
|
|
|
|
|
&& item + shipping == sub
|
|
|
|
|
|
&& sub + vat == total
|
|
|
|
|
|
&& shipping - Money::NetFromGross(ship) <= 1
|
|
|
|
|
|
&& Money::NetFromGross(ship) - shipping <= 1;
|
|
|
|
|
|
if (!ok && broke.empty()) {
|
|
|
|
|
|
broke = std::format("unit {} qty {} ship {}: {} + {} vs {}",
|
|
|
|
|
|
unit, qty, ship, item, shipping, sub);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(broke.empty(),
|
|
|
|
|
|
"invoice: EU columns add up across the whole price grid", broke);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
o.vatIncluded = false;
|
|
|
|
|
|
o.buyer.country = "GB";
|
|
|
|
|
|
o.goodsMinor = 93107;
|
|
|
|
|
|
o.shippingMinor = 2395;
|
|
|
|
|
|
o.totalMinor = 95502;
|
|
|
|
|
|
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// The mirror image of the EU table: a zero-rated export carries no VAT to
|
|
|
|
|
|
// strip, so every line is the gross that was charged and NetFromGross must
|
|
|
|
|
|
// never touch it. 93107 stays €931.07 (netting it again would print
|
|
|
|
|
|
// €769.48) and 2395 stays €23.95 (€19.79 netted) — the two branches
|
|
|
|
|
|
// swapping their treatment is exactly the accident these pin down.
|
|
|
|
|
|
Check(ex.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: export item line stays gross");
|
|
|
|
|
|
Check(ex.find("| Shipping | 1 | €23.95 |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: export shipping line stays gross");
|
|
|
|
|
|
Check(ex.find("| **Total** | | **€955.02** |\n") != std::string::npos,
|
|
|
|
|
|
"invoice: export total carries no VAT label");
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// ── 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 = "GB";
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// The bare newline is only the loudest of the shapes that would widen the
|
|
|
|
|
|
// envelope. Under `msmtp -t` the To: header IS the recipient list, so
|
|
|
|
|
|
// every address Form::LooksLikeEmail rejects must yield NO message —
|
|
|
|
|
|
// a comma is the cheapest extra-recipient smuggle of the lot, and it is
|
|
|
|
|
|
// barred only because that shared form validator happens to bar it.
|
|
|
|
|
|
// Pinning the coupling here means a future loosening of LooksLikeEmail
|
|
|
|
|
|
// (a legitimate-looking change to a form helper) cannot quietly re-open
|
|
|
|
|
|
// the envelope, and each of these carries a buyer's name and address.
|
|
|
|
|
|
// "…, evil@…" comma, plus a second '@'
|
|
|
|
|
|
// "…> , <evil@…" angle brackets, comma, second '@'
|
|
|
|
|
|
// "…\rBcc: …" bare CR — a header break on its own under CRLF
|
|
|
|
|
|
// "a@b" no dot in the domain
|
|
|
|
|
|
// "" empty, below the minimum length
|
|
|
|
|
|
for (const std::string_view addr : { "a@b.example, evil@x.example",
|
|
|
|
|
|
"a@b.example> , <evil@x.example",
|
|
|
|
|
|
"a@b.example\rBcc: x@y.example",
|
|
|
|
|
|
"a@b",
|
|
|
|
|
|
"" }) {
|
|
|
|
|
|
o.buyer.email = std::string(addr);
|
|
|
|
|
|
Check(Server::BuildOrderConfirmationEmail(
|
|
|
|
|
|
o, "F", "", "x", "u", "S", "D").empty(),
|
|
|
|
|
|
"email: address the envelope check rejects yields no message", addr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the GPG key id alphabet ───────────────────────────────────────
|
|
|
|
|
|
// gGpgKeyId is interpolated straight into a std::system() command line
|
|
|
|
|
|
// between single quotes, so a single accepted quote character is remote
|
|
|
|
|
|
// code execution as the shop user. The alphabet check in
|
|
|
|
|
|
// ConfigureInvoicing is the entire defence. None of this reaches gpg:
|
|
|
|
|
|
// a refused id leaves signing unconfigured, which is what we assert.
|
|
|
|
|
|
Check(!Server::InvoiceSigningConfigured(), "invoice: signing starts unconfigured");
|
|
|
|
|
|
for (const std::string_view bad : { "abc'; touch /tmp/pwned; '",
|
|
|
|
|
|
"0xDEADBEEF BEEF",
|
|
|
|
|
|
"0xDEADBEEF`id`",
|
|
|
|
|
|
"0xDEADBEEF$(id)",
|
|
|
|
|
|
"0xDEADBEEF\nBEEF" }) {
|
|
|
|
|
|
Server::ConfigureInvoicing(std::string(bad));
|
|
|
|
|
|
Check(!Server::InvoiceSigningConfigured(),
|
|
|
|
|
|
"invoice: key id outside the safe alphabet is refused", bad);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The other half of the same contract, which the caller leans on: with no
|
|
|
|
|
|
// signer installed the answer is refusal, never the plaintext. Returning
|
|
|
|
|
|
// the markdown here would serve an UNSIGNED invoice through the path that
|
|
|
|
|
|
// promises a signed one — and the caller cannot tell the difference.
|
|
|
|
|
|
Check(!Server::ClearsignInvoice("# x").has_value(),
|
|
|
|
|
|
"invoice: unconfigured signing yields nullopt, not the plaintext");
|
|
|
|
|
|
|
|
|
|
|
|
// What a fingerprint or a uid email actually needs: alnum plus @ . _ - +.
|
|
|
|
|
|
Server::ConfigureInvoicing("0xDEADBEEF@catcrafts.net");
|
|
|
|
|
|
Check(Server::InvoiceSigningConfigured(),
|
|
|
|
|
|
"invoice: a key id inside the safe alphabet is accepted");
|
|
|
|
|
|
// Put the process back the way we found it — nothing after this line
|
|
|
|
|
|
// should be able to shell out to gpg.
|
|
|
|
|
|
Server::ConfigureInvoicing("");
|
|
|
|
|
|
Check(!Server::InvoiceSigningConfigured(),
|
|
|
|
|
|
"invoice: an empty key id means no signing");
|
|
|
|
|
|
|
|
|
|
|
|
// ── MAIL_FROM is a header, and is guarded like one ────────────────
|
|
|
|
|
|
// MAIL_FROM is written verbatim into the From: header of a message
|
|
|
|
|
|
// delivered with `msmtp -t`, where the headers ARE the envelope: one
|
|
|
|
|
|
// smuggled newline adds a recipient to EVERY order confirmation, and each
|
|
|
|
|
|
// of those carries the buyer's name and full postal address.
|
|
|
|
|
|
Check(!Server::MailConfigured(), "mail: starts unconfigured");
|
|
|
|
|
|
Check(Server::MailFrom().empty(), "mail: no From before configuration");
|
|
|
|
|
|
Server::ConfigureMail(Server::MailConfig{
|
|
|
|
|
|
"true", "Catcrafts <info@catcrafts.net>\nBcc: leak@evil.example" });
|
|
|
|
|
|
// Refused WHOLE, not sanitised: the guard returns before gMail is
|
|
|
|
|
|
// assigned, so the command does not install either. A half-applied config
|
|
|
|
|
|
// would be the dangerous outcome — a mailer that runs with a bad From.
|
|
|
|
|
|
Check(!Server::MailConfigured(),
|
|
|
|
|
|
"mail: a From with a line break rejects the whole config");
|
|
|
|
|
|
Check(Server::MailFrom().empty(),
|
|
|
|
|
|
"mail: a rejected From is never installed", Server::MailFrom());
|
|
|
|
|
|
|
|
|
|
|
|
// kSellerName + kSellerSite, so an operator who sets MAIL_COMMAND and
|
|
|
|
|
|
// forgets MAIL_FROM still sends from an address that exists.
|
|
|
|
|
|
Server::ConfigureMail(Server::MailConfig{ "true", "" });
|
|
|
|
|
|
Check(Server::MailConfigured(), "mail: a clean config installs the command");
|
|
|
|
|
|
Check(Server::MailFrom() == "Catcrafts <info@catcrafts.net>",
|
|
|
|
|
|
"mail: empty MAIL_FROM defaults to the shop inbox", Server::MailFrom());
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
if (failures != 0) {
|
|
|
|
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|