This commit is contained in:
parent
aa2327ff1d
commit
2fa6e70af1
10 changed files with 619 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue