From 15abd2ab29b100b252a1c1c1dc721cada8295401 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:13:37 +0000 Subject: [PATCH 1/6] Mail every invoice document to the accounting mailbox 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) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- directus-cms/.env.example | 5 + .../invoice-lifecycle/__tests__/index.test.ts | 149 ++++++++++++++- .../src/invoice-lifecycle/index.ts | 83 +++++++-- .../src/invoice-lifecycle/invoice-actions.vue | 24 ++- .../shared/__tests__/accounting-mail.test.ts | 170 ++++++++++++++++++ .../src/shared/accounting-mail.ts | 165 +++++++++++++++++ .../src/shared/email-service.ts | 28 +-- .../src/shared/invoice-service.ts | 5 +- .../__tests__/index.test.ts | 112 +++++++++++- .../src/ticket-order-processing/index.ts | 15 ++ docs/tickets.md | 27 ++- 11 files changed, 741 insertions(+), 42 deletions(-) create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts create mode 100644 directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts diff --git a/directus-cms/.env.example b/directus-cms/.env.example index 86328cd7..fee8b3ed 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -118,6 +118,11 @@ EMAIL_SENDMAIL_NEW_LINE="unix" # EMAIL_SMTP_USER="noreply@programmier.bar" # EMAIL_SMTP_PASSWORD="" +# Accounting mailbox: every invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) +# is mailed here as a PDF attachment, one per mail, for timely recording in the books. Leave empty +# to skip the accounting copy (e.g. locally). See docs/tickets.md. +ACCOUNTING_INVOICE_EMAIL="" + # Password Brevo's transactional webhook sends via Basic auth (POST /brevo-webhook, see # docs/newsletter.md). Without it the endpoint refuses every event (503). `openssl rand -hex 32` BREVO_WEBHOOK_SECRET="" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index 0622e2b6..bcf187ff 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import { sendTemplatedEmail } from '../../shared/email-service.ts' +import { sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' +import { postSlackMessage } from '../../shared/postSlackMessage.ts' import registerEndpoint from '../index.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, @@ -11,13 +12,21 @@ jest.mock('@directus/extensions-sdk', () => ({ // `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. jest.mock('../../shared/museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) -// The lifecycle endpoint must NEVER send mail. Mocking the shared email service -// lets the tests assert that even if a future change wires it in, it is not called. +// The lifecycle endpoint must NEVER mail the customer (no templated mail), only the +// plain accounting copy. Mocking the shared email service lets the tests assert both. jest.mock('../../shared/email-service.ts', () => ({ sendTemplatedEmail: jest.fn(), + sendRawEmail: jest.fn(), +})) +jest.mock('../../shared/postSlackMessage.ts', () => ({ + postSlackMessage: jest.fn(), })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) +const sendRawEmailMock = jest.mocked(sendRawEmail) +const postSlackMessageMock = jest.mocked(postSlackMessage) + +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } type RouteHandler = (req: any, res: any) => Promise @@ -101,7 +110,9 @@ function issuedOriginalRow(overrides: Record = {}) { } } -async function setup(options: { order?: Record; invoices?: any[]; conference?: any } = {}) { +async function setup( + options: { order?: Record; invoices?: any[]; conference?: any; env?: Record } = {} +) { const db: Db = { ticket_orders: [{ ...BASE_ORDER, ...options.order }], conferences: [options.conference ?? { ...CONFERENCE }], @@ -116,6 +127,8 @@ async function setup(options: { order?: Record; invoices?: any[]; c updates: [] as Array<{ collection: string; id: any; data: any }>, deletes: [] as Array<{ collection: string; id: any }>, uploads: [] as any[], + /** Bytes of each uploaded file, in upload order. */ + uploadedBytes: [] as Buffer[], accountabilities: [] as any[], } @@ -170,7 +183,10 @@ async function setup(options: { order?: Record; invoices?: any[]; c } class FakeFilesService { - async uploadOne(_stream: any, meta: any) { + async uploadOne(stream: any, meta: any) { + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + calls.uploadedBytes.push(Buffer.concat(chunks)) calls.uploads.push(meta) return `file-new-${++fileSeq}` } @@ -187,7 +203,7 @@ async function setup(options: { order?: Record; invoices?: any[]; c const context = { logger, services: { ItemsService: FakeItemsService, FilesService: FakeFilesService, MailService }, - env: {}, + env: options.env ?? {}, getSchema: async () => ({}), } @@ -217,6 +233,9 @@ async function setup(options: { order?: Record; invoices?: any[]; c beforeEach(() => { sendTemplatedEmailMock.mockReset() + sendRawEmailMock.mockReset() + sendRawEmailMock.mockResolvedValue(true) + postSlackMessageMock.mockReset() }) describe('invoice-lifecycle endpoint', () => { @@ -285,6 +304,7 @@ describe('invoice-lifecycle endpoint', () => { invoice_number: 'PB-CON26-001', invoice_file: 'file-new-1', document_id: 'doc-original', + accounting_mail: 'skipped', // no ACCOUNTING_INVOICE_EMAIL in this setup }) // Document row updated in place: new file, refreshed snapshot, same number. @@ -521,7 +541,7 @@ describe('invoice-lifecycle endpoint', () => { expect(sendTemplatedEmailMock).not.toHaveBeenCalled() }) - test('never instantiates the mail service', async () => { + test('never instantiates the mail service directly (all mail goes through the shared helpers)', async () => { const { call, MailService } = await setup({ invoices: [issuedOriginalRow()] }) await call('cancellation') expect(MailService).not.toHaveBeenCalled() @@ -595,4 +615,119 @@ describe('invoice-lifecycle endpoint', () => { } }) }) + + describe('accounting copy', () => { + test('a correction is mailed to accounting with the stored PDF', async () => { + const { call, calls, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mail.to).toBe('accounting@example.test') + expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002') + expect(mail.text).toContain('zu Rechnung PB-CON26-001') + expect(mail.attachments).toHaveLength(1) + expect(mail.attachments![0]!.filename).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') + // Same bytes as the stored file. + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(mail.attachments![0]!.content.subarray(0, 4).toString()).toBe('%PDF') + + // The customer is not mailed, and the accounting copy does not issue the document. + expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + expect(db.ticket_invoices.find((r) => r.type === 'correction').sent_at).toBeNull() + }) + + test('a cancellation is mailed to accounting', async () => { + const { call, calls } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mail.subject).toBe('Stornorechnung PB-CON26-002') + expect(mail.attachments![0]!.filename).toBe('Stornorechnung-PB-CON26-002.pdf') + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + }) + + test('a pre-issuance regenerate sends the new version, marked as replacing the previous one', async () => { + const { call, calls } = await setup({ + invoices: [issuedOriginalRow({ sent_at: null })], + env: ACCOUNTING_ENV, + }) + + const res = await call('regenerate') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') + expect(mail.attachments![0]!.filename).toBe('Rechnung-PB-CON26-001.pdf') + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + }) + + test('a regenerated correction keeps its document type and reference in the accounting mail', async () => { + const correction = issuedOriginalRow({ + id: 'doc-correction-1', + type: 'correction', + invoice_number: 'PB-CON26-002', + related_invoice: 'doc-original', + date_created: '2026-02-01T00:00:00.000Z', + sent_at: null, + }) + const { call } = await setup({ invoices: [issuedOriginalRow(), correction], env: ACCOUNTING_ENV }) + + const res = await call('regenerate') + + expect(res.statusCode).toBe(200) + const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002 (ersetzt vorherige Fassung)') + expect(mail.text).toContain('zu Rechnung PB-CON26-001') + }) + + test('is skipped with an info log when ACCOUNTING_INVOICE_EMAIL is not set', async () => { + const { call, logger } = await setup({ invoices: [issuedOriginalRow()] }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('skipped') + expect(sendRawEmailMock).not.toHaveBeenCalled() + const infoLog = logger.info.mock.calls.map((c) => String(c[0])).join('\n') + expect(infoLog).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(logger.error).not.toHaveBeenCalled() + }) + + test('persistent failure: document is still created, Slack is alerted, endpoint answers 200', async () => { + sendRawEmailMock.mockResolvedValue(false) + const { call, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('failed') + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('Stornorechnung PB-CON26-002') + expect(db.ticket_invoices.find((r) => r.type === 'cancellation')).toMatchObject({ + invoice_number: 'PB-CON26-002', + sent_at: null, + }) + }) + + test('blocked operations and internal orders send nothing', async () => { + const blocked = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + expect((await blocked.call('regenerate')).statusCode).toBe(400) + + const internal = await setup({ order: { is_internal: true }, env: ACCOUNTING_ENV }) + expect((await internal.call('correction')).statusCode).toBe(400) + + expect(sendRawEmailMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts index a4f7439e..1f8a7ee2 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts @@ -1,6 +1,7 @@ /// import { defineEndpoint } from '@directus/extensions-sdk' import type { SandboxEndpointRouter } from 'directus:api' +import { sendInvoiceToAccounting, type AccountingInvoiceDocument } from '../shared/accounting-mail.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { buildInvoiceSnapshot, @@ -73,9 +74,11 @@ interface OrderContext { * invoice's snapshot and an explicit reference to it. Touches neither Stripe nor * tickets nor the order status. * - * None of the routes sends an email, none deletes a file, and issued documents are - * never modified — every operation only ever adds a new document or (pre-issuance) - * swaps the PDF of the not-yet-sent one. + * None of the routes emails the customer, none deletes a file, and issued documents + * are never modified — every operation only ever adds a new document or (pre-issuance) + * swaps the PDF of the not-yet-sent one. Every rendered PDF is copied to the + * accounting mailbox (`ACCOUNTING_INVOICE_EMAIL`, see shared/accounting-mail.ts); the + * response reports that as `accounting_mail: 'sent' | 'skipped' | 'failed'`. */ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger @@ -223,13 +226,35 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => return env.STORAGE_LOCATIONS?.split(',')[0] } + /** + * Copy a freshly rendered document to the accounting mailbox. Runs after the + * document row is stored and never throws, so a mail problem cannot turn a + * successfully created document into an error response (persistent failures are + * escalated to Slack inside). + */ + function sendToAccounting(req: any, ctx: OrderContext, doc: Omit) { + return sendInvoiceToAccounting( + { ...doc, orderNumber: ctx.order.order_number }, + { + env, + emailContext: { + logger, + services: context.services, + getSchema: context.getSchema, + accountability: req.accountability, + }, + source: ENDPOINT_NAME, + } + ) + } + /** * Regenerate: replace the PDF of the current (not yet issued) invoice document * in place. Keeps the invoice number and the original invoice date. The previous * file is intentionally NOT deleted. Once the document has been sent, this is * blocked — issued documents are immutable and must be corrected instead. */ - registerRoute('regenerate', async (_req, ctx) => { + registerRoute('regenerate', async (req, ctx) => { const current = requireCurrentDocument(ctx) if (ctx.order.status !== 'paid') { @@ -250,6 +275,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.conference) let rendered: RenderInvoiceResult + let correctedNumber: string | null = null if (current.type === 'correction') { // A regenerated correction must keep its printed reference to the corrected // invoice (§31 Abs. 5 UStDV) — without a resolvable predecessor the document @@ -261,6 +287,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `Correction ${current.invoice_number} does not reference a known invoice document — cannot regenerate it with the legally required reference` ) } + correctedNumber = predecessor.invoice_number rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber: current.invoice_number, @@ -295,7 +322,25 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Regenerated invoice ${current.invoice_number} for order ${ctx.order.order_number} (file: ${rendered.fileId})` ) - return { invoice_number: current.invoice_number, invoice_file: rendered.fileId, document_id: current.id } + // The earlier version was already copied to accounting when it was created + // (the paid hook does so even when the customer mail failed, which is exactly + // when a regenerate is possible). Send the replacement too, marked as such, so + // the books hold the version the customer will actually receive. + const accountingMail = await sendToAccounting(req, ctx, { + documentKind: current.type === 'correction' ? 'correction' : 'invoice', + invoiceNumber: current.invoice_number, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: correctedNumber, + replacesPreviousVersion: true, + }) + + return { + invoice_number: current.invoice_number, + invoice_file: rendered.fileId, + document_id: current.id, + accounting_mail: accountingMail, + } }) /** @@ -303,9 +348,9 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * current billing data with its own invoice number and today's date, explicitly * referencing the corrected invoice. The corrected document stays untouched; * the order's legacy pointers move to the correction (the new authoritative - * invoice). No email is sent. + * invoice). The customer is not emailed; accounting gets a copy. */ - registerRoute('correction', async (_req, ctx) => { + registerRoute('correction', async (req, ctx) => { const current = requireCurrentDocument(ctx) if (ctx.order.status !== 'paid') { @@ -361,11 +406,20 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created correction ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) + const accountingMail = await sendToAccounting(req, ctx, { + documentKind: 'correction', + invoiceNumber, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, corrects: current.invoice_number, + accounting_mail: accountingMail, } }) @@ -373,10 +427,10 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * Cancellation (Stornorechnung): create a NEW document with its own invoice * number, negative amounts mirroring the referenced invoice's immutable snapshot * and an explicit reference to it. Deliberately does NOT touch Stripe, tickets, - * the order status or the order's invoice pointers, and sends no email — it only - * records the cancellation document. + * the order status or the order's invoice pointers, and does not email the + * customer — it only records the cancellation document (accounting gets a copy). */ - registerRoute('cancellation', async (_req, ctx) => { + registerRoute('cancellation', async (req, ctx) => { const current = requireCurrentDocument(ctx) if (!current.sent_at) { @@ -438,11 +492,20 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created cancellation ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) + const accountingMail = await sendToAccounting(req, ctx, { + documentKind: 'cancellation', + invoiceNumber, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, cancels: current.invoice_number, + accounting_mail: accountingMail, } }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue index c36ffad0..f298b2da 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue @@ -36,7 +36,8 @@ const ACTION_TEXT: Record = { + sent: 'Kopie an die Buchhaltung verschickt.', + failed: 'Kopie an die Buchhaltung fehlgeschlagen — Hinweis ging an Slack, bitte manuell weiterleiten.', + skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', +} + let loadStateRunId = 0 async function loadState(): Promise { @@ -106,12 +115,13 @@ async function runAction(action: ActionKey): Promise { // server-side admin check runs against the logged-in user. const response = await api.post(`/invoice-lifecycle/${encodeURIComponent(props.primaryKey)}/${action}`) const nr = response.data.invoice_number - successMessage.value = + const message = action === 'regenerate' ? `Rechnung ${nr} wurde neu generiert. Seite neu laden, um die neue Datei zu sehen.` : action === 'correction' - ? `Rechnungsberichtigung ${nr} wurde erstellt (berichtigt ${response.data.corrects}). Sie wurde NICHT automatisch versendet.` - : `Stornorechnung ${nr} wurde erstellt (storniert ${response.data.cancels}). Sie wurde NICHT automatisch versendet.` + ? `Rechnungsberichtigung ${nr} wurde erstellt (berichtigt ${response.data.corrects}). Sie wurde NICHT automatisch an den Kunden versendet.` + : `Stornorechnung ${nr} wurde erstellt (storniert ${response.data.cancels}). Sie wurde NICHT automatisch an den Kunden versendet.` + successMessage.value = `${message} ${ACCOUNTING_MAIL_TEXT[response.data.accounting_mail] ?? ''}`.trim() confirmAction.value = null await loadState() } catch (error: any) { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts new file mode 100644 index 00000000..3bac10d7 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { + buildAccountingMail, + getAccountingInvoiceEmail, + sendInvoiceToAccounting, + type AccountingInvoiceDocument, +} from '../accounting-mail.ts' +import { sendRawEmail } from '../email-service.ts' +import { postSlackMessage } from '../postSlackMessage.ts' + +// `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. +jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) +jest.mock('../email-service.ts', () => ({ sendRawEmail: jest.fn() })) +jest.mock('../postSlackMessage.ts', () => ({ postSlackMessage: jest.fn() })) + +const sendRawEmailMock = jest.mocked(sendRawEmail) +const postSlackMessageMock = jest.mocked(postSlackMessage) + +const DOC: AccountingInvoiceDocument = { + documentKind: 'invoice', + invoiceNumber: 'PB-CON26-001', + pdfBuffer: Buffer.from('%PDF-fake'), + invoiceFileName: 'Rechnung-PB-CON26-001.pdf', + orderNumber: 'ORD-2026-ABC123', +} + +const ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +function makeOptions(env: Record | undefined = ENV) { + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + return { + logger, + options: { + env, + emailContext: { logger: logger as any, services: {} as any, getSchema: async () => ({}) }, + source: 'test-source', + }, + } +} + +beforeEach(() => { + sendRawEmailMock.mockReset() + sendRawEmailMock.mockResolvedValue(true) + postSlackMessageMock.mockReset() +}) + +describe('getAccountingInvoiceEmail', () => { + test.each([ + ['unset', {}], + ['empty', { ACCOUNTING_INVOICE_EMAIL: '' }], + ['whitespace', { ACCOUNTING_INVOICE_EMAIL: ' ' }], + ['not a string', { ACCOUNTING_INVOICE_EMAIL: true }], + ['no env at all', undefined], + ])('returns null when %s', (_label, env) => { + expect(getAccountingInvoiceEmail(env as any)).toBeNull() + }) + + test('returns the trimmed address', () => { + expect(getAccountingInvoiceEmail({ ACCOUNTING_INVOICE_EMAIL: ' accounting@example.test ' })).toBe( + 'accounting@example.test' + ) + }) +}) + +describe('buildAccountingMail', () => { + test('original invoice: minimal subject, one-line body', () => { + const mail = buildAccountingMail(DOC) + expect(mail.subject).toBe('Rechnung PB-CON26-001') + expect(mail.text).toBe('Rechnung PB-CON26-001 (Bestellung ORD-2026-ABC123) im Anhang.\n') + }) + + test('correction and cancellation name their document type and the referenced invoice', () => { + const correction = buildAccountingMail({ + ...DOC, + documentKind: 'correction', + invoiceNumber: 'PB-CON26-002', + referenceNumber: 'PB-CON26-001', + }) + expect(correction.subject).toBe('Rechnungsberichtigung PB-CON26-002') + expect(correction.text).toContain('zu Rechnung PB-CON26-001') + + const cancellation = buildAccountingMail({ + ...DOC, + documentKind: 'cancellation', + invoiceNumber: 'PB-CON26-003', + }) + expect(cancellation.subject).toBe('Stornorechnung PB-CON26-003') + }) + + test('a replacement version is marked in the subject', () => { + const mail = buildAccountingMail({ ...DOC, replacesPreviousVersion: true }) + expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') + expect(mail.text).toContain('ersetzt') + }) +}) + +describe('sendInvoiceToAccounting', () => { + test('sends one plain-text mail with exactly the document PDF attached', async () => { + const { options } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') + + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const [mail, emailContext] = sendRawEmailMock.mock.calls[0]! + expect(mail).toEqual({ + to: 'accounting@example.test', + subject: 'Rechnung PB-CON26-001', + text: 'Rechnung PB-CON26-001 (Bestellung ORD-2026-ABC123) im Anhang.\n', + attachments: [ + { filename: 'Rechnung-PB-CON26-001.pdf', content: DOC.pdfBuffer, contentType: 'application/pdf' }, + ], + }) + expect(mail.attachments![0]!.content).toBe(DOC.pdfBuffer) // the very same bytes + // The upload mailbox rejects HTML/links/images and checks the sender: plain text + // only, and no from/sender/envelope override of the configured EMAIL_FROM. + expect(mail).not.toHaveProperty('html') + expect(mail).not.toHaveProperty('from') + expect(mail).not.toHaveProperty('sender') + expect(mail).not.toHaveProperty('envelope') + expect(mail.text).not.toMatch(/https?:|www\./) + expect(emailContext).toBe(options.emailContext) + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('skips with one info log (no error) when the mailbox is not configured', async () => { + const { options, logger } = makeOptions({}) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + + expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(logger.info).toHaveBeenCalledTimes(1) + expect(logger.info.mock.calls[0]![0]).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(logger.info.mock.calls[0]![0]).toContain('PB-CON26-001') + expect(logger.warn).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) + + test('retries a failed send and recovers without escalating', async () => { + sendRawEmailMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const { options } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') + + expect(sendRawEmailMock).toHaveBeenCalledTimes(2) + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('persistent failure: alerts Slack with the invoice number and does not throw', async () => { + sendRawEmailMock.mockResolvedValue(false) + const { options, logger } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') + + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('PB-CON26-001') + expect(slack).toContain('ORD-2026-ABC123') + expect(slack).toContain('Rechnung-PB-CON26-001.pdf') + // The address is configuration, not something to broadcast. + expect(slack).not.toContain('accounting@example.test') + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('PB-CON26-001') + }) + + test('a throwing transport and a failing Slack alert are both swallowed', async () => { + sendRawEmailMock.mockRejectedValue(new Error('smtp exploded')) + postSlackMessageMock.mockRejectedValue(new Error('slack down')) + const { options, logger } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') + + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts new file mode 100644 index 00000000..6c34e6e1 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts @@ -0,0 +1,165 @@ +/** + * Accounting copy of every invoice document. + * + * Each generated invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) is + * mailed to an accounting mailbox, one PDF per mail, so it is recorded in the books + * promptly (GoBD). This is a separate mail — never a BCC on the customer mail — and is + * independent of whether the customer mail went out: the document exists either way. + * + * The accounting mailbox uploads attachments into the bookkeeping system and rejects + * mails with links, images, logos or QR codes. So the mail is plain text only (no HTML, + * no template, no links, no signature), carries exactly one PDF, and names the invoice + * number in the subject. It sets no `from`, `sender` or `envelope`: Directus' MailService + * fills `from` with EMAIL_FROM and nodemailer derives the SMTP envelope sender (MAIL FROM) + * from it, so the envelope sender is the configured EMAIL_FROM. + * + * The mailbox comes from the `ACCOUNTING_INVOICE_EMAIL` Directus env var. Without it the + * accounting copy is skipped (info log), so environments without bookkeeping (local, + * staging) keep working unchanged. + */ + +import { sendRawEmail, type EmailServiceContext } from './email-service.js' +import type { InvoiceDocumentKind } from './invoice-generator.js' +import { INVOICE_DOCUMENT_LABEL } from './invoice-service.js' +import { postSlackMessage } from './postSlackMessage.js' + +export const ACCOUNTING_INVOICE_EMAIL_ENV = 'ACCOUNTING_INVOICE_EMAIL' + +/** How often to attempt sending the accounting copy before escalating to Slack. */ +export const ACCOUNTING_MAIL_ATTEMPTS = 3 + +export interface AccountingInvoiceDocument { + documentKind: InvoiceDocumentKind + invoiceNumber: string + /** The exact bytes of the stored PDF. */ + pdfBuffer: Buffer + /** Same file name as the stored PDF, e.g. `Rechnung-PB-CON26-001.pdf`. */ + invoiceFileName: string + orderNumber?: string | null + /** Number of the corrected/cancelled invoice (corrections and cancellations only). */ + referenceNumber?: string | null + /** + * The document was re-rendered under the same number (pre-issuance regenerate), so + * accounting may already hold an earlier version that this one replaces. + */ + replacesPreviousVersion?: boolean +} + +export type AccountingMailResult = 'sent' | 'skipped' | 'failed' + +export interface AccountingMailOptions { + /** The extension's env (hook/endpoint context `env`). */ + env: Record | undefined + emailContext: EmailServiceContext + /** Hook or endpoint name, for logs and the Slack alert. */ + source: string +} + +/** The configured accounting mailbox, or null when the env var is unset or empty. */ +export function getAccountingInvoiceEmail(env: Record | undefined): string | null { + const value = env?.[ACCOUNTING_INVOICE_EMAIL_ENV] + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + +/** Minimal subject and one-line plain-text body — no cover letter needed. */ +export function buildAccountingMail(doc: AccountingInvoiceDocument): { subject: string; text: string } { + const name = `${INVOICE_DOCUMENT_LABEL[doc.documentKind]} ${doc.invoiceNumber}` + const subject = doc.replacesPreviousVersion ? `${name} (ersetzt vorherige Fassung)` : name + + let text = name + if (doc.referenceNumber) { + text += ` zu Rechnung ${doc.referenceNumber}` + } + if (doc.orderNumber) { + text += ` (Bestellung ${doc.orderNumber})` + } + text += ' im Anhang.' + if (doc.replacesPreviousVersion) { + text += ' Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.' + } + + return { subject, text: `${text}\n` } +} + +/** + * Mail the document to the accounting mailbox. Never throws: a failing accounting copy + * must not fail order processing or a lifecycle operation. Retries a few times and, on + * persistent failure, alerts Slack with the invoice number so a human forwards the PDF. + * + * Deliberately does not touch `sent_at` — that records the customer dispatch only. + */ +export async function sendInvoiceToAccounting( + doc: AccountingInvoiceDocument, + options: AccountingMailOptions +): Promise { + const { emailContext, source } = options + const logger = emailContext.logger + + try { + const to = getAccountingInvoiceEmail(options.env) + if (!to) { + logger.info( + `${source}: ${ACCOUNTING_INVOICE_EMAIL_ENV} is not set — skipping accounting copy of ${doc.invoiceNumber}` + ) + return 'skipped' + } + + const { subject, text } = buildAccountingMail(doc) + + for (let attempt = 1; attempt <= ACCOUNTING_MAIL_ATTEMPTS; attempt++) { + let sent = false + try { + sent = await sendRawEmail( + { + to, + subject, + text, + attachments: [ + { + filename: doc.invoiceFileName, + content: doc.pdfBuffer, + contentType: 'application/pdf', + }, + ], + }, + emailContext + ) + } catch (err: any) { + logger.warn(`${source}: Accounting copy of ${doc.invoiceNumber} threw: ${err?.message || err}`) + } + + if (sent) { + logger.info(`${source}: Sent accounting copy of ${doc.invoiceNumber}`) + return 'sent' + } + + logger.warn( + `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed` + ) + } + + const label = INVOICE_DOCUMENT_LABEL[doc.documentKind] + const order = doc.orderNumber ? ` zu Bestellung ${doc.orderNumber}` : '' + logger.error( + `${source}: Accounting copy of ${label} ${doc.invoiceNumber}${order} could not be sent after ${ACCOUNTING_MAIL_ATTEMPTS} attempts` + ) + try { + await postSlackMessage( + `:warning: *${source}*: ${label} ${doc.invoiceNumber}${order} konnte nach ${ACCOUNTING_MAIL_ATTEMPTS} Versuchen nicht an das Buchhaltungspostfach geschickt werden. Bitte das PDF (${doc.invoiceFileName}) aus Directus manuell an die Buchhaltung weiterleiten. Details im Directus-Log.` + ) + } catch (slackErr: any) { + logger.error(`${source}: Failed to send Slack notification: ${slackErr?.message || slackErr}`) + } + return 'failed' + } catch (err: any) { + // Last line of defence: nothing in here may break the caller. + logger.error( + `${source}: Unexpected error sending accounting copy of ${doc.invoiceNumber}: ${err?.message || err}` + ) + return 'failed' + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts index 5eaf0308..e0cdf8c7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts @@ -105,6 +105,16 @@ export async function getEmailTemplate( } } +/** Map attachments to the nodemailer shape MailService passes through. */ +function toMailAttachments(attachments: EmailAttachment[]) { + return attachments.map((a) => ({ + filename: a.filename, + content: a.content, + contentType: a.contentType || 'application/pdf', + ...(a.cid && { cid: a.cid }), + })) +} + /** * Send an email using a template from the CMS. */ @@ -141,14 +151,7 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema ...(options.replyTo && { replyTo: options.replyTo }), ...(options.cc && { cc: options.cc }), ...(options.headers && { headers: options.headers }), - ...(options.attachments && { - attachments: options.attachments.map((a) => ({ - filename: a.filename, - content: a.content, - contentType: a.contentType || 'application/pdf', - ...(a.cid && { cid: a.cid }), - })), - }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to} using template '${options.templateKey}'`) @@ -167,12 +170,14 @@ export async function sendRawEmail( options: { to: string subject: string - html: string - /** Plain-text alternative, for clients that don't render HTML. */ + /** HTML body. May be omitted for a plain-text-only mail (then `text` is required). */ + html?: string + /** Plain-text body or alternative, for clients that don't render HTML. */ text?: string replyTo?: string /** Extra mail headers (e.g. List-Unsubscribe); passed through MailService to nodemailer. */ headers?: Record + attachments?: EmailAttachment[] }, context: EmailServiceContext ): Promise { @@ -190,10 +195,11 @@ export async function sendRawEmail( await mailService.send({ to: options.to, subject: options.subject, - html: options.html, + ...(options.html && { html: options.html }), ...(options.text && { text: options.text }), ...(options.replyTo && { replyTo: options.replyTo }), ...(options.headers && { headers: options.headers }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to}`) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts index 3e323213..c1bc1174 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts @@ -260,7 +260,8 @@ export interface RenderInvoiceResult { invoiceFileName: string } -const KIND_LABEL: Record = { +/** Human-readable document name, used for PDF file names, titles and mail subjects. */ +export const INVOICE_DOCUMENT_LABEL: Record = { invoice: 'Rechnung', correction: 'Rechnungsberichtigung', cancellation: 'Stornorechnung', @@ -323,7 +324,7 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi const pdfBuffer = await generateInvoicePdf(invoiceData) - const label = KIND_LABEL[kind] + const label = INVOICE_DOCUMENT_LABEL[kind] const invoiceFileName = `${label}-${invoiceNumber}.pdf` const pdfStream = Readable.from([pdfBuffer]) const fileId = await filesService.uploadOne(pdfStream, { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts index eb3179d8..26440d92 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import { sendTemplatedEmail } from '../../shared/email-service.ts' +import { sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' import { generateInvoiceNumber } from '../../shared/invoice-generator.ts' import { issueOriginalInvoice, markInvoiceSent } from '../../shared/invoice-service.ts' import { postSlackMessage } from '../../shared/postSlackMessage.ts' @@ -14,6 +14,7 @@ jest.mock('@directus/extensions-sdk', () => ({ jest.mock('../../shared/email-service.ts', () => ({ sendTemplatedEmail: jest.fn(), + sendRawEmail: jest.fn(), })) jest.mock('../../shared/settings.ts', () => ({ getSetting: jest.fn(async () => 'https://www.programmier.bar'), @@ -24,6 +25,12 @@ jest.mock('../../shared/invoice-generator.ts', () => ({ jest.mock('../../shared/invoice-service.ts', () => ({ issueOriginalInvoice: jest.fn(), markInvoiceSent: jest.fn(), + // Used by the (real) accounting-mail helper for the mail subject. + INVOICE_DOCUMENT_LABEL: { + invoice: 'Rechnung', + correction: 'Rechnungsberichtigung', + cancellation: 'Stornorechnung', + }, })) jest.mock('../../shared/ticket-utils.ts', () => ({ generateUniqueTicketCode: jest.fn(), @@ -34,6 +41,7 @@ jest.mock('../../shared/postSlackMessage.ts', () => ({ })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) +const sendRawEmailMock = jest.mocked(sendRawEmail) const postSlackMessageMock = jest.mocked(postSlackMessage) const issueOriginalInvoiceMock = jest.mocked(issueOriginalInvoice) const markInvoiceSentMock = jest.mocked(markInvoiceSent) @@ -83,7 +91,9 @@ const CONFERENCE = { ticket_max_quantity: null, } -function setup(orderOverrides: Record = {}) { +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +function setup(orderOverrides: Record = {}, env: Record = {}) { const order = { ...ORDER, ...orderOverrides } const serviceInstances: Record = {} @@ -117,7 +127,7 @@ function setup(orderOverrides: Record = {}) { logger, services: { ItemsService: FakeItemsService, FilesService, MailService }, getSchema: async () => ({}), - env: {}, + env, } registerHook(register as any, hookContext as any) @@ -132,6 +142,7 @@ function setup(orderOverrides: Record = {}) { beforeEach(() => { sendTemplatedEmailMock.mockReset() + sendRawEmailMock.mockReset() issueOriginalInvoiceMock.mockReset() markInvoiceSentMock.mockReset() postSlackMessageMock.mockReset() @@ -148,6 +159,7 @@ beforeEach(() => { snapshot: {} as any, }) sendTemplatedEmailMock.mockResolvedValue(true) + sendRawEmailMock.mockResolvedValue(true) }) describe('ticket-order-processing hook', () => { @@ -273,4 +285,98 @@ describe('ticket-order-processing hook', () => { const templates = sendTemplatedEmailMock.mock.calls.map(([opts]) => (opts as any).templateKey) expect(templates).toEqual(['ticket_profile_invitation', 'ticket_profile_invitation']) }) + + describe('accounting copy', () => { + const invitations = () => + sendTemplatedEmailMock.mock.calls.filter( + ([opts]) => (opts as any).templateKey === 'ticket_profile_invitation' + ) + + test('mails the original invoice PDF to the accounting mailbox as a separate mail', async () => { + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mail.to).toBe('accounting@example.test') + expect(mail.subject).toBe('Rechnung PB-CON26-001') + expect(mail.text).toContain('ORD-2026-ABC123') + expect(mail.html).toBeUndefined() // plain text, not the customer template + // Exactly the generated document: same bytes, same file name, one per mail. + expect(mail.attachments).toEqual([ + { + filename: 'Rechnung-PB-CON26-001.pdf', + content: Buffer.from('%PDF-fake'), + contentType: 'application/pdf', + }, + ]) + + // Not piggybacked onto the customer mail. + const confirmation = sendTemplatedEmailMock.mock.calls.find( + ([options]) => (options as any).templateKey === 'ticket_order_confirmation' + )![0] as any + expect(confirmation.cc).toBeUndefined() + expect(confirmation.bcc).toBeUndefined() + }) + + test('is sent even when the customer confirmation mail fails — and never stamps sent_at', async () => { + sendTemplatedEmailMock.mockResolvedValue(false) + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + expect(sendRawEmailMock.mock.calls[0]![0].subject).toBe('Rechnung PB-CON26-001') + expect(markInvoiceSentMock).not.toHaveBeenCalled() + }) + + test('is skipped with an info log when ACCOUNTING_INVOICE_EMAIL is not set', async () => { + const { invoke, logger } = setup({}, { ACCOUNTING_INVOICE_EMAIL: '' }) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).not.toHaveBeenCalled() + const skipLogs = logger.info.mock.calls + .map((call) => String(call[0])) + .filter((msg) => msg.includes('ACCOUNTING_INVOICE_EMAIL')) + expect(skipLogs).toHaveLength(1) + expect(skipLogs[0]).toContain('PB-CON26-001') + expect(logger.error).not.toHaveBeenCalled() + // Customer flow is unaffected. + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) + }) + + test('persistent failure: retried, escalated to Slack with the invoice number, hook carries on', async () => { + sendRawEmailMock.mockResolvedValue(false) + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('PB-CON26-001') + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ORD-2026-ABC123') + + // The customer still got their invoice, issuance is recorded from THAT mail, + // tickets exist and attendees were invited. + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) + expect(invitations()).toHaveLength(2) + }) + + test('a throwing mail transport does not break order processing either', async () => { + sendRawEmailMock.mockRejectedValue(new Error('smtp exploded')) + postSlackMessageMock.mockRejectedValue(new Error('slack down')) + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) + expect(invitations()).toHaveLength(2) + }) + + test('internal orders have no invoice, so nothing goes to accounting', async () => { + const { invoke } = setup({ is_internal: true }, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index 336ab0f8..db65861d 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { defineHook } from '@directus/extensions-sdk' +import { sendInvoiceToAccounting } from '../shared/accounting-mail.js' import { sendTemplatedEmail, type EmailServiceContext } from '../shared/email-service.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { issueOriginalInvoice, markInvoiceSent } from '../shared/invoice-service.js' @@ -217,6 +218,20 @@ export default defineHook(({ action }, hookContext) => { logger.info( `${HOOK_NAME}: Invoice ${invoiceNumber} generated and stored (file: ${invoiceResult.fileId}, document: ${invoiceResult.documentId})` ) + + // Accounting copy (GoBD: timely recording). The document exists now, + // so it goes out regardless of what happens to the customer mail below. + // Never throws; persistent failures are escalated to Slack inside. + await sendInvoiceToAccounting( + { + documentKind: 'invoice', + invoiceNumber, + pdfBuffer, + invoiceFileName, + orderNumber: order.order_number, + }, + { env, emailContext: context, source: HOOK_NAME } + ) } else { logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) } diff --git a/docs/tickets.md b/docs/tickets.md index d2bc07bb..5e356634 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -30,6 +30,7 @@ The flow consists of four main phases: |----------|-------------|----------| | `STORAGE_LOCATIONS` | Comma-separated storage location IDs (first is used for invoice PDFs) | Yes | | Email transport config | Directus email settings (`EMAIL_TRANSPORT`, `EMAIL_SMTP_HOST`, etc.) | Yes | +| `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent | Production | ## External Services @@ -157,8 +158,9 @@ Key-value settings used by extension hooks. 1. Creates `tickets` records with unique codes and profile tokens 2. Generates invoice PDF (pdfkit) and uploads to Directus files 3. Updates order with `invoice_number` and `invoice_file` - 4. Sends `ticket_order_confirmation` email to purchaser (with invoice PDF attachment, CC to `billing_email` if set) - 5. Sends `ticket_profile_invitation` email to all attendees + 4. Sends a copy of the invoice PDF to the accounting mailbox (see [Accounting copy](#accounting-copy)) + 5. Sends `ticket_order_confirmation` email to purchaser (with invoice PDF attachment, CC to `billing_email` if set) + 6. Sends `ticket_profile_invitation` email to all attendees ### `ticket-profile-completion` @@ -175,6 +177,27 @@ Key-value settings used by extension hooks. - **Seller**: Lotum media GmbH, Am Goldstein 1, 61231 Bad Nauheim, USt-ID DE272856704 - **Line items**: Show netto (net) prices; VAT (19%) shown separately in totals +## Accounting Copy + +Every generated invoice document is mailed to `ACCOUNTING_INVOICE_EMAIL` (GoBD: timely recording), +as its own plain-text mail from `EMAIL_FROM` with exactly one PDF attached — the same bytes and file +name as the stored file. Subject: `Rechnung PB-CON26-001`, `Rechnungsberichtigung …` or +`Stornorechnung …`. The upload mailbox rejects mails with links, images, logos or QR codes, so the +mail carries no HTML, template, link or signature. The code sets no sender/envelope override, so the +SMTP envelope sender (MAIL FROM) is `EMAIL_FROM` — unless the mail relay rewrites it, which has to be +checked on the relay side. + +| Document | Sent by | +|----------|---------| +| Original invoice | `ticket-order-processing`, right after the document is generated — independent of the customer mail | +| Rechnungsberichtigung / Stornorechnung | `invoice-lifecycle` endpoint, when the document is created | +| Regenerated (not yet issued) document | `invoice-lifecycle` endpoint, subject suffixed `(ersetzt vorherige Fassung)` — accounting already holds the earlier version under the same number | + +Internal orders have no invoice, so nothing is sent. The accounting copy never sets `sent_at` (that +records the customer dispatch). It is retried up to three times; if it still fails, a Slack alert +names the invoice number so the PDF can be forwarded by hand. Order processing and the lifecycle +endpoint carry on either way. + ## Data Flow ``` From 3838712e8cc147455ddaab416a109ea0f9f131b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:10:43 +0000 Subject: [PATCH 2/6] Send the accounting copy over a dedicated SMTP transport 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) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- directus-cms/.env.example | 15 ++ .../extension.config.js | 5 +- .../package-lock.json | 10 + .../package.json | 1 + .../invoice-lifecycle/__tests__/index.test.ts | 45 ++-- .../src/invoice-lifecycle/index.ts | 27 +-- .../src/invoice-lifecycle/invoice-actions.vue | 2 + .../shared/__tests__/accounting-mail.test.ts | 170 ++++++++++++--- .../src/shared/accounting-mail.ts | 194 ++++++++++++++---- .../src/shared/email-service.ts | 28 +-- .../__tests__/index.test.ts | 60 ++++-- .../src/ticket-order-processing/index.ts | 2 +- docs/tickets.md | 26 ++- 13 files changed, 444 insertions(+), 141 deletions(-) diff --git a/directus-cms/.env.example b/directus-cms/.env.example index fee8b3ed..64772bd3 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -123,6 +123,21 @@ EMAIL_SENDMAIL_NEW_LINE="unix" # to skip the accounting copy (e.g. locally). See docs/tickets.md. ACCOUNTING_INVOICE_EMAIL="" +# Dedicated SMTP transport for that copy, NOT the EMAIL_* transport above: the accounting mailbox +# only accepts mail whose envelope sender (MAIL FROM) is its registered sender address, and the +# transactional mail provider always uses its own return path. ACCOUNTING_MAIL_FROM must be that +# registered address (sender and envelope sender). ACCOUNTING_SMTP_SECURE works like +# EMAIL_SMTP_SECURE: "true" for port 465 (implicit TLS), "false" for 587 (STARTTLS); unset, it +# follows the port. Google Workspace: host smtp.gmail.com, port 465, secure true. Use an app +# password and never commit it. If ACCOUNTING_INVOICE_EMAIL is set but any of these is missing, no +# copy is sent and Slack is alerted. +ACCOUNTING_SMTP_HOST="" +ACCOUNTING_SMTP_PORT="" +ACCOUNTING_SMTP_SECURE="" +ACCOUNTING_SMTP_USER="" +ACCOUNTING_SMTP_PASSWORD="" +ACCOUNTING_MAIL_FROM="" + # Password Brevo's transactional webhook sends via Basic auth (POST /brevo-webhook, see # docs/newsletter.md). Without it the endpoint refuses every event (503). `openssl rand -hex 32` BREVO_WEBHOOK_SECRET="" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js index 3e4ef028..09f7af92 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js @@ -4,7 +4,8 @@ * Some dependencies (pdfkit, passkit-generator) use Node.js globals like * `__dirname` or native modules that don't work when bundled into ESM. * mjml is CommonJS with a large dependency tree (juice, cssnano, html-minifier) - * that is not meant to be bundled either. + * that is not meant to be bundled either; the same goes for nodemailer (CommonJS, used for + * the accounting-mail SMTP transport). * By marking them as external, they load from node_modules at runtime. */ export default { @@ -12,7 +13,7 @@ export default { { name: 'externalize-native-modules', resolveId(id) { - if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml') { + if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml' || id === 'nodemailer') { return { id, external: true } } return null diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json index f2b96f3b..e1b8bb6f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "mjml": "^5.4.1", + "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" @@ -12576,6 +12577,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json index 1b3a9390..dad3d0a9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json @@ -221,6 +221,7 @@ }, "dependencies": { "mjml": "^5.4.1", + "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index bcf187ff..e11e7c20 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { resetAccountingMailState } from '../../shared/accounting-mail.ts' import { sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' import { postSlackMessage } from '../../shared/postSlackMessage.ts' import registerEndpoint from '../index.ts' @@ -22,11 +23,26 @@ jest.mock('../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), })) +// The accounting copy goes through its own SMTP transport, never through MailService. +const mockSendMail = jest.fn<(mail: any) => Promise>() +jest.mock('nodemailer', () => ({ + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, +})) + const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) const sendRawEmailMock = jest.mocked(sendRawEmail) const postSlackMessageMock = jest.mocked(postSlackMessage) -const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } +const ACCOUNTING_ENV = { + ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', + ACCOUNTING_SMTP_HOST: 'smtp.example.test', + ACCOUNTING_SMTP_PORT: 465, + ACCOUNTING_SMTP_SECURE: true, + ACCOUNTING_SMTP_USER: 'sender@example.test', + ACCOUNTING_SMTP_PASSWORD: 'app-password', + ACCOUNTING_MAIL_FROM: 'sender@example.test', +} type RouteHandler = (req: any, res: any) => Promise @@ -234,7 +250,9 @@ async function setup( beforeEach(() => { sendTemplatedEmailMock.mockReset() sendRawEmailMock.mockReset() - sendRawEmailMock.mockResolvedValue(true) + mockSendMail.mockReset() + mockSendMail.mockResolvedValue({ messageId: 'x' }) + resetAccountingMailState() postSlackMessageMock.mockReset() }) @@ -624,9 +642,12 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - expect(sendRawEmailMock).toHaveBeenCalledTimes(1) - const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] expect(mail.to).toBe('accounting@example.test') + expect(mail.from).toBe('sender@example.test') + expect(mail.envelope).toEqual({ from: 'sender@example.test', to: 'accounting@example.test' }) + expect(sendRawEmailMock).not.toHaveBeenCalled() expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002') expect(mail.text).toContain('zu Rechnung PB-CON26-001') expect(mail.attachments).toHaveLength(1) @@ -647,8 +668,8 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - expect(sendRawEmailMock).toHaveBeenCalledTimes(1) - const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] expect(mail.subject).toBe('Stornorechnung PB-CON26-002') expect(mail.attachments![0]!.filename).toBe('Stornorechnung-PB-CON26-002.pdf') expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) @@ -665,7 +686,7 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - const mail = sendRawEmailMock.mock.calls[0]![0] + const mail = mockSendMail.mock.calls[0]![0] expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') expect(mail.attachments![0]!.filename).toBe('Rechnung-PB-CON26-001.pdf') expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) @@ -685,7 +706,7 @@ describe('invoice-lifecycle endpoint', () => { const res = await call('regenerate') expect(res.statusCode).toBe(200) - const mail = sendRawEmailMock.mock.calls[0]![0] + const mail = mockSendMail.mock.calls[0]![0] expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002 (ersetzt vorherige Fassung)') expect(mail.text).toContain('zu Rechnung PB-CON26-001') }) @@ -697,21 +718,21 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('skipped') - expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() const infoLog = logger.info.mock.calls.map((c) => String(c[0])).join('\n') expect(infoLog).toContain('ACCOUNTING_INVOICE_EMAIL') expect(logger.error).not.toHaveBeenCalled() }) test('persistent failure: document is still created, Slack is alerted, endpoint answers 200', async () => { - sendRawEmailMock.mockResolvedValue(false) + mockSendMail.mockRejectedValue(new Error('550 sender rejected')) const { call, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) const res = await call('cancellation') expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('failed') - expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(mockSendMail).toHaveBeenCalledTimes(3) expect(postSlackMessageMock).toHaveBeenCalledTimes(1) expect(postSlackMessageMock.mock.calls[0]![0]).toContain('Stornorechnung PB-CON26-002') expect(db.ticket_invoices.find((r) => r.type === 'cancellation')).toMatchObject({ @@ -727,7 +748,7 @@ describe('invoice-lifecycle endpoint', () => { const internal = await setup({ order: { is_internal: true }, env: ACCOUNTING_ENV }) expect((await internal.call('correction')).statusCode).toBe(400) - expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() }) }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts index 1f8a7ee2..ed8ef136 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts @@ -78,7 +78,7 @@ interface OrderContext { * are never modified — every operation only ever adds a new document or (pre-issuance) * swaps the PDF of the not-yet-sent one. Every rendered PDF is copied to the * accounting mailbox (`ACCOUNTING_INVOICE_EMAIL`, see shared/accounting-mail.ts); the - * response reports that as `accounting_mail: 'sent' | 'skipped' | 'failed'`. + * response reports that as `accounting_mail: 'sent' | 'skipped' | 'misconfigured' | 'failed'`. */ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger @@ -232,19 +232,10 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * successfully created document into an error response (persistent failures are * escalated to Slack inside). */ - function sendToAccounting(req: any, ctx: OrderContext, doc: Omit) { + function sendToAccounting(ctx: OrderContext, doc: Omit) { return sendInvoiceToAccounting( { ...doc, orderNumber: ctx.order.order_number }, - { - env, - emailContext: { - logger, - services: context.services, - getSchema: context.getSchema, - accountability: req.accountability, - }, - source: ENDPOINT_NAME, - } + { env, logger, source: ENDPOINT_NAME } ) } @@ -254,7 +245,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * file is intentionally NOT deleted. Once the document has been sent, this is * blocked — issued documents are immutable and must be corrected instead. */ - registerRoute('regenerate', async (req, ctx) => { + registerRoute('regenerate', async (_req, ctx) => { const current = requireCurrentDocument(ctx) if (ctx.order.status !== 'paid') { @@ -326,7 +317,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => // (the paid hook does so even when the customer mail failed, which is exactly // when a regenerate is possible). Send the replacement too, marked as such, so // the books hold the version the customer will actually receive. - const accountingMail = await sendToAccounting(req, ctx, { + const accountingMail = await sendToAccounting(ctx, { documentKind: current.type === 'correction' ? 'correction' : 'invoice', invoiceNumber: current.invoice_number, pdfBuffer: rendered.pdfBuffer, @@ -350,7 +341,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * the order's legacy pointers move to the correction (the new authoritative * invoice). The customer is not emailed; accounting gets a copy. */ - registerRoute('correction', async (req, ctx) => { + registerRoute('correction', async (_req, ctx) => { const current = requireCurrentDocument(ctx) if (ctx.order.status !== 'paid') { @@ -406,7 +397,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created correction ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) - const accountingMail = await sendToAccounting(req, ctx, { + const accountingMail = await sendToAccounting(ctx, { documentKind: 'correction', invoiceNumber, pdfBuffer: rendered.pdfBuffer, @@ -430,7 +421,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * the order status or the order's invoice pointers, and does not email the * customer — it only records the cancellation document (accounting gets a copy). */ - registerRoute('cancellation', async (req, ctx) => { + registerRoute('cancellation', async (_req, ctx) => { const current = requireCurrentDocument(ctx) if (!current.sent_at) { @@ -492,7 +483,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created cancellation ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) - const accountingMail = await sendToAccounting(req, ctx, { + const accountingMail = await sendToAccounting(ctx, { documentKind: 'cancellation', invoiceNumber, pdfBuffer: rendered.pdfBuffer, diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue index f298b2da..4ddcb07a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue @@ -67,6 +67,8 @@ const ACCOUNTING_MAIL_TEXT: Record = { sent: 'Kopie an die Buchhaltung verschickt.', failed: 'Kopie an die Buchhaltung fehlgeschlagen — Hinweis ging an Slack, bitte manuell weiterleiten.', skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', + misconfigured: + 'Keine Kopie an die Buchhaltung: SMTP-Zugang unvollständig konfiguriert (siehe Slack) — bitte manuell weiterleiten.', } let loadStateRunId = 0 diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts index 3bac10d7..3b8a0d46 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts @@ -1,19 +1,25 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import nodemailer from 'nodemailer' import { buildAccountingMail, getAccountingInvoiceEmail, + getAccountingSmtpConfig, + resetAccountingMailState, sendInvoiceToAccounting, type AccountingInvoiceDocument, } from '../accounting-mail.ts' -import { sendRawEmail } from '../email-service.ts' import { postSlackMessage } from '../postSlackMessage.ts' // `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) -jest.mock('../email-service.ts', () => ({ sendRawEmail: jest.fn() })) jest.mock('../postSlackMessage.ts', () => ({ postSlackMessage: jest.fn() })) +const mockSendMail = jest.fn<(mail: any) => Promise>() +jest.mock('nodemailer', () => ({ + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, +})) -const sendRawEmailMock = jest.mocked(sendRawEmail) +const createTransportMock = jest.mocked(nodemailer.createTransport) const postSlackMessageMock = jest.mocked(postSlackMessage) const DOC: AccountingInvoiceDocument = { @@ -24,23 +30,26 @@ const DOC: AccountingInvoiceDocument = { orderNumber: 'ORD-2026-ABC123', } -const ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } +const ENV = { + ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', + ACCOUNTING_SMTP_HOST: 'smtp.example.test', + ACCOUNTING_SMTP_PORT: '587', + ACCOUNTING_SMTP_SECURE: 'false', + ACCOUNTING_SMTP_USER: 'sender@example.test', + ACCOUNTING_SMTP_PASSWORD: 'app-password', + ACCOUNTING_MAIL_FROM: 'sender@example.test', +} function makeOptions(env: Record | undefined = ENV) { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } - return { - logger, - options: { - env, - emailContext: { logger: logger as any, services: {} as any, getSchema: async () => ({}) }, - source: 'test-source', - }, - } + return { logger, options: { env, logger, source: 'test-source' } } } beforeEach(() => { - sendRawEmailMock.mockReset() - sendRawEmailMock.mockResolvedValue(true) + resetAccountingMailState() + createTransportMock.mockClear() + mockSendMail.mockReset() + mockSendMail.mockResolvedValue({ messageId: 'x' }) postSlackMessageMock.mockReset() }) @@ -62,6 +71,46 @@ describe('getAccountingInvoiceEmail', () => { }) }) +describe('getAccountingSmtpConfig', () => { + test('reads the dedicated transport from env, accepting Directus-cast values', () => { + expect(getAccountingSmtpConfig({ ...ENV, ACCOUNTING_SMTP_PORT: 465, ACCOUNTING_SMTP_SECURE: true })).toEqual({ + config: { + host: 'smtp.example.test', + port: 465, + secure: true, + user: 'sender@example.test', + password: 'app-password', + from: 'sender@example.test', + }, + missing: [], + }) + }) + + test.each([ + [465, true], + [587, false], + ])('derives secure from port %s when ACCOUNTING_SMTP_SECURE is unset', (port, secure) => { + const { ACCOUNTING_SMTP_SECURE: _unused, ...env } = ENV + expect(getAccountingSmtpConfig({ ...env, ACCOUNTING_SMTP_PORT: String(port) }).config?.secure).toBe(secure) + }) + + test('names every missing or invalid variable', () => { + const result = getAccountingSmtpConfig({ + ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', + ACCOUNTING_SMTP_HOST: 'smtp.example.test', + ACCOUNTING_SMTP_PORT: 'abc', + ACCOUNTING_SMTP_PASSWORD: '', + }) + expect(result.config).toBeNull() + expect([...result.missing].sort()).toEqual([ + 'ACCOUNTING_MAIL_FROM', + 'ACCOUNTING_SMTP_PASSWORD', + 'ACCOUNTING_SMTP_PORT', + 'ACCOUNTING_SMTP_USER', + ]) + }) +}) + describe('buildAccountingMail', () => { test('original invoice: minimal subject, one-line body', () => { const mail = buildAccountingMail(DOC) @@ -95,59 +144,103 @@ describe('buildAccountingMail', () => { }) describe('sendInvoiceToAccounting', () => { - test('sends one plain-text mail with exactly the document PDF attached', async () => { + test('sends one plain-text mail with exactly the document PDF over the dedicated SMTP transport', async () => { const { options } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') - expect(sendRawEmailMock).toHaveBeenCalledTimes(1) - const [mail, emailContext] = sendRawEmailMock.mock.calls[0]! + // Transport built from the ACCOUNTING_SMTP_* env, STARTTLS enforced on 587. + expect(createTransportMock).toHaveBeenCalledTimes(1) + expect(createTransportMock).toHaveBeenCalledWith({ + host: 'smtp.example.test', + port: 587, + secure: false, + requireTLS: true, + auth: { user: 'sender@example.test', pass: 'app-password' }, + }) + + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] expect(mail).toEqual({ + from: 'sender@example.test', to: 'accounting@example.test', + // The mailbox checks MAIL FROM against the registered sender. + envelope: { from: 'sender@example.test', to: 'accounting@example.test' }, subject: 'Rechnung PB-CON26-001', text: 'Rechnung PB-CON26-001 (Bestellung ORD-2026-ABC123) im Anhang.\n', attachments: [ { filename: 'Rechnung-PB-CON26-001.pdf', content: DOC.pdfBuffer, contentType: 'application/pdf' }, ], }) - expect(mail.attachments![0]!.content).toBe(DOC.pdfBuffer) // the very same bytes - // The upload mailbox rejects HTML/links/images and checks the sender: plain text - // only, and no from/sender/envelope override of the configured EMAIL_FROM. + expect(mail.attachments[0].content).toBe(DOC.pdfBuffer) // the very same bytes + // The upload mailbox rejects HTML, links and images. expect(mail).not.toHaveProperty('html') - expect(mail).not.toHaveProperty('from') - expect(mail).not.toHaveProperty('sender') - expect(mail).not.toHaveProperty('envelope') expect(mail.text).not.toMatch(/https?:|www\./) - expect(emailContext).toBe(options.emailContext) expect(postSlackMessageMock).not.toHaveBeenCalled() }) + test('creates the transport once and reuses it', async () => { + const { options } = makeOptions() + await sendInvoiceToAccounting(DOC, options) + await sendInvoiceToAccounting({ ...DOC, invoiceNumber: 'PB-CON26-002' }, options) + + expect(createTransportMock).toHaveBeenCalledTimes(1) + expect(mockSendMail).toHaveBeenCalledTimes(2) + }) + test('skips with one info log (no error) when the mailbox is not configured', async () => { const { options, logger } = makeOptions({}) await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') - expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(createTransportMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() expect(logger.info).toHaveBeenCalledTimes(1) expect(logger.info.mock.calls[0]![0]).toContain('ACCOUNTING_INVOICE_EMAIL') expect(logger.info.mock.calls[0]![0]).toContain('PB-CON26-001') expect(logger.warn).not.toHaveBeenCalled() expect(logger.error).not.toHaveBeenCalled() + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('incomplete SMTP config: skips with a warning per document and alerts Slack once per process', async () => { + const { ACCOUNTING_SMTP_PASSWORD: _unused, ...env } = ENV + const { options, logger } = makeOptions(env) + + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('misconfigured') + await expect(sendInvoiceToAccounting({ ...DOC, invoiceNumber: 'PB-CON26-002' }, options)).resolves.toBe( + 'misconfigured' + ) + + // No mail through any transport — in particular no fallback that would bounce. + expect(createTransportMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn.mock.calls[0]![0]).toContain('ACCOUNTING_SMTP_PASSWORD') + expect(logger.warn.mock.calls[1]![0]).toContain('PB-CON26-002') + + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('ACCOUNTING_SMTP_PASSWORD') + expect(slack).toContain('PB-CON26-001') + expect(slack).not.toContain('accounting@example.test') }) test('retries a failed send and recovers without escalating', async () => { - sendRawEmailMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + mockSendMail.mockRejectedValueOnce(new Error('421 try again')).mockResolvedValueOnce({ messageId: 'x' }) const { options } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') - expect(sendRawEmailMock).toHaveBeenCalledTimes(2) + expect(mockSendMail).toHaveBeenCalledTimes(2) expect(postSlackMessageMock).not.toHaveBeenCalled() }) test('persistent failure: alerts Slack with the invoice number and does not throw', async () => { - sendRawEmailMock.mockResolvedValue(false) + mockSendMail.mockRejectedValue(new Error('550 sender not registered')) const { options, logger } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') - expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(mockSendMail).toHaveBeenCalledTimes(3) + expect(logger.warn.mock.calls.map((c) => c[0]).join('\n')).toContain('550 sender not registered') expect(postSlackMessageMock).toHaveBeenCalledTimes(1) const slack = postSlackMessageMock.mock.calls[0]![0] expect(slack).toContain('PB-CON26-001') @@ -158,13 +251,26 @@ describe('sendInvoiceToAccounting', () => { expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('PB-CON26-001') }) - test('a throwing transport and a failing Slack alert are both swallowed', async () => { - sendRawEmailMock.mockRejectedValue(new Error('smtp exploded')) + test('a failing Slack alert is swallowed too', async () => { + mockSendMail.mockRejectedValue(new Error('smtp exploded')) postSlackMessageMock.mockRejectedValue(new Error('slack down')) const { options, logger } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') - expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(mockSendMail).toHaveBeenCalledTimes(3) expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') }) + + test('a transport that cannot be created is retried, alerted and does not throw', async () => { + createTransportMock.mockImplementation(() => { + throw new Error('bad transport options') + }) + const { options, logger } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') + + expect(createTransportMock).toHaveBeenCalledTimes(3) + expect(logger.warn.mock.calls.map((c) => c[0]).join('\n')).toContain('bad transport options') + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + createTransportMock.mockImplementation(() => ({ sendMail: mockSendMail }) as any) + }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts index 6c34e6e1..faec58a6 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts @@ -9,22 +9,38 @@ * The accounting mailbox uploads attachments into the bookkeeping system and rejects * mails with links, images, logos or QR codes. So the mail is plain text only (no HTML, * no template, no links, no signature), carries exactly one PDF, and names the invoice - * number in the subject. It sets no `from`, `sender` or `envelope`: Directus' MailService - * fills `from` with EMAIL_FROM and nodemailer derives the SMTP envelope sender (MAIL FROM) - * from it, so the envelope sender is the configured EMAIL_FROM. + * number in the subject. * - * The mailbox comes from the `ACCOUNTING_INVOICE_EMAIL` Directus env var. Without it the - * accounting copy is skipped (info log), so environments without bookkeeping (local, - * staging) keep working unchanged. + * It also rejects (550 at RCPT TO) every mail whose SMTP envelope sender (MAIL FROM) is + * not the registered sender address. The Directus MailService goes through the + * transactional mail provider, which always uses its own return path, so it can never + * satisfy that check. The accounting copy therefore uses its own SMTP transport + * (`ACCOUNTING_SMTP_*`, `ACCOUNTING_MAIL_FROM`) and sets both `from` and the envelope + * sender explicitly. There is deliberately no fallback to the MailService: it would only + * produce bounces. + * + * The mailbox comes from `ACCOUNTING_INVOICE_EMAIL`. Without it the accounting copy is + * skipped (info log), so environments without bookkeeping (local, staging) keep working + * unchanged. With it but an incomplete SMTP configuration, the copy is skipped with a + * warning and a Slack alert (once per process) — that needs a human. */ -import { sendRawEmail, type EmailServiceContext } from './email-service.js' +import nodemailer from 'nodemailer' import type { InvoiceDocumentKind } from './invoice-generator.js' import { INVOICE_DOCUMENT_LABEL } from './invoice-service.js' import { postSlackMessage } from './postSlackMessage.js' export const ACCOUNTING_INVOICE_EMAIL_ENV = 'ACCOUNTING_INVOICE_EMAIL' +/** Env vars the dedicated SMTP transport needs (`ACCOUNTING_SMTP_SECURE` is optional). */ +export const ACCOUNTING_SMTP_REQUIRED_ENV = [ + 'ACCOUNTING_SMTP_HOST', + 'ACCOUNTING_SMTP_PORT', + 'ACCOUNTING_SMTP_USER', + 'ACCOUNTING_SMTP_PASSWORD', + 'ACCOUNTING_MAIL_FROM', +] as const + /** How often to attempt sending the accounting copy before escalating to Slack. */ export const ACCOUNTING_MAIL_ATTEMPTS = 3 @@ -45,19 +61,34 @@ export interface AccountingInvoiceDocument { replacesPreviousVersion?: boolean } -export type AccountingMailResult = 'sent' | 'skipped' | 'failed' +export type AccountingMailResult = 'sent' | 'skipped' | 'misconfigured' | 'failed' + +type Env = Record | undefined export interface AccountingMailOptions { /** The extension's env (hook/endpoint context `env`). */ - env: Record | undefined - emailContext: EmailServiceContext + env: Env + logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } /** Hook or endpoint name, for logs and the Slack alert. */ source: string } -/** The configured accounting mailbox, or null when the env var is unset or empty. */ -export function getAccountingInvoiceEmail(env: Record | undefined): string | null { - const value = env?.[ACCOUNTING_INVOICE_EMAIL_ENV] +export interface AccountingSmtpConfig { + host: string + port: number + secure: boolean + user: string + password: string + /** The sender address registered with the accounting mailbox; also the envelope sender. */ + from: string +} + +/** Directus casts env values ("465" → 465, "true" → true); accept both forms. */ +function envString(env: Env, key: string): string | null { + const value = env?.[key] + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } if (typeof value !== 'string') { return null } @@ -65,6 +96,75 @@ export function getAccountingInvoiceEmail(env: Record | undefin return trimmed === '' ? null : trimmed } +/** The configured accounting mailbox, or null when the env var is unset or empty. */ +export function getAccountingInvoiceEmail(env: Env): string | null { + const value = env?.[ACCOUNTING_INVOICE_EMAIL_ENV] + return typeof value === 'string' ? envString(env, ACCOUNTING_INVOICE_EMAIL_ENV) : null +} + +/** + * The dedicated SMTP transport's settings, or the names of the missing env vars. + * + * `ACCOUNTING_SMTP_SECURE` has the same meaning as Directus' `EMAIL_SMTP_SECURE`: true + * means implicit TLS (port 465), false means STARTTLS (port 587). Setting it to true on + * 587 fails the handshake, so when it is unset it is derived from the port. + */ +export function getAccountingSmtpConfig( + env: Env +): { config: AccountingSmtpConfig; missing: [] } | { config: null; missing: string[] } { + const missing: string[] = ACCOUNTING_SMTP_REQUIRED_ENV.filter((key) => envString(env, key) === null) + const port = Number(envString(env, 'ACCOUNTING_SMTP_PORT')) + if (envString(env, 'ACCOUNTING_SMTP_PORT') !== null && !(Number.isInteger(port) && port > 0)) { + missing.push('ACCOUNTING_SMTP_PORT') + } + if (missing.length > 0) { + return { config: null, missing: [...new Set(missing)] } + } + + const secureRaw = envString(env, 'ACCOUNTING_SMTP_SECURE') + const secure = secureRaw === null ? port === 465 : secureRaw.toLowerCase() === 'true' + + return { + config: { + host: envString(env, 'ACCOUNTING_SMTP_HOST')!, + port, + secure, + user: envString(env, 'ACCOUNTING_SMTP_USER')!, + password: envString(env, 'ACCOUNTING_SMTP_PASSWORD')!, + from: envString(env, 'ACCOUNTING_MAIL_FROM')!, + }, + missing: [], + } +} + +// Process-wide state: one lazily created transport, one misconfiguration alert. +let cachedTransport: { key: string; transport: { sendMail: (mail: any) => Promise } } | null = null +let misconfigurationAlerted = false + +/** Reset the lazily created transport and the once-per-process alert flag (tests only). */ +export function resetAccountingMailState(): void { + cachedTransport = null + misconfigurationAlerted = false +} + +function getTransport(config: AccountingSmtpConfig) { + const key = JSON.stringify(config) + if (!cachedTransport || cachedTransport.key !== key) { + cachedTransport = { + key, + transport: nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + // Without implicit TLS, insist on STARTTLS: credentials never go out in clear. + requireTLS: !config.secure, + auth: { user: config.user, pass: config.password }, + }), + } + } + return cachedTransport.transport +} + /** Minimal subject and one-line plain-text body — no cover letter needed. */ export function buildAccountingMail(doc: AccountingInvoiceDocument): { subject: string; text: string } { const name = `${INVOICE_DOCUMENT_LABEL[doc.documentKind]} ${doc.invoiceNumber}` @@ -96,8 +196,7 @@ export async function sendInvoiceToAccounting( doc: AccountingInvoiceDocument, options: AccountingMailOptions ): Promise { - const { emailContext, source } = options - const logger = emailContext.logger + const { logger, source } = options try { const to = getAccountingInvoiceEmail(options.env) @@ -108,38 +207,51 @@ export async function sendInvoiceToAccounting( return 'skipped' } - const { subject, text } = buildAccountingMail(doc) + const { config, missing } = getAccountingSmtpConfig(options.env) + if (!config) { + logger.warn( + `${source}: ${ACCOUNTING_INVOICE_EMAIL_ENV} is set but ${missing.join(', ')} missing or invalid — skipping accounting copy of ${doc.invoiceNumber}` + ) + if (!misconfigurationAlerted) { + misconfigurationAlerted = true + try { + await postSlackMessage( + `:warning: *${source}*: Rechnungskopien an die Buchhaltung werden nicht verschickt — ${ACCOUNTING_INVOICE_EMAIL_ENV} ist gesetzt, aber ${missing.join(', ')} fehlt bzw. ist ungültig. Erste betroffene Rechnung: ${doc.invoiceNumber}. Bitte die Directus-Umgebung ergänzen, neu starten und die seitdem erzeugten PDFs manuell an die Buchhaltung weiterleiten (Directus-Log nennt jede Nummer). Dieser Hinweis kommt nur einmal pro Prozessstart.` + ) + } catch (slackErr: any) { + logger.error(`${source}: Failed to send Slack notification: ${slackErr?.message || slackErr}`) + } + } + return 'misconfigured' + } + const { subject, text } = buildAccountingMail(doc) for (let attempt = 1; attempt <= ACCOUNTING_MAIL_ATTEMPTS; attempt++) { - let sent = false try { - sent = await sendRawEmail( - { - to, - subject, - text, - attachments: [ - { - filename: doc.invoiceFileName, - content: doc.pdfBuffer, - contentType: 'application/pdf', - }, - ], - }, - emailContext - ) - } catch (err: any) { - logger.warn(`${source}: Accounting copy of ${doc.invoiceNumber} threw: ${err?.message || err}`) - } - - if (sent) { + // Inside the retry: a transport that cannot be created ends up in the Slack alert too. + const transport = getTransport(config) + await transport.sendMail({ + from: config.from, + to, + // The accounting mailbox checks MAIL FROM against the registered sender. + envelope: { from: config.from, to }, + subject, + text, + attachments: [ + { + filename: doc.invoiceFileName, + content: doc.pdfBuffer, + contentType: 'application/pdf', + }, + ], + }) logger.info(`${source}: Sent accounting copy of ${doc.invoiceNumber}`) return 'sent' + } catch (err: any) { + logger.warn( + `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed: ${err?.message || err}` + ) } - - logger.warn( - `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed` - ) } const label = INVOICE_DOCUMENT_LABEL[doc.documentKind] diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts index e0cdf8c7..5eaf0308 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts @@ -105,16 +105,6 @@ export async function getEmailTemplate( } } -/** Map attachments to the nodemailer shape MailService passes through. */ -function toMailAttachments(attachments: EmailAttachment[]) { - return attachments.map((a) => ({ - filename: a.filename, - content: a.content, - contentType: a.contentType || 'application/pdf', - ...(a.cid && { cid: a.cid }), - })) -} - /** * Send an email using a template from the CMS. */ @@ -151,7 +141,14 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema ...(options.replyTo && { replyTo: options.replyTo }), ...(options.cc && { cc: options.cc }), ...(options.headers && { headers: options.headers }), - ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), + ...(options.attachments && { + attachments: options.attachments.map((a) => ({ + filename: a.filename, + content: a.content, + contentType: a.contentType || 'application/pdf', + ...(a.cid && { cid: a.cid }), + })), + }), }) logger.info(`Email sent to ${options.to} using template '${options.templateKey}'`) @@ -170,14 +167,12 @@ export async function sendRawEmail( options: { to: string subject: string - /** HTML body. May be omitted for a plain-text-only mail (then `text` is required). */ - html?: string - /** Plain-text body or alternative, for clients that don't render HTML. */ + html: string + /** Plain-text alternative, for clients that don't render HTML. */ text?: string replyTo?: string /** Extra mail headers (e.g. List-Unsubscribe); passed through MailService to nodemailer. */ headers?: Record - attachments?: EmailAttachment[] }, context: EmailServiceContext ): Promise { @@ -195,11 +190,10 @@ export async function sendRawEmail( await mailService.send({ to: options.to, subject: options.subject, - ...(options.html && { html: options.html }), + html: options.html, ...(options.text && { text: options.text }), ...(options.replyTo && { replyTo: options.replyTo }), ...(options.headers && { headers: options.headers }), - ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to}`) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts index 26440d92..7d1e1a55 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { resetAccountingMailState } from '../../shared/accounting-mail.ts' import { sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' import { generateInvoiceNumber } from '../../shared/invoice-generator.ts' import { issueOriginalInvoice, markInvoiceSent } from '../../shared/invoice-service.ts' @@ -40,6 +41,13 @@ jest.mock('../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), })) +// The accounting copy goes through its own SMTP transport, never through MailService. +const mockSendMail = jest.fn<(mail: any) => Promise>() +jest.mock('nodemailer', () => ({ + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, +})) + const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) const sendRawEmailMock = jest.mocked(sendRawEmail) const postSlackMessageMock = jest.mocked(postSlackMessage) @@ -91,7 +99,15 @@ const CONFERENCE = { ticket_max_quantity: null, } -const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } +const ACCOUNTING_ENV = { + ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', + ACCOUNTING_SMTP_HOST: 'smtp.example.test', + ACCOUNTING_SMTP_PORT: 465, + ACCOUNTING_SMTP_SECURE: true, + ACCOUNTING_SMTP_USER: 'sender@example.test', + ACCOUNTING_SMTP_PASSWORD: 'app-password', + ACCOUNTING_MAIL_FROM: 'sender@example.test', +} function setup(orderOverrides: Record = {}, env: Record = {}) { const order = { ...ORDER, ...orderOverrides } @@ -143,6 +159,8 @@ function setup(orderOverrides: Record = {}, env: Record { sendTemplatedEmailMock.mockReset() sendRawEmailMock.mockReset() + mockSendMail.mockReset() + resetAccountingMailState() issueOriginalInvoiceMock.mockReset() markInvoiceSentMock.mockReset() postSlackMessageMock.mockReset() @@ -159,7 +177,7 @@ beforeEach(() => { snapshot: {} as any, }) sendTemplatedEmailMock.mockResolvedValue(true) - sendRawEmailMock.mockResolvedValue(true) + mockSendMail.mockResolvedValue({ messageId: 'x' }) }) describe('ticket-order-processing hook', () => { @@ -296,9 +314,13 @@ describe('ticket-order-processing hook', () => { const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).toHaveBeenCalledTimes(1) - const mail = sendRawEmailMock.mock.calls[0]![0] + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] expect(mail.to).toBe('accounting@example.test') + // Registered sender as header AND envelope sender, over the dedicated transport. + expect(mail.from).toBe('sender@example.test') + expect(mail.envelope).toEqual({ from: 'sender@example.test', to: 'accounting@example.test' }) + expect(sendRawEmailMock).not.toHaveBeenCalled() expect(mail.subject).toBe('Rechnung PB-CON26-001') expect(mail.text).toContain('ORD-2026-ABC123') expect(mail.html).toBeUndefined() // plain text, not the customer template @@ -324,8 +346,8 @@ describe('ticket-order-processing hook', () => { const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).toHaveBeenCalledTimes(1) - expect(sendRawEmailMock.mock.calls[0]![0].subject).toBe('Rechnung PB-CON26-001') + expect(mockSendMail).toHaveBeenCalledTimes(1) + expect(mockSendMail.mock.calls[0]![0].subject).toBe('Rechnung PB-CON26-001') expect(markInvoiceSentMock).not.toHaveBeenCalled() }) @@ -333,7 +355,7 @@ describe('ticket-order-processing hook', () => { const { invoke, logger } = setup({}, { ACCOUNTING_INVOICE_EMAIL: '' }) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() const skipLogs = logger.info.mock.calls .map((call) => String(call[0])) .filter((msg) => msg.includes('ACCOUNTING_INVOICE_EMAIL')) @@ -345,11 +367,11 @@ describe('ticket-order-processing hook', () => { }) test('persistent failure: retried, escalated to Slack with the invoice number, hook carries on', async () => { - sendRawEmailMock.mockResolvedValue(false) + mockSendMail.mockRejectedValue(new Error('550 sender rejected')) const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(mockSendMail).toHaveBeenCalledTimes(3) expect(postSlackMessageMock).toHaveBeenCalledTimes(1) expect(postSlackMessageMock.mock.calls[0]![0]).toContain('PB-CON26-001') expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ORD-2026-ABC123') @@ -361,12 +383,26 @@ describe('ticket-order-processing hook', () => { }) test('a throwing mail transport does not break order processing either', async () => { - sendRawEmailMock.mockRejectedValue(new Error('smtp exploded')) + mockSendMail.mockRejectedValue(new Error('smtp exploded')) postSlackMessageMock.mockRejectedValue(new Error('slack down')) const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(mockSendMail).toHaveBeenCalledTimes(3) + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) + expect(invitations()).toHaveLength(2) + }) + + test('incomplete SMTP config: no mail, warning and Slack alert, order processing unaffected', async () => { + const { ACCOUNTING_MAIL_FROM: _unused, ...env } = ACCOUNTING_ENV + const { invoke, logger } = setup({}, env) + await invoke({ status: 'paid' }) + + expect(mockSendMail).not.toHaveBeenCalled() + expect(sendRawEmailMock).not.toHaveBeenCalled() // no fallback to MailService + expect(logger.warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('ACCOUNTING_MAIL_FROM') + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ACCOUNTING_MAIL_FROM') expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) expect(invitations()).toHaveLength(2) }) @@ -375,7 +411,7 @@ describe('ticket-order-processing hook', () => { const { invoke } = setup({ is_internal: true }, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(mockSendMail).not.toHaveBeenCalled() expect(postSlackMessageMock).not.toHaveBeenCalled() }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index db65861d..932e2ad8 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -230,7 +230,7 @@ export default defineHook(({ action }, hookContext) => { invoiceFileName, orderNumber: order.order_number, }, - { env, emailContext: context, source: HOOK_NAME } + { env, logger, source: HOOK_NAME } ) } else { logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) diff --git a/docs/tickets.md b/docs/tickets.md index 5e356634..7aa1c25f 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -31,6 +31,9 @@ The flow consists of four main phases: | `STORAGE_LOCATIONS` | Comma-separated storage location IDs (first is used for invoice PDFs) | Yes | | Email transport config | Directus email settings (`EMAIL_TRANSPORT`, `EMAIL_SMTP_HOST`, etc.) | Yes | | `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent | Production | +| `ACCOUNTING_SMTP_HOST`, `ACCOUNTING_SMTP_PORT`, `ACCOUNTING_SMTP_USER`, `ACCOUNTING_SMTP_PASSWORD` | Dedicated SMTP transport for the accounting copy (e.g. Google Workspace SMTP with an app password) | When `ACCOUNTING_INVOICE_EMAIL` is set | +| `ACCOUNTING_SMTP_SECURE` | `true` = implicit TLS (465), `false` = STARTTLS (587); unset: derived from the port | No | +| `ACCOUNTING_MAIL_FROM` | Sender address registered with the accounting mailbox; used as `From` and envelope sender | When `ACCOUNTING_INVOICE_EMAIL` is set | ## External Services @@ -180,12 +183,23 @@ Key-value settings used by extension hooks. ## Accounting Copy Every generated invoice document is mailed to `ACCOUNTING_INVOICE_EMAIL` (GoBD: timely recording), -as its own plain-text mail from `EMAIL_FROM` with exactly one PDF attached — the same bytes and file -name as the stored file. Subject: `Rechnung PB-CON26-001`, `Rechnungsberichtigung …` or -`Stornorechnung …`. The upload mailbox rejects mails with links, images, logos or QR codes, so the -mail carries no HTML, template, link or signature. The code sets no sender/envelope override, so the -SMTP envelope sender (MAIL FROM) is `EMAIL_FROM` — unless the mail relay rewrites it, which has to be -checked on the relay side. +as its own plain-text mail with exactly one PDF attached — the same bytes and file name as the stored +file. Subject: `Rechnung PB-CON26-001`, `Rechnungsberichtigung …` or `Stornorechnung …`. The upload +mailbox rejects mails with links, images, logos or QR codes, so the mail carries no HTML, template, +link or signature. + +**Why a separate SMTP transport:** the upload mailbox rejects every mail (550 at `RCPT TO`) whose SMTP +envelope sender (MAIL FROM) is not the sender address registered there. The Directus MailService +sends through the transactional mail provider, which always uses its own return path, so it can never +pass that check. The accounting copy therefore goes through its own nodemailer transport +(`ACCOUNTING_SMTP_*`, e.g. Google Workspace SMTP with an app password) and sets both `From` and the +envelope sender to `ACCOUNTING_MAIL_FROM`. There is deliberately no fallback to the MailService — it +would only produce bounces. + +Setup: register `ACCOUNTING_MAIL_FROM` as an allowed sender in the accounting mailbox's settings, +create an app password for that account, and put it in the Directus environment only — never commit +it. If `ACCOUNTING_INVOICE_EMAIL` is set but any SMTP variable or `ACCOUNTING_MAIL_FROM` is missing, +no copy is sent: each document logs a warning, and Slack is alerted once per process start. | Document | Sent by | |----------|---------| From 74ae9ee80d09dee77cad67c7157817adf78e361c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:38:42 +0000 Subject: [PATCH 3/6] Send the accounting copy through the regular MailService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 – 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) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- directus-cms/.env.example | 20 +- .../extension.config.js | 5 +- .../package-lock.json | 10 - .../package.json | 1 - .../invoice-lifecycle/__tests__/index.test.ts | 85 +++-- .../src/invoice-lifecycle/index.ts | 21 +- .../src/invoice-lifecycle/invoice-actions.vue | 2 - .../shared/__tests__/accounting-mail.test.ts | 330 +++++++++--------- .../src/shared/accounting-mail.ts | 262 +++++--------- .../src/shared/email-service.ts | 37 +- .../src/shared/invoice-generator.ts | 3 + .../src/shared/postSlackMessage.ts | 8 + .../__tests__/index.test.ts | 138 ++------ .../src/ticket-order-processing/index.ts | 7 +- docs/tickets.md | 43 ++- 15 files changed, 406 insertions(+), 566 deletions(-) diff --git a/directus-cms/.env.example b/directus-cms/.env.example index 139838ce..c205e961 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -124,25 +124,11 @@ EMAIL_SENDMAIL_NEW_LINE="unix" # EMAIL_SMTP_PASSWORD="" # Accounting mailbox: every invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) -# is mailed here as a PDF attachment, one per mail, for timely recording in the books. Leave empty -# to skip the accounting copy (e.g. locally). See docs/tickets.md. +# is mailed here as a PDF attachment, one per mail, through the regular EMAIL_* transport above +# (sender EMAIL_FROM). Leave empty to skip the accounting copy (e.g. locally; with Slack configured +# that is reported once per process start). See docs/tickets.md. ACCOUNTING_INVOICE_EMAIL="" -# Dedicated SMTP transport for that copy, NOT the EMAIL_* transport above: the accounting mailbox -# only accepts mail whose envelope sender (MAIL FROM) is its registered sender address, and the -# transactional mail provider always uses its own return path. ACCOUNTING_MAIL_FROM must be that -# registered address (sender and envelope sender). ACCOUNTING_SMTP_SECURE works like -# EMAIL_SMTP_SECURE: "true" for port 465 (implicit TLS), "false" for 587 (STARTTLS); unset, it -# follows the port. Google Workspace: host smtp.gmail.com, port 465, secure true. Use an app -# password and never commit it. If ACCOUNTING_INVOICE_EMAIL is set but any of these is missing, no -# copy is sent and Slack is alerted. -ACCOUNTING_SMTP_HOST="" -ACCOUNTING_SMTP_PORT="" -ACCOUNTING_SMTP_SECURE="" -ACCOUNTING_SMTP_USER="" -ACCOUNTING_SMTP_PASSWORD="" -ACCOUNTING_MAIL_FROM="" - # Password Brevo's transactional webhook sends via Basic auth (POST /brevo-webhook, see # docs/newsletter.md). Without it the endpoint refuses every event (503). `openssl rand -hex 32` BREVO_WEBHOOK_SECRET="" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js index 09f7af92..3e4ef028 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js @@ -4,8 +4,7 @@ * Some dependencies (pdfkit, passkit-generator) use Node.js globals like * `__dirname` or native modules that don't work when bundled into ESM. * mjml is CommonJS with a large dependency tree (juice, cssnano, html-minifier) - * that is not meant to be bundled either; the same goes for nodemailer (CommonJS, used for - * the accounting-mail SMTP transport). + * that is not meant to be bundled either. * By marking them as external, they load from node_modules at runtime. */ export default { @@ -13,7 +12,7 @@ export default { { name: 'externalize-native-modules', resolveId(id) { - if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml' || id === 'nodemailer') { + if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml') { return { id, external: true } } return null diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json index e1b8bb6f..f2b96f3b 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "dependencies": { "mjml": "^5.4.1", - "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" @@ -12577,15 +12576,6 @@ "node": ">=18" } }, - "node_modules/nodemailer": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", - "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nopt": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json index dad3d0a9..1b3a9390 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json @@ -221,7 +221,6 @@ }, "dependencies": { "mjml": "^5.4.1", - "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index f1e26edd..2f30c135 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts @@ -21,28 +21,17 @@ jest.mock('../../shared/email-service.ts', () => ({ })) jest.mock('../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), -})) - -// The accounting copy goes through its own SMTP transport, never through MailService. -const mockSendMail = jest.fn<(mail: any) => Promise>() -jest.mock('nodemailer', () => ({ - __esModule: true, - default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, + isSlackConfigured: jest.fn(() => false), })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) const sendRawEmailMock = jest.mocked(sendRawEmail) const postSlackMessageMock = jest.mocked(postSlackMessage) -const ACCOUNTING_ENV = { - ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', - ACCOUNTING_SMTP_HOST: 'smtp.example.test', - ACCOUNTING_SMTP_PORT: 465, - ACCOUNTING_SMTP_SECURE: true, - ACCOUNTING_SMTP_USER: 'sender@example.test', - ACCOUNTING_SMTP_PASSWORD: 'app-password', - ACCOUNTING_MAIL_FROM: 'sender@example.test', -} +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +/** The accounting mail handed to the shared email service (n-th call). */ +const accountingMail = (n = 0) => sendRawEmailMock.mock.calls[n]![0] as any type RouteHandler = (req: any, res: any) => Promise @@ -250,8 +239,7 @@ async function setup( beforeEach(() => { sendTemplatedEmailMock.mockReset() sendRawEmailMock.mockReset() - mockSendMail.mockReset() - mockSendMail.mockResolvedValue({ messageId: 'x' }) + sendRawEmailMock.mockResolvedValue(true) resetAccountingMailState() postSlackMessageMock.mockReset() }) @@ -642,37 +630,40 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - expect(mockSendMail).toHaveBeenCalledTimes(1) - const mail = mockSendMail.mock.calls[0]![0] + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = accountingMail() expect(mail.to).toBe('accounting@example.test') - expect(mail.from).toBe('sender@example.test') - expect(mail.envelope).toEqual({ from: 'sender@example.test', to: 'accounting@example.test' }) - expect(sendRawEmailMock).not.toHaveBeenCalled() - expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002') - expect(mail.text).toContain('zu Rechnung PB-CON26-001') + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001` + ) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') + expect(mail.html).toBeUndefined() // plain text, not a template expect(mail.attachments).toHaveLength(1) - expect(mail.attachments![0]!.filename).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') + expect(mail.attachments[0].filename).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') // Same bytes as the stored file. - expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) - expect(mail.attachments![0]!.content.subarray(0, 4).toString()).toBe('%PDF') + expect(mail.attachments[0].content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(mail.attachments[0].content.subarray(0, 4).toString()).toBe('%PDF') // The customer is not mailed, and the accounting copy does not issue the document. expect(sendTemplatedEmailMock).not.toHaveBeenCalled() expect(db.ticket_invoices.find((r) => r.type === 'correction').sent_at).toBeNull() }) - test('a cancellation is mailed to accounting', async () => { + test('a cancellation is mailed to accounting with its negative amount', async () => { const { call, calls } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) const res = await call('cancellation') expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - expect(mockSendMail).toHaveBeenCalledTimes(1) - const mail = mockSendMail.mock.calls[0]![0] - expect(mail.subject).toBe('Stornorechnung PB-CON26-002') - expect(mail.attachments![0]!.filename).toBe('Stornorechnung-PB-CON26-002.pdf') - expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} – Stornorechnung (Gutschrift) PB-CON26-002 zu PB-CON26-001` + ) + expect(mail.text).toMatch(/Bruttobetrag: -\d/) + expect(mail.attachments[0].filename).toBe('Stornorechnung-PB-CON26-002.pdf') + expect(mail.attachments[0].content.equals(calls.uploadedBytes[0]!)).toBe(true) expect(sendTemplatedEmailMock).not.toHaveBeenCalled() }) @@ -686,10 +677,12 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('sent') - const mail = mockSendMail.mock.calls[0]![0] - expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') - expect(mail.attachments![0]!.filename).toBe('Rechnung-PB-CON26-001.pdf') - expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + const mail = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} – Ticketrechnung PB-CON26-001 (ersetzt vorherige Fassung)` + ) + expect(mail.attachments[0].filename).toBe('Rechnung-PB-CON26-001.pdf') + expect(mail.attachments[0].content.equals(calls.uploadedBytes[0]!)).toBe(true) }) test('a regenerated correction keeps its document type and reference in the accounting mail', async () => { @@ -706,9 +699,11 @@ describe('invoice-lifecycle endpoint', () => { const res = await call('regenerate') expect(res.statusCode).toBe(200) - const mail = mockSendMail.mock.calls[0]![0] - expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002 (ersetzt vorherige Fassung)') - expect(mail.text).toContain('zu Rechnung PB-CON26-001') + const mail = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001 (ersetzt vorherige Fassung)` + ) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') }) test('is skipped with an info log when ACCOUNTING_INVOICE_EMAIL is not set', async () => { @@ -718,21 +713,21 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('skipped') - expect(mockSendMail).not.toHaveBeenCalled() + expect(sendRawEmailMock).not.toHaveBeenCalled() const infoLog = logger.info.mock.calls.map((c) => String(c[0])).join('\n') expect(infoLog).toContain('ACCOUNTING_INVOICE_EMAIL') expect(logger.error).not.toHaveBeenCalled() }) test('persistent failure: document is still created, Slack is alerted, endpoint answers 200', async () => { - mockSendMail.mockRejectedValue(new Error('550 sender rejected')) + sendRawEmailMock.mockResolvedValue(false) const { call, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) const res = await call('cancellation') expect(res.statusCode).toBe(200) expect(res.body.accounting_mail).toBe('failed') - expect(mockSendMail).toHaveBeenCalledTimes(3) + expect(sendRawEmailMock).toHaveBeenCalledTimes(3) expect(postSlackMessageMock).toHaveBeenCalledTimes(1) expect(postSlackMessageMock.mock.calls[0]![0]).toContain('Stornorechnung PB-CON26-002') expect(db.ticket_invoices.find((r) => r.type === 'cancellation')).toMatchObject({ @@ -748,7 +743,7 @@ describe('invoice-lifecycle endpoint', () => { const internal = await setup({ order: { is_internal: true }, env: ACCOUNTING_ENV }) expect((await internal.call('correction')).statusCode).toBe(400) - expect(mockSendMail).not.toHaveBeenCalled() + expect(sendRawEmailMock).not.toHaveBeenCalled() }) }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts index ba8a7bfb..0cb14f8d 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts @@ -2,6 +2,7 @@ import { defineEndpoint } from '@directus/extensions-sdk' import type { SandboxEndpointRouter } from 'directus:api' import { sendInvoiceToAccounting, type AccountingInvoiceDocument } from '../shared/accounting-mail.js' +import type { EmailServiceContext } from '../shared/email-service.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { buildInvoiceSnapshot, @@ -81,13 +82,14 @@ interface OrderContext { * are never modified — every operation only ever adds a new document or (pre-issuance) * swaps the PDF of the not-yet-sent one. Every rendered PDF is copied to the * accounting mailbox (`ACCOUNTING_INVOICE_EMAIL`, see shared/accounting-mail.ts); the - * response reports that as `accounting_mail: 'sent' | 'skipped' | 'misconfigured' | 'failed'`. + * response reports that as `accounting_mail: 'sent' | 'skipped' | 'failed'`. */ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger const ItemsService = context.services.ItemsService const FilesService = context.services.FilesService const env = context.env + const emailContext: EmailServiceContext = { logger, services: context.services, getSchema: context.getSchema } class HttpError extends Error { status: number @@ -241,7 +243,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => function sendToAccounting(ctx: OrderContext, doc: Omit) { return sendInvoiceToAccounting( { ...doc, orderNumber: ctx.order.order_number }, - { env, logger, source: ENDPOINT_NAME } + { env, emailContext, source: ENDPOINT_NAME } ) } @@ -272,7 +274,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) let rendered: RenderInvoiceResult - let correctedNumber: string | null = null + let predecessorRef: { invoice_number: string; invoice_date: string } | null = null if (current.type === 'correction') { // A regenerated correction must keep its printed reference to the corrected // invoice (§31 Abs. 5 UStDV) — without a resolvable predecessor the document @@ -284,7 +286,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `Correction ${current.invoice_number} does not reference a known invoice document — cannot regenerate it with the legally required reference` ) } - correctedNumber = predecessor.invoice_number + predecessorRef = predecessor rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber: current.invoice_number, @@ -328,9 +330,12 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const accountingMail = await sendToAccounting(ctx, { documentKind: current.type === 'correction' ? 'correction' : 'invoice', invoiceNumber: current.invoice_number, + invoiceDate: new Date(current.invoice_date), + snapshot, pdfBuffer: rendered.pdfBuffer, invoiceFileName: rendered.invoiceFileName, - referenceNumber: correctedNumber, + referenceNumber: predecessorRef?.invoice_number ?? null, + referenceDate: predecessorRef ? new Date(predecessorRef.invoice_date) : null, replacesPreviousVersion: true, }) @@ -409,9 +414,12 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const accountingMail = await sendToAccounting(ctx, { documentKind: 'correction', invoiceNumber, + invoiceDate, + snapshot, pdfBuffer: rendered.pdfBuffer, invoiceFileName: rendered.invoiceFileName, referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), }) return { @@ -496,9 +504,12 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const accountingMail = await sendToAccounting(ctx, { documentKind: 'cancellation', invoiceNumber, + invoiceDate, + snapshot, pdfBuffer: rendered.pdfBuffer, invoiceFileName: rendered.invoiceFileName, referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), }) return { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue index 4ddcb07a..f298b2da 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue @@ -67,8 +67,6 @@ const ACCOUNTING_MAIL_TEXT: Record = { sent: 'Kopie an die Buchhaltung verschickt.', failed: 'Kopie an die Buchhaltung fehlgeschlagen — Hinweis ging an Slack, bitte manuell weiterleiten.', skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', - misconfigured: - 'Keine Kopie an die Buchhaltung: SMTP-Zugang unvollständig konfiguriert (siehe Slack) — bitte manuell weiterleiten.', } let loadStateRunId = 0 diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts index 3b8a0d46..fd8e0ed9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts @@ -1,56 +1,80 @@ -import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import nodemailer from 'nodemailer' +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' import { buildAccountingMail, getAccountingInvoiceEmail, - getAccountingSmtpConfig, resetAccountingMailState, sendInvoiceToAccounting, type AccountingInvoiceDocument, } from '../accounting-mail.ts' -import { postSlackMessage } from '../postSlackMessage.ts' +import { isSlackConfigured, postSlackMessage } from '../postSlackMessage.ts' // `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) -jest.mock('../postSlackMessage.ts', () => ({ postSlackMessage: jest.fn() })) -const mockSendMail = jest.fn<(mail: any) => Promise>() -jest.mock('nodemailer', () => ({ - __esModule: true, - default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, -})) - -const createTransportMock = jest.mocked(nodemailer.createTransport) +jest.mock('../postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), isSlackConfigured: jest.fn() })) + const postSlackMessageMock = jest.mocked(postSlackMessage) +const isSlackConfiguredMock = jest.mocked(isSlackConfigured) + +// The real shared email service runs on top of a fake Directus MailService. +const mailSendMock = jest.fn<(mail: any) => Promise>() +class FakeMailService { + send = mailSendMock +} + +const NBSP = ' ' const DOC: AccountingInvoiceDocument = { documentKind: 'invoice', - invoiceNumber: 'PB-CON26-001', + invoiceNumber: 'PB-CON26-012', + invoiceDate: new Date(2026, 9, 21), + snapshot: { + conference_title: 'programmier.con 2026', + service_date_start: '2026-11-25', + service_date_end: '2026-11-26', + total_gross_cents: 118900, + }, pdfBuffer: Buffer.from('%PDF-fake'), - invoiceFileName: 'Rechnung-PB-CON26-001.pdf', + invoiceFileName: 'Rechnung-PB-CON26-012.pdf', orderNumber: 'ORD-2026-ABC123', } -const ENV = { - ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', - ACCOUNTING_SMTP_HOST: 'smtp.example.test', - ACCOUNTING_SMTP_PORT: '587', - ACCOUNTING_SMTP_SECURE: 'false', - ACCOUNTING_SMTP_USER: 'sender@example.test', - ACCOUNTING_SMTP_PASSWORD: 'app-password', - ACCOUNTING_MAIL_FROM: 'sender@example.test', +const CORRECTION: AccountingInvoiceDocument = { + ...DOC, + documentKind: 'correction', + invoiceNumber: 'PB-CON26-049', + invoiceFileName: 'Rechnungsberichtigung-PB-CON26-049.pdf', + referenceNumber: 'PB-CON26-040', + referenceDate: new Date(2026, 9, 1), } +const CANCELLATION: AccountingInvoiceDocument = { + ...CORRECTION, + documentKind: 'cancellation', + invoiceNumber: 'PB-CON26-050', + invoiceFileName: 'Stornorechnung-PB-CON26-050.pdf', + snapshot: { ...DOC.snapshot, total_gross_cents: -118900 }, +} + +const ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + function makeOptions(env: Record | undefined = ENV) { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } - return { logger, options: { env, logger, source: 'test-source' } } + const emailContext = { + logger: logger as any, + services: { ItemsService: class {}, MailService: FakeMailService }, + getSchema: async () => ({}), + } + return { logger, options: { env, emailContext, source: 'test-source' } } } beforeEach(() => { resetAccountingMailState() - createTransportMock.mockClear() - mockSendMail.mockReset() - mockSendMail.mockResolvedValue({ messageId: 'x' }) + mailSendMock.mockReset() + mailSendMock.mockResolvedValue({ messageId: 'x' }) postSlackMessageMock.mockReset() + postSlackMessageMock.mockResolvedValue(undefined) + isSlackConfiguredMock.mockReset() + isSlackConfiguredMock.mockReturnValue(true) }) describe('getAccountingInvoiceEmail', () => { @@ -71,206 +95,164 @@ describe('getAccountingInvoiceEmail', () => { }) }) -describe('getAccountingSmtpConfig', () => { - test('reads the dedicated transport from env, accepting Directus-cast values', () => { - expect(getAccountingSmtpConfig({ ...ENV, ACCOUNTING_SMTP_PORT: 465, ACCOUNTING_SMTP_SECURE: true })).toEqual({ - config: { - host: 'smtp.example.test', - port: 465, - secure: true, - user: 'sender@example.test', - password: 'app-password', - from: 'sender@example.test', - }, - missing: [], - }) +describe('buildAccountingMail', () => { + test('original invoice: classifiable subject and a fact sheet without customer data', () => { + const mail = buildAccountingMail(DOC) + expect(mail.subject).toBe('Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012') + expect(mail.text).toBe( + [ + 'Dokumentart: Ausgangsrechnung – Ticketrechnung', + 'Veranstaltung: programmier.con 2026', + 'Rechnungsnummer: PB-CON26-012', + 'Rechnungsdatum: 21.10.2026', + 'Leistungszeitraum: 25.–26.11.2026', + `Bruttobetrag: 1.189,00${NBSP}€`, + 'Aussteller: Lotum media GmbH', + '', + 'Das Dokument liegt als PDF bei (Rechnung-PB-CON26-012.pdf).', + '', + ].join('\n') + ) + // No order number either: the body only describes the document. + expect(mail.text).not.toContain('ORD-2026-ABC123') }) - test.each([ - [465, true], - [587, false], - ])('derives secure from port %s when ACCOUNTING_SMTP_SECURE is unset', (port, secure) => { - const { ACCOUNTING_SMTP_SECURE: _unused, ...env } = ENV - expect(getAccountingSmtpConfig({ ...env, ACCOUNTING_SMTP_PORT: String(port) }).config?.secure).toBe(secure) + test('correction names the corrected invoice in subject and body', () => { + const mail = buildAccountingMail(CORRECTION) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040' + ) + expect(mail.text).toContain('Dokumentart: Ausgangsrechnung – Rechnungsberichtigung\n') + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') }) - test('names every missing or invalid variable', () => { - const result = getAccountingSmtpConfig({ - ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', - ACCOUNTING_SMTP_HOST: 'smtp.example.test', - ACCOUNTING_SMTP_PORT: 'abc', - ACCOUNTING_SMTP_PASSWORD: '', - }) - expect(result.config).toBeNull() - expect([...result.missing].sort()).toEqual([ - 'ACCOUNTING_MAIL_FROM', - 'ACCOUNTING_SMTP_PASSWORD', - 'ACCOUNTING_SMTP_PORT', - 'ACCOUNTING_SMTP_USER', - ]) + test('cancellation is marked as Gutschrift with a negative amount', () => { + const mail = buildAccountingMail(CANCELLATION) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040' + ) + expect(mail.text).toContain('Dokumentart: Ausgangsrechnung – Stornorechnung (Gutschrift)\n') + expect(mail.text).toContain(`Bruttobetrag: -1.189,00${NBSP}€\n`) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') }) -}) -describe('buildAccountingMail', () => { - test('original invoice: minimal subject, one-line body', () => { - const mail = buildAccountingMail(DOC) - expect(mail.subject).toBe('Rechnung PB-CON26-001') - expect(mail.text).toBe('Rechnung PB-CON26-001 (Bestellung ORD-2026-ABC123) im Anhang.\n') + test('a replacement version is marked in subject and body', () => { + const mail = buildAccountingMail({ ...DOC, replacesPreviousVersion: true }) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012 (ersetzt vorherige Fassung)' + ) + expect(mail.text).toContain('Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.') }) - test('correction and cancellation name their document type and the referenced invoice', () => { - const correction = buildAccountingMail({ - ...DOC, - documentKind: 'correction', - invoiceNumber: 'PB-CON26-002', - referenceNumber: 'PB-CON26-001', - }) - expect(correction.subject).toBe('Rechnungsberichtigung PB-CON26-002') - expect(correction.text).toContain('zu Rechnung PB-CON26-001') - - const cancellation = buildAccountingMail({ - ...DOC, - documentKind: 'cancellation', - invoiceNumber: 'PB-CON26-003', + test('omits what is not known: conference title, Leistungszeitraum, reference date', () => { + const mail = buildAccountingMail({ + ...CORRECTION, + referenceDate: null, + snapshot: { conference_title: '', service_date_start: null, service_date_end: null, total_gross_cents: 0 }, }) - expect(cancellation.subject).toBe('Stornorechnung PB-CON26-003') - }) - - test('a replacement version is marked in the subject', () => { - const mail = buildAccountingMail({ ...DOC, replacesPreviousVersion: true }) - expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') - expect(mail.text).toContain('ersetzt') + expect(mail.subject).toBe('Ausgangsrechnung – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040') + expect(mail.text).not.toContain('Veranstaltung') + expect(mail.text).not.toContain('Leistungszeitraum') + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040\n') }) }) describe('sendInvoiceToAccounting', () => { - test('sends one plain-text mail with exactly the document PDF over the dedicated SMTP transport', async () => { + test('sends one plain-text mail with exactly the document PDF through the MailService', async () => { const { options } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') - // Transport built from the ACCOUNTING_SMTP_* env, STARTTLS enforced on 587. - expect(createTransportMock).toHaveBeenCalledTimes(1) - expect(createTransportMock).toHaveBeenCalledWith({ - host: 'smtp.example.test', - port: 587, - secure: false, - requireTLS: true, - auth: { user: 'sender@example.test', pass: 'app-password' }, - }) - - expect(mockSendMail).toHaveBeenCalledTimes(1) - const mail = mockSendMail.mock.calls[0]![0] + expect(mailSendMock).toHaveBeenCalledTimes(1) + const mail = mailSendMock.mock.calls[0]![0] + const expected = buildAccountingMail(DOC) + // No `from`: the MailService uses EMAIL_FROM like for every other mail. expect(mail).toEqual({ - from: 'sender@example.test', to: 'accounting@example.test', - // The mailbox checks MAIL FROM against the registered sender. - envelope: { from: 'sender@example.test', to: 'accounting@example.test' }, - subject: 'Rechnung PB-CON26-001', - text: 'Rechnung PB-CON26-001 (Bestellung ORD-2026-ABC123) im Anhang.\n', + subject: expected.subject, + text: expected.text, attachments: [ - { filename: 'Rechnung-PB-CON26-001.pdf', content: DOC.pdfBuffer, contentType: 'application/pdf' }, + { filename: 'Rechnung-PB-CON26-012.pdf', content: DOC.pdfBuffer, contentType: 'application/pdf' }, ], }) expect(mail.attachments[0].content).toBe(DOC.pdfBuffer) // the very same bytes - // The upload mailbox rejects HTML, links and images. - expect(mail).not.toHaveProperty('html') - expect(mail.text).not.toMatch(/https?:|www\./) - expect(postSlackMessageMock).not.toHaveBeenCalled() - }) - - test('creates the transport once and reuses it', async () => { - const { options } = makeOptions() - await sendInvoiceToAccounting(DOC, options) - await sendInvoiceToAccounting({ ...DOC, invoiceNumber: 'PB-CON26-002' }, options) - - expect(createTransportMock).toHaveBeenCalledTimes(1) - expect(mockSendMail).toHaveBeenCalledTimes(2) - }) - - test('skips with one info log (no error) when the mailbox is not configured', async () => { - const { options, logger } = makeOptions({}) - await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') - - expect(createTransportMock).not.toHaveBeenCalled() - expect(mockSendMail).not.toHaveBeenCalled() - expect(logger.info).toHaveBeenCalledTimes(1) - expect(logger.info.mock.calls[0]![0]).toContain('ACCOUNTING_INVOICE_EMAIL') - expect(logger.info.mock.calls[0]![0]).toContain('PB-CON26-001') - expect(logger.warn).not.toHaveBeenCalled() - expect(logger.error).not.toHaveBeenCalled() expect(postSlackMessageMock).not.toHaveBeenCalled() }) - test('incomplete SMTP config: skips with a warning per document and alerts Slack once per process', async () => { - const { ACCOUNTING_SMTP_PASSWORD: _unused, ...env } = ENV - const { options, logger } = makeOptions(env) - - await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('misconfigured') - await expect(sendInvoiceToAccounting({ ...DOC, invoiceNumber: 'PB-CON26-002' }, options)).resolves.toBe( - 'misconfigured' - ) - - // No mail through any transport — in particular no fallback that would bounce. - expect(createTransportMock).not.toHaveBeenCalled() - expect(mockSendMail).not.toHaveBeenCalled() - - expect(logger.warn).toHaveBeenCalledTimes(2) - expect(logger.warn.mock.calls[0]![0]).toContain('ACCOUNTING_SMTP_PASSWORD') - expect(logger.warn.mock.calls[1]![0]).toContain('PB-CON26-002') - - expect(postSlackMessageMock).toHaveBeenCalledTimes(1) - const slack = postSlackMessageMock.mock.calls[0]![0] - expect(slack).toContain('ACCOUNTING_SMTP_PASSWORD') - expect(slack).toContain('PB-CON26-001') - expect(slack).not.toContain('accounting@example.test') - }) - test('retries a failed send and recovers without escalating', async () => { - mockSendMail.mockRejectedValueOnce(new Error('421 try again')).mockResolvedValueOnce({ messageId: 'x' }) + mailSendMock.mockRejectedValueOnce(new Error('421 try again')).mockResolvedValueOnce({ messageId: 'x' }) const { options } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') - expect(mockSendMail).toHaveBeenCalledTimes(2) + expect(mailSendMock).toHaveBeenCalledTimes(2) expect(postSlackMessageMock).not.toHaveBeenCalled() }) test('persistent failure: alerts Slack with the invoice number and does not throw', async () => { - mockSendMail.mockRejectedValue(new Error('550 sender not registered')) + mailSendMock.mockRejectedValue(new Error('transport down')) const { options, logger } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') - expect(mockSendMail).toHaveBeenCalledTimes(3) - expect(logger.warn.mock.calls.map((c) => c[0]).join('\n')).toContain('550 sender not registered') + expect(mailSendMock).toHaveBeenCalledTimes(3) + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('transport down') expect(postSlackMessageMock).toHaveBeenCalledTimes(1) const slack = postSlackMessageMock.mock.calls[0]![0] - expect(slack).toContain('PB-CON26-001') + expect(slack).toContain('PB-CON26-012') expect(slack).toContain('ORD-2026-ABC123') - expect(slack).toContain('Rechnung-PB-CON26-001.pdf') + expect(slack).toContain('Rechnung-PB-CON26-012.pdf') // The address is configuration, not something to broadcast. expect(slack).not.toContain('accounting@example.test') - expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('PB-CON26-001') }) test('a failing Slack alert is swallowed too', async () => { - mockSendMail.mockRejectedValue(new Error('smtp exploded')) + mailSendMock.mockRejectedValue(new Error('transport down')) postSlackMessageMock.mockRejectedValue(new Error('slack down')) const { options, logger } = makeOptions() await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') - expect(mockSendMail).toHaveBeenCalledTimes(3) + expect(mailSendMock).toHaveBeenCalledTimes(3) expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') }) - test('a transport that cannot be created is retried, alerted and does not throw', async () => { - createTransportMock.mockImplementation(() => { - throw new Error('bad transport options') + describe('mailbox not configured', () => { + afterEach(() => { + expect(mailSendMock).not.toHaveBeenCalled() }) - const { options, logger } = makeOptions() - await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') - expect(createTransportMock).toHaveBeenCalledTimes(3) - expect(logger.warn.mock.calls.map((c) => c[0]).join('\n')).toContain('bad transport options') - expect(postSlackMessageMock).toHaveBeenCalledTimes(1) - createTransportMock.mockImplementation(() => ({ sendMail: mockSendMail }) as any) + test('skips every document with an info log and alerts Slack once per process', async () => { + const { options, logger } = makeOptions({}) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CORRECTION, options)).resolves.toBe('skipped') + + expect(logger.info).toHaveBeenCalledTimes(2) + expect(logger.info.mock.calls[0]![0]).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(logger.info.mock.calls[1]![0]).toContain('PB-CON26-049') + expect(logger.error).not.toHaveBeenCalled() + + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(slack).toContain('PB-CON26-012') + }) + + test('an undelivered alert is retried with the next document', async () => { + postSlackMessageMock.mockRejectedValueOnce(new Error('slack down')) + const { options, logger } = makeOptions({}) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CORRECTION, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CANCELLATION, options)).resolves.toBe('skipped') + + expect(postSlackMessageMock).toHaveBeenCalledTimes(2) + expect(postSlackMessageMock.mock.calls[1]![0]).toContain('PB-CON26-049') + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') + }) + + test('stays quiet when Slack is not configured', async () => { + isSlackConfiguredMock.mockReturnValue(false) + const { options, logger } = makeOptions({ ACCOUNTING_INVOICE_EMAIL: '' }) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + + expect(postSlackMessageMock).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts index faec58a6..d90d0d53 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts @@ -2,58 +2,62 @@ * Accounting copy of every invoice document. * * Each generated invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) is - * mailed to an accounting mailbox, one PDF per mail, so it is recorded in the books - * promptly (GoBD). This is a separate mail — never a BCC on the customer mail — and is - * independent of whether the customer mail went out: the document exists either way. + * mailed to the accounting mailbox, one PDF per mail, so it is recorded in the books + * promptly (GoBD). Accounting takes it from there into the bookkeeping system. This is a + * separate mail — never a BCC on the customer mail — and is independent of whether the + * customer mail went out: the document exists either way. * - * The accounting mailbox uploads attachments into the bookkeeping system and rejects - * mails with links, images, logos or QR codes. So the mail is plain text only (no HTML, - * no template, no links, no signature), carries exactly one PDF, and names the invoice - * number in the subject. - * - * It also rejects (550 at RCPT TO) every mail whose SMTP envelope sender (MAIL FROM) is - * not the registered sender address. The Directus MailService goes through the - * transactional mail provider, which always uses its own return path, so it can never - * satisfy that check. The accounting copy therefore uses its own SMTP transport - * (`ACCOUNTING_SMTP_*`, `ACCOUNTING_MAIL_FROM`) and sets both `from` and the envelope - * sender explicitly. There is deliberately no fallback to the MailService: it would only - * produce bounces. + * It goes through the Directus MailService like every other mail (shared email service, + * sender `EMAIL_FROM`). Subject and plain-text body state what the document is + * (Ausgangsrechnung, document type, number, reference, amount) so it can be classified + * without opening the PDF. The body carries no customer data — the PDF has everything. * * The mailbox comes from `ACCOUNTING_INVOICE_EMAIL`. Without it the accounting copy is - * skipped (info log), so environments without bookkeeping (local, staging) keep working - * unchanged. With it but an incomplete SMTP configuration, the copy is skipped with a - * warning and a Slack alert (once per process) — that needs a human. + * skipped (info log, plus one Slack alert per process so a missing production setting + * does not go unnoticed), and environments without bookkeeping keep working unchanged. */ -import nodemailer from 'nodemailer' -import type { InvoiceDocumentKind } from './invoice-generator.js' -import { INVOICE_DOCUMENT_LABEL } from './invoice-service.js' -import { postSlackMessage } from './postSlackMessage.js' +import { sendRawEmail, type EmailServiceContext } from './email-service.js' +import { INVOICE_ISSUER_NAME, type InvoiceDocumentKind } from './invoice-generator.js' +import { + formatInvoiceDate, + formatServiceDate, + INVOICE_DOCUMENT_LABEL, + type InvoiceSnapshot, +} from './invoice-service.js' +import { isSlackConfigured, postSlackMessage } from './postSlackMessage.js' +import { formatPrice } from './ticket-utils.js' export const ACCOUNTING_INVOICE_EMAIL_ENV = 'ACCOUNTING_INVOICE_EMAIL' -/** Env vars the dedicated SMTP transport needs (`ACCOUNTING_SMTP_SECURE` is optional). */ -export const ACCOUNTING_SMTP_REQUIRED_ENV = [ - 'ACCOUNTING_SMTP_HOST', - 'ACCOUNTING_SMTP_PORT', - 'ACCOUNTING_SMTP_USER', - 'ACCOUNTING_SMTP_PASSWORD', - 'ACCOUNTING_MAIL_FROM', -] as const - /** How often to attempt sending the accounting copy before escalating to Slack. */ export const ACCOUNTING_MAIL_ATTEMPTS = 3 +/** Document type as named in the accounting mail (more specific than the PDF title). */ +export const ACCOUNTING_DOCUMENT_LABEL: Record = { + invoice: 'Ticketrechnung', + correction: 'Rechnungsberichtigung', + cancellation: 'Stornorechnung (Gutschrift)', +} + export interface AccountingInvoiceDocument { documentKind: InvoiceDocumentKind invoiceNumber: string + invoiceDate: Date + /** The snapshot the PDF was rendered from (conference, Leistungsdatum, gross amount). */ + snapshot: Pick< + InvoiceSnapshot, + 'conference_title' | 'service_date_start' | 'service_date_end' | 'total_gross_cents' + > /** The exact bytes of the stored PDF. */ pdfBuffer: Buffer /** Same file name as the stored PDF, e.g. `Rechnung-PB-CON26-001.pdf`. */ invoiceFileName: string + /** For logs and the Slack alert only — not part of the mail. */ orderNumber?: string | null - /** Number of the corrected/cancelled invoice (corrections and cancellations only). */ + /** The corrected/cancelled invoice (corrections and cancellations only). */ referenceNumber?: string | null + referenceDate?: Date | null /** * The document was re-rendered under the same number (pre-issuance regenerate), so * accounting may already hold an earlier version that this one replaces. @@ -61,128 +65,61 @@ export interface AccountingInvoiceDocument { replacesPreviousVersion?: boolean } -export type AccountingMailResult = 'sent' | 'skipped' | 'misconfigured' | 'failed' - -type Env = Record | undefined +export type AccountingMailResult = 'sent' | 'skipped' | 'failed' export interface AccountingMailOptions { /** The extension's env (hook/endpoint context `env`). */ - env: Env - logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } - /** Hook or endpoint name, for logs and the Slack alert. */ + env: Record | undefined + /** Mail goes through the shared email service; its logger is used for all logs. */ + emailContext: EmailServiceContext + /** Hook or endpoint name, for logs and Slack alerts. */ source: string } -export interface AccountingSmtpConfig { - host: string - port: number - secure: boolean - user: string - password: string - /** The sender address registered with the accounting mailbox; also the envelope sender. */ - from: string -} - -/** Directus casts env values ("465" → 465, "true" → true); accept both forms. */ -function envString(env: Env, key: string): string | null { - const value = env?.[key] - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - if (typeof value !== 'string') { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed -} - /** The configured accounting mailbox, or null when the env var is unset or empty. */ -export function getAccountingInvoiceEmail(env: Env): string | null { +export function getAccountingInvoiceEmail(env: Record | undefined): string | null { const value = env?.[ACCOUNTING_INVOICE_EMAIL_ENV] - return typeof value === 'string' ? envString(env, ACCOUNTING_INVOICE_EMAIL_ENV) : null + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null } -/** - * The dedicated SMTP transport's settings, or the names of the missing env vars. - * - * `ACCOUNTING_SMTP_SECURE` has the same meaning as Directus' `EMAIL_SMTP_SECURE`: true - * means implicit TLS (port 465), false means STARTTLS (port 587). Setting it to true on - * 587 fails the handshake, so when it is unset it is derived from the port. - */ -export function getAccountingSmtpConfig( - env: Env -): { config: AccountingSmtpConfig; missing: [] } | { config: null; missing: string[] } { - const missing: string[] = ACCOUNTING_SMTP_REQUIRED_ENV.filter((key) => envString(env, key) === null) - const port = Number(envString(env, 'ACCOUNTING_SMTP_PORT')) - if (envString(env, 'ACCOUNTING_SMTP_PORT') !== null && !(Number.isInteger(port) && port > 0)) { - missing.push('ACCOUNTING_SMTP_PORT') - } - if (missing.length > 0) { - return { config: null, missing: [...new Set(missing)] } - } - - const secureRaw = envString(env, 'ACCOUNTING_SMTP_SECURE') - const secure = secureRaw === null ? port === 465 : secureRaw.toLowerCase() === 'true' +// Process-wide: alert Slack about a missing mailbox once (again after a failed post). +let missingMailboxReported = false - return { - config: { - host: envString(env, 'ACCOUNTING_SMTP_HOST')!, - port, - secure, - user: envString(env, 'ACCOUNTING_SMTP_USER')!, - password: envString(env, 'ACCOUNTING_SMTP_PASSWORD')!, - from: envString(env, 'ACCOUNTING_MAIL_FROM')!, - }, - missing: [], - } -} - -// Process-wide state: one lazily created transport, one misconfiguration alert. -let cachedTransport: { key: string; transport: { sendMail: (mail: any) => Promise } } | null = null -let misconfigurationAlerted = false - -/** Reset the lazily created transport and the once-per-process alert flag (tests only). */ +/** Reset the once-per-process alert flag (tests only). */ export function resetAccountingMailState(): void { - cachedTransport = null - misconfigurationAlerted = false + missingMailboxReported = false } -function getTransport(config: AccountingSmtpConfig) { - const key = JSON.stringify(config) - if (!cachedTransport || cachedTransport.key !== key) { - cachedTransport = { - key, - transport: nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.secure, - // Without implicit TLS, insist on STARTTLS: credentials never go out in clear. - requireTLS: !config.secure, - auth: { user: config.user, pass: config.password }, - }), - } - } - return cachedTransport.transport -} - -/** Minimal subject and one-line plain-text body — no cover letter needed. */ +/** Subject and plain-text body that identify the document for automatic classification. */ export function buildAccountingMail(doc: AccountingInvoiceDocument): { subject: string; text: string } { - const name = `${INVOICE_DOCUMENT_LABEL[doc.documentKind]} ${doc.invoiceNumber}` - const subject = doc.replacesPreviousVersion ? `${name} (ersetzt vorherige Fassung)` : name - - let text = name - if (doc.referenceNumber) { - text += ` zu Rechnung ${doc.referenceNumber}` - } - if (doc.orderNumber) { - text += ` (Bestellung ${doc.orderNumber})` - } - text += ' im Anhang.' + const conference = doc.snapshot.conference_title?.trim() + const label = ACCOUNTING_DOCUMENT_LABEL[doc.documentKind] + const reference = doc.referenceNumber ? ` zu ${doc.referenceNumber}` : '' + const replaces = doc.replacesPreviousVersion ? ' (ersetzt vorherige Fassung)' : '' + const subject = `Ausgangsrechnung${conference ? ` ${conference}` : ''} – ${label} ${doc.invoiceNumber}${reference}${replaces}` + + const serviceDate = formatServiceDate(doc.snapshot.service_date_start, doc.snapshot.service_date_end) + const lines = [ + `Dokumentart: Ausgangsrechnung – ${label}`, + ...(conference ? [`Veranstaltung: ${conference}`] : []), + `Rechnungsnummer: ${doc.invoiceNumber}`, + `Rechnungsdatum: ${formatInvoiceDate(doc.invoiceDate)}`, + ...(serviceDate ? [`Leistungszeitraum: ${serviceDate}`] : []), + ...(doc.referenceNumber + ? [ + `Bezug auf Originalrechnung: ${doc.referenceNumber}${doc.referenceDate ? ` vom ${formatInvoiceDate(doc.referenceDate)}` : ''}`, + ] + : []), + `Bruttobetrag: ${formatPrice(doc.snapshot.total_gross_cents)}`, + `Aussteller: ${INVOICE_ISSUER_NAME}`, + '', + `Das Dokument liegt als PDF bei (${doc.invoiceFileName}).`, + ] if (doc.replacesPreviousVersion) { - text += ' Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.' + lines.push('Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.') } - return { subject, text: `${text}\n` } + return { subject, text: `${lines.join('\n')}\n` } } /** @@ -196,7 +133,10 @@ export async function sendInvoiceToAccounting( doc: AccountingInvoiceDocument, options: AccountingMailOptions ): Promise { - const { logger, source } = options + const { emailContext, source } = options + const { logger } = emailContext + const label = INVOICE_DOCUMENT_LABEL[doc.documentKind] + const order = doc.orderNumber ? ` zu Bestellung ${doc.orderNumber}` : '' try { const to = getAccountingInvoiceEmail(options.env) @@ -204,58 +144,44 @@ export async function sendInvoiceToAccounting( logger.info( `${source}: ${ACCOUNTING_INVOICE_EMAIL_ENV} is not set — skipping accounting copy of ${doc.invoiceNumber}` ) - return 'skipped' - } - - const { config, missing } = getAccountingSmtpConfig(options.env) - if (!config) { - logger.warn( - `${source}: ${ACCOUNTING_INVOICE_EMAIL_ENV} is set but ${missing.join(', ')} missing or invalid — skipping accounting copy of ${doc.invoiceNumber}` - ) - if (!misconfigurationAlerted) { - misconfigurationAlerted = true + if (!missingMailboxReported && isSlackConfigured()) { + missingMailboxReported = true try { await postSlackMessage( - `:warning: *${source}*: Rechnungskopien an die Buchhaltung werden nicht verschickt — ${ACCOUNTING_INVOICE_EMAIL_ENV} ist gesetzt, aber ${missing.join(', ')} fehlt bzw. ist ungültig. Erste betroffene Rechnung: ${doc.invoiceNumber}. Bitte die Directus-Umgebung ergänzen, neu starten und die seitdem erzeugten PDFs manuell an die Buchhaltung weiterleiten (Directus-Log nennt jede Nummer). Dieser Hinweis kommt nur einmal pro Prozessstart.` + `:warning: *${source}*: ${ACCOUNTING_INVOICE_EMAIL_ENV} ist nicht gesetzt — Rechnungsdokumente gehen nicht an die Buchhaltung. Erstes betroffenes Dokument: ${label} ${doc.invoiceNumber}${order}. Falls diese Umgebung Rechnungen für die Buchhaltung erzeugt: Variable setzen, Directus neu starten und die seitdem erzeugten PDFs manuell weiterleiten (das Directus-Log nennt jede Nummer). Dieser Hinweis kommt einmal pro Prozessstart.` ) } catch (slackErr: any) { + // Not delivered: let the next document try again. + missingMailboxReported = false logger.error(`${source}: Failed to send Slack notification: ${slackErr?.message || slackErr}`) } } - return 'misconfigured' + return 'skipped' } const { subject, text } = buildAccountingMail(doc) for (let attempt = 1; attempt <= ACCOUNTING_MAIL_ATTEMPTS; attempt++) { - try { - // Inside the retry: a transport that cannot be created ends up in the Slack alert too. - const transport = getTransport(config) - await transport.sendMail({ - from: config.from, + // sendRawEmail never throws; it logs the transport error itself. + const sent = await sendRawEmail( + { to, - // The accounting mailbox checks MAIL FROM against the registered sender. - envelope: { from: config.from, to }, subject, text, attachments: [ - { - filename: doc.invoiceFileName, - content: doc.pdfBuffer, - contentType: 'application/pdf', - }, + { filename: doc.invoiceFileName, content: doc.pdfBuffer, contentType: 'application/pdf' }, ], - }) + }, + emailContext + ) + if (sent) { logger.info(`${source}: Sent accounting copy of ${doc.invoiceNumber}`) return 'sent' - } catch (err: any) { - logger.warn( - `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed: ${err?.message || err}` - ) } + logger.warn( + `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed` + ) } - const label = INVOICE_DOCUMENT_LABEL[doc.documentKind] - const order = doc.orderNumber ? ` zu Bestellung ${doc.orderNumber}` : '' logger.error( `${source}: Accounting copy of ${label} ${doc.invoiceNumber}${order} could not be sent after ${ACCOUNTING_MAIL_ATTEMPTS} attempts` ) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts index 5eaf0308..590b42c0 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts @@ -70,6 +70,16 @@ export interface SendEmailOptions { headers?: Record } +/** Attachments in the shape MailService expects (PDF unless stated otherwise). */ +function toMailAttachments(attachments: EmailAttachment[]) { + return attachments.map((a) => ({ + filename: a.filename, + content: a.content, + contentType: a.contentType || 'application/pdf', + ...(a.cid && { cid: a.cid }), + })) +} + /** * Get an email template from the email_templates collection. */ @@ -141,14 +151,7 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema ...(options.replyTo && { replyTo: options.replyTo }), ...(options.cc && { cc: options.cc }), ...(options.headers && { headers: options.headers }), - ...(options.attachments && { - attachments: options.attachments.map((a) => ({ - filename: a.filename, - content: a.content, - contentType: a.contentType || 'application/pdf', - ...(a.cid && { cid: a.cid }), - })), - }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to} using template '${options.templateKey}'`) @@ -161,19 +164,24 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema /** * Send a raw email (without using a template from CMS). - * Useful for dynamically generated content. + * Useful for dynamically generated content. Without `html` the mail is plain text only. */ export async function sendRawEmail( options: { to: string subject: string - html: string - /** Plain-text alternative, for clients that don't render HTML. */ - text?: string replyTo?: string /** Extra mail headers (e.g. List-Unsubscribe); passed through MailService to nodemailer. */ headers?: Record - }, + attachments?: EmailAttachment[] + } & ( + | { + html: string + /** Plain-text alternative, for clients that don't render HTML. */ + text?: string + } + | { html?: undefined; text: string } + ), context: EmailServiceContext ): Promise { const { logger, services, getSchema } = context @@ -190,10 +198,11 @@ export async function sendRawEmail( await mailService.send({ to: options.to, subject: options.subject, - html: options.html, + ...(options.html !== undefined && { html: options.html }), ...(options.text && { text: options.text }), ...(options.replyTo && { replyTo: options.replyTo }), ...(options.headers && { headers: options.headers }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to}`) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts index 95f4680f..45439a66 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts @@ -154,6 +154,9 @@ export function fitHeadingFontSize( return fontSize } +/** The issuing company as printed on every invoice document. */ +export const INVOICE_ISSUER_NAME = SELLER.name + function formatEur(cents: number): string { return new Intl.NumberFormat('de-DE', { style: 'currency', diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts index d704f8fb..f2365dd9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts @@ -3,6 +3,14 @@ import { WebClient } from '@slack/web-api' // Create Slack web client instance const webClient = new WebClient(process.env.SLACK_BOT_TOKEN as string) +/** + * Whether a bot token and channel are configured. Optional alerts (e.g. about a missing + * setting) check this first, so environments without Slack stay quiet. + */ +export function isSlackConfigured(): boolean { + return Boolean(process.env.SLACK_BOT_TOKEN && process.env.SLACK_CHANNEL_ID) +} + /** * A helper function that posts a Slack message to a specific channel. * diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts index 98e5f928..cbb78143 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import { resetAccountingMailState } from '../../shared/accounting-mail.ts' -import { sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' +import { sendInvoiceToAccounting } from '../../shared/accounting-mail.ts' +import { sendTemplatedEmail } from '../../shared/email-service.ts' import { generateInvoiceNumber } from '../../shared/invoice-generator.ts' import { issueOriginalInvoice, markInvoiceSent } from '../../shared/invoice-service.ts' import { postSlackMessage } from '../../shared/postSlackMessage.ts' @@ -15,7 +15,6 @@ jest.mock('@directus/extensions-sdk', () => ({ jest.mock('../../shared/email-service.ts', () => ({ sendTemplatedEmail: jest.fn(), - sendRawEmail: jest.fn(), })) jest.mock('../../shared/settings.ts', () => ({ getSetting: jest.fn(async () => 'https://www.programmier.bar'), @@ -26,12 +25,6 @@ jest.mock('../../shared/invoice-generator.ts', () => ({ jest.mock('../../shared/invoice-service.ts', () => ({ issueOriginalInvoice: jest.fn(), markInvoiceSent: jest.fn(), - // Used by the (real) accounting-mail helper for the mail subject. - INVOICE_DOCUMENT_LABEL: { - invoice: 'Rechnung', - correction: 'Rechnungsberichtigung', - cancellation: 'Stornorechnung', - }, })) jest.mock('../../shared/ticket-utils.ts', () => ({ generateUniqueTicketCode: jest.fn(), @@ -41,15 +34,14 @@ jest.mock('../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), })) -// The accounting copy goes through its own SMTP transport, never through MailService. -const mockSendMail = jest.fn<(mail: any) => Promise>() -jest.mock('nodemailer', () => ({ - __esModule: true, - default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, +// Building and sending the accounting mail is covered in shared/__tests__/accounting-mail.test.ts; +// here it only matters that the hook hands over the right document at the right time. +jest.mock('../../shared/accounting-mail.ts', () => ({ + sendInvoiceToAccounting: jest.fn(), })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) -const sendRawEmailMock = jest.mocked(sendRawEmail) +const sendInvoiceToAccountingMock = jest.mocked(sendInvoiceToAccounting) const postSlackMessageMock = jest.mocked(postSlackMessage) const issueOriginalInvoiceMock = jest.mocked(issueOriginalInvoice) const markInvoiceSentMock = jest.mocked(markInvoiceSent) @@ -99,15 +91,7 @@ const CONFERENCE = { ticket_max_quantity: null, } -const ACCOUNTING_ENV = { - ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', - ACCOUNTING_SMTP_HOST: 'smtp.example.test', - ACCOUNTING_SMTP_PORT: 465, - ACCOUNTING_SMTP_SECURE: true, - ACCOUNTING_SMTP_USER: 'sender@example.test', - ACCOUNTING_SMTP_PASSWORD: 'app-password', - ACCOUNTING_MAIL_FROM: 'sender@example.test', -} +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } function setup(orderOverrides: Record = {}, env: Record = {}) { const order = { ...ORDER, ...orderOverrides } @@ -153,14 +137,13 @@ function setup(orderOverrides: Record = {}, env: Record { sendTemplatedEmailMock.mockReset() - sendRawEmailMock.mockReset() - mockSendMail.mockReset() - resetAccountingMailState() + sendInvoiceToAccountingMock.mockReset() + sendInvoiceToAccountingMock.mockResolvedValue('sent') issueOriginalInvoiceMock.mockReset() markInvoiceSentMock.mockReset() postSlackMessageMock.mockReset() @@ -174,10 +157,9 @@ beforeEach(() => { fileId: 'file-1', pdfBuffer: Buffer.from('%PDF-fake'), invoiceFileName: 'Rechnung-PB-CON26-001.pdf', - snapshot: {} as any, + snapshot: { conference_title: 'programmier.con 2026' } as any, }) sendTemplatedEmailMock.mockResolvedValue(true) - mockSendMail.mockResolvedValue({ messageId: 'x' }) }) describe('ticket-order-processing hook', () => { @@ -310,32 +292,31 @@ describe('ticket-order-processing hook', () => { ([opts]) => (opts as any).templateKey === 'ticket_profile_invitation' ) - test('mails the original invoice PDF to the accounting mailbox as a separate mail', async () => { - const { invoke } = setup({}, ACCOUNTING_ENV) + test('hands the original invoice document to the accounting mail, separate from the customer mail', async () => { + const { invoke, hookContext } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(mockSendMail).toHaveBeenCalledTimes(1) - const mail = mockSendMail.mock.calls[0]![0] - expect(mail.to).toBe('accounting@example.test') - // Registered sender as header AND envelope sender, over the dedicated transport. - expect(mail.from).toBe('sender@example.test') - expect(mail.envelope).toEqual({ from: 'sender@example.test', to: 'accounting@example.test' }) - expect(sendRawEmailMock).not.toHaveBeenCalled() - expect(mail.subject).toBe('Rechnung PB-CON26-001') - expect(mail.text).toContain('ORD-2026-ABC123') - expect(mail.html).toBeUndefined() // plain text, not the customer template - // Exactly the generated document: same bytes, same file name, one per mail. - expect(mail.attachments).toEqual([ - { - filename: 'Rechnung-PB-CON26-001.pdf', - content: Buffer.from('%PDF-fake'), - contentType: 'application/pdf', - }, - ]) + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) + const [doc, options] = sendInvoiceToAccountingMock.mock.calls[0]! + const issueParams = issueOriginalInvoiceMock.mock.calls[0]![0] + expect(doc).toEqual({ + documentKind: 'invoice', + invoiceNumber: 'PB-CON26-001', + // The very date printed on the invoice, and the snapshot it was rendered from. + invoiceDate: issueParams.invoiceDate, + snapshot: { conference_title: 'programmier.con 2026' }, + pdfBuffer: Buffer.from('%PDF-fake'), + invoiceFileName: 'Rechnung-PB-CON26-001.pdf', + orderNumber: 'ORD-2026-ABC123', + }) + expect(options.env).toBe(hookContext.env) + expect(options.source).toBe('ticket-order-processing') + // Goes through the shared email service with the hook's services. + expect(options.emailContext.services).toBe(hookContext.services) // Not piggybacked onto the customer mail. const confirmation = sendTemplatedEmailMock.mock.calls.find( - ([options]) => (options as any).templateKey === 'ticket_order_confirmation' + ([opts]) => (opts as any).templateKey === 'ticket_order_confirmation' )![0] as any expect(confirmation.cc).toBeUndefined() expect(confirmation.bcc).toBeUndefined() @@ -346,73 +327,26 @@ describe('ticket-order-processing hook', () => { const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(mockSendMail).toHaveBeenCalledTimes(1) - expect(mockSendMail.mock.calls[0]![0].subject).toBe('Rechnung PB-CON26-001') + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) expect(markInvoiceSentMock).not.toHaveBeenCalled() }) - test('is skipped with an info log when ACCOUNTING_INVOICE_EMAIL is not set', async () => { - const { invoke, logger } = setup({}, { ACCOUNTING_INVOICE_EMAIL: '' }) - await invoke({ status: 'paid' }) - - expect(mockSendMail).not.toHaveBeenCalled() - const skipLogs = logger.info.mock.calls - .map((call) => String(call[0])) - .filter((msg) => msg.includes('ACCOUNTING_INVOICE_EMAIL')) - expect(skipLogs).toHaveLength(1) - expect(skipLogs[0]).toContain('PB-CON26-001') - expect(logger.error).not.toHaveBeenCalled() - // Customer flow is unaffected. - expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) - }) - - test('persistent failure: retried, escalated to Slack with the invoice number, hook carries on', async () => { - mockSendMail.mockRejectedValue(new Error('550 sender rejected')) + test('a failed accounting copy does not affect the customer flow', async () => { + sendInvoiceToAccountingMock.mockResolvedValue('failed') const { invoke } = setup({}, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(mockSendMail).toHaveBeenCalledTimes(3) - expect(postSlackMessageMock).toHaveBeenCalledTimes(1) - expect(postSlackMessageMock.mock.calls[0]![0]).toContain('PB-CON26-001') - expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ORD-2026-ABC123') - // The customer still got their invoice, issuance is recorded from THAT mail, // tickets exist and attendees were invited. expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) expect(invitations()).toHaveLength(2) }) - test('a throwing mail transport does not break order processing either', async () => { - mockSendMail.mockRejectedValue(new Error('smtp exploded')) - postSlackMessageMock.mockRejectedValue(new Error('slack down')) - const { invoke } = setup({}, ACCOUNTING_ENV) - await invoke({ status: 'paid' }) - - expect(mockSendMail).toHaveBeenCalledTimes(3) - expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) - expect(invitations()).toHaveLength(2) - }) - - test('incomplete SMTP config: no mail, warning and Slack alert, order processing unaffected', async () => { - const { ACCOUNTING_MAIL_FROM: _unused, ...env } = ACCOUNTING_ENV - const { invoke, logger } = setup({}, env) - await invoke({ status: 'paid' }) - - expect(mockSendMail).not.toHaveBeenCalled() - expect(sendRawEmailMock).not.toHaveBeenCalled() // no fallback to MailService - expect(logger.warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('ACCOUNTING_MAIL_FROM') - expect(postSlackMessageMock).toHaveBeenCalledTimes(1) - expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ACCOUNTING_MAIL_FROM') - expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) - expect(invitations()).toHaveLength(2) - }) - test('internal orders have no invoice, so nothing goes to accounting', async () => { const { invoke } = setup({ is_internal: true }, ACCOUNTING_ENV) await invoke({ status: 'paid' }) - expect(mockSendMail).not.toHaveBeenCalled() - expect(postSlackMessageMock).not.toHaveBeenCalled() + expect(sendInvoiceToAccountingMock).not.toHaveBeenCalled() }) }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index 2b92dfdf..03488106 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -198,13 +198,14 @@ export default defineHook(({ action }, hookContext) => { schema, }) + const invoiceDate = new Date() const invoiceResult = await issueOriginalInvoice({ order, conferenceTitle: conference.title, servicePeriod: { start_on: conference.start_on, end_on: conference.end_on }, ticketCount: attendees.length, invoiceNumber, - invoiceDate: new Date(), + invoiceDate, ordersService, invoicesService, filesService, @@ -228,11 +229,13 @@ export default defineHook(({ action }, hookContext) => { { documentKind: 'invoice', invoiceNumber, + invoiceDate, + snapshot: invoiceResult.snapshot, pdfBuffer, invoiceFileName, orderNumber: order.order_number, }, - { env, logger, source: HOOK_NAME } + { env, emailContext: context, source: HOOK_NAME } ) } else { logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) diff --git a/docs/tickets.md b/docs/tickets.md index 382e10e4..36fbb53c 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -31,10 +31,7 @@ The flow consists of four main phases: | `STORAGE_LOCATIONS` | Comma-separated storage location IDs (first is used for invoice PDFs) | Yes | | `INVOICE_FILES_FOLDER` | Id of the `directus_files` folder invoice PDFs are uploaded into; the Public policy must not read it (see [Invoice File Privacy](#invoice-file-privacy)). Unset or invalid: invoices still upload, but into the public library (warning + Slack alert) | Yes | | Email transport config | Directus email settings (`EMAIL_TRANSPORT`, `EMAIL_SMTP_HOST`, etc.) | Yes | -| `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent | Production | -| `ACCOUNTING_SMTP_HOST`, `ACCOUNTING_SMTP_PORT`, `ACCOUNTING_SMTP_USER`, `ACCOUNTING_SMTP_PASSWORD` | Dedicated SMTP transport for the accounting copy (e.g. Google Workspace SMTP with an app password) | When `ACCOUNTING_INVOICE_EMAIL` is set | -| `ACCOUNTING_SMTP_SECURE` | `true` = implicit TLS (465), `false` = STARTTLS (587); unset: derived from the port | No | -| `ACCOUNTING_MAIL_FROM` | Sender address registered with the accounting mailbox; used as `From` and envelope sender | When `ACCOUNTING_INVOICE_EMAIL` is set | +| `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent (Slack is alerted once per process start) | Production | ## External Services @@ -184,23 +181,21 @@ Key-value settings used by extension hooks. ## Accounting Copy Every generated invoice document is mailed to `ACCOUNTING_INVOICE_EMAIL` (GoBD: timely recording), -as its own plain-text mail with exactly one PDF attached — the same bytes and file name as the stored -file. Subject: `Rechnung PB-CON26-001`, `Rechnungsberichtigung …` or `Stornorechnung …`. The upload -mailbox rejects mails with links, images, logos or QR codes, so the mail carries no HTML, template, -link or signature. - -**Why a separate SMTP transport:** the upload mailbox rejects every mail (550 at `RCPT TO`) whose SMTP -envelope sender (MAIL FROM) is not the sender address registered there. The Directus MailService -sends through the transactional mail provider, which always uses its own return path, so it can never -pass that check. The accounting copy therefore goes through its own nodemailer transport -(`ACCOUNTING_SMTP_*`, e.g. Google Workspace SMTP with an app password) and sets both `From` and the -envelope sender to `ACCOUNTING_MAIL_FROM`. There is deliberately no fallback to the MailService — it -would only produce bounces. - -Setup: register `ACCOUNTING_MAIL_FROM` as an allowed sender in the accounting mailbox's settings, -create an app password for that account, and put it in the Directus environment only — never commit -it. If `ACCOUNTING_INVOICE_EMAIL` is set but any SMTP variable or `ACCOUNTING_MAIL_FROM` is missing, -no copy is sent: each document logs a warning, and Slack is alerted once per process start. +from where accounting takes it into the bookkeeping system. It goes through the Directus MailService +like every other mail (`sendRawEmail` in `shared/email-service.ts`, sender `EMAIL_FROM`), as its own +plain-text mail with exactly one PDF attached — the same bytes and file name as the stored file. + +Subject and body say what the document is, so it can be classified without opening the PDF: + +``` +Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012 +Ausgangsrechnung programmier.con 2026 – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040 +Ausgangsrechnung programmier.con 2026 – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-049 +``` + +The conference name is the order's conference title. The body lists Dokumentart, Rechnungsnummer, +Rechnungsdatum, Leistungszeitraum, the referenced original invoice (corrections and cancellations), +Bruttobetrag and Aussteller — no customer data; the PDF carries everything else. | Document | Sent by | |----------|---------| @@ -210,8 +205,10 @@ no copy is sent: each document logs a warning, and Slack is alerted once per pro Internal orders have no invoice, so nothing is sent. The accounting copy never sets `sent_at` (that records the customer dispatch). It is retried up to three times; if it still fails, a Slack alert -names the invoice number so the PDF can be forwarded by hand. Order processing and the lifecycle -endpoint carry on either way. +names the invoice number so the PDF can be forwarded by hand. If `ACCOUNTING_INVOICE_EMAIL` is unset, +each document is skipped with an info log, and Slack (when configured) is alerted once per process +start with the first affected invoice number. Order processing and the lifecycle endpoint carry on +either way. ## Data Flow From d7ed00ac5919b55e9ab7a381be5c4e0978500a9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:50:01 +0000 Subject: [PATCH 4/6] Name the conference ticket in every accounting mail subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subject and Dokumentart now read "Ausgangsrechnung Ticket – ", 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) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../invoice-lifecycle/__tests__/index.test.ts | 8 ++++---- .../shared/__tests__/accounting-mail.test.ts | 20 +++++++++++-------- .../src/shared/accounting-mail.ts | 8 +++++--- docs/tickets.md | 9 +++++---- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index 2f30c135..59ac98c1 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts @@ -634,7 +634,7 @@ describe('invoice-lifecycle endpoint', () => { const mail = accountingMail() expect(mail.to).toBe('accounting@example.test') expect(mail.subject).toBe( - `Ausgangsrechnung ${CONFERENCE.title} – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001` + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001` ) expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') expect(mail.html).toBeUndefined() // plain text, not a template @@ -659,7 +659,7 @@ describe('invoice-lifecycle endpoint', () => { expect(sendRawEmailMock).toHaveBeenCalledTimes(1) const mail = accountingMail() expect(mail.subject).toBe( - `Ausgangsrechnung ${CONFERENCE.title} – Stornorechnung (Gutschrift) PB-CON26-002 zu PB-CON26-001` + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Stornorechnung (Gutschrift) PB-CON26-002 zu PB-CON26-001` ) expect(mail.text).toMatch(/Bruttobetrag: -\d/) expect(mail.attachments[0].filename).toBe('Stornorechnung-PB-CON26-002.pdf') @@ -679,7 +679,7 @@ describe('invoice-lifecycle endpoint', () => { expect(res.body.accounting_mail).toBe('sent') const mail = accountingMail() expect(mail.subject).toBe( - `Ausgangsrechnung ${CONFERENCE.title} – Ticketrechnung PB-CON26-001 (ersetzt vorherige Fassung)` + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Rechnung PB-CON26-001 (ersetzt vorherige Fassung)` ) expect(mail.attachments[0].filename).toBe('Rechnung-PB-CON26-001.pdf') expect(mail.attachments[0].content.equals(calls.uploadedBytes[0]!)).toBe(true) @@ -701,7 +701,7 @@ describe('invoice-lifecycle endpoint', () => { expect(res.statusCode).toBe(200) const mail = accountingMail() expect(mail.subject).toBe( - `Ausgangsrechnung ${CONFERENCE.title} – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001 (ersetzt vorherige Fassung)` + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001 (ersetzt vorherige Fassung)` ) expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts index fd8e0ed9..7bc8e947 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts @@ -98,10 +98,10 @@ describe('getAccountingInvoiceEmail', () => { describe('buildAccountingMail', () => { test('original invoice: classifiable subject and a fact sheet without customer data', () => { const mail = buildAccountingMail(DOC) - expect(mail.subject).toBe('Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012') + expect(mail.subject).toBe('Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012') expect(mail.text).toBe( [ - 'Dokumentart: Ausgangsrechnung – Ticketrechnung', + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnung', 'Veranstaltung: programmier.con 2026', 'Rechnungsnummer: PB-CON26-012', 'Rechnungsdatum: 21.10.2026', @@ -120,18 +120,22 @@ describe('buildAccountingMail', () => { test('correction names the corrected invoice in subject and body', () => { const mail = buildAccountingMail(CORRECTION) expect(mail.subject).toBe( - 'Ausgangsrechnung programmier.con 2026 – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040' + 'Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040' + ) + expect(mail.text).toContain( + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung\n' ) - expect(mail.text).toContain('Dokumentart: Ausgangsrechnung – Rechnungsberichtigung\n') expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') }) test('cancellation is marked as Gutschrift with a negative amount', () => { const mail = buildAccountingMail(CANCELLATION) expect(mail.subject).toBe( - 'Ausgangsrechnung programmier.con 2026 – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040' + 'Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040' + ) + expect(mail.text).toContain( + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift)\n' ) - expect(mail.text).toContain('Dokumentart: Ausgangsrechnung – Stornorechnung (Gutschrift)\n') expect(mail.text).toContain(`Bruttobetrag: -1.189,00${NBSP}€\n`) expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') }) @@ -139,7 +143,7 @@ describe('buildAccountingMail', () => { test('a replacement version is marked in subject and body', () => { const mail = buildAccountingMail({ ...DOC, replacesPreviousVersion: true }) expect(mail.subject).toBe( - 'Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012 (ersetzt vorherige Fassung)' + 'Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012 (ersetzt vorherige Fassung)' ) expect(mail.text).toContain('Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.') }) @@ -150,7 +154,7 @@ describe('buildAccountingMail', () => { referenceDate: null, snapshot: { conference_title: '', service_date_start: null, service_date_end: null, total_gross_cents: 0 }, }) - expect(mail.subject).toBe('Ausgangsrechnung – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040') + expect(mail.subject).toBe('Ausgangsrechnung Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040') expect(mail.text).not.toContain('Veranstaltung') expect(mail.text).not.toContain('Leistungszeitraum') expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040\n') diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts index d90d0d53..bdcf9b9c 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts @@ -35,7 +35,7 @@ export const ACCOUNTING_MAIL_ATTEMPTS = 3 /** Document type as named in the accounting mail (more specific than the PDF title). */ export const ACCOUNTING_DOCUMENT_LABEL: Record = { - invoice: 'Ticketrechnung', + invoice: 'Rechnung', correction: 'Rechnungsberichtigung', cancellation: 'Stornorechnung (Gutschrift)', } @@ -96,11 +96,13 @@ export function buildAccountingMail(doc: AccountingInvoiceDocument): { subject: const label = ACCOUNTING_DOCUMENT_LABEL[doc.documentKind] const reference = doc.referenceNumber ? ` zu ${doc.referenceNumber}` : '' const replaces = doc.replacesPreviousVersion ? ' (ersetzt vorherige Fassung)' : '' - const subject = `Ausgangsrechnung${conference ? ` ${conference}` : ''} – ${label} ${doc.invoiceNumber}${reference}${replaces}` + // " Ticket" names what was sold; the invoice number follows the type as the unique key. + const product = conference ? `${conference} Ticket` : 'Ticket' + const subject = `Ausgangsrechnung ${product} – ${label} ${doc.invoiceNumber}${reference}${replaces}` const serviceDate = formatServiceDate(doc.snapshot.service_date_start, doc.snapshot.service_date_end) const lines = [ - `Dokumentart: Ausgangsrechnung – ${label}`, + `Dokumentart: Ausgangsrechnung ${product} – ${label}`, ...(conference ? [`Veranstaltung: ${conference}`] : []), `Rechnungsnummer: ${doc.invoiceNumber}`, `Rechnungsdatum: ${formatInvoiceDate(doc.invoiceDate)}`, diff --git a/docs/tickets.md b/docs/tickets.md index 36fbb53c..bc9ed6bf 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -188,12 +188,13 @@ plain-text mail with exactly one PDF attached — the same bytes and file name a Subject and body say what the document is, so it can be classified without opening the PDF: ``` -Ausgangsrechnung programmier.con 2026 – Ticketrechnung PB-CON26-012 -Ausgangsrechnung programmier.con 2026 – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040 -Ausgangsrechnung programmier.con 2026 – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-049 +Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012 +Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040 +Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040 ``` -The conference name is the order's conference title. The body lists Dokumentart, Rechnungsnummer, +The conference name is the order's conference title (just `Ticket` if it is empty); the invoice number +follows the document type as the unique key. The body lists Dokumentart, Rechnungsnummer, Rechnungsdatum, Leistungszeitraum, the referenced original invoice (corrections and cancellations), Bruttobetrag and Aussteller — no customer data; the PDF carries everything else. From ecf5581c3dfc36f9581abe0a81cb985f961fb774 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:27:48 +0000 Subject: [PATCH 5/6] Don't hold up ticket creation for the accounting copy 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) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../__tests__/index.test.ts | 21 +++++++++++++++++++ .../src/ticket-order-processing/index.ts | 9 ++++++-- docs/tickets.md | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts index cbb78143..6de11386 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts @@ -322,6 +322,27 @@ describe('ticket-order-processing hook', () => { expect(confirmation.bcc).toBeUndefined() }) + test('is not awaited: tickets and customer mails do not wait for a slow accounting copy', async () => { + // An accounting copy that never settles (e.g. slow mail provider, retries, Slack). + sendInvoiceToAccountingMock.mockReturnValue(new Promise(() => {})) + const { invoke, serviceInstances } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) + expect(serviceInstances['tickets']![0].createOne).toHaveBeenCalledTimes(2) + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) // confirmation mail went out + expect(invitations()).toHaveLength(2) + }) + + test('an unexpected rejection of the accounting copy is logged, not thrown', async () => { + sendInvoiceToAccountingMock.mockRejectedValue(new Error('boom')) + const { invoke, logger } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(invitations()).toHaveLength(2) + expect(logger.error.mock.calls.map((c) => String(c[0])).join('\n')).toContain('boom') + }) + test('is sent even when the customer confirmation mail fails — and never stamps sent_at', async () => { sendTemplatedEmailMock.mockResolvedValue(false) const { invoke } = setup({}, ACCOUNTING_ENV) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index 03488106..a6389906 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -223,9 +223,10 @@ export default defineHook(({ action }, hookContext) => { ) // Accounting copy (GoBD: timely recording). The document exists now, - // so it goes out regardless of what happens to the customer mail below. + // so it goes out regardless of what happens below. Not awaited: its + // retries and Slack alert must not delay tickets or customer mails. // Never throws; persistent failures are escalated to Slack inside. - await sendInvoiceToAccounting( + void sendInvoiceToAccounting( { documentKind: 'invoice', invoiceNumber, @@ -236,6 +237,10 @@ export default defineHook(({ action }, hookContext) => { orderNumber: order.order_number, }, { env, emailContext: context, source: HOOK_NAME } + ).catch((err: any) => + logger.error( + `${HOOK_NAME}: Accounting copy of ${invoiceNumber} failed unexpectedly: ${err?.message || err}` + ) ) } else { logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) diff --git a/docs/tickets.md b/docs/tickets.md index bc9ed6bf..9872a874 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -200,7 +200,7 @@ Bruttobetrag and Aussteller — no customer data; the PDF carries everything els | Document | Sent by | |----------|---------| -| Original invoice | `ticket-order-processing`, right after the document is generated — independent of the customer mail | +| Original invoice | `ticket-order-processing`, started right after the document is generated and not awaited — tickets and customer mails never wait for it | | Rechnungsberichtigung / Stornorechnung | `invoice-lifecycle` endpoint, when the document is created | | Regenerated (not yet issued) document | `invoice-lifecycle` endpoint, subject suffixed `(ersetzt vorherige Fassung)` — accounting already holds the earlier version under the same number | From 9e4b5f46be81e50a69114929f76afa9db7e0f862 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Wed, 21 Oct 2026 16:45:30 +0200 Subject: [PATCH 6/6] Simplify failure message for accounting mail Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/invoice-lifecycle/invoice-actions.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue index f298b2da..efa20470 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue @@ -65,7 +65,7 @@ const ACTION_TEXT: Record = { sent: 'Kopie an die Buchhaltung verschickt.', - failed: 'Kopie an die Buchhaltung fehlgeschlagen — Hinweis ging an Slack, bitte manuell weiterleiten.', + failed: 'Kopie an die Buchhaltung fehlgeschlagen — bitte manuell weiterleiten.', skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', }