Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.
Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.
Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.
Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.
I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.
Frontend service: eventsService.duplicateEvent(eventId, data).
The README claimed 2GB RAM as the minimum, but two background-processor
worker loops × sharp.concurrency(2) means up to four libvips threads can
decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on
a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one
heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on
thumbnails until restart:unless-stopped brings it back. Reported in #602,
filed as #628.
Three changes, smallest-surface-area each:
1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY
is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2
and log a one-shot warning naming the override env var. Explicit env-var
setters keep their value. os.totalmem() reports container memory under
cgroup v2 so this works in Docker / k8s as well as bare metal.
2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB
only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1
with the throughput trade-off spelled out. Added the 503-on-OOM symptom
so the next reporter finds it via search.
3. docker-compose.production.yml — commented mem_limit / memswap_limit
example on the backend service. Off by default (don't surprise existing
deployments) but visible to operators thinking about shared/multi-tenant
hosts. restart:unless-stopped already on every service.
No code path for memory-aware runtime throttling (Luca's option 4) — out
of scope for a bug fix; tracked separately if #1-#3 don't close the case.
Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.
Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.
The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).
I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.
No schema changes. No plaintext at rest.
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.
The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.
CSV / XMP / JSON exports are unchanged.
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
renderTaxReportCsv income/cost/all output assertions (income drops supplier
rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
touching pdftoppm/disk (the per-file resource bound).
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV
export (the on-screen report stays complete). Income-only emits just the
outgoing rows + the income summary line (+ the per-rate breakdown in the PDF);
cost-only emits the incoming-invoice + expense rows + the cost line and drops
the income-by-rate breakdown. Useful in Liechtenstein where, under the income
threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual
costs — handing the Treuhänder just the income (or just the cost) basis is
cleaner.
Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that
filters report.ledger by row.type + the summary lines; the /pdf + /csv routes
accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend:
scope <select> beside the export buttons, threaded through buildQueryString.
i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by
the Treuhänder) per the scoping decision.
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).
Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
override — instead of the per-customer column alone, via a shared
customerFeatureAllowed() helper. Admin disabling a feature globally is now
honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
registered" and hid the reclaim). Treat null as "not configured":
vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.
Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor
the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter
prefixed risky leading chars, so an admin-/sender-controlled cell beginning with
= + - @ TAB CR executes as a formula when the Treuhänder opens the export. New
shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into
all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char.
Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was
INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit
row, so a second replica / rolling-deploy overlap double-ingested the same mail.
Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now
CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent
claim hits the unique constraint and skips cleanly (shared isUniqueViolation
helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after
10 min so no attachment is orphaned.
NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 —
that column is a SOFT dedup key by design (manual re-uploads are kept as flagged
'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index
would break that feature. The file race only yields an extra 'unsorted' row (a
data-quality nit, caught by the existing manual Duplikat backstop), not a
double-count. Rationale to be added to the PR reply.
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
saved document preserves the snapshot instead of falling back to the
rate→code map. Payload types (quotes + bills) carry vatCode.
72 tests pass; build green.
Slice 1 of the VAT consolidation backend:
- PUT /admin/settings/accounting accepts accounting_vat_registered (bool) +
accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns
them parsed, so no GET change needed.
- New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so
the invoice/quote editors can populate their VAT dropdown even when the
accounting layer is off. Management CRUD stays under /admin/ledger.
Wires the vat_code snapshot (migration 130) through the write paths: quote
create/update, the main invoice create, and the Storno carry-over (so a
cancellation exports the same code as the invoice it reverses). Guarded with
hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a
later slice — inert until then, falls back to the rate→code map). 72 tests pass.
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code
snapshot column to quotes + invoices, and the Treuhänder export now prefers the
invoice's snapshotted code over the (mutable) rate→code map, so a historical
invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded;
behaviour-neutral until the editors start writing the snapshot (next slices).
Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/
reclaim toggle.
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)
- ledgerService.dateOnly + taxReportService CSV now format Date objects to
yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
string dates).
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.
Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
Description, Income, Expenses, ContraAccount (the income/expense account),
VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
both Banana variants. Labels relabelled: "Banana — double-entry" and
"Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.
Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.
- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
with no quoting, .txt extension, text/plain content-type. generic + bexio stay
comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).
The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
for you) and 'Accounting journal' (for your accountant), each with a
one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
Chart-of-accounts mapping), so gate it on the 'accounting' master — the
group only renders when accounting is on, and the backend /export route no
longer requires the 'taxReport' sub-flag (the router already requires accounting).
Build + node --check + JSON parse green.
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.
- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
flagged for native review).
Build + node --check + JSON parse green.
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.
Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:
1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
/api/admin/login and /api/admin/auth/login, but the real admin auth routes
live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
So during maintenance both the login POST and the session check 503'd. The
503 on /auth/session made the frontend read every admin as logged-out, and
also tripped the axios interceptor that force-enables maintenance globally.
Fixed the allow-list to the actual endpoints.
2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
every /admin/* route unless an admin session already existed — covering the
/admin/login page itself. A logged-out admin could never reach the form to
get a session (catch-22). /admin/login is now always allowed through.
With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):
- linkDealToProject: collect ALL customers across the deal's quote/contract/
invoice lineage and reject only when none is the project's customer. Mirrors
the events path, where a multi-customer event already attaches if any of its
customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
message aligned with the lineage check.
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:
- assignEvent: an event may only join a project that shares its customer. The
event's customer(s) come from event_customer_assignments; a customer-assigned
project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
and an empty project ADOPTS a single-customer event's customer. This is why
a foreign-customer event could previously be attached.
- updateProject: re-labelling a project to a customer that conflicts with the
events/quotes/contracts it already holds is rejected (clearing to null is
still allowed), so the customer can't be swapped out from under existing
content.
Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
Resolves the two blockers and the actionable concerns/nits from review.
Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
re-pointing events/quotes/contracts or adopting a customer. The editors set
project_id via quoteService/contractService → linkDealToProject (not
assignDocument), so the guard lives at that chokepoint. Null-project adoption
("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
de+en) wired into HoursSection + quote/contract editor onError (concern 5).
Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
for gallery mails; email_data doc-number match for CRM mails) instead of the
recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
isn't all-zero during the upgrade window.
Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
emailProcessor; clarifying comments for the list/detail perms split and the
count-vs-value (0 vs em-dash) convention.
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
markInboundPaid with the entered reference) instead of opening the pay dialog
and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
currency='CHF', but email/upload invoices often have a null currency →
silently excluded. Now include null-currency rows (treated as the report
currency). Also replaced COALESCE(invoice_date, created_at) with a split
date filter (invoice_date BETWEEN, else created_at range) to avoid the
mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
export (buildPostings).
en/de: categorizedPaidToast.
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.
Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
Symptom: an emailed attachment landed in Incoming invoices but the message
never appeared under Received emails. The attachment is saved BEFORE the
received_emails insert, so any throw there left the audit row unwritten and
silently swallowed.
- coerce a malformed Date: header (Invalid Date) to now — it would otherwise
throw on the Postgres timestamp insert (most likely root cause)
- isolate each attachment in its own try so one bad file can't skip the audit
- truncate from_address to the column width; persist attachment errors + an
'error' status so partial failures are visible
- log loudly when the received_emails insert itself fails (no more silent loss)
Self-healing: the stuck message was never marked \Seen, so the next poll
re-processes it and writes the row.
- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
(e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
(respects the incomingMail flag) and reports disabled/unconfigured/busy or N
ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
The round-trip recipient is imap_user (not hardcoded). Some hosts use a
non-email IMAP login — guard against silently sending to a bogus address:
return a clear 'recipient_not_email' error explaining to use a mailbox whose
username is its email, or test connection + manual send instead.
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
for that subject token; deletes the test message on arrival so it never hits
the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
reports recipient + delivery time. Distinct reasons mapped (smtp/imap
unconfigured, send_failed, not_received→504).
- en/de strings
- emailIntakeService.testConnection(): logs in, opens the configured folder,
reports message/unread counts (non-destructive). Accepts current form creds
so it works before saving; masked password falls back to stored.
- route POST /admin/email/incoming-config/test
- IMAP card: 'Test connection' button beside Save; toast shows folder + counts
- capitalize 'IMAP Host' label to match 'SMTP Host'
Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely
different servers/credentials, hence the distinct field set (Folder; no From).
The IMAP card was restyled to match SMTP but didn't carry the required-field
markers. Aligned the required set (protocol differences kept):
- red asterisks on Host *, Port *, Username * (SMTP marks Host/Port/From-Email;
IMAP has no From-Email but always needs a login)
- client-side guard mirroring handleSaveSmtp (block save without host/port/user)
- backend POST /incoming-config now requires imap_user (the poller's
getImapConfig returns null without it)
- en/de requiredFields string
- IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card
padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked
full-width fields, full-width primary Save button
- Folder is now a dropdown auto-populated by a 'Detect' button instead of a
free-text path: backend emailIntakeService.listFolders() lists IMAP
mailboxes (POST /admin/email/incoming-config/folders, accepts current form
creds, masked password falls back to stored); UI auto-selects the inbox
(special-use) folder
- en/de strings added
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case:
- taxReportService.getTaxReport now returns a cost side (loadCosts:
incoming invoices + internal expenses, company- or event-booked,
schema-guarded) plus a summary (income / costs / result, VAT payable)
- declined/duplicate costs excluded; re-billed costs kept (matching
re-bill revenue is counted, so the net is correct)
- CSV + PDF exports gain a Costs section and an income/costs/result
summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine —
flag for native review)
- frontend tax page renders the summary card, a costs table (company
vs event), and a 'verify with Treuhänder' disclaimer
- tax-report tests cover the cost aggregation + zeroed summary when the
accounting tables are absent; adminCrmAuth test enables the accounting
master flag the route now requires
fr/nl/pt/ru strings are machine-generated and need native review.
- transformExpense surfaces invoiced (billed_invoice_id), paid
(supplier_paid), paidAt, paymentMethod, customerAccountId
- updateExpense throws EXPENSE_LOCKED once invoiced (edit until then)
- rebillExpense mints a client invoice line + locks the expense
- markExpensePaid toggles manual paid state
- adminExpenses: POST /:id/invoice (rebill) + POST /:id/paid
- adminTaxReport now gated by accounting master + taxReport sub-flag
(independent of bills; tax export moved out of CRM into Accounting)