order confirmation
All checks were successful
Deploy / build-deploy (push) Successful in 6m14s

This commit is contained in:
Jorijn van der Graaf 2026-08-09 00:14:09 +02:00
commit 2fa6e70af1
10 changed files with 619 additions and 20 deletions

View file

@ -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.