All checks were successful
Deploy / build-deploy (push) Successful in 2m32s
493 lines
24 KiB
C++
493 lines
24 KiB
C++
/*
|
|
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.
|
|
*/
|
|
|
|
// catcrafts-server — the native product.
|
|
//
|
|
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML)
|
|
// and runs the shop — orders, the payment rails, the reconciler.
|
|
//
|
|
// This file is only the CLI dispatch and the serve loop; the logic lives in
|
|
// Catcrafts.ServerCore (see project.cpp). The unit tests that used to ride
|
|
// along here as `--selftest` are real crafter-build tests now, one suite per
|
|
// tests/<Name>/main.cpp:
|
|
//
|
|
// crafter-build test --product=server
|
|
|
|
import std;
|
|
import Catcrafts.Shared;
|
|
import Catcrafts.Server;
|
|
|
|
using namespace Catcrafts;
|
|
|
|
namespace {
|
|
|
|
std::string ReadFile(const std::filesystem::path& p) {
|
|
std::ifstream in(p, std::ios::binary);
|
|
if (!in) return {};
|
|
std::ostringstream buf;
|
|
buf << in.rdbuf();
|
|
return buf.str();
|
|
}
|
|
|
|
// Load content/ from disk. The wasm host reads the same bytes out of the VFS
|
|
// instead; the loaders are shared, so only the source of the bytes differs.
|
|
// Content loader for the CLI modes (--render, --routes, --sitemap, --feed).
|
|
//
|
|
// Must stay in step with Server::LoadContent, which the --serve path uses. They
|
|
// are separate because the CLI wants a value it can pass around while the server
|
|
// keeps process-wide state — but a field added to one and forgotten in the other
|
|
// shows up as content silently missing from exactly one code path, which is how
|
|
// products came to be absent from --routes and --sitemap while the live server
|
|
// served them fine.
|
|
Views::SiteContent LoadContent(const std::filesystem::path& root) {
|
|
Views::SiteContent c;
|
|
c.projects = Content::Projects();
|
|
c.products = Content::Products();
|
|
c.legal = Content::LegalPages();
|
|
c.demos = Content::Demos();
|
|
c.posts = LoadPosts(ReadFile(root / "posts.json"));
|
|
c.rates = LoadRates(ReadFile(root / "rates.json"));
|
|
return c;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
const std::vector<std::string_view> args(argv + 1, argv + argc);
|
|
const auto has = [&](std::string_view f) {
|
|
return std::find(args.begin(), args.end(), f) != args.end();
|
|
};
|
|
|
|
// --render <path>: emit the full server-rendered document for a route.
|
|
//
|
|
// This is the SSR path in miniature, and it is how the markup gets
|
|
// inspected without a browser: same renderers, same content files, same
|
|
// output the server will eventually put on the wire.
|
|
if (args.size() >= 2 && args[0] == "--render") {
|
|
const Views::SiteContent content = LoadContent("content");
|
|
const Route route = ParseRoute(args[1]);
|
|
const Views::RenderedPage page = Views::RenderRoute(route, content);
|
|
std::print("{}", Views::RenderDocument(
|
|
page,
|
|
Views::RenderNav(NavKindFor(route.kind)),
|
|
Views::RenderFooter(),
|
|
/*bootScripts=*/"", // no wasm on a plain server render
|
|
/*cssHref=*/"/styles.css"));
|
|
return 0;
|
|
}
|
|
|
|
// --sitemap / --feed: generated from the same route table and Post model
|
|
// the pages use, so they cannot drift from what the site actually serves.
|
|
// The checked-in sitemap.xml this replaces still listed three blog posts
|
|
// that no longer exist.
|
|
//
|
|
// Html::Escape's output is valid XML text: & < > " are
|
|
// shared with XML, and it emits an apostrophe as the numeric reference
|
|
// ' rather than the HTML-only '. So no separate XML escaper.
|
|
if (has("--sitemap")) {
|
|
const Views::SiteContent content = LoadContent("content");
|
|
std::print("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
|
for (std::string_view p : SitemapPaths()) {
|
|
std::print(" <url><loc>https://catcrafts.net{}</loc></url>\n",
|
|
Html::Escape(p).Str());
|
|
}
|
|
// Product URLs come from the loaded catalogue rather than a second
|
|
// hardcoded list, so the sitemap cannot advertise a product that does
|
|
// not exist or miss one that does.
|
|
for (const Product& pr : content.products) {
|
|
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
|
|
Html::Escape(pr.slug).Str());
|
|
}
|
|
// Same rule as the served sitemap: only posts that actually have a
|
|
// page. Both must agree, because this is the copy baked into the wasm
|
|
// bundle and that one is what a crawler fetches.
|
|
for (const Post& po : content.posts) {
|
|
if (!po.HasPage()) continue;
|
|
std::print(" <url><loc>https://catcrafts.net/posts/{}</loc></url>\n",
|
|
Html::Escape(po.slug).Str());
|
|
}
|
|
std::print("</urlset>\n");
|
|
return 0;
|
|
}
|
|
|
|
if (has("--feed")) {
|
|
const Views::SiteContent content = LoadContent("content");
|
|
std::print("{}", Views::RenderAtomFeed(content.posts));
|
|
return 0;
|
|
}
|
|
|
|
// --routes: status + title for every route, for a quick smoke check.
|
|
if (has("--routes")) {
|
|
const Views::SiteContent content = LoadContent("content");
|
|
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos",
|
|
"/shop/fp6plus-pmos", "/shop/donation", "/shop/nope",
|
|
"/financials",
|
|
"/order/0123456789abcdef0123456789abcdef",
|
|
"/order/not-a-token",
|
|
"/legal/privacy", "/legal/imprint",
|
|
"/legal/terms", "/legal/nope",
|
|
"/projects", "/posts", "/posts/nope", "/demos",
|
|
"/demos/raytracer", "/demos/nope", "/demo",
|
|
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
|
|
const Route r = ParseRoute(p);
|
|
const Views::RenderedPage page = Views::RenderRoute(r, content);
|
|
std::println("{:<22} status={} bytes={:<6} title={}",
|
|
p, page.status, page.main.Size(), page.meta.title);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// --serve [port] [--content=DIR] [--webroot=DIR]
|
|
//
|
|
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
|
|
// Catcrafts.Server-Http.cpp for why not HTTP/3.
|
|
//
|
|
// Both directories are options rather than fixed paths because the
|
|
// development layout and the deployed layout differ: in the repo the
|
|
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
|
|
// while on the server the content is installed next to the binary and the
|
|
// bundle IS the webroot Caddy serves.
|
|
if (!args.empty() && args[0] == "--serve") {
|
|
std::uint16_t port = 8081;
|
|
std::filesystem::path contentDir = "content";
|
|
std::filesystem::path webroot;
|
|
// Default alongside the content in dev; the systemd unit points this at
|
|
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
|
// directory is publicly served and wiped by rsync --delete each deploy.
|
|
std::filesystem::path ordersPath = "orders.jsonl";
|
|
// Payment rail selection, one slot per payment choice the buyer gets.
|
|
// Flags beat environment beats default, and the default for each slot
|
|
// is "the provider whose key is set, off otherwise" — so a box with no
|
|
// credentials serves the whole site minus checkout instead of refusing
|
|
// to start, and a box with only one key offers only that one method.
|
|
//
|
|
// bank MOLLIE_API_KEY iDEAL, cards, transfer
|
|
// crypto EURC_CHAINS self-hosted EURC, no processor, no key
|
|
//
|
|
// The crypto slot is selected by the presence of a chains FILE rather
|
|
// than a credential: the self-hosted rail has no credential, which is
|
|
// the feature.
|
|
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
|
const char* eurcChains = std::getenv("EURC_CHAINS");
|
|
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
|
|
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
|
|
std::filesystem::path railState;
|
|
std::string redirectBase = [] {
|
|
const char* v = std::getenv("ORDER_REDIRECT_BASE");
|
|
return v && *v ? std::string(v) : std::string("https://catcrafts.net");
|
|
}();
|
|
|
|
for (std::size_t i = 1; i < args.size(); ++i) {
|
|
const std::string_view a = args[i];
|
|
if (a.starts_with("--content=")) {
|
|
contentDir = a.substr(10);
|
|
} else if (a.starts_with("--webroot=")) {
|
|
webroot = a.substr(10);
|
|
} else if (a.starts_with("--orders=")) {
|
|
ordersPath = a.substr(9);
|
|
} else if (a.starts_with("--rail=")) {
|
|
railMode = a.substr(7);
|
|
} else if (a.starts_with("--crypto-rail=")) {
|
|
cryptoMode = a.substr(14);
|
|
} else if (a.starts_with("--rail-state=")) {
|
|
railState = a.substr(13);
|
|
} else if (a.starts_with("--redirect-base=")) {
|
|
redirectBase = a.substr(16);
|
|
} else {
|
|
std::uint32_t parsed = 0;
|
|
if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{}
|
|
&& parsed > 0 && parsed <= 65535) {
|
|
port = static_cast<std::uint16_t>(parsed);
|
|
} else {
|
|
std::println(std::cerr, "--serve: unrecognised argument '{}'", a);
|
|
return 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The bundle's index.html supplies the <script> tags with their
|
|
// per-build ?v= cache buster, which is why they are read rather than
|
|
// hardcoded — a hardcoded tag would silently serve a stale module.
|
|
//
|
|
// A missing bundle is NOT fatal: every route except /demo renders
|
|
// completely without the wasm, so the site degrades to plain SSR
|
|
// instead of refusing to start.
|
|
std::filesystem::path bundleIndex;
|
|
std::error_code ec;
|
|
if (!webroot.empty()) {
|
|
bundleIndex = webroot / "index.html";
|
|
if (!std::filesystem::exists(bundleIndex, ec)) bundleIndex.clear();
|
|
} else if (std::filesystem::is_directory("bin", ec)) {
|
|
for (const auto& e : std::filesystem::directory_iterator("bin", ec)) {
|
|
if (e.is_directory() && e.path().filename().string().starts_with("Catcrafts.Net-")) {
|
|
bundleIndex = e.path() / "index.html";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (bundleIndex.empty()) {
|
|
std::println(std::cerr,
|
|
"catcrafts-server: no wasm bundle index.html found; "
|
|
"/demo will render without the renderer");
|
|
}
|
|
|
|
if (!std::filesystem::is_directory(contentDir, ec)) {
|
|
std::println(std::cerr, "catcrafts-server: content directory '{}' not found",
|
|
contentDir.string());
|
|
return 2;
|
|
}
|
|
|
|
Server::SetOrdersPath(ordersPath);
|
|
// The /financials aggregates. Same derivation convention as the rail
|
|
// marker and the shipping cache: state hangs off the orders path. The
|
|
// file is written by the owner's tooling and only read here.
|
|
{
|
|
Server::FinancialsConfig finCfg;
|
|
finCfg.publicPath = ordersPath;
|
|
finCfg.publicPath += ".financials.json";
|
|
Server::ConfigureFinancials(std::move(finCfg));
|
|
}
|
|
Server::LoadContent(contentDir, bundleIndex);
|
|
// Refuse to serve an empty catalogue: it almost always means the
|
|
// content path is wrong or a JSON file is malformed, and a silently
|
|
// empty projects page looks like a design choice rather than a bug.
|
|
if (Server::ContentProjectCount() == 0) {
|
|
std::println(std::cerr,
|
|
"catcrafts-server: no projects loaded from '{}' — refusing to start",
|
|
contentDir.string());
|
|
return 2;
|
|
}
|
|
|
|
// The rails. State (only the fake rail has any — its paid marker;
|
|
// neither real provider needs a session or a keypair) defaults next to
|
|
// the orders file: same directory, same lifecycle, same backup.
|
|
if (railState.empty()) {
|
|
railState = ordersPath;
|
|
railState += ".fake-paid";
|
|
}
|
|
// A mode whose credential is missing is a misconfiguration, not a
|
|
// reason to quietly serve a checkout that 502s at the last step. Both
|
|
// slots are checked the same way, and both name the env var they want.
|
|
// The self-hosted rail's two lists. Same derivation convention as every
|
|
// other piece of state — hung off the orders path, so one directory
|
|
// holds the whole shop's mutable life — and both overridable because
|
|
// the pool in particular is a file a human tops up from the wallet.
|
|
//
|
|
// EURC_CHAINS is what SELECTS this rail (see above), so it has no
|
|
// derived default: a chains file that appeared by convention rather
|
|
// than by intent would switch the crypto slot over on its own.
|
|
std::filesystem::path eurcChainsPath;
|
|
if (eurcChains && *eurcChains) eurcChainsPath = eurcChains;
|
|
std::filesystem::path eurcPoolPath;
|
|
if (const char* v = std::getenv("EURC_POOL"); v && *v) {
|
|
eurcPoolPath = v;
|
|
} else {
|
|
eurcPoolPath = ordersPath;
|
|
eurcPoolPath += ".eurc-addresses";
|
|
}
|
|
int eurcWindowHours = 24;
|
|
if (const char* v = std::getenv("EURC_WINDOW_HOURS"); v && *v) {
|
|
const std::string_view s(v);
|
|
int parsed = 0;
|
|
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
|
|
&& parsed > 0 && parsed <= 24 * 30) {
|
|
eurcWindowHours = parsed;
|
|
} else {
|
|
std::println(std::cerr,
|
|
"catcrafts-server: EURC_WINDOW_HOURS='{}' is not a "
|
|
"sane hour count — refusing to start", s);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
auto build = [&](const std::string& mode, const char* key, const char* keyName,
|
|
std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
|
Server::RailConfig cfg;
|
|
cfg.mode = mode;
|
|
cfg.apiKey = key ? key : "";
|
|
cfg.statePath = railState;
|
|
cfg.redirectBase = redirectBase;
|
|
cfg.eurcChainsPath = eurcChainsPath;
|
|
cfg.eurcPoolPath = eurcPoolPath;
|
|
cfg.eurcWindowHours = eurcWindowHours;
|
|
const bool needsKey = mode == "mollie";
|
|
if (needsKey && cfg.apiKey.empty()) {
|
|
std::println(std::cerr,
|
|
"catcrafts-server: rail '{}' selected but {} is not set — "
|
|
"refusing to start with a rail that cannot work",
|
|
mode, keyName);
|
|
return false;
|
|
}
|
|
out = Server::MakeRail(cfg);
|
|
// "off" is a legitimate choice and yields no rail; a mode nobody
|
|
// recognises silently would too, which is how a typo becomes a
|
|
// shop that quietly stops taking one kind of money. So an
|
|
// unrecognised mode is still a hard refusal — but a mode we DO
|
|
// recognise, failing on its runtime data, is not the same fault
|
|
// and must not be answered the same way (see below).
|
|
if (!out && mode != "off") {
|
|
static constexpr std::string_view kKnown[] = {
|
|
"mollie", "eurc", "fake", "fake-crypto"
|
|
};
|
|
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
|
|
if (!known) {
|
|
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
|
|
return false;
|
|
}
|
|
// A KNOWN rail that could not load its data — for "eurc",
|
|
// its chains file or address pool. MakeEurcRail has already
|
|
// said which and why, so this only names the files.
|
|
//
|
|
// This is a WARNING and not a refusal, and the reason is the
|
|
// blast radius. An exhausted address pool is a state a
|
|
// stranger can drive the shop into (every crypto checkout
|
|
// spends an address), and answering it with "the process
|
|
// refuses to boot" turns a spent pool into the entire website
|
|
// down — every page, the bank rail included — held down by
|
|
// Restart=always until a human runs an offline wallet
|
|
// ceremony. That trade is never right: this box already
|
|
// "serves the whole site minus checkout" when no credentials
|
|
// exist at all (see the slot notes above), and one rail's
|
|
// data going bad is strictly less than that.
|
|
//
|
|
// Silent degradation is the other failure to avoid, so the
|
|
// warning is loud, the listening line below reports
|
|
// crypto=off, and tools/enable-eurc.sh refuses to call an
|
|
// enable successful without the rail's own load line.
|
|
std::println(std::cerr,
|
|
"catcrafts-server: WARNING: the '{}' rail could not load "
|
|
"its chains file ({}) or address pool ({}) — see above. "
|
|
"CONTINUING WITHOUT IT: that payment choice is off and "
|
|
"the rest of the site is unaffected.",
|
|
mode, eurcChainsPath.string(), eurcPoolPath.string());
|
|
out.reset();
|
|
return true;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
Server::PaymentRails rails;
|
|
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", rails.bank)) return 2;
|
|
// The crypto slot carries no credential at all; what it needs instead
|
|
// rode in on cfg.eurc* above.
|
|
if (!build(cryptoMode, nullptr, "", rails.crypto)) return 2;
|
|
|
|
Server::ConfigurePayments(std::move(rails), redirectBase);
|
|
|
|
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
|
// keyring. Unset means unsigned dev invoices with a visible marker.
|
|
if (const char* v = std::getenv("INVOICE_GPG_KEY"); v && *v) {
|
|
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 the ONLY source of shipping prices: no credentials and
|
|
// no cached table means checkout refuses every order (loudly logged at
|
|
// startup). Dev and e2e get a table by writing the cache file next to
|
|
// the orders file by hand — same format the refresh writes, so no test
|
|
// hook exists for this and none can drift from production.
|
|
Server::ShippingConfig shipCfg;
|
|
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
|
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
|
if (const char* v = std::getenv("SENDCLOUD_METHOD")) shipCfg.methodName = v;
|
|
shipCfg.cachePath = ordersPath;
|
|
shipCfg.cachePath += ".shipping.json";
|
|
Server::ConfigureShipping(shipCfg);
|
|
|
|
return Server::Serve(port);
|
|
}
|
|
|
|
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
|
// the escape hatch for a payment confirmed out-of-band (or a refund):
|
|
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
|
if (!args.empty() && args[0] == "--orders") {
|
|
std::filesystem::path file = "orders.jsonl";
|
|
std::string markPaid, markShipped, cancel;
|
|
for (std::size_t i = 1; i < args.size(); ++i) {
|
|
const std::string_view a = args[i];
|
|
auto next = [&]() -> std::string {
|
|
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
|
|
};
|
|
if (a == "--mark-paid") markPaid = next();
|
|
else if (a == "--mark-shipped") markShipped = next();
|
|
else if (a == "--cancel") cancel = next();
|
|
else file = a;
|
|
}
|
|
Server::SetOrdersPath(file);
|
|
|
|
auto transition = [&](const std::string& token, std::string_view status) -> int {
|
|
auto order = Server::FindOrder(token);
|
|
if (!order) {
|
|
std::println(std::cerr, "no such order: {}", token);
|
|
return 1;
|
|
}
|
|
const std::string now = std::format(
|
|
"{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
|
std::chrono::system_clock::now()));
|
|
if (!Server::AppendOrderStatus(token, status, now)) {
|
|
std::println(std::cerr, "could not append to {}", file.string());
|
|
return 1;
|
|
}
|
|
if (status == "paid") Server::AssignInvoiceNumber(token, now);
|
|
std::println("{}: {} -> {}", order->reference, order->status, status);
|
|
return 0;
|
|
};
|
|
if (!markPaid.empty()) return transition(markPaid, "paid");
|
|
if (!markShipped.empty()) return transition(markShipped, "shipped");
|
|
if (!cancel.empty()) return transition(cancel, "cancelled");
|
|
|
|
const auto orders = Server::ListOrders();
|
|
std::println("orders: {}", orders.size());
|
|
if (orders.empty()) return 0;
|
|
std::println("");
|
|
// `pay` is the rail the order was created on, `via` what actually
|
|
// settled it. Both, because they answer different questions: an order
|
|
// stuck awaiting needs the first (which provider's dashboard to open),
|
|
// and a paid one needs the second (whether the money can still be
|
|
// pulled back — cards can, iDEAL and crypto cannot).
|
|
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
|
"reference", "status", "total", "cc", "colour", "qty", "pay",
|
|
"via", "created", "token");
|
|
for (const auto& o : orders) {
|
|
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
|
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
|
o.buyer.country, o.color.empty() ? "-" : o.color,
|
|
o.quantity,
|
|
o.payChoice.empty() ? "-" : o.payChoice,
|
|
o.paidVia.empty() ? "-" : o.paidVia,
|
|
o.createdAt, o.token);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
|
|
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
|
" [--rail=off|fake|mollie]\n"
|
|
" [--crypto-rail=off|fake-crypto|eurc]\n"
|
|
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
|
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
|
"\n"
|
|
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail.\n"
|
|
" EURC_CHAINS=FILE selects the self-hosted crypto rail (no key:\n"
|
|
" that is the point); EURC_POOL=FILE of receiving addresses,\n"
|
|
" default <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
|
|
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
|
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
|
return 0;
|
|
}
|