Ctrl K
ComunidadGuías…

AGENTS.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# AI Agent Instructions

This file is the canonical instruction source for AI coding agents working on this repository.

## Core principles

- Make focused, minimal diffs.
- Preserve existing architecture and coding style.
- Inspect nearby code and follow established patterns.
- Prefer modern standards and APIs where they fit the existing codebase.
- Do not rewrite whole files unless necessary.
- Do not introduce unrelated formatting churn.
- Present a plan and ask before making broad architectural changes.
- When uncertain, ask for clarification before making changes.

## Project context

## EGroupware project context

EGroupware is a large PHP/TypeScript/JavaScript web groupware application. The main repo contains many first-party apps
such as `api`, `admin`, `calendar`, `addressbook`, `mail`, `filemanager`, `infolog`, `timesheet`, `resources`, `setup`,
and others. Do not assume changes are isolated to one app without checking shared `api` and `setup` code.

### Repository shape

- Backend code is primarily PHP.
- Frontend code includes TypeScript, CSS, HTML, and build tooling.
- Shared backend framework code lives under `api/`.
- Frontend framework code lives under `kdots/` and `api/js/etemplate`.
- Database setup and upgrade logic lives under `setup/` and app-specific setup directories.
- Individual applications live in top-level directories such as `calendar/`, `addressbook/`, `mail/`, `infolog/`, and
  `timesheet/`.

Primary expectations:

- Respect EGroupware conventions.
- Maintain backwards compatibility unless the task explicitly says otherwise.
- Prefer incremental, reviewable changes.
- Avoid speculative abstractions.
- Develop a plan before making changes.
- Keep UI, API, and database changes aligned with existing patterns, but mention improvements to match modern best
  practices.

## Code change rules

- Read the relevant files first.
- Search for existing implementations before adding new ones.
- Identify the smallest safe change.
- Keep diffs small and app-scoped when possible.
- Before modifying app behaviour, check whether the pattern is implemented in another EGroupware app. Suggest
  refactoring when appropriate.
- Avoid changing shared `api/` behaviour unless the task requires it.
- For schema or setup changes, check app setup files and update paths.
- Preserve backwards compatibility for existing installations.
- Do not remove legacy compatibility code without explicit approval.
- Check whether tests, migrations, translations, or documentation need updates.
- Any new user-facing phrase (PHP `lang('...')`, JS `egw.lang('...')`, or `label=`/`value=`/`placeholder=`/etc.
  text in `.xet` templates) must be added to `$app/lang/egw_en.lang`, and translated in `$app/lang/egw_de.lang`
  when feasible. Files are tab-separated: `<phrase-lowercased>\t<app-name>\t<lang-code>\t<translation>` (translation
  keeps original casing/punctuation). Check both `en` and `de` files before adding, to avoid duplicate keys.
  - `common` is a pseudo app-name (not a real app) for phrases needed everywhere - most of `api/lang/*` uses it,
    but ANY app's lang file can add a row tagged `common` too (eg. `mail/lang/egw_en.lang` has
    `mail\tcommon\ten\tMail` for the app's own display name, so other apps can show "Mail" without redefining
    it). Before adding a phrase, grep for it tagged `common` across all lang files (not just `api/lang/`) - skip
    adding it again if found, regardless of which file it lives in.
  - Determine the *correct* app-name by checking which apps' lang files are actually guaranteed to be loaded for
    the code path the phrase lives in, not just which directory the source file is in. Check every class involved
    for an explicit `Api\Translation::add_app(...)` in its constructor - both directions matter, not just the one
    you're editing. Example (verified both ways): `admin_mail` is also reachable via the mail app's account wizard
    (`mail_wizard extends admin_mail`), and `admin_mail::__construct()` force-loads `mail`'s lang file
    (`Api\Translation::add_app('mail')`) - but `mail_wizard::__construct()` *also* force-loads `admin`'s
    (`Api\Translation::add_app('admin')`), so for this particular pair both lang files are cross-loaded regardless
    of entry point, and a phrase used by either class can go in either `admin/lang/*` or `mail/lang/*`. Don't
    assume a one-directional gap from checking only the class you're touching - check the other side too.
- When modifying a `.xet` file under an app's `templates/default/`, check for a `templates/mobile/` counterpart
  with the same template id (`<template id="...">`) and apply the equivalent change there too, and vice versa.
  These commonly drift independently - eg. a stale `autoloading=`/menuaction attribute cleaned up on one device's
  template but left behind on the other's, even though both share the same JS app class and `et2_ready()` logic.
- Do not make commits without explicit instructions.
- For major/user-visible features (not routine fixes/refactors), the commit message's first line must be
  `* <app-name>: <message>` (eg. `* mail: add S/MIME CSR export/import`), so it gets picked up by the automated
  release-changelog parser. Only the first line is parsed - keep it short and phrase it for end users, not
  developers (no internal class/method names, no implementation detail). Further lines are for developers and are
  not parsed.
- Do not modify generated JavaScript files, they're automatically built.
- A `.css` file that has a `.less` file of the same name next to it (eg. `mail/templates/default/
  app.less` / `app.css`) is compiled output, checked into git for convenience - never hand-edit it.
  Edit the `.less` source, then regenerate the `.css` with `lessc <file>.less <file>.css` and commit
  both together in the same commit. Before trusting a recompile, diff it against the currently
  checked-in `.css` first: a plain `lessc` run should reproduce it byte-for-byte when the `.less`
  hasn't changed yet - if it doesn't, the two are already out of sync (eg. from a past hand-edit of
  the `.css` only), and blindly overwriting the `.css` would silently delete real, already-shipped
  rules. Port the missing content into the `.less` source first (keeping it byte-identical once
  compiled), then make your actual change on top of that.

## Coding standards

See `doc/etemplate2/pages/tutorials/web-component-authoring.md` for information on coding standards and best practices
for webComponents.

For standing incremental-modernization rules (jQuery removal, preferred ajax patterns, PHP warning
hygiene, ...) that apply whenever you touch a section of code, see `doc/ai/modernization.md`.

## Testing

See `doc/ai/testing.md`.

Before finalizing:

- Run the most relevant available tests when practical.
- If tests cannot be run, state why.
- Mention any untested risk areas.

## Reviews

For code review behaviour, follow `doc/ai/review-checklist.md`.

## Ongoing/major project docs

Larger, multi-session efforts get a dedicated doc under `doc/ai/projects/` instead of living only in
session notes - check there before starting related work, and add one when starting a project of
similar scope.

The list below is capped at the 10 most recently updated projects, most-recent first. After any
update to a project's own doc, move its bullet to the top of this list (creating one if it's new),
and drop the 11th entry if that pushes the list over 10 - dropping it here does not touch the doc
file itself. The full set of project docs, including everything trimmed off this list, always
lives in `doc/ai/projects/` - check there directly for anything not shown here.

- `doc/ai/projects/mail-zpush-jmap-folders.md` - **DONE**: closed the real gap behind ActiveSync
  (`mail_zpush.inc.php`) breaking on any Stalwart-migrated account - it implements no JMAP-specific
  methods at all, so `Api\Mail`'s folder-management methods it calls (`getFolderStatus()`,
  `folderExists()`, `createFolder()`/`renameFolder()`/`deleteFolder()`, `getFolderObjects()`,
  `reopen()`) fell through to raw `Horde_Imap_Client_Socket` calls neither `Imap\Jmap` nor
  `Imap\Stalwart` overrode - found live via egroupware.org's own Stalwart migration breaking
  ActiveSync's "Ping" mechanism entirely. Full method-by-method map of all 33 `Api\Mail` methods
  `mail_zpush` calls showed every message-level operation was already properly JMAP-native
  (dispatches to a private `jmap<Method>()` helper or an inline guard) - the gap was narrowly
  confined to 9 folder-management methods. All 9 now have JMAP-native `Imap\Jmap` overrides
  (`Mailbox/get`/`Mailbox/set`, same template `getACL()`/`setACL()` established) - 2
  (`subscribeMailbox`/`listSubscribedMailboxes`) shipped same-day by a separate session
  (`adc28974b4`, hit independently via the **regular Mail UI**'s subscription popup, confirming this
  wasn't ActiveSync-only), the remaining 7 (`listMailboxes`/`status`/`openMailbox`/`createMailbox`/
  `renameMailbox`/`deleteMailbox` - `mailboxExist()` needed no override of its own) in `e90b7c8793`.
  17 new mock-based tests plus the full related suite green; **live-verified** against
  boulder.egroupware.org's own local Stalwart (real account, real authenticated session) - every
  read path incl. the exact `Api\Mail::getFolderStatus()` call `AlterPingChanges()` makes returns
  correct live data end-to-end (mutating ops not live-tested, see the doc's own "Verification"
  section). The account-specific `mail_zpush::_mailInstance()` stop-gap (`96c1f784ae`) was then
  **removed** (`2b0d16b9df`) so ActiveSync actually exercises the new overrides via real traffic,
  instead of silently continuing through the old classic-IMAP fallback.
  **Master only, no 26 backport for any of this** (ralf, 2026-10-29) - 26 is missing much more of
  this JMAP-compatibility surface and will be rebased onto master soon.
- `doc/ai/projects/mail-jmap-imap-inversion.md` - corrected (2026-10-29) a wrong 2026-08-26 Phase-2-
  scoping claim that `mail_zpush.inc.php` was "not a real consumer" of unguarded raw-IMAP calls - that
  screening only checked its own direct `icServer->` references, missing that it's a heavy consumer
  of `Api\Mail` methods that call `icServer->` two or three layers down (see
  [[mail-zpush-jmap-folders]], found via the same live outage). Phase 1 (restructuring already-JMAP-
  native code into the new `Api\Jmap`/`Mail\Jmap` object model) stays committed+pushed from
  2026-08-26; Phase 2 (making `Api\Mail` itself JMAP-shaped) stays scoped-but-not-started - still no
  easy slice found among its own obvious candidates, independent of today's correction.
- `doc/ai/projects/sql-fragment-type-safety.md` - closing GHSA-vx89-j2x7-5wp2's vulnerability
  *class* (not just its two entry points, already patched separately): `Api\Db` let a caller splice
  raw SQL into a `$where`/`$cols`/`$data` array via an integer array key, indistinguishable from an
  attacker-influenced array that merely happens to contain one. New `Api\Db\SqlFragment` makes "this
  is deliberately raw SQL" an explicit value type; `Api\Db::requireSqlFragmentObjects()` is a
  per-instance opt-in that then throws `Db\Exception\InvalidArgument` for a non-`SqlFragment` value
  at an integer key. Infrastructure done (commit `a46f539449`); calendar fully converted as the pilot
  (commit `196de24beb`, 61+11+2 sites across `calendar_so`/`calendar_boupdate`/`calendar_uilist`,
  `requireSqlFragmentObjects(true)` enabled on calendar's own cloned `Db` instance) - also fixed one
  core-framework instance of the same bug found along the way (`Db::replaceAsUpdate()`). The
  per-instance *clone* (vs. the shared global connection) is a **temporary** stopgap for the
  transition period - the doc keeps a list of every clone site to revert once
  `requireSqlFragmentObjects()` becomes the default (opt-out) for `Api\Db` generally. Master only, no
  26 backport (the actual vulnerability is already closed on both branches via the entry-point
  patches). Next: `Api\Storage`/`Api\Storage\Base` (in progress) - the shared SO-layer base class many
  apps extend; a plain grep for the append idiom was already proven insufficient on calendar (raw
  fragments also show up as bare inline array-literal elements and under other variable names), so
  this needs the same per-call-site reading, not just a pattern search.
- `doc/ai/projects/smallpart-s3-videos.md` - design for adding S3 storage to smallPART/ViDoTeach
  videos (ticket #89091 item 4), currently plain files served directly by nginx with zero PHP in the
  serving path - a requirement that rules out a literal copy of `Stylite\Vfs\S3\StreamWrapper`
  (every VFS read goes through its stream wrapper for a cache-miss download, incompatible with
  nginx's static `alias`). Reuses `stylite/src/Vfs/S3/StorageTrait.php` as-is (same storage config,
  multi-storage redundancy, automatic >64MB multipart upload) with a `smallpart-video/<video_hash>`
  key prefix in the same bucket as VFS/backups. New columns on `egw_smallpart_videos`
  (`video_s3_flags`, `video_aes_key`, `video_s3_lru`, `video_s3_lock_started`) drive an async
  migrate-to-S3 job and a daily local-cache-eviction job. The "is this video's file still local"
  check moves into the student UI's pre-playback path (two call sites: server-side in
  `videoSrc()`/`readVideo()` for a normal page load, a new ajax method for the client-side
  already-loaded-metadata video-switch path found in `app.ts`'s `ltiVideoSelection()`) instead of an
  nginx `error_page`/`X-Accel-Redirect` fallback, so zero nginx config changes are needed anywhere.
  Concurrent cold-miss downloads are guarded by a DB compare-and-swap claim (`DOWNLOADING` bit +
  stale-lock timeout) rather than filesystem locking, since Hosting's `$files_dir` is NFS shared
  across pods/k8s nodes where `O_EXCL`/`flock()` have known reliability gaps; the actual download is
  staged to a temp path and `rename()`d into place atomically so nginx never serves a partial file.
  Design/discussion phase only, no code written yet - see the doc's "Open questions" for what's still
  undecided (an LRU-fallback column vs. `filemtime()`, re-upload orphaned-key cleanup, a possible
  new-upload eviction grace period).
- `doc/ai/projects/et2-nextmatch-conversion.md` - per-app migration from the legacy
  `et2_extension_nextmatch` widget (`<nextmatch>`) to the `Et2Nextmatch` web component
  (`<et2-nextmatch>`). Covers the template-rename checklist, the legacy-widget-API-to-`Et2Nextmatch`
  replacement table for app JS/TS, lifecycle timing pitfalls, and the `columnselection_pref` ->
  `columnPreferenceName` audit/fix for apps already converted. Admin is now converted (all nine of
  its lists, desktop + mobile), which added: a nextmatch in a *popup* gets no filterbox at all
  (`Nextmatch::beforeSendToClient()` returns early for `output_mode === 2`), `num_rows => 0` no
  longer defers the initial fetch, `class="hide"` no longer survives `set_disabled(false)`, a `<row
  class="$field">` direct binding fails where `$row_cont[field]` works, and a list sharing its page
  with another widget needs `layout="stack"` - whose `grow="1"` companion does not survive `.xet`
  parsing, because `transformAttributes()` only reflects attributes a widget declares. Unconverted
  templates now fall back to `Et2Nextmatch` automatically (`api/etemplate.php`, opt out with
  `legacy="true"`) - a stopgap, not a conversion.
- `doc/ai/projects/nextmatch-action-ajax-conversion.md` - moving nextmatch context-menu actions off
  the full eTemplate submit they silently fall through to (no `onExecute`/`url`/`egw_open` ->
  `nm_action: "submit"` -> `index()` re-runs -> a brand new nextmatch, losing scroll, selection and
  row state), plus picker dialogs replacing the actions that render one sub-menu entry per row of
  user data (categories, distribution lists, addressbooks, share targets). Phases 0-6 DONE
  (addressbook, infolog, tracker, calendar, timesheet, filemanager, projectmanager; the generic
  overflow dialog and its `…` affordance; the category and distribution-list dialogs; the
  `open_popup` bucket). Phase 7 is empty and the rest is blocked on `et2-nextmatch-conversion.md`,
  because the legacy `nm_action()` dispatcher is deliberately NOT touched - an app is only
  reachable once its list template uses `<et2-nextmatch>`. Master only, no 26 backport; spans 4
  repos (tracker, projectmanager and records are separate). Read it for the measured per-app
  inventory (three passes, because a source scan alone gives both false positives and negatives),
  the `onExecute`-inheritance lever that converts a whole dynamic submenu in one line, the
  submenu-vs-dialog rule and per-app category cardinality, and the 2026-09-23 in-browser
  verification run - which found three bugs no code-reading pass would have: `csv_export` sticking
  in the stored nextmatch value and silently freezing the session's cached query (so "select all"
  and `delete_list` used page-load-time filters), popup inputs resolved against the whole template
  instead of their own popup (infolog's Start date shares an id with a filter, so setting it
  cleared it), and calendar's endpoint living on `calendar_uilist` with no `menuaction` declared.
- `doc/ai/projects/knowledgebase-app.md` - design of a brand-new `knowledgebase` app to supersede
  the deprecated `phpbrain` (Knowledge Base) and `wiki` apps, built on `Api\Storage`/
  `Api\Storage\Tracking`/`Api\Categories`/`Api\Acl` rather than either legacy app's bespoke
  persistence/ACL/history. Covers the 3-pane (category tree / nextmatch list / document view)
  UI, the new document/comment/rating/related-document schema, the 3-tier document>category>owner
  `Api\Acl` model (designed specifically so both legacy apps' data can be imported), dual
  Markdown/HTML content support reusing the existing `Et2MarkdownEditMixin`/`Et2HtmlArea` widgets
  (no new editor needed), history via the shared `egw_history_log`/`<historylog>` widget instead
  of wiki's full-copy-per-revision storage, and migration mappings from both legacy apps. Design
  phase, no code written yet - deferred for later phases: multi-category-per-document, public/
  anonymous access, and phpbrain's FAQ-style question-intake pipeline.
- `doc/ai/projects/push-fallback-longpoll.md` - giving the swoole-less push
  fallback (shared hosting / tarball-in-docroot installs with no `swoolepush`
  daemon) a low-latency, PHP-FPM-friendly delivery path (bounded long-poll +
  SSE) and real client-side auto-detection, instead of the old static
  server-config flag. Phases 1-4, 6 and 7 done (Phase 5, the importexport
  progress-bar fix, not started); several live regressions found and fixed
  post-rollout. See the doc for full architecture, phase-by-phase detail, and
  the regression write-ups.
- `doc/ai/projects/pdf-player-pdfjs-update.md` - updated
  `api/js/etemplate/CustomHtmlElements/pdf-player.ts` (used by smallpart/ViDoTeach to page through a
  PDF like a video) from the abandoned `@bundled-es-modules/pdfjs-dist@2.5.207-rc1` wrapper to
  Mozilla's real `pdfjs-dist`, pinned `~5.4.624` (native ESM, zero `eval()`) - **not** the newest
  `6.3.289`: that version (and `5.5.207`+) crashes every render with
  `TypeError: ...getOrInsertComputed is not a function`, a JS `Map` method not yet implemented by
  *any* shipping browser (checked directly against Playwright's bundled Chromium 141/Firefox 142) -
  found immediately by the test harness built first (same "test harness before the bump" approach
  as the celtic/lti update), which is exactly why that harness existed. Also required adding
  `"skipLibCheck": true` to the repo's `tsconfig.json` (pdfjs-dist's own `.d.ts` files need
  TypeScript 5.7+ lib definitions; this repo pins TS `^4.9.5` repo-wide - `skipLibCheck` only skips
  checking `.d.ts` files, not this repo's own source). Fixed a real dead-code bug in `pdf-player.ts`'s
  own `src` property getter/setter along the way (infinite recursion / TypeError, never reached in
  production since `et2_video.ts` only ever uses the `src` attribute). All 7 harness tests + the
  full `api` jstest group (2502 tests) green on both browsers - but the harness did NOT catch a
  real live bug: a red error toast on boulder.egroupware.org right after deploy
  ("Setting up fake worker failed"), root-caused to (1) `workerSrc` being a bare/page-relative path
  instead of an `egw.webserverUrl`-based absolute one, and (2) many web servers (confirmed:
  boulder's nginx) not mapping `.mjs` to a JS content-type, which no server-config fix is viable
  for across self-hosted installs we don't control. Fixed deployment-agnostically by having
  `pdf-player.ts` `fetch()` the worker as text and re-serve it to pdf.js as an explicitly-typed
  `Blob` URL (`ensureWorkerSrc()`), plus adding `blob:` to smallpart's own CSP `script-src`
  (`smallpart/src/Hooks.php`'s `csp_frame_src` hook) since executing a Blob as a module falls back
  to `script-src` when no `worker-src` is set. Verified live with nginx's `.mjs` bug deliberately
  left in its broken state, to prove the fix carries no server-config dependency. See the doc's
  "Live bug found post-deployment" section for the full trace. Also documents (not fixed) a
  pre-existing render-cancellation gap that can throw pdf.js's own "Cannot use the same canvas"
  error on fast repeated page turns, and a small/larger effort estimate for optionally also
  modernizing `pdf-player.ts` into this repo's Lit/`Et2Widget` web-component conventions (which it
  does not follow at all, same as its sibling `multi-video.ts`) - separate from and not required for
  the pdfjs-dist bump, not attempted.
- `doc/ai/projects/et2-historylog-conversion.md` - replacing the legacy `historylog` widget
  (`<historylog>`) with an `et2-historylog` web component built on `Et2Datagrid`, plus the filtering
  the legacy widget never had. DONE - the legacy widget is deleted. Covers why composition beats subclassing
  `Et2Nextmatch`, the per-row polymorphic value cell (the one thing `Et2Datagrid`'s fixed row
  template does not do natively), the deliberately narrow scope (sort locked, no selection, no
  actions, no values returned), the filter drawer echoing `Et2AppBox`, the zero-template-edit
  migration via `api/etemplate.php`'s `ADD_ET2_PREFIX_LEGACY_REGEXP` and why the server-side tag
  still needs dual registration, and four pre-existing `History::get_rows()` gaps found while
  reading (dead `colfilter`, ignored `search`, calendar's apparently-dead `filter` SQL fragment, and
  the pass-through `validate()` that must be allowlisted before any of it is honoured), plus what a
  diff needs that a virtualized shadow-DOM row takes away from it.
## Security and data handling

- Do not commit secrets, tokens, credentials, private keys, or production data.
- Do not put a real person's personal data - email addresses, names, message subjects/content, IP
  addresses, or similar - into test fixtures, code comments, commit messages, or docs, even when
  describing a real bug found against a real message/account (own accounts under your control, e.g.
  ralf@/rb@egroupware.org, are fine to reference). There is no consent to publish a third party's
  data this way, and unlike a private bug tracker this repo's history is public. If a real example
  is genuinely useful for understanding the bug, replace it with an anonymized/synthetic
  equivalent that demonstrates the same shape (a fake name/address like `sender@example.invalid`, a
  generated key/message fixture, a made-up subject line) - describe the fact pattern ("a sender
  using Content-Transfer-Encoding: base64 on the signature part"), not the identity behind it. This
  applies retroactively too: if you notice already-committed text like this (yours or someone
  else's), redact it in a new commit rather than leaving it - and flag to the user immediately if
  it turns out to already be pushed to a public remote, since a new commit alone doesn't remove it
  from history.
- Do not weaken authentication, authorization, validation, escaping, or CSRF protections.
- Treat user input as unsafe.
- Preserve existing permission checks.
- In an `Etemplate`, any `$content` key with NO corresponding widget in the `.xet` template is read-only from the
  client's perspective: `Etemplate::exec()` sends the whole `$content` array to the browser regardless of widgets
  (so `this.et2.getArrayMgr('content').getEntry('key')` works client-side with no widget needed), but the client
  can only ever submit values back for keys that DO have a widget. Sensitive/authorization-relevant values (an
  internal row id, an "acting on behalf of user X" id, etc.) that must survive across submits should be passed via
  `$preserv` (the `exec()` parameter, commonly just `$content` itself) and deliberately given NO widget - adding a
  `<hidden id="...">` for one makes it client-writable on every subsequent submit, not just readable. Also apply
  this to any new ajax method (`ajax_*`): its `$_data` payload is a fully untrusted raw request, not merely
  "whatever the legitimate UI would send" - explicitly re-verify the caller is authorized for every id/account
  it references (found via review: a new `ajax_smimeCreateKeypair()` endpoint trusted a client-supplied
  `account_id`/`acc_id` with no ownership check at all).
- `Api\Mail\Credentials` can encrypt a stored credential (IMAP/SMTP/S-MIME password, etc.) with either a
  system-wide secret (`SYSTEM_AES`) or the owning user's OWN session password (`USER_AES`) - the latter is
  chosen automatically whenever the credential is written for the currently-logged-in user's own
  `account_id` (`Credentials::encrypt_openssl_aes()`). A `USER_AES`-encrypted credential can genuinely NOT
  be decrypted by anyone else, including an admin - `decrypt()` returns `Credentials::UNAVAILABLE` for any
  session that isn't that same user's. This is by design (a real security boundary), not a bug: if a
  credential an admin wrote for a user (`SYSTEM_AES`, admin-readable) later gets re-encrypted because the
  user touched it themselves (now `USER_AES`), the admin correctly loses access - surface that as "not
  available to you" in error messages, don't try to work around it.

## File downloads

Do NOT trigger a file download from JS by creating a synthetic `<a>` with a `download` attribute and
calling `.click()` on it. Confirmed unreliable: it can silently fail to send ANY request to the server at
all (empty network tab, no console error), observed specifically from inside an EGroupware popup window.

Use `Etemplate2.postSubmit(button)` instead (`api/js/etemplate/etemplate2.ts`) - it builds and submits a
real `<form method="POST">` targeting the template's own `EGroupware\Api\Etemplate.process_exec` endpoint,
which reliably reaches the server and lets it respond with the file directly. Existing usage examples:
`calendar/js/app.ts` (`category_report_submit()`, `postSubmit()` call near line 4342),
`infolog/js/app.ts` (`postSubmit()` near line 518), `api/js/etemplate/Et2Dialog/Et2Dialog.ts`.

Pattern:
- Client: give the trigger widget a plain, flat `id` (NOT `button[...]` bracket notation - that's a
  different, `_set_button()`-specific mechanism for the main save/apply/delete buttons), plus
  `noSubmit="true"` so et2's own default click-submit doesn't also fire, and
  `onclick="app.<app>.<method>"` where the handler calls `this.et2.getInstanceManager().postSubmit(_widget)`.
- Server: `postSubmit()` routes back to the SAME controller method that rendered the template (via the
  stored `etemplate_exec_id`, merging `$preserv` with the newly submitted/validated values - see
  `Etemplate::process_exec()`). That method checks `!empty($content['<that same flat id>'])` - a clicked
  flat-id widget's own value is submitted directly as `$content[id]`, NOT nested under
  `$content['button'][...]` - then emits the file (`Api\Header\Content::safe()` + `echo` + `exit()`)
  instead of falling through to the normal `$tpl->exec()` redraw.
- Bonus: because this reuses the same controller/menuaction as the rest of the form, all the usual
  server-trusted content (`$preserv`-carried ids, etc.) is already available - no separate URL-param
  threading needed for a GET-based download endpoint.

## Output expectations

When reporting work:

- Summarize what changed.
- Mention tests run.
- Mention known limitations or follow-up risks.