diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 15:57:08 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 15:57:08 +0200 |
| commit | 90068419d51b0289c65d4db9e14849fbecd34a95 (patch) | |
| tree | 8d9dbf65ee6d869a6d3cac94ec0dc7639f9d6f8a | |
| parent | 9c3892b09a439e5bafa80b7c9ef910b5af0d8598 (diff) | |
invoice: send the issued PDF over SMTP
| -rw-r--r-- | docs/PROTOCOL.md | 17 | ||||
| -rw-r--r-- | src/commands.c | 242 | ||||
| -rw-r--r-- | tests/test_core.c | 356 |
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\"}}", |
