summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:57:08 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:57:08 +0200
commit90068419d51b0289c65d4db9e14849fbecd34a95 (patch)
tree8d9dbf65ee6d869a6d3cac94ec0dc7639f9d6f8a
parent9c3892b09a439e5bafa80b7c9ef910b5af0d8598 (diff)
downloadbokf-90068419d51b0289c65d4db9e14849fbecd34a95.tar.gz
bokf-90068419d51b0289c65d4db9e14849fbecd34a95.zip
invoice: send the issued PDF over SMTP
-rw-r--r--docs/PROTOCOL.md17
-rw-r--r--src/commands.c242
-rw-r--r--tests/test_core.c356
3 files changed, 614 insertions, 1 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index a4e61ec..95c2369 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -156,7 +156,8 @@ enforced at the database level via composite keys (see `SCHEMA.md`).
`ORG_REQUIRED`, `ORG_FORBIDDEN`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`,
`UNBALANCED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`, `FISCAL_YEAR_NOT_FOUND`,
`FISCAL_YEAR_CLOSED`, `PERIOD_LOCKED`, `DATE_OUT_OF_RANGE`, `IMMUTABLE`,
-`SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `DB_BUSY`, `INTERNAL`.
+`SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `SMTP_NOT_CONFIGURED`,
+`SMTP_FAILED`, `DB_BUSY`, `INTERNAL`.
Codes are stable; `message` is human-readable and may change. `details` is
machine-readable where offered.
@@ -584,6 +585,7 @@ removes one link and is a `NOT_FOUND` when it does not exist. Both mutate
| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `last_sent_at`, `last_sent_to` |
| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first |
| `invoice.pdf` | `id` | stored PDF as `content_base64` |
+| `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` |
The draft object is the argument set shared by `invoice.preview` and
`invoice.issue`:
@@ -627,6 +629,19 @@ writes nothing. `invoice.pdf` returns the stored document as base64
(`JVBERi0` after decoding is the PDF magic). When the setting
`invoice_bankgiro` is present it is printed in the document's Bankgiro field.
+`invoice.send` mails the stored PDF to the customer's `email` (or the `to`
+override) with subject `Faktura <number>` and a Swedish body. It needs the
+settings `smtp_host` and `smtp_from`; `smtp_port` defaults to 587 and
+`smtp_security` to `starttls`. When `smtp_user` is set, the secret
+`smtp_password` must be present and decryptable with the daemon's key, else
+the command is `SMTP_NOT_CONFIGURED`. The password is decrypted from the
+encrypted setting, handed to the SMTP client and never written to the audit
+log or returned in an error. A refused or failed delivery is `SMTP_FAILED`
+with the client's error text. On success `last_sent_at`/`last_sent_to` are
+updated and the `invoice.send` audit entry stores `{id,to,subject}` only.
+`dry_run` validates configuration, recipient and stored document and returns
+the recipient and subject without sending or updating anything.
+
## 8. The TUI is just a client
`bokftui` logs in over the same socket, picks an org and issues the same
diff --git a/src/commands.c b/src/commands.c
index 0ac9ace..5dd920e 100644
--- a/src/commands.c
+++ b/src/commands.c
@@ -22,6 +22,7 @@
#include "secret.h"
#include "seed.h"
#include "sie.h"
+#include "smtp.h"
#include "sru.h"
#include "util.h"
#include "version.h"
@@ -1620,6 +1621,8 @@ static const char AGENT_INSTRUCTIONS[] =
"- Fakturering: `customer.create` keeps the customer register; `invoice.preview`\n"
" renders a draft without consuming a number, and `invoice.issue` takes the\n"
" next number, stores the PDF and posts the voucher in one transaction.\n"
+ " `invoice.send` e-mails the stored PDF; it needs the org's SMTP settings\n"
+ " and sends to the customer's address unless `to` overrides it.\n"
"\n"
"## Discovery\n"
"- `describe` lists every implemented command with permissions.\n"
@@ -4367,6 +4370,237 @@ static yyjson_mut_val *h_invoice_pdf(struct req *r)
return o;
}
+/* Whole kronor with a space as thousands separator, e.g. 91500 -> "91 500". */
+static void invoice_send_amount(int64_t ore, char *out, size_t n)
+{
+ char digits[24];
+ snprintf(digits, sizeof digits, "%lld", (long long)((ore + 50) / 100));
+ size_t len = strlen(digits);
+ size_t o = 0;
+ for (size_t i = 0; i < len && o + 1 < n; i++) {
+ if (i > 0 && (len - i) % 3 == 0)
+ out[o++] = ' ';
+ out[o++] = digits[i];
+ }
+ out[o] = '\0';
+}
+
+static yyjson_mut_val *h_invoice_send(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT i.number,i.ocr,i.due_date,i.document_id,i.total_ore,"
+ "c.name,COALESCE(c.email,''),COALESCE(o.name,'')"
+ " FROM invoices i"
+ " JOIN customers c ON c.org_id=i.org_id AND c.id=i.customer_id"
+ " JOIN orgs o ON o.id=i.org_id"
+ " WHERE i.org_id=?1 AND i.id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice not found");
+ }
+ int64_t number = sqlite3_column_int64(st, 0);
+ int64_t document_id = sqlite3_column_type(st, 3) == SQLITE_NULL
+ ? 0
+ : sqlite3_column_int64(st, 3);
+ int64_t total_ore = sqlite3_column_int64(st, 4);
+ char ocr[40], due_date[16], customer_name[256], customer_email[256];
+ char org_name[256];
+ snprintf(ocr, sizeof ocr, "%s", sq(sqlite3_column_text(st, 1)));
+ snprintf(due_date, sizeof due_date, "%s", sq(sqlite3_column_text(st, 2)));
+ snprintf(customer_name, sizeof customer_name, "%s",
+ sq(sqlite3_column_text(st, 5)));
+ snprintf(customer_email, sizeof customer_email, "%s",
+ sq(sqlite3_column_text(st, 6)));
+ snprintf(org_name, sizeof org_name, "%s", sq(sqlite3_column_text(st, 7)));
+ sqlite3_finalize(st);
+ st = NULL;
+
+ if (document_id <= 0)
+ return fail(r, "NOT_FOUND", "invoice has no stored PDF");
+ if (sqlite3_prepare_v2(
+ r->db, "SELECT content FROM attachments WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, document_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice PDF not found");
+ }
+ const void *blob = sqlite3_column_blob(st, 0);
+ size_t pdf_len = (size_t)sqlite3_column_bytes(st, 0);
+ unsigned char *pdf = xmalloc(pdf_len ? pdf_len : 1);
+ if (blob && pdf_len)
+ memcpy(pdf, blob, pdf_len);
+ sqlite3_finalize(st);
+ st = NULL;
+
+ const char *to = arg_str(r->args, "to");
+ if (!to || !*to)
+ to = customer_email;
+ if (!*to) {
+ free(pdf);
+ return fail(r, "INVALID_ARGS", "customer has no e-mail address");
+ }
+
+ char *smtp_host = db_setting(r->db, r->org_id, "smtp_host");
+ char *smtp_port = db_setting(r->db, r->org_id, "smtp_port");
+ char *smtp_user = db_setting(r->db, r->org_id, "smtp_user");
+ char *smtp_from = db_setting(r->db, r->org_id, "smtp_from");
+ char *smtp_security = db_setting(r->db, r->org_id, "smtp_security");
+ char *smtp_password = db_setting(r->db, r->org_id, "smtp_password");
+ char *password = NULL;
+ char *body = NULL;
+ yyjson_mut_val *res = NULL;
+
+ if (!smtp_host || !*smtp_host || !smtp_from || !*smtp_from) {
+ fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set");
+ goto done;
+ }
+ const char *user = smtp_user && *smtp_user ? smtp_user : "";
+ if (*user) {
+ if (!smtp_password || !*smtp_password) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "smtp_user is set but smtp_password is missing");
+ goto done;
+ }
+ if (!secret_available()) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "smtp_password is set but BOKFD_SECRET_KEY is missing or"
+ " invalid");
+ goto done;
+ }
+ if (secret_decrypt(smtp_password, &password) != 0 || !password) {
+ fail(r, "SMTP_NOT_CONFIGURED",
+ "cannot decrypt smtp_password, check BOKFD_SECRET_KEY");
+ goto done;
+ }
+ }
+ int port = 587;
+ if (smtp_port && *smtp_port) {
+ long v = strtol(smtp_port, NULL, 10);
+ if (v >= 1 && v <= 65535)
+ port = (int)v;
+ }
+ const char *security =
+ smtp_security && *smtp_security ? smtp_security : "starttls";
+
+ char subject[64];
+ snprintf(subject, sizeof subject, "Faktura %lld", (long long)number);
+ char amount[32];
+ invoice_send_amount(total_ore, amount, sizeof amount);
+ size_t body_len = strlen(org_name) + 320;
+ body = xmalloc(body_len);
+ snprintf(body, body_len,
+ "Hej,\n\nBifogat finner du faktura %lld på %s kr med"
+ " förfallodatum %s.\nAnge OCR %s vid betalning.\n\n"
+ "Med vänlig hälsning\n%s\n",
+ (long long)number, amount, due_date, ocr, org_name);
+
+ char safe_name[256];
+ snprintf(safe_name, sizeof safe_name, "%s", customer_name);
+ for (char *p = safe_name; *p; p++)
+ if (*p == '/')
+ *p = '-';
+ char attach_name[320];
+ snprintf(attach_name, sizeof attach_name, "Faktura %lld %s.pdf",
+ (long long)number, safe_name);
+
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "subject", subject);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ res = o;
+ goto done;
+ }
+
+ struct smtp_message m;
+ memset(&m, 0, sizeof m);
+ m.host = smtp_host;
+ m.port = port;
+ m.security = security;
+ m.user = user;
+ m.password = password ? password : "";
+ m.from = smtp_from;
+ m.from_name = org_name;
+ m.to = to;
+ m.subject = subject;
+ m.body = body;
+ m.attach_name = attach_name;
+ m.attach = pdf;
+ m.attach_len = pdf_len;
+
+ char errbuf[512];
+ if (smtp_send(&m, errbuf, sizeof errbuf) != 0) {
+ fail(r, "SMTP_FAILED", errbuf[0] ? errbuf : "smtp send failed");
+ goto done;
+ }
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE invoices SET last_sent_at=?1,last_sent_to=?2"
+ " WHERE org_id=?3 AND id=?4",
+ -1, &st, NULL) != SQLITE_OK) {
+ fail(r, "INTERNAL", "database error");
+ goto done;
+ }
+ sqlite3_bind_text(st, 1, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 2, to, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 3, r->org_id);
+ sqlite3_bind_int64(st, 4, id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ st = NULL;
+ if (rc != SQLITE_DONE) {
+ fail(r, "DB_BUSY", sqlite3_errmsg(r->db));
+ goto done;
+ }
+
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *a = yyjson_mut_obj(doc);
+ yyjson_mut_doc_set_root(doc, a);
+ yyjson_mut_obj_add_int(doc, a, "id", id);
+ yyjson_mut_obj_add_strcpy(doc, a, "to", to);
+ yyjson_mut_obj_add_strcpy(doc, a, "subject", subject);
+ char *reqjson = yyjson_mut_write(doc, 0, NULL);
+ yyjson_mut_doc_free(doc);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "invoice.send", reqjson ? reqjson : "{}", "OK", NULL);
+ free(reqjson);
+
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sent_to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts);
+ res = o;
+
+done:
+ free(smtp_host);
+ free(smtp_port);
+ free(smtp_user);
+ free(smtp_from);
+ free(smtp_security);
+ free(smtp_password);
+ free(password);
+ free(body);
+ free(pdf);
+ return res;
+}
+
/* ------------------------------------------------------------------ */
/* report rules (per-org moms mapping) */
/* ------------------------------------------------------------------ */
@@ -8001,6 +8235,12 @@ static const struct cmd_arg args_invoice_list[] = {
{ "limit", ARG_INT, 0, "200", NULL, "Page size, 1-1000" },
};
+static const struct cmd_arg args_invoice_send[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Invoice id" },
+ { "to", ARG_STR, 0, NULL, NULL,
+ "Recipient e-mail; defaults to the customer's address" },
+};
+
const struct command g_commands[] = {
{ "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 },
{ "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL,
@@ -8178,6 +8418,8 @@ const struct command g_commands[] = {
h_invoice_list, CMD_ARGS(args_invoice_list) },
{ "invoice.pdf", "Fetch the stored invoice PDF", PERM_READ, 1, 0, 0,
h_invoice_pdf, CMD_ARGS(args_invoice_get) },
+ { "invoice.send", "E-mail the stored invoice PDF to the customer",
+ PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) },
};
const size_t g_commands_count = sizeof g_commands / sizeof g_commands[0];
diff --git a/tests/test_core.c b/tests/test_core.c
index 47b6acf..29985e2 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -343,6 +343,134 @@ static void test_transport(void)
}
}
+/* Minimal SMTP server for invoice.send: 220, EHLO (multiline), MAIL/RCPT,
+ 354, DATA capture until ".", 250 and 221. The transcript goes to logpath
+ as "C:" (client), "S:" (server) and "D:" (data) lines. */
+static int smtp_child_line(int fd, char *buf, size_t n)
+{
+ size_t o = 0;
+ for (;;) {
+ char ch;
+ ssize_t r = read(fd, &ch, 1);
+ if (r <= 0)
+ return -1;
+ if (ch == '\n') {
+ while (o > 0 && buf[o - 1] == '\r')
+ o--;
+ buf[o] = '\0';
+ return (int)o;
+ }
+ if (o + 1 < n)
+ buf[o++] = ch;
+ }
+}
+
+static void smtp_child_say(int fd, FILE *log, const char *line)
+{
+ fprintf(log, "S: %s\n", line);
+ fflush(log);
+ char out[512];
+ int n = snprintf(out, sizeof out, "%s\r\n", line);
+ if (n > 0) {
+ ssize_t wr = write(fd, out, (size_t)n);
+ (void)wr;
+ }
+}
+
+static void smtp_child(int wfd, const char *logpath, int reject_rcpt)
+{
+ signal(SIGPIPE, SIG_IGN);
+ alarm(20);
+ int sfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (sfd < 0)
+ _exit(1);
+ struct sockaddr_in sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) != 0 ||
+ listen(sfd, 1) != 0)
+ _exit(1);
+ socklen_t slen = sizeof sa;
+ if (getsockname(sfd, (struct sockaddr *)&sa, &slen) != 0)
+ _exit(1);
+ int port = ntohs(sa.sin_port);
+ if (write(wfd, &port, sizeof port) != (ssize_t)sizeof port)
+ _exit(1);
+ close(wfd);
+ int cfd = accept(sfd, NULL, NULL);
+ if (cfd < 0)
+ _exit(1);
+ FILE *log = fopen(logpath, "w");
+ if (!log)
+ _exit(1);
+ smtp_child_say(cfd, log, "220 test.invalid ESMTP");
+ char line[2048];
+ while (smtp_child_line(cfd, line, sizeof line) >= 0) {
+ fprintf(log, "C: %s\n", line);
+ fflush(log);
+ if (strncmp(line, "EHLO", 4) == 0 ||
+ strncmp(line, "HELO", 4) == 0) {
+ smtp_child_say(cfd, log, "250-test.invalid");
+ smtp_child_say(cfd, log, "250 AUTH PLAIN LOGIN");
+ } else if (strncmp(line, "AUTH", 4) == 0) {
+ smtp_child_say(cfd, log, "235 2.7.0 authenticated");
+ } else if (strncmp(line, "MAIL FROM:", 10) == 0) {
+ smtp_child_say(cfd, log, "250 2.1.0 ok");
+ } else if (strncmp(line, "RCPT TO:", 8) == 0) {
+ if (reject_rcpt) {
+ smtp_child_say(cfd, log, "550 5.1.1 no such user");
+ break;
+ }
+ smtp_child_say(cfd, log, "250 2.1.5 ok");
+ } else if (strcmp(line, "DATA") == 0) {
+ smtp_child_say(cfd, log, "354 end with .");
+ for (;;) {
+ if (smtp_child_line(cfd, line, sizeof line) < 0)
+ break;
+ fprintf(log, "D: %s\n", line);
+ fflush(log);
+ if (strcmp(line, ".") == 0)
+ break;
+ }
+ smtp_child_say(cfd, log, "250 2.0.0 queued");
+ } else if (strcmp(line, "QUIT") == 0) {
+ smtp_child_say(cfd, log, "221 2.0.0 bye");
+ break;
+ } else {
+ smtp_child_say(cfd, log, "250 2.0.0 ok");
+ }
+ }
+ fclose(log);
+ close(cfd);
+ close(sfd);
+ _exit(0);
+}
+
+static pid_t fake_smtp_start(int *out_port, const char *logpath, int reject_rcpt)
+{
+ int pfd[2];
+ if (pipe(pfd) != 0)
+ return -1;
+ pid_t pid = fork();
+ if (pid == 0) {
+ close(pfd[0]);
+ smtp_child(pfd[1], logpath, reject_rcpt);
+ }
+ close(pfd[1]);
+ int port = 0;
+ ssize_t got = read(pfd[0], &port, sizeof port);
+ close(pfd[0]);
+ if (got != (ssize_t)sizeof port || port <= 0) {
+ if (pid > 0)
+ waitpid(pid, NULL, 0);
+ return -1;
+ }
+ *out_port = port;
+ return pid;
+}
+
static int count_snapshots(const char *dir)
{
DIR *d = opendir(dir);
@@ -3023,6 +3151,234 @@ int main(void)
free(preview_b64);
+ /* ---------------- invoice.send over a fake SMTP server ----------- */
+
+ static const char smtp_key[] =
+ "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f";
+ char smtp_log[600];
+ snprintf(smtp_log, sizeof smtp_log, "%s/smtp.log", tmpdir);
+ unlink(smtp_log);
+ setenv("BOKFD_SECRET_KEY", smtp_key, 1);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp1\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_host\",\"value\":\"127.0.0.1\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp2\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_from\",\"value\":\"fakturor@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* user set: the password must decrypt and AUTH PLAIN must run */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp2b\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_user\",\"value\":\"faktura@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp3\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_security\",\"value\":\"plain\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp4\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hemlig\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "[redacted]");
+ yyjson_doc_free(d);
+
+ /* dry_run before any server exists: valid config, no send, no update */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp5\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"id\":%lld,\"to\":\"test@example.se\"}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.id") == inv1);
+ CHECK_STR(d, "result.to", "test@example.se");
+ CHECK_STR(d, "result.subject", "Faktura 17761");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp6\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(yyjson_is_null(jget(d, "result.last_sent_at")));
+ yyjson_doc_free(d);
+
+ int smtp_port = 0;
+ pid_t smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 0);
+ CHECK(smtp_pid > 0);
+ CHECK(smtp_port > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp7\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ char sent_at[64] = "";
+ d = call(reqf("{\"v\":1,\"id\":\"smtp8\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.id") == inv1);
+ CHECK_STR(d, "result.sent_to", "faktura@andra.se");
+ if (jstr(d, "result.at"))
+ snprintf(sent_at, sizeof sent_at, "%s", jstr(d, "result.at"));
+ CHECK(sent_at[0] != '\0');
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+
+ {
+ FILE *lf = fopen(smtp_log, "rb");
+ CHECK(lf != NULL);
+ if (lf) {
+ char lbuf[65536];
+ size_t ln = fread(lbuf, 1, sizeof lbuf - 1, lf);
+ lbuf[ln] = '\0';
+ fclose(lf);
+ CHECK(strstr(lbuf, "Faktura 17761") != NULL);
+ CHECK(strstr(lbuf, "application/pdf") != NULL);
+ CHECK(strstr(lbuf, "To: <faktura@andra.se>") != NULL);
+ CHECK(strstr(lbuf, "hemlig") == NULL);
+ }
+ }
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp9\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.last_sent_at", sent_at);
+ CHECK_STR(d, "result.last_sent_to", "faktura@andra.se");
+ yyjson_doc_free(d);
+
+ char *send_audit = db_text(
+ g_db, "SELECT request_json FROM audit_log"
+ " WHERE action='invoice.send' ORDER BY seq DESC LIMIT 1");
+ CHECK(send_audit != NULL);
+ CHECK(send_audit && strstr(send_audit, "\"subject\":\"Faktura 17761\""));
+ CHECK(send_audit && strstr(send_audit, "hemlig") == NULL);
+ free(send_audit);
+
+ /* with a user set the stored password must decrypt */
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"smtp9b\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_STR(d, "error.code", "SMTP_NOT_CONFIGURED");
+ yyjson_doc_free(d);
+ setenv("BOKFD_SECRET_KEY", smtp_key, 1);
+
+ /* a refused recipient maps to SMTP_FAILED and leaves the stamp alone */
+ unlink(smtp_log);
+ smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 1);
+ CHECK(smtp_pid > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp10\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp11\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_STR(d, "error.code", "SMTP_FAILED");
+ CHECK(jstr(d, "error.message") && strstr(jstr(d, "error.message"), "550"));
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp12\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.last_sent_at", sent_at);
+ CHECK_STR(d, "result.last_sent_to", "faktura@andra.se");
+ yyjson_doc_free(d);
+
+ /* missing SMTP settings on a fresh org; recipient is checked first */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp13\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"SMTP Saknas AB\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t smtp_org = jint(d, "result.id");
+ CHECK(smtp_org > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp14\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Med Epost AB\",\"email\":\"mottagare@example.se\"}}",
+ g_session, (int)smtp_org));
+ CHECK_OK(d);
+ int64_t smtp_cust = jint(d, "result.id");
+ CHECK(smtp_cust > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp15\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Utan Epost AB\"}}",
+ g_session, (int)smtp_org));
+ CHECK_OK(d);
+ int64_t no_mail_cust = jint(d, "result.id");
+ CHECK(no_mail_cust > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp16\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"Tjänst\","
+ "\"quantity\":\"1\",\"unit_price_ore\":10000,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)smtp_org, (long long)smtp_cust));
+ CHECK_OK(d);
+ int64_t smtp_inv = jint(d, "result.id");
+ CHECK(smtp_inv > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp17\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)smtp_org, (long long)smtp_inv));
+ CHECK_STR(d, "error.code", "SMTP_NOT_CONFIGURED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp18\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"Tjänst\","
+ "\"quantity\":\"1\",\"unit_price_ore\":10000,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)smtp_org, (long long)no_mail_cust));
+ CHECK_OK(d);
+ int64_t no_mail_inv = jint(d, "result.id");
+ CHECK(no_mail_inv > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp19\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)smtp_org, (long long)no_mail_inv));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ CHECK(jstr(d, "error.message") &&
+ strstr(jstr(d, "error.message"), "no e-mail") != NULL);
+ yyjson_doc_free(d);
+
+ unsetenv("BOKFD_SECRET_KEY");
+
/* ---------------- moms rules: ranges merge per box ------------- */
d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"org.create\","
"\"session\":\"%s\",\"args\":{\"name\":\"Moms AB\"}}",