This commit is contained in:
parent
aa2327ff1d
commit
2fa6e70af1
10 changed files with 619 additions and 20 deletions
|
|
@ -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 <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
|
||||
|
||||
```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
|
||||
|
||||
<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 --render /projects # dump one page's HTML
|
||||
<server>/catcrafts-server --orders FILE # the orders ledger + manual transitions
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
std::array<fs::path, 1> ifaces = {
|
||||
"server/interfaces/Catcrafts.Server",
|
||||
};
|
||||
std::array<fs::path, 7> impls = {
|
||||
std::array<fs::path, 8> impls = {
|
||||
"server/implementations/main",
|
||||
"server/implementations/Catcrafts.Server-Http",
|
||||
"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-Bunq",
|
||||
"server/implementations/Catcrafts.Server-Shipping",
|
||||
"server/implementations/Catcrafts.Server-Mail",
|
||||
};
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,15 @@ std::string NowIso8601() {
|
|||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
// RFC 5322 date for the email header, always UTC. Without the L flag
|
||||
// std::format's %a/%b are locale-independent English — exactly what a mail
|
||||
// header needs, whatever locale the host booted with.
|
||||
std::string NowRfc2822() {
|
||||
return std::format("{:%a, %d %b %Y %H:%M:%S} +0000",
|
||||
std::chrono::floor<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
// POST /shop/<slug> — create an order.
|
||||
//
|
||||
// The sequence is: validate -> compute the amount SERVER-SIDE -> get a payment
|
||||
|
|
@ -667,6 +676,108 @@ void ReconcilerLoop(const std::stop_token& stop) {
|
|||
}
|
||||
}
|
||||
|
||||
// Build and hand ONE order's confirmation to the mail command. False means
|
||||
// "not sent" in every failure mode — the caller retries later, and the
|
||||
// notified event that stops a resend is only written on success.
|
||||
bool SendConfirmationEmail(const OrderRecord& order) {
|
||||
// The invoice rides along, so its number must exist. It normally does
|
||||
// from the paid transition; an order paid before invoicing existed gets
|
||||
// its number here, exactly as the download route grants one.
|
||||
OrderRecord o = order;
|
||||
if (o.invoiceNumber.empty()) {
|
||||
if (!AssignInvoiceNumber(o.token, NowIso8601())) return false;
|
||||
const auto reread = FindOrder(o.token);
|
||||
if (!reread || reread->invoiceNumber.empty()) return false;
|
||||
o = *reread;
|
||||
}
|
||||
|
||||
std::string productName = o.product;
|
||||
std::string colorLabel = o.color;
|
||||
if (const Product* pr = gContent.FindProduct(o.product)) {
|
||||
productName = pr->name;
|
||||
if (const Variant* v = pr->FindVariant(o.color)) colorLabel = v->label;
|
||||
}
|
||||
|
||||
// Same signature rule as the download: with a key configured, a signing
|
||||
// failure means no email now (retry later), never an unsigned invoice.
|
||||
std::string invoice = BuildInvoiceMarkdown(o, productName, colorLabel);
|
||||
if (InvoiceSigningConfigured()) {
|
||||
const auto signedText = ClearsignInvoice(invoice);
|
||||
if (!signedText) {
|
||||
std::println(std::cerr, "mail: invoice signing failed for {} — retrying later",
|
||||
o.reference);
|
||||
return false;
|
||||
}
|
||||
invoice = *signedText;
|
||||
} else {
|
||||
invoice = "UNSIGNED — development copy; production invoices are "
|
||||
"GPG-clearsigned.\n\n" + invoice;
|
||||
}
|
||||
|
||||
const std::string message = BuildOrderConfirmationEmail(
|
||||
o, productName, colorLabel, MailFrom(),
|
||||
std::format("{}/order/{}", gRedirectBase, o.token), invoice, NowRfc2822());
|
||||
if (message.empty()) {
|
||||
// The address failed the envelope shape check. That cannot heal by
|
||||
// waiting, but the ledger stays honest: no notified event is written
|
||||
// for an email that never left, and the backoff caps the log noise.
|
||||
std::println(std::cerr, "mail: order {} has an unmailable address", o.reference);
|
||||
return false;
|
||||
}
|
||||
if (!SendMailMessage(message)) return false;
|
||||
|
||||
AppendOrderNotified(o.token, NowIso8601());
|
||||
std::println(std::cerr, "order {} confirmation emailed", o.reference);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The mailer: watches the ledger for paid orders that were never emailed.
|
||||
//
|
||||
// A sweep rather than a hook inside PollAndAdvance, deliberately: every path
|
||||
// to paid — the reconciler, the buyer's arrival poll, the manual CLI even on
|
||||
// a later restart — funnels into the same ledger, so the ledger is the one
|
||||
// thing worth watching. It also keeps the SMTP handoff off the request
|
||||
// thread: the arrival poll renders the buyer's order page, and that page
|
||||
// must not wait on a mail server. Crash-safety errs toward a duplicate
|
||||
// email (send, then append the notified event), never a missing one.
|
||||
void MailerLoop(const std::stop_token& stop) {
|
||||
struct Attempt {
|
||||
int failures = 0;
|
||||
std::chrono::steady_clock::time_point next;
|
||||
};
|
||||
std::unordered_map<std::string, Attempt> attempts;
|
||||
|
||||
while (!stop.stop_requested()) {
|
||||
// Seconds after the paid transition, not milliseconds — nobody
|
||||
// watches their inbox harder than that, and the fold is cheap at
|
||||
// this volume.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
if (stop.stop_requested()) break;
|
||||
|
||||
for (const OrderRecord& order : ListOrders()) {
|
||||
if (order.status != "paid" && order.status != "shipped") continue;
|
||||
if (!order.confirmationSentAt.empty()) {
|
||||
attempts.erase(order.token);
|
||||
continue;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
Attempt& att = attempts[order.token];
|
||||
if (att.failures > 0 && now < att.next) continue;
|
||||
|
||||
if (SendConfirmationEmail(order)) {
|
||||
attempts.erase(order.token);
|
||||
} else {
|
||||
// 1, 2, 4 … 64 minutes: a broken mail command must not turn
|
||||
// the journal into a metronome, but recovery is still found
|
||||
// within the hour without a restart.
|
||||
++att.failures;
|
||||
att.next = now + std::chrono::minutes(
|
||||
1 << std::min(att.failures - 1, 6));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int Serve(std::uint16_t port) {
|
||||
|
|
@ -717,6 +828,14 @@ int Serve(std::uint16_t port) {
|
|||
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
||||
}
|
||||
|
||||
// The mailer only exists when a mail command is configured. Without one
|
||||
// the order page and the invoice download remain the buyer's receipt —
|
||||
// degraded, not broken, like every other optional integration here.
|
||||
std::optional<std::jthread> mailer;
|
||||
if (MailConfigured()) {
|
||||
mailer.emplace([](std::stop_token st) { MailerLoop(st); });
|
||||
}
|
||||
|
||||
// Shipping rates: one fetch at startup, then daily. RefreshShippingTable
|
||||
// is a no-op without Sendcloud credentials, and every failure mode leaves
|
||||
// the previous table (cached or zone fallback) in charge.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
183
server/implementations/Catcrafts.Server-Mail.cpp
Normal file
183
server/implementations/Catcrafts.Server-Mail.cpp
Normal 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
|
||||
|
|
@ -8,10 +8,12 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
// Order storage: an append-only JSON-lines event log.
|
||||
//
|
||||
// Two event types share the file:
|
||||
// Four event types share the file:
|
||||
//
|
||||
// {"type":"order", ...full record...} written once, at checkout
|
||||
// {"type":"status", "id":..,"status":..} one per transition
|
||||
// {"type":"order", ...full record...} written once, at checkout
|
||||
// {"type":"status", "id":..,"status":..} one per transition
|
||||
// {"type":"invoice", "id":..,"number":..} the number assignment, at paid
|
||||
// {"type":"notified","id":..,"what":..} the confirmation email left
|
||||
//
|
||||
// Current state is a left fold over the file; later events win. Nothing is
|
||||
// ever rewritten, so the log doubles as the audit trail the tax records need,
|
||||
|
|
@ -126,6 +128,14 @@ std::vector<OrderRecord> FoldLocked() {
|
|||
if (!r) continue;
|
||||
r->invoiceNumber = std::string(doc->Str("number"));
|
||||
r->invoicedAt = std::string(doc->Str("at"));
|
||||
} else if (type == "notified") {
|
||||
OrderRecord* r = find(doc->Str("id"));
|
||||
if (!r) continue;
|
||||
// "what" names the message so a future shipped-notice can share
|
||||
// the event type without re-marking the confirmation as sent.
|
||||
if (doc->Str("what") == "confirmation") {
|
||||
r->confirmationSentAt = std::string(doc->Str("at"));
|
||||
}
|
||||
} else if (type == "status") {
|
||||
OrderRecord* r = find(doc->Str("id"));
|
||||
if (!r) continue; // status for an unknown order: skip, keep folding
|
||||
|
|
@ -257,6 +267,13 @@ std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
|||
return number;
|
||||
}
|
||||
|
||||
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
return AppendLine(std::format(
|
||||
R"({{"type":"notified","at":"{}","id":"{}","what":"confirmation"}})",
|
||||
JsonEscape(isoTimestamp), JsonEscape(token)));
|
||||
}
|
||||
|
||||
std::optional<OrderRecord> FindOrder(std::string_view token) {
|
||||
std::lock_guard lock(gOrdersMutex);
|
||||
for (OrderRecord& r : FoldLocked()) {
|
||||
|
|
|
|||
|
|
@ -691,6 +691,72 @@ void RunMoneySelfTest() {
|
|||
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
|
||||
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
|
||||
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
|
||||
|
||||
// ── the order confirmation email ──────────────────────────────
|
||||
// Same order, EU shape again; the attachment stands in for the
|
||||
// clearsigned invoice — the builder must carry it verbatim.
|
||||
o.vatIncluded = true;
|
||||
o.buyer.country = "NL";
|
||||
o.goodsMinor = 112660;
|
||||
o.shippingMinor = 863;
|
||||
o.totalMinor = 113523;
|
||||
const std::string mail = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
|
||||
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||||
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
|
||||
"email: From header");
|
||||
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
|
||||
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
|
||||
"email: subject carries the reference");
|
||||
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
|
||||
"email: date header");
|
||||
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
|
||||
!= std::string::npos,
|
||||
"email: message id from the token");
|
||||
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
|
||||
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
|
||||
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
|
||||
"email: item names colour and quantity");
|
||||
Check(mail.find("€1135.23") != std::string::npos, "email: total");
|
||||
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
|
||||
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
|
||||
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
|
||||
!= std::string::npos,
|
||||
"email: order page link");
|
||||
Check(mail.find("filename=\"catcrafts-invoice-"
|
||||
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
|
||||
"email: attachment filename is the invoice number");
|
||||
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
|
||||
"email: attachment body verbatim");
|
||||
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
|
||||
"email: multipart closes");
|
||||
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
|
||||
|
||||
// The export wording mirrors the invoice's VAT treatment.
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.totalMinor = 95502;
|
||||
const std::string exMail = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||||
Check(exMail.find("zero-rated export") != std::string::npos,
|
||||
"email: export VAT wording");
|
||||
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
|
||||
|
||||
// A single unit does not advertise a quantity.
|
||||
o.quantity = 1;
|
||||
const std::string one = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||||
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
|
||||
|
||||
// The last line of defence: an address that could smuggle a header
|
||||
// yields NO message at all, however it got into the record.
|
||||
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
|
||||
Check(Server::BuildOrderConfirmationEmail(
|
||||
o, "F", "", "x", "u", "S", "D").empty(),
|
||||
"email: header-injecting address yields no message");
|
||||
}
|
||||
|
||||
// ── rates loader ──────────────────────────────────────────────────
|
||||
|
|
@ -966,6 +1032,17 @@ int main(int argc, char** argv) {
|
|||
Server::ConfigureInvoicing(v);
|
||||
}
|
||||
|
||||
// Order email: a sendmail-compatible command ("msmtp -t" on the
|
||||
// server) that reads the message on stdin and takes the recipient
|
||||
// from its headers. Unset means no email is sent — the order page
|
||||
// and invoice download remain the buyer's receipt.
|
||||
{
|
||||
Server::MailConfig mailCfg;
|
||||
if (const char* v = std::getenv("MAIL_COMMAND"); v && *v) mailCfg.command = v;
|
||||
if (const char* v = std::getenv("MAIL_FROM"); v && *v) mailCfg.from = v;
|
||||
Server::ConfigureMail(std::move(mailCfg));
|
||||
}
|
||||
|
||||
// Sendcloud is optional: without credentials the compiled-in zone
|
||||
// table prices all shipping, which is exactly how dev and e2e run.
|
||||
// With credentials the refresh thread fetches per-country rates.
|
||||
|
|
@ -1042,6 +1119,7 @@ int main(int argc, char** argv) {
|
|||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD");
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,12 @@ export namespace Catcrafts::Server {
|
|||
|
||||
// ── orders ────────────────────────────────────────────────────────
|
||||
//
|
||||
// An append-only JSON-lines EVENT LOG, not a database. Two event types:
|
||||
// "order" (the full record, written once) and "status" (a transition).
|
||||
// Current state is a fold over the file — later events win. Nothing is
|
||||
// ever rewritten in place, so the file is also the audit trail, and a
|
||||
// crash mid-append costs at most the line being written.
|
||||
// An append-only JSON-lines EVENT LOG, not a database. Four event types:
|
||||
// "order" (the full record, written once), "status" (a transition),
|
||||
// "invoice" (the number assignment) and "notified" (the confirmation
|
||||
// email left). Current state is a fold over the file — later events win.
|
||||
// Nothing is ever rewritten in place, so the file is also the audit
|
||||
// trail, and a crash mid-append costs at most the line being written.
|
||||
//
|
||||
// The volume argument: this sells single-digit units per week. When that
|
||||
// is wrong by two orders of magnitude, the log imports into SQLite in one
|
||||
|
|
@ -69,6 +70,8 @@ export namespace Catcrafts::Server {
|
|||
std::string paidVia; // method that settled it ("ideal", "creditcard")
|
||||
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
||||
// event; empty = not (yet) emailed
|
||||
};
|
||||
|
||||
void SetOrdersPath(const std::filesystem::path& path);
|
||||
|
|
@ -95,12 +98,28 @@ export namespace Catcrafts::Server {
|
|||
std::optional<std::string> AssignInvoiceNumber(std::string_view token,
|
||||
std::string_view isoTimestamp);
|
||||
|
||||
// Appends the notified event: this order's confirmation email was
|
||||
// accepted by the mail command. Written AFTER the handoff succeeds, so a
|
||||
// crash between send and append errs toward a duplicate email — an
|
||||
// apology — never toward a buyer who paid and heard nothing.
|
||||
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp);
|
||||
|
||||
// ── invoices ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A paid order's invoice: plain markdown, clearsigned with the shop's
|
||||
// GPG key so its authenticity outlives this server. The page invites the
|
||||
// buyer to download it rather than promising to host receipts forever.
|
||||
|
||||
// The registered business identity — on every invoice and at the foot of
|
||||
// every order email. One definition, like the rest of the compiled-in
|
||||
// authored content; the selftest pins the values.
|
||||
inline constexpr std::string_view kSellerName = "Catcrafts";
|
||||
inline constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
|
||||
inline constexpr std::string_view kSellerCity = "3315NN Dordrecht";
|
||||
inline constexpr std::string_view kSellerKvk = "78437059";
|
||||
inline constexpr std::string_view kSellerVat = "NL003329281B38";
|
||||
inline constexpr std::string_view kSellerSite = "catcrafts.net";
|
||||
|
||||
// Pure and exported for the self-test: everything on a Dutch invoice —
|
||||
// seller identity (KVK/VAT), sequential number, dates, buyer address,
|
||||
// per-line amounts, VAT treatment for EU and export.
|
||||
|
|
@ -125,6 +144,44 @@ export namespace Catcrafts::Server {
|
|||
std::string NewOrderToken();
|
||||
std::string ReferenceFromToken(std::string_view token);
|
||||
|
||||
// ── the order confirmation email ──────────────────────────────────
|
||||
//
|
||||
// A paid order gets ONE email: the confirmation, with the clearsigned
|
||||
// invoice attached. Delivery shells out to a sendmail-compatible command
|
||||
// (msmtp -t on the server) exactly as signing shells out to gpg — TLS,
|
||||
// AUTH and deliverability are the parts a hand-rolled SMTP client
|
||||
// reimplements badly, and this sends a handful of messages per week.
|
||||
// The command reads the complete RFC 5322 message on stdin and takes the
|
||||
// recipient from its headers; the buyer's address was validated at
|
||||
// checkout to be header-safe (Form::LooksLikeEmail rejects CR, LF,
|
||||
// commas and angle brackets for exactly this moment).
|
||||
|
||||
struct MailConfig {
|
||||
std::string command; // MAIL_COMMAND, e.g. "msmtp -t"; empty = no email
|
||||
std::string from; // MAIL_FROM header; defaults to the shop inbox
|
||||
};
|
||||
void ConfigureMail(MailConfig config);
|
||||
bool MailConfigured();
|
||||
// The configured From header — the mailer passes it to the builder.
|
||||
std::string MailFrom();
|
||||
|
||||
// Pure and exported for the self-test: the complete MIME message, a
|
||||
// plain-text confirmation plus the invoice as a markdown attachment.
|
||||
// Returns an empty string when the buyer's address fails the envelope
|
||||
// shape check — the last line of defence sits where the envelope is
|
||||
// built, not in the history of the record.
|
||||
std::string BuildOrderConfirmationEmail(const OrderRecord& order,
|
||||
std::string_view productName,
|
||||
std::string_view colorLabel,
|
||||
std::string_view from,
|
||||
std::string_view orderUrl,
|
||||
std::string_view invoiceAttachment,
|
||||
std::string_view dateRfc2822);
|
||||
|
||||
// Pipe one message into the configured command. False means "not sent,
|
||||
// keep it queued": the mailer never writes a notified event on failure.
|
||||
bool SendMailMessage(const std::string& message);
|
||||
|
||||
// ── payments ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
||||
|
|
|
|||
79
tools/e2e.sh
79
tools/e2e.sh
|
|
@ -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; }
|
||||
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 &
|
||||
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 <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
|
||||
echo "== checkout (coming soon) =="
|
||||
# A perfectly valid order must be refused while the shop is closed: after
|
||||
|
|
|
|||
Loading…
Reference in a new issue