The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.
- release-please.yml: add `target-branch: stable` so it releases from
the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
package.json), set `.release-please-manifest.json` to 3.44.0, and drop
the bogus 2.7.0 CHANGELOG section.
The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
- Search now hits the backend (debounced) so results aren't truncated to the
first loaded page: /received gains a `q` filter (sender/subject); the frontend
passes the debounced term to every list query. The instant client-side filter
stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
namespaces (were English inline-fallbacks only). Swiss-German spelling.
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
HTML with the unsanitized From name and set it as innerHTML on the composer's
contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
from_address in the stub AND DOMPurify-sanitize the composer body before
innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
(queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
— NOT the shared /email mount, so the pre-existing email-config endpoints stay
ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
(customer search is prefix/fuzzy), else leaves the picker to the admin.
- Search box in the header filters the current folder's list (sender/subject),
client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
/item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
system folders.
Frontend build + migration boot (157) verified.
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
CustomerPicker uses its 'label' prop as the selected-customer chip text, so
passing the static 'Customer' string hid the actual name. Pass the resolved
customer's name as label and add a separate field heading.
The toolbar doc buttons now open a real document-action flow instead of just
loading an email template:
- DocumentActionModal resolves the customer from the message's sender address
(customers/search); if no match, the CustomerPicker lets you search or create
a passive customer inline.
- Create new -> jumps to the real editor prefilled with the customer
(quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF
all come from the existing CRM. Gallery opens the event editor.
- Select existing -> lists that customer's quotes/contracts/invoices and drops
the chosen document number into a reply composer.
- Toolbar buttons are gated by the global feature flags (quotes/contracts/bills).
Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills
already had it). Frontend-only; reuses existing endpoints. Build verified.
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
settings — migration 156 adds smtp_* + from_* to mail_accounts;
emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
SMTP identity (falls back to the global from). Manual/reply sends from the
Messages UI use the 'customers' identity, so replies come from hello@.
Frontend build + migration boot (156) verified.
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.
- New send-composer (MessageComposer): loads the rendered template (via
previewTemplate) or a reply stub into a fully-editable body — the admin can
rewrite it or drop a note anywhere before sending. On send it goes out as-is
(server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
configured SMTP identity; POST /admin/email/send sanitizes + sends + records
the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
the human/edited messages (which finally populates that folder). /queue gains
an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
Create Quote/Contract/Invoice open the composer with that template loaded;
Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
(later phases). After send, jumps to Customers ▸ Sent.
Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
Address review on #764: backfillDunningRuns emitted invoice.sent without a
target, so enabling dunning would also enroll every historical open invoice
into any custom invoice.sent flow. Pass the enabled flow's id through to
emitWorkflowEvent so the backfill only touches dunning. Also note the
computeWakeAt both-fields (untilVar + delay) behaviour change in its comment.
Second inbound mailbox and real message bodies for the Messages viewer.
Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
accounting keeps its exact attachment->expenses behavior, customer mail is
logged with its body and NOT routed to accounting. Inbound HTML is sanitized
server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
(bodies excluded from the list); new GET /received/:id returns the body;
GET/POST /accounts + /accounts/test manage the extra mailboxes.
Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
configure + test the hello@ IMAP box.
No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.
Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.
Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
impossible dates (day 00, month 13, 31 Feb…), so the field never
commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
and return '' instead of throwing — defence in depth for the ~57 call
sites that could otherwise white-screen on a bad date.
Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):
- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
/ Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
received_emails (listReceived). Customers folders show an explanatory empty
state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
Book-as-expense-Re-bill) present but disabled — wired in later phases.
Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
Addresses the PR #763 review: the invoice_sent / storno_issued / payment-check
/ paid-admin-notification emails share the identical event-first language bug
and never set __language, so a German customer on an English-gallery event got
an English email body with German-formatted amounts.
Each call site already computes the locale it formats amounts in, so this is a
one-liner per call — the body language now matches the amount formatting:
- invoice_sent, storno_issued (sending.js) -> __language: ctx.locale
- payment-check, invoice_paid_admin_notification (payments.js) -> __language: locale
Leak-safe (no template references {{__language}}) and falls back to the
existing event-first resolution when unset, per the mechanism added in #763.
Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:
- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
timeline instead of restarting a fresh grace clock.
Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
- Billing/dunning emails no longer render in the gallery event's language.
emailProcessor now honors an explicit `__language` in the email data
(else falls back to the event-first recipient resolution), and the invoice
reminder passes the customer/invoice locale (customer.preferred_language
|| invoice.language || 'de'). Fixes German customers getting English
dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
(green tint + readable text on both light and dark surfaces) instead of a
hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
(CustomerDetailPage) plus the business-profile default; updated the helper
text to note billing emails now honor it too. (#761)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.
Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.
- Migration 153: hero_logo_size nullable + backfill NULL so existing
galleries inherit the global size (restores GalleryLayout's prior
live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.
Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).
Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.
- Migration 152: make events.hero_logo_visible nullable and NULL out the
defaulted rows so existing galleries follow the global going
forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
(GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
Always show / Always hide (en + de).
Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.
Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.
Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:
- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
through to the SPA — which has no /s/ route. Dead for humans (no 302
redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
/s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
segments; the crawler-detect location regex allowed only one, so they
never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
segments (quoted regex — the braces would otherwise be parsed as nginx
config delimiters). client-access still matches (its token is in ?query,
one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).
Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.
One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)