You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Mail every invoice document to the accounting mailbox - #282
Before: Invoice documents of the ticket platform only reached the customer. The original invoice went out with the order confirmation; corrections (Rechnungsberichtigung) and cancellations (Stornorechnung) created through the invoice-lifecycle endpoint were not mailed anywhere. Accounting had to collect the PDFs from Directus by hand, so timely recording of each invoice (GoBD) depended on someone remembering to do it.
After: Every generated invoice document is also mailed to the accounting inbox (ACCOUNTING_INVOICE_EMAIL), from where accounting takes it into the bookkeeping system. It is a separate mail, not a BCC on the customer mail, sent through the regular mail setup like every other CMS mail (Directus MailService, sender EMAIL_FROM). Each mail carries exactly one PDF (same bytes and file name as the stored file), and subject and body say what the document is, so it can be classified without opening the PDF:
Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung
Veranstaltung: programmier.con 2026
Rechnungsnummer: PB-CON26-049
Rechnungsdatum: 21.10.2026
Leistungszeitraum: 25.–26.11.2026
Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026
Bruttobetrag: 1.189,00 €
Aussteller: Lotum media GmbH
Das Dokument liegt als PDF bei (Rechnungsberichtigung-PB-CON26-049.pdf).
The conference name comes from the order's conference title (just Ticket if it is empty); the invoice number follows the document type as the unique key. The body contains no customer data (no names, e-mail or postal addresses); the PDF carries everything else. Internal (0 €) orders have no invoice, so nothing is sent for them.
How: A shared helper, shared/accounting-mail.ts (sendInvoiceToAccounting), builds subject and plain-text body and sends the mail with the existing sendRawEmail from shared/email-service.ts. sendRawEmail now also accepts attachments and a text-only body; the attachment mapping is shared with sendTemplatedEmail instead of duplicated. The helper:
reads the inbox from ACCOUNTING_INVOICE_EMAIL. If it is unset or empty, it skips the mail with an info log, and alerts Slack once per process start with the first affected invoice number (retried with the next document if the post fails; silent when Slack is not configured).
retries up to 3 times. If it still fails, it posts a Slack alert with the invoice number and order number so the PDF can be forwarded by hand.
never throws, so a failed accounting mail cannot break order processing or the endpoint.
never touches sent_at, which keeps recording only the customer dispatch.
Where the mail is sent:
ticket-order-processing: right after the original invoice is generated, whether or not the customer mail later succeeds (the document exists either way).
invoice-lifecycle: after a correction or cancellation is created. Responses include accounting_mail: 'sent' | 'skipped' | 'failed', and the admin interface shows it in the success message. The confirmation texts now say "no mail to the customer" instead of "no mail at all".
Regenerate: A regenerate (same number, re-rendered) is only allowed while sent_at is null, and that is exactly the state after the paid hook already sent the accounting copy (customer mail failed, or a correction not yet dispatched). Skipping it would leave a stale version in the books while the customer later receives the new one, so the regenerated PDF is sent too, marked (ersetzt vorherige Fassung) in subject and body. A regenerated correction keeps its type and its reference to the corrected invoice.
Tests: Unit tests for the helper (subject/body per document type, missing optional fields, the real sendRawEmail on a fake MailService with exactly one attachment and no custom sender, retry and Slack escalation, unset inbox alert once / retried / silent without Slack), plus hook and endpoint cases: original handed over with the printed invoice date and snapshot, also when the customer mail fails; correction, cancellation and regenerate sent with uploaded bytes = attached bytes; persistent failure leaves the document and sent_at untouched; internal orders and blocked operations send nothing.
prettier:check, lint, test (48 suites, 672 tests) and build pass.
Deployment:
Set ACCOUNTING_INVOICE_EMAIL to the accounting inbox in the Directus env and restart. Nothing else is needed; the mail uses the existing EMAIL_* configuration.
Mails go out only for documents created after the deploy. Older documents are not sent retroactively.
Each generated invoice document (original invoice from the paid hook,
Rechnungsberichtigung and Stornorechnung from the invoice-lifecycle
endpoint) is sent as a separate plain-text mail with exactly one PDF
attachment to ACCOUNTING_INVOICE_EMAIL, for timely recording (GoBD).
A pre-issuance regenerate sends the replacement version, marked
"(ersetzt vorherige Fassung)", since accounting already holds the
earlier version under the same number.
Unset env var: skipped with an info log. Failures are retried three
times, then alerted to Slack with the invoice number; they never fail
order processing or the endpoint and never touch sent_at.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
The accounting upload mailbox rejects mail (550 at RCPT TO) unless the
SMTP envelope sender equals its registered sender address. The Directus
MailService goes through the transactional mail provider, which always
uses its own return path, so it can never pass that check.
Send the accounting copy through its own nodemailer transport configured
by ACCOUNTING_SMTP_HOST/PORT/SECURE/USER/PASSWORD and ACCOUNTING_MAIL_FROM,
created lazily once, with `from` and the envelope sender both set to
ACCOUNTING_MAIL_FROM. No fallback to the MailService. If
ACCOUNTING_INVOICE_EMAIL is set but the SMTP config is incomplete, skip
with a warning per document and a Slack alert once per process.
nodemailer is added as a bundle dependency (same version Directus
ships) and kept external in the Rollup build. The sendRawEmail
attachment extension is reverted, since nothing uses it anymore.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
As discussed, we will not be used an additional mail transporter just for the transfer of invoices to DATEV.
Instead, we will do the following:
Jan: können wir das Mailversand Problem so lösen, dass wir zB eine Mail an accounting@ schicken und von dort aus mittels Zappier oder so eine Weiterleitung zu Datev machen? Dann nutzen wir eine echte Inbox, ohne, dass wir zwei Mailanbieter im CMS haben. Und solange alles weiterhin über Brevo läuft können wir deren Logs etc für alles Nutzen.
Claude: Das geht, aber nur, wenn der Zapier-Schritt die Mail über das Google-Konto von accounting@ verschickt, z. B. mit der Gmail-Aktion „Send Email“. DATEV prüft den technischen Absender. Zapiers eigener Mailversand und eine normale Gmail-Weiterleitung setzen dort eine andere Adresse, und DATEV lehnt dann mit 550 ab. Mit diesem Weg fällt in #282 der eigene SMTP-Zugang weg, und die Buchhaltungskopie geht einfach per Brevo an accounting@.
So we can continue to use the existing SMTP provider (Brevo) and merely send invoices to accounting@lotum.de
From there, they will be forwarded to DATEV.
So, as the next step, we need to remove any hint of the previously created "new shared helper, shared/accounting-mail.ts (sendInvoiceToAccounting), that sends the mail through its own SMTP transport (nodemailer, created lazily once)" and then can re-use the exisitng mail infrastructure after all.
The accounting copy now goes to ACCOUNTING_INVOICE_EMAIL through the shared
email service (Directus MailService, sender EMAIL_FROM) like every other mail;
accounting takes it from that inbox into the bookkeeping system. The dedicated
SMTP transport, its env vars, the nodemailer dependency and the related
misconfiguration alert are removed.
sendRawEmail can now send plain-text-only mails with attachments, reusing the
attachment mapping of sendTemplatedEmail.
Subject and body identify the document for automatic classification:
"Ausgangsrechnung <conference> – Ticketrechnung / Rechnungsberichtigung ... zu ... /
Stornorechnung (Gutschrift) ... zu ...", with Dokumentart, Rechnungsnummer,
Rechnungsdatum, Leistungszeitraum, Bezug auf Originalrechnung, Bruttobetrag
and Aussteller in the body, no customer data.
An unset ACCOUNTING_INVOICE_EMAIL is now reported to Slack once per process
(first affected invoice number; retried after a failed post; silent without
Slack configuration).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
As discussed, we will not be used an additional mail transporter just for the transfer of invoices to DATEV.
Done in 74ae9ee: the separate transport and its env vars are gone; each invoice document now goes through the existing mail setup (sendRawEmail) to ACCOUNTING_INVOICE_EMAIL, with a subject/body that names it as Ausgangsrechnung with type, number and reference.
Subject and Dokumentart now read "Ausgangsrechnung <conference> Ticket – <type>
<number>", e.g. "Ausgangsrechnung programmier.con 2026 Ticket – Rechnung
PB-CON26-012"; just "Ticket" without a conference title. The invoice number
follows the type as the unique key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
The paid hook awaited the accounting copy (up to three attempts plus a Slack
alert) before creating tickets and sending the customer mails. It is now
started without awaiting, right after the invoice document exists, so it
still goes out whatever happens later; an unexpected rejection is logged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
Two regenerate requests can overlap while the first waits for accounting mail: the endpoint stores each replacement before sending, and sent_at remains null. If the older send is delayed or retried, its PDF can arrive after the newer one. For a billing-address-only change, both mails have identical subjects, bodies and filenames, including this replacement marker, so accounting cannot tell which version is current from the mail metadata. Include a persisted, ordered revision tied to the stored replacement in each mail, and test reversed delivery order. Keep the paid hook non-blocking.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Richard Hartmann · Slack thread
Before: Invoice documents of the ticket platform only reached the customer. The original invoice went out with the order confirmation; corrections (Rechnungsberichtigung) and cancellations (Stornorechnung) created through the invoice-lifecycle endpoint were not mailed anywhere. Accounting had to collect the PDFs from Directus by hand, so timely recording of each invoice (GoBD) depended on someone remembering to do it.
After: Every generated invoice document is also mailed to the accounting inbox (
ACCOUNTING_INVOICE_EMAIL), from where accounting takes it into the bookkeeping system. It is a separate mail, not a BCC on the customer mail, sent through the regular mail setup like every other CMS mail (DirectusMailService, senderEMAIL_FROM). Each mail carries exactly one PDF (same bytes and file name as the stored file), and subject and body say what the document is, so it can be classified without opening the PDF:The conference name comes from the order's conference title (just
Ticketif it is empty); the invoice number follows the document type as the unique key. The body contains no customer data (no names, e-mail or postal addresses); the PDF carries everything else. Internal (0 €) orders have no invoice, so nothing is sent for them.How: A shared helper,
shared/accounting-mail.ts(sendInvoiceToAccounting), builds subject and plain-text body and sends the mail with the existingsendRawEmailfromshared/email-service.ts.sendRawEmailnow also accepts attachments and a text-only body; the attachment mapping is shared withsendTemplatedEmailinstead of duplicated. The helper:ACCOUNTING_INVOICE_EMAIL. If it is unset or empty, it skips the mail with an info log, and alerts Slack once per process start with the first affected invoice number (retried with the next document if the post fails; silent when Slack is not configured).sent_at, which keeps recording only the customer dispatch.Where the mail is sent:
ticket-order-processing: right after the original invoice is generated, whether or not the customer mail later succeeds (the document exists either way).invoice-lifecycle: after a correction or cancellation is created. Responses includeaccounting_mail: 'sent' | 'skipped' | 'failed', and the admin interface shows it in the success message. The confirmation texts now say "no mail to the customer" instead of "no mail at all".Regenerate: A regenerate (same number, re-rendered) is only allowed while
sent_atis null, and that is exactly the state after the paid hook already sent the accounting copy (customer mail failed, or a correction not yet dispatched). Skipping it would leave a stale version in the books while the customer later receives the new one, so the regenerated PDF is sent too, marked(ersetzt vorherige Fassung)in subject and body. A regenerated correction keeps its type and its reference to the corrected invoice.Tests: Unit tests for the helper (subject/body per document type, missing optional fields, the real
sendRawEmailon a fakeMailServicewith exactly one attachment and no custom sender, retry and Slack escalation, unset inbox alert once / retried / silent without Slack), plus hook and endpoint cases: original handed over with the printed invoice date and snapshot, also when the customer mail fails; correction, cancellation and regenerate sent with uploaded bytes = attached bytes; persistent failure leaves the document andsent_atuntouched; internal orders and blocked operations send nothing.prettier:check,lint,test(48 suites, 672 tests) andbuildpass.Deployment:
ACCOUNTING_INVOICE_EMAILto the accounting inbox in the Directus env and restart. Nothing else is needed; the mail uses the existingEMAIL_*configuration.🤖 Generated with Claude Code
https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw