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