From 76e4999112d397113e914b7fedfc9bdf0a67ae21 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:16:10 +0200 Subject: [PATCH 1/4] Correct v9.4.4 task visibility to WebGUI only --- CHANGELOG.md | 21 +- README.md | 128 +---- VERSION | 2 +- src/postmaster/runtime.py | 574 ++++++++------------ src/postmaster/runtime_core.py | 357 ++++++++++++ src/postmaster/scheduler_engine.py | 6 +- tests/test_v9_4_3_task_visibility.py | 330 +---------- tests/test_v9_4_4_webgui_task_correction.py | 350 ++++++++++++ 8 files changed, 977 insertions(+), 791 deletions(-) create mode 100644 src/postmaster/runtime_core.py create mode 100644 tests/test_v9_4_4_webgui_task_correction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8da5d0..828c231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable release should update `VERSION`, this changelog, and publish an immutable Git tag/release named `vX.Y.Z`. +## 9.4.4 - 2026-08-20 + +### Fixed +- Restored the public task MCP/API contract to v9.4.2 compatibility. `list_jobs(owner_id=None, project_id=None, status=None, limit=200)` again has no `include_completed` parameter, includes `status=completed` records by default, and uses the pre-v9.4.3 MCP serialization without the `{ok, count, jobs}` wrapper. +- Restored `SchedulerEngine.list_jobs` to the v9.4.2 signature and semantics. Completed-task visibility is no longer a scheduler/backend default and explicit owner/project/status filters behave as they did in v9.4.2. +- Removed the duplicate runtime `get_job` registration that could log `Tool already exists: get_job`. The original read-only `get_job(job_id)` tool from v9.4.2 remains the single canonical registration. +- Removed the v9.4.3 `build_status.task_detail_view` and `build_status.completed_tasks_hidden_by_default` capability flags, because completed visibility/detail is now strictly a WebGUI presentation concern. + +### Added +- WebGUI-only Tasks filtering: completed tasks remain in the database and MCP results, but the Tasks page hides them by default and offers `Show completed (N)` / `Hide completed` controls. +- A `View` action for every displayed task and a read-only task-detail panel showing id, owner/project, title/description, action type, execution profile, schedule, timezone, approval mode, status, timestamps, last error and safely rendered payload. +- Dashboard-local `show_completed=1` and `view_job=` navigation that preserves the Tasks tab. A completed task can be opened directly by ID even while completed rows remain hidden from the default list. +- Regression coverage for the v9.4.2 MCP schema/output contract, completed visibility in MCP versus WebGUI, single `get_job` registration, safe detail rendering, counts, Pause/Resume, scheduler status, due jobs, completion and recurring advancement. + +### Compatibility / deployment +- `create_job`, `update_job`, `pause_job`, `resume_job`, `complete_job`, `delete_job`, `get_job_history`, `list_due_jobs`, `scheduler_status`, recurrence advancement, approval/security and persistent task storage are unchanged. +- No scheduler/database migration, environment variable, port, volume or Cloudflare rule is introduced. +- `postmaster-mcp.yml` remains byte-for-byte unchanged. Existing `POSTMASTER_VERSION=latest` deployments with update checks enabled can select v9.4.4 with the normal restart/redeploy after the stable release is published. + ## 9.4.3 - 2026-08-20 ### Added @@ -159,4 +178,4 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable rel - CI coverage for runtime import, bootstrap, model provisioning, MIME regressions and knowledge operations. ### Changed -- Public project naming and configuration became provider-agnostic. +- Public project naming and configuration became provider-agnostic. \ No newline at end of file diff --git a/README.md b/README.md index af05805..38501e4 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ The important v9 changes are: - improved MIME parsing for forwarded mail and HTML-heavy messages; - CI coverage for the bootstrap, MIME parser, knowledge store and semantic-model provisioning; - persistent small-file storage plus native ChatGPT file inputs in v9.2; -- task list/detail UX with completed tasks hidden by default and explicit retrieval in v9.4.3; +- WebGUI-only task list/detail UX in v9.4.4, while the public task MCP/API contract is restored to v9.4.2 compatibility; - semantic release history through `VERSION`, `CHANGELOG.md` and immutable `vX.Y.Z` release tags. --- @@ -450,18 +450,18 @@ Review Junk and restore genuine false positives. Check unread mail and summarize messages requiring attention. ``` -From v9.4.3 the task read UX mirrors the Memory/Skill list/detail pattern. Completed tasks remain stored with the persistent status `completed`, but they are hidden from the normal list unless the caller asks for them explicitly: +v9.4.4 deliberately restores the public task MCP/API behavior to the v9.4.2 contract: ```text -list_jobs() -> non-completed tasks only -list_jobs(include_completed=true) -> non-completed + completed tasks -list_jobs(status="completed") -> completed tasks explicitly -get_job(job_id) -> complete record for one task +list_jobs(owner_id=None, project_id=None, status=None, limit=200) +get_job(job_id) ``` -`list_jobs` returns one structured MCP result with `{ok, count, jobs}`. The individual job objects keep their existing fields for compatibility, while `get_job` is the full detail view with owner/project, description, action type, execution profile, schedule, approval mode, status, timestamps, last error and payload. Owner/project/status filters still combine normally, and the result `limit` is applied after completed tasks are excluded. +There is no `include_completed` parameter. `list_jobs()` includes completed tasks by default, an explicit `status="completed"` filter still selects completed records, and the MCP output uses the same pre-v9.4.3 serialization rather than a `{ok, count, jobs}` wrapper. `get_job` is not new in v9.4.4; it is the original read-only v9.4.2 task lookup and is registered only once. -Hiding a completed task is presentation only: no record is renamed to `done`, archived, deleted or migrated. `scheduler_status` still counts completed tasks, and `get_job` can read a completed task directly by ID. The server persists the task state; the AI client performs the reasoning and explicit action. +The completed-task UX now exists **only in the WebGUI**. On the Tasks page, rows with the persistent status `completed` are hidden by default, `Show completed (N)` reveals them, and `Hide completed` returns to the default view. Each displayed task has a `View` action, and the detail view shows the complete stored task record including owner/project, description, action type, execution profile, schedule, approval mode, status, timestamps, last error and safely rendered payload. A completed task can be opened directly with the dashboard-local `view_job=` query even while completed rows are hidden from the list. + +Hiding a completed task is presentation only: no record is renamed to `done`, archived, deleted or migrated. `scheduler_status` still counts completed tasks, MCP callers still receive them from the default `list_jobs()`, and the server remains a passive task registry; the AI client performs the reasoning and explicit action. --- @@ -617,114 +617,4 @@ v9 keeps the same practical deployment goal — **paste one YAML into Portainer* Persistent data remains under `/data`; migrating an existing installation should preserve the data volume and its encryption keys. -Before replacing a working v8.7 deployment, back up the persistent data volume and test v9 against your real mailboxes and reverse-proxy configuration. - ---- - -# Privacy and public distribution - -The public repository intentionally contains no mailbox credentials, private recipient allowlists, personal domains, private project context or conversation data. - -The compact semantic model is derived only from a public Apache-2.0 source model and contains no user-specific training data. - -Deployment-specific secrets belong in your private Portainer stack or secret-management layer, not in the public repository. - ---- - -# License - -Apache License 2.0. See: - -```text -LICENSE -NOTICE -``` - - -## v9.1 small-file store - -v9.1 adds a private persistent store for small reference files. Metadata is kept in SQLite while file bytes are stored as SHA-256-addressed blobs under `/data/files`, so user-provided filenames never become filesystem paths. The default public stack limits individual files to 1 MiB, the logical store to 100 MiB and 1000 records; hard application caps prevent accidentally configuring unbounded values. - -MCP clients can save UTF-8 text directly or binary data as base64, list scoped metadata, read text with a character budget, retrieve binary content as base64, update metadata and delete files. Owner/project scopes reuse the scheduler registry. The WebGUI has a Files tab for upload, download and deletion. Downloads are forced as attachments with `X-Content-Type-Options: nosniff`; Postmaster never executes stored content and does not expose public file URLs. - -The file store is intentionally separate from Knowledge in v9.1. Uploading a document does not automatically inject it into semantic context; a later version can add explicit opt-in document extraction/indexing without making arbitrary uploads part of prompts by default. - ---- - -# Versioning and updates - -Stable Postmaster releases use Semantic Versioning and are recorded in `CHANGELOG.md`. The repository `VERSION` file contains the application version, while GitHub release tags use `vX.Y.Z`. - -For a Portainer deployment: - -```text -POSTMASTER_VERSION=latest -> follow the latest stable GitHub Release on restart -POSTMASTER_VERSION=v9.2.0 -> stay pinned to that exact release -POSTMASTER_VERSION= -> stay pinned to an immutable commit -``` - -`build_status` reports the application `version`, the resolved running `build`, and the `requested_version` policy so an MCP client can distinguish `latest` from the concrete release actually running. - -# Native ChatGPT file upload (v9.2) - -The portable MCP `save_file(content_base64=...)` tool remains available. ChatGPT clients can instead use `save_uploaded_file` or `save_uploaded_files`; those tools declare `_meta["openai/fileParams"]`, so ChatGPT passes temporary authorized file download objects rather than forcing large Base64 strings through model context. - -Remote downloads are HTTPS-only, bounded by the same per-file store limit while streaming, limited in redirects and timeout, checked against non-public address resolution, and then stored through the same SHA-256 content-addressed `FileStore`. Uploaded content is never executed or automatically added to semantic Knowledge. - -# Native Postmaster file handoff (v9.3) - -v9.3 completes the reverse path from Postmaster to MCP clients. `get_stored_file_resource(file_id, transport="auto")` returns a real MCP `ResourceLink` content block using the canonical FileStore `file_id`; constructing the link reads metadata only and does not serialize the link into text or load the stored blob. - -The preferred hierarchy is native ResourceLink/file reference, signed HTTPS streaming, MCP `resources/read`, Base64 fallback, and inline Base64 only as a last resort. `postmaster://files/{file_id}` is registered as a resource template, and the SDK turns returned bytes into protocol `BlobResourceContents` when a client follows the MCP resource. - -`GET` and `HEAD /files/{file_id}` provide temporary HMAC-signed HTTPS capabilities with byte-range support. The HTTP path streams the original content-addressed blob directly: it does not resize, recompress, transcode, Base64-encode, or create a second transfer copy. - -The existing `PUBLIC_MCP_HOST` is reused as the normal HTTPS base for the same service, so the public `postmaster-mcp.yml` does not need new required variables. Advanced deployments may optionally override the file base or signing behavior with `FILE_STORE_PUBLIC_BASE_URL`, `FILE_STORE_DOWNLOAD_SECRET`, and `FILE_STORE_DOWNLOAD_URL_TTL_SECONDS`; otherwise the signing secret is generated once and persisted at `/data/file-store-download.secret` and the TTL defaults to 900 seconds. - -An existing stack with `POSTMASTER_VERSION=latest` can therefore receive v9.3 by restarting after the stable release is published. If the external access layer protects the full app, ensure the signed `/files/*` route is reachable according to the deployment's proxy policy without weakening protection for `/mcp`, the dashboard, or unrelated routes. - -See `docs/FILE_HANDOFF.md` for the handoff hierarchy, security model, signed URL behavior and deployment details. - -# Per-link tracking and clean Sent copies (v9.4) - -v9.4 adds per-link HTTP/HTTPS click telemetry to the existing tracked-delivery pipeline while leaving the existing `/track/open/*` pixel behavior unchanged. Eligible recipient HTML anchors are rewritten to opaque URLs under: - -```text -/t/c/ -``` - -The random token identifies a server-side link occurrence; recipient data and destination URLs are not encoded into it. The click endpoint resolves the stored record, records a `link` event with the existing country/source/browser/OS/User-Agent/client-fingerprint enrichment and immediately redirects to the exact stored `original_url`. Query parameters supplied to `/t/c/` are never accepted as redirect destinations. - -The v9.4 unique-click definition is: - -```text -delivery_id + link_id + client_fingerprint -``` - -Analytics expose total clicks, unique clicks, unique recipients, first/last click, destination host, per-campaign/per-delivery/per-link filtering and top links. Existing `tracking_status` and `get_tracking_campaign` are extended, while `get_tracking_summary`, `list_tracking_links` and `list_tracking_events` provide read-only link/event queries. Opaque tracking tokens are not returned by list/dashboard APIs. - -Recipient and Sent MIME are now generated separately from the same canonical body and attachment inputs. The recipient copy keeps the existing tracking pixel and tracked URLs; the archived Sent copy keeps original URLs and contains no active recipient pixel or `/t/c/`. `Message-ID`, Date and normal threading headers are preserved and attachments reuse the same original bytes. This prevents sender self-opens/self-clicks from being attributed to the recipient for messages generated by v9.4+. - -The single-YAML bootstrap is unchanged. With `POSTMASTER_VERSION=latest` and `POSTMASTER_CHECK_UPDATES_ON_START=true`, restart the stack after the stable release is published. - -Cloudflare Access is external to the container. Keep the existing public bypasses for `/track/open/*` and `/api/amp/*`, and add exactly one new public bypass required by v9.4: - -```text -/t/c/* -``` - -Do not expose `/mcp`, dashboard/admin/private APIs, mail/task/memory/skill/file-management endpoints or tracking analytics. The pre-existing v9.3 signed `/files/*` handoff remains a separate deployment-policy concern and is not added automatically as part of v9.4. - -See `docs/LINK_TRACKING.md` for architecture, schema, Sent-clean behavior, analytics and the live Cloudflare preflight. - -# Explicit reply/follow-up semantics (v9.4.2) - -v9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered. - -Tracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required. - -# Task list/detail UX (v9.4.3) - -v9.4.3 makes task reads behave more like persistent Memory and Skill reads. `list_jobs()` is now the scannable list view and hides stored `completed` tasks by default; callers can opt back into all records with `include_completed=true` or request completed tasks directly with `status="completed"`. The persisted database value remains `completed` and is never renamed to `done`. - -`get_job(job_id)` is the full read-only detail view and remains able to open a completed task directly. The list response is a single structured `{ok, count, jobs}` envelope while each job record preserves its existing fields. Completed rows remain in the registry and remain part of `scheduler_status` counts. No task-state migration or deployment configuration change is required. +Before replacing a working v8.7 deployment, back up the persistent data volume and test v9 against your real mailboxes and reverse-proxy configuration. \ No newline at end of file diff --git a/VERSION b/VERSION index 3200162..86a1d29 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.4.3 +9.4.4 diff --git a/src/postmaster/runtime.py b/src/postmaster/runtime.py index 0e645fa..11edef1 100644 --- a/src/postmaster/runtime.py +++ b/src/postmaster/runtime.py @@ -1,390 +1,278 @@ from __future__ import annotations +import json import os from html import escape -from typing import Any +from urllib.parse import urlencode import uvicorn -from mcp.types import CallToolResult from starlette.requests import Request -from starlette.responses import HTMLResponse, PlainTextResponse, RedirectResponse -from starlette.routing import Mount, Route - -from . import server as _base -from .file_handoff import ( - read_stored_file_resource, - stored_file_http_response, - stored_file_resource_result, -) -from .link_tracking import link_store -from .link_tracking_html import eligible_web_url -from .tracked_mail import LinkTrackingMailClient - -mcp = _base.mcp -_legacy_build_status = _base.build_status -_legacy_tracking_status = _base.tracking_status -_legacy_get_tracking_campaign = _base.get_tracking_campaign -_legacy_dashboard_home = _base.dashboard_home - - -def mail_client(account_id: str | None = None) -> LinkTrackingMailClient: - return LinkTrackingMailClient(_base.account_store().settings(account_id)) - -_base.mail_client = mail_client - - -def build_status(): - status = _legacy_build_status() - status["native_file_resource_handoff"] = True - status["link_tracking"] = True - status["sent_copy_tracking_sanitized"] = True - status["provider_qualitative_classification"] = True - status["explicit_reply_follow_up_modes"] = True - status["follow_up_email"] = True - status["follow_up_draft"] = True - status["task_detail_view"] = True - status["completed_tasks_hidden_by_default"] = True - return status - -mcp.remove_tool("build_status") -mcp.add_tool(build_status, name="build_status") -_base.build_status = build_status +from starlette.responses import HTMLResponse +from starlette.routing import Route +from . import runtime_core as _core -def list_jobs( - owner_id: str | None = None, - project_id: str | None = None, - status: str | None = None, - limit: int = 200, - include_completed: bool = False, -): - """Read-only. List registered tasks. Completed tasks are hidden unless explicitly requested.""" - rows = _base._safe_call( - _base.scheduler().list_jobs, - owner_id=owner_id, - project_id=project_id, - status=status, - limit=limit, - include_completed=include_completed, - ) - if isinstance(rows, dict) and rows.get("ok") is False: - return {"ok": False, "error": rows.get("error", "Unable to list jobs"), "count": 0, "jobs": []} - return {"ok": True, "count": len(rows), "jobs": rows} - -mcp.remove_tool("list_jobs") -mcp.add_tool(list_jobs, name="list_jobs") -_base.list_jobs = list_jobs - - -@mcp.tool() -def get_job(job_id: str): - """Read-only. Return the complete stored record for one registered task.""" - return _base._safe_call(_base.scheduler().get_job, job_id) - - -@mcp.tool() -def follow_up_email( - mailbox: str, - uid: str, - body: str = "", - cc: list[str] | None = None, - bcc: list[str] | None = None, - body_html: str | None = None, - attachments: list[dict[str, Any]] | None = None, - track_opens: bool | None = None, - campaign_id: str | None = None, - account_id: str | None = None, -): - """ - WRITE ACTION. Threaded follow-up to an outbound/Sent message from the selected account. - - The original visible To/Cc recipients are reused after removing the sender account and - configured account identities. Original Bcc is never rediscovered or exposed. Inbound - messages are rejected and should use reply_email instead. - - Tracking follows the same account-default/explicit override semantics and the same v9.4 - recipient/Sent-clean pipeline as send_email and reply_email. - """ - return _base._safe_call( - mail_client(account_id).follow_up_email, - mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, - body_html=body_html, attachments=attachments, - track_opens=track_opens, campaign_id=campaign_id, - ) - - -@mcp.tool() -def create_follow_up_draft( - mailbox: str, - uid: str, - body: str = "", - cc: list[str] | None = None, - bcc: list[str] | None = None, - body_html: str | None = None, - attachments: list[dict[str, Any]] | None = None, - account_id: str | None = None, -): - """ - WRITE ACTION. Save a threaded follow-up draft for an outbound/Sent message. - - The draft reuses the original visible To/Cc after sender/alias filtering, never recovers - original Bcc, and rejects inbound messages so reply/follow-up semantics stay explicit. - """ - return _base._safe_call( - mail_client(account_id).create_follow_up_draft, - mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, - body_html=body_html, attachments=attachments, - ) - - -@mcp.tool() -def get_stored_file_resource(file_id: str, transport: str = "auto") -> CallToolResult: - return stored_file_resource_result(_base.file_store(), file_id, transport) - - -@mcp.resource( - "postmaster://files/{file_id}", - name="postmaster_stored_file", - description="Original bytes for a Postmaster FileStore file identified by canonical file_id.", +# v9.4.4 keeps the v9.4.2 task MCP/backend contract in runtime_core and layers +# completed-task visibility/detail only onto the browser dashboard. +for _name in dir(_core): + if _name.startswith("_"): + continue + globals()[_name] = getattr(_core, _name) + +_base = _core._base +_tracking_dashboard_fragment = _core._tracking_dashboard_fragment +_legacy_dashboard_home = _core.dashboard_home +mcp = _core.mcp +app = _core.app + +_TASK_DETAIL_FIELDS = ( + "id", + "owner_id", + "project_id", + "title", + "description", + "action_type", + "execution_profile_id", + "schedule_type", + "schedule_value", + "timezone", + "approval_mode", + "status", + "next_run_utc", + "created_at", + "updated_at", + "last_run_utc", + "last_error", + "payload", ) -def stored_file_resource(file_id: str) -> bytes: - return read_stored_file_resource(_base.file_store(), file_id) -def tracking_status(): - base = _legacy_tracking_status() - if isinstance(base, dict) and base.get("ok"): - base["link_tracking"] = link_store().status() - base["link_tracking"]["provider_classification_query_time"] = True - base["event_types"] = ["pixel", "amp_xhr", "link"] - return base - -mcp.remove_tool("tracking_status") -mcp.add_tool(tracking_status, name="tracking_status") -_base.tracking_status = tracking_status - +def _task_dashboard_url( + request: Request, + *, + show_completed: bool, + view_job: str | None = None, +) -> str: + params: dict[str, str] = {} + account_id = (request.query_params.get("account") or "").strip() + if account_id: + params["account"] = account_id + if show_completed: + params["show_completed"] = "1" + if view_job: + params["view_job"] = view_job + query = urlencode(params) + return "/" + (("?" + query) if query else "") + "#scheduler" + + +def _task_detail_html(request: Request, *, show_completed: bool) -> str: + job_id = (request.query_params.get("view_job") or "").strip() + if not job_id: + return "" + + detail = _base._safe_call(_base.scheduler().get_job, job_id) + close_url = escape( + _task_dashboard_url(request, show_completed=show_completed), + quote=True, + ) + if not isinstance(detail, dict) or detail.get("ok") is False: + message = "Task could not be loaded" + if isinstance(detail, dict): + message = str(detail.get("error") or message) + return f""" +
+

Task detail

+
{escape(message)}
+
+""" -def get_tracking_campaign(campaign_id: str): - base = _legacy_get_tracking_campaign(campaign_id) - if isinstance(base, dict) and base.get("ok") is False: - return base - try: - base["link_tracking"] = link_store().summary(campaign_id=campaign_id) - base["top_links"] = link_store().top_links(campaign_id=campaign_id, limit=25) - except Exception as exc: - base["link_tracking_error"] = f"{type(exc).__name__}: {exc}" - return base + rows: list[str] = [] + for field in _TASK_DETAIL_FIELDS: + value = detail.get(field) + if field == "payload": + try: + rendered = json.dumps( + value if value is not None else {}, + ensure_ascii=False, + indent=2, + sort_keys=True, + default=str, + ) + except Exception: + rendered = str(value) + cell = ( + '
'
+                + escape(rendered)
+                + "
" + ) + elif value is None or value == "": + cell = '' + elif field == "description": + cell = ( + '
' + + escape(str(value)) + + "
" + ) + else: + cell = '' + escape(str(value)) + "" + rows.append(f"{escape(field)}{cell}") -mcp.remove_tool("get_tracking_campaign") -mcp.add_tool(get_tracking_campaign, name="get_tracking_campaign") -_base.get_tracking_campaign = get_tracking_campaign + return f""" +
+

Task detail

+
{''.join(rows)}
+
+""" -@mcp.tool() -def get_tracking_summary( - campaign_id: str | None = None, - delivery_id: str | None = None, - link_id: str | None = None, - account_id: str | None = None, -): - """Read-only click summary with stable fingerprint uniques plus query-time provider estimates.""" - return _base._safe_call( - link_store().summary, - campaign_id=campaign_id, delivery_id=delivery_id, link_id=link_id, account_id=account_id, +def _task_dashboard_fragment(request: Request) -> str: + show_completed = request.query_params.get("show_completed") == "1" + listed = _base._safe_call(_base.scheduler().list_jobs, limit=1000) + all_jobs = listed if isinstance(listed, list) else [] + visible_jobs = ( + all_jobs + if show_completed + else [job for job in all_jobs if str(job.get("status") or "") != "completed"] ) - -@mcp.tool() -def list_tracking_links( - campaign_id: str | None = None, - delivery_id: str | None = None, - link_id: str | None = None, - account_id: str | None = None, - clicked_only: bool = False, - limit: int = 500, -): - """Read-only tracked link occurrences and aggregates; opaque tokens are never returned.""" - return _base._safe_call( - link_store().list_links, - campaign_id=campaign_id, delivery_id=delivery_id, link_id=link_id, - account_id=account_id, clicked_only=clicked_only, limit=limit, + status_result = _base._safe_call(_base.scheduler().status) + job_counts = ( + status_result.get("job_counts") or {} + if isinstance(status_result, dict) + else {} ) + completed_count = int(job_counts.get("completed") or 0) + stored_count = sum(int(value or 0) for value in job_counts.values()) + if stored_count == 0 and all_jobs: + stored_count = len(all_jobs) + completed_count = sum( + 1 for job in all_jobs if str(job.get("status") or "") == "completed" + ) + due_result = _base._safe_call(_base.scheduler().list_due_jobs, limit=1000) + due_count = len(due_result) if isinstance(due_result, list) else 0 -@mcp.tool() -def list_tracking_events( - delivery_id: str | None = None, - campaign_id: str | None = None, - link_id: str | None = None, - recipient: str | None = None, - account_id: str | None = None, - event_type: str | None = None, - limit: int = 500, -): - """Read-only unified tracking events. Link rows include query-time provider classification fields.""" - return _base._safe_call( - link_store().unified_events, - delivery_id=delivery_id, campaign_id=campaign_id, link_id=link_id, - recipient=recipient, account_id=account_id, event_type=event_type, limit=limit, + toggle_url = escape( + _task_dashboard_url(request, show_completed=not show_completed), + quote=True, ) - - -async def public_stored_file_download(request: Request): - return stored_file_http_response(request, _base.file_store(), require_signature=True) - - -async def dashboard_file_download(request: Request): - return stored_file_http_response(request, _base.file_store(), require_signature=False) - - -async def tracking_click(request: Request): - token = str(request.path_params.get("token", "")) - try: - link = link_store().get_by_token(token) - destination = str(link.get("original_url") or "") - if not eligible_web_url(destination): - raise ValueError("Stored link destination is not an HTTP/HTTPS URL") - except Exception: - return PlainTextResponse("Not found", status_code=404, headers={"Cache-Control": "no-store"}) - - forwarded = request.headers.get("x-forwarded-for", "") - client_ip = forwarded.split(",", 1)[0].strip() if forwarded else "" - if not client_ip and request.client: - client_ip = request.client.host or "" - try: - link_store().record_click( - link, - user_agent=request.headers.get("user-agent", ""), - client_ip=client_ip, - country_code=request.headers.get("cf-ipcountry", ""), + toggle_label = "Hide completed" if show_completed else f"Show completed ({completed_count})" + if show_completed: + count_text = ( + f"{len(visible_jobs)} shown · {completed_count} completed · " + f"{due_count} due · {stored_count} stored" ) - except Exception: - _base.logger.info("Link click could not be recorded", exc_info=True) - - response = RedirectResponse(destination, status_code=302) - response.headers["Cache-Control"] = "private, no-store, no-cache, max-age=0" - response.headers["Pragma"] = "no-cache" - return response - - -def _tracking_dashboard_fragment(account_id: str | None = None) -> str: - summary = link_store().summary(account_id=account_id) - qualitative = summary.get("qualitative_estimate") or {} - share = qualitative.get("potential_provider_share") or {} - top = link_store().top_links(account_id=account_id, limit=20) - events = link_store().unified_events(account_id=account_id, limit=100) - top_rows = [] - for row in top: - label = str(row.get("anchor_text") or row.get("destination_host") or row.get("original_url") or "") - top_rows.append( - "" - f"{escape(str(row.get('link_id','')))}" - f"{escape(label)}" - f"{escape(str(row.get('destination_host','')))}" - f"{int(row.get('total_clicks') or 0)}" - f"{int(row.get('unique_clicks') or 0)}" - f"{int(row.get('unique_recipients') or 0)}" - f"{escape(str(row.get('first_click') or ''))}" - f"{escape(str(row.get('last_click') or ''))}" + else: + count_text = ( + f"{len(visible_jobs)} shown · {completed_count} completed hidden · " + f"{due_count} due · {stored_count} stored" ) - if not top_rows: - top_rows.append('No link clicks recorded yet.') - event_rows = [] - for row in events: - source = " / ".join(x for x in (str(row.get("country_code") or ""), str(row.get("client_source") or "")) if x) - browser_os = " / ".join(x for x in (str(row.get("browser") or ""), str(row.get("os") or "")) if x) - label = str(row.get("anchor_text") or row.get("destination_host") or "") - ua = str(row.get("user_agent") or "")[:180] - provider_class = str(row.get("provider_classification") or "") - provider_likelihood = str(row.get("provider_likelihood") if row.get("provider_likelihood") is not None else "") - provider_guess = str(row.get("provider_guess") or "") - reasons = "; ".join(str(x) for x in (row.get("classification_reasons") or [])) - event_rows.append( - "" - f"{escape(str(row.get('event_type') or ''))}" - f"{escape(str(row.get('recipient') or ''))}" - f"{escape(str(row.get('observed_at') or ''))}" - f"{escape(source)}{escape(browser_os)}" - f"{escape(str(row.get('campaign_id') or ''))}
{escape(str(row.get('delivery_id') or ''))}" - f"{escape(str(row.get('client_fingerprint') or ''))}" - f"{escape(provider_likelihood)}{escape(provider_class)}{escape(provider_guess)}" - f"{escape(reasons[:180])}" - f"{escape(label)}{escape(str(row.get('link_id') or ''))}" - f"{escape(str(row.get('destination_host') or ''))}" - f"{escape(str(row.get('position') if row.get('position') is not None else ''))}" - f"{escape(ua)}" + job_rows: list[str] = [] + for job in visible_jobs: + raw_id = str(job.get("id") or "") + job_id = escape(raw_id) + status = escape(str(job.get("status") or "")) + title = escape(str(job.get("title") or "")) + owner = escape(str(job.get("owner_id") or "")) + project = escape(str(job.get("project_id") or "")) + action = escape(str(job.get("action_type") or "")) + next_run = escape(str(job.get("next_run_utc") or "—")) + payload = job.get("payload") or {} + account_ref = ( + escape(str(payload.get("account_id") or "")) + if isinstance(payload, dict) + else "" + ) + view_url = escape( + _task_dashboard_url( + request, + show_completed=show_completed, + view_job=raw_id, + ), + quote=True, + ) + buttons = f'' + if status == "paused": + buttons += f"""
+ +
""" + elif status != "completed": + buttons += f"""
+ +
""" + account_note = ( + f'
account ref: {account_ref}
' + if account_ref + else "" + ) + job_rows.append( + f""" +{title}
{job_id}
+{owner}
{project}{account_note} +{action}{status}{next_run} +{buttons}""" ) - if not event_rows: - event_rows.append('No tracking events recorded yet.') - - unique_clicks = int(summary.get("unique_clicks") or 0) - likely_provider = int(qualitative.get("likely_provider_unique_clicks") or 0) - human_or_unclassified = int(qualitative.get("likely_human_or_unclassified_unique_clicks") or 0) - uncertain = int(qualitative.get("uncertain_unique_clicks") or 0) - share_percent = float(share.get("percent") or 0.0) - suspects = qualitative.get("provider_suspects") or {} - suspect_text = ", ".join(f"{key}: {value}" for key, value in suspects.items()) or "none" + detail_html = _task_detail_html(request, show_completed=show_completed) + empty_text = ( + "No tasks registered" + if show_completed + else "No non-completed tasks registered" + ) return f""" +
+
+{detail_html}
-

Qualitative click estimate

v9.4.1 query-time heuristic
-

Stable fingerprint uniques remain unchanged. Provider likelihood is recalculated from stored evidence and never deletes or rewrites raw events.

-

{unique_clicks} unique fingerprint clicks  ·  {likely_provider} likely provider/proxy  ·  {human_or_unclassified} likely human or unclassified  ·  {uncertain} uncertain

-

Potential provider share: {likely_provider}/{unique_clicks} ({share_percent:.1f}%). Provider suspects: {escape(suspect_text)}. Confidence: {escape(str(qualitative.get('confidence') or 'low'))}.

+

Task registry

{escape(count_text)}
+

No cron worker runs here. Dates and recurrence are stored only so an AI or user can query what is due. Tasks never send email or execute actions by themselves. Completed tasks remain stored; the default hiding on this page is WebGUI-only.

+
+{''.join(job_rows) or f''}
TaskOwner / projectTypeStatusDue / next UTC
{escape(empty_text)}
-
-

Top links

v9.4 click analytics
-

Unique click = delivery_id + link_id + client_fingerprint. The qualitative classifier is an additive interpretation layer and does not change these totals.

-
{''.join(top_rows)}
Link IDLabelDestination hostTotalUniqueRecipientsFirst clickLast click
-
-
-

Tracking events

pixel / AMP / link
-
{''.join(event_rows)}
TypeRecipientObserved UTCCountry / sourceBrowser / OSCampaign / deliveryClient fingerprintProvider %ClassificationProvider guessReasonsLink labelLink IDDestinationPositionUser-Agent
+
""" +def _replace_task_dashboard(body: str, fragment: str) -> str: + start_marker = '
' + end_marker = "\n", "nested": {"value": 7}}, + ) + self._mark_completed(completed["id"]) + + with patch.object(runtime._base, "scheduler", return_value=self.engine): + default_html = runtime._task_dashboard_fragment(self._request()) + self.assertIn("Active <unsafe>", default_html) + self.assertIn("Paused task", default_html) + self.assertNotIn("Completed detail", default_html) + self.assertIn("Show completed (1)", default_html) + self.assertIn("1 completed hidden", default_html) + self.assertIn("3 stored", default_html) + self.assertIn("/dashboard/job/pause", default_html) + self.assertIn("/dashboard/job/resume", default_html) + self.assertIn(">View<", default_html) + + shown_html = runtime._task_dashboard_fragment( + self._request("show_completed=1") + ) + self.assertIn("Completed detail", shown_html) + self.assertIn("Hide completed", shown_html) + self.assertIn("1 completed", shown_html) + self.assertNotIn("completed hidden", shown_html) + + detail_html = runtime._task_dashboard_fragment( + self._request(f"view_job={completed['id']}") + ) + self.assertIn("Task detail", detail_html) + self.assertIn("Completed detail", detail_html) + self.assertIn("Show completed (1)", detail_html) + for field in ( + "id", "owner_id", "project_id", "title", "description", + "action_type", "execution_profile_id", "schedule_type", + "schedule_value", "timezone", "approval_mode", "status", + "next_run_utc", "created_at", "updated_at", "last_run_utc", + "last_error", "payload", + ): + self.assertIn(f"{field}", detail_html) + self.assertNotIn("", detail_html) + self.assertIn("<script>alert(1)</script>", detail_html) + self.assertNotIn('', detail_html) + self.assertIn("<img src=x onerror="alert(1)">", detail_html) + + self.assertEqual(self.engine.get_job(active["id"])["status"], "scheduled") + self.assertEqual(self.engine.get_job(completed["id"])["status"], "completed") + + def test_pause_resume_due_complete_recurring_and_status_do_not_regress(self) -> None: + paused = self._create_job(title="Pause regression") + paused_state = self.engine.pause_job(paused["id"]) + self.assertEqual(paused_state["status"], "paused") + resumed_state = self.engine.resume_job(paused["id"]) + self.assertEqual(resumed_state["status"], "scheduled") + + once = self._create_job( + title="Due once", + schedule_type="once", + schedule_value=self._future_once(), + payload={"source": "create-regression"}, + ) + self._force_due(once["id"]) + self.assertIn(once["id"], [row["id"] for row in self.engine.list_due_jobs()]) + once_done = self.engine.complete_job(once["id"], note="once complete") + self.assertEqual(once_done["status"], "completed") + self.assertIsNone(once_done["next_run_utc"]) + self.assertIn(once["id"], [row["id"] for row in self.engine.list_jobs()]) + self.assertEqual(self.engine.status()["job_counts"].get("completed"), 1) + self.assertNotIn(once["id"], [row["id"] for row in self.engine.list_due_jobs()]) + + recurring = self._create_job( + title="Recurring", + schedule_type="interval", + schedule_value="3600", + ) + self._force_due(recurring["id"]) + recurring_done = self.engine.complete_job(recurring["id"], note="advance") + self.assertEqual(recurring_done["status"], "scheduled") + self.assertIsNotNone(recurring_done["last_run_utc"]) + self.assertGreater( + datetime.fromisoformat(recurring_done["next_run_utc"]), + datetime.now(timezone.utc), + ) + + +if __name__ == "__main__": + unittest.main() From 75aa911a1d5a961bc77f06a754f9e55abf8d8a8f Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:29:07 +0200 Subject: [PATCH 2/4] Fix v9.4.4 compatibility regression test targets --- tests/test_v9_4_4_webgui_task_correction.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_v9_4_4_webgui_task_correction.py b/tests/test_v9_4_4_webgui_task_correction.py index 66d6f84..f0355c8 100644 --- a/tests/test_v9_4_4_webgui_task_correction.py +++ b/tests/test_v9_4_4_webgui_task_correction.py @@ -136,8 +136,8 @@ def test_mcp_list_jobs_schema_output_and_single_get_job_match_v942(self) -> None try: import postmaster.runtime as runtime - runtime.scheduler.cache_clear() - runtime_engine = runtime.scheduler() + runtime._base.scheduler.cache_clear() + runtime_engine = runtime._base.scheduler() runtime_engine.create_project( owner_id=runtime_engine.settings.default_owner_id, project_id="runtime-project", @@ -175,11 +175,13 @@ def test_mcp_list_jobs_schema_output_and_single_get_job_match_v942(self) -> None (completed["id"],), ) + list_signature = inspect.signature(runtime._base.list_jobs) self.assertEqual( - list(inspect.signature(runtime.list_jobs).parameters), + list(list_signature.parameters), ["owner_id", "project_id", "status", "limit"], ) - self.assertNotIn("include_completed", inspect.signature(runtime.list_jobs).parameters) + self.assertNotIn("include_completed", list_signature.parameters) + self.assertEqual(list_signature.parameters["limit"].default, 200) async def exercise_mcp(): async with Client(runtime.mcp, raise_exceptions=True) as client: @@ -236,6 +238,13 @@ def decoded_rows(result) -> list[dict]: detail_payload = json.loads(detail.content[0].text) self.assertEqual(detail_payload["id"], completed["id"]) + server_source = Path(runtime._base.__file__).read_text(encoding="utf-8") + runtime_source = Path(runtime.__file__).read_text(encoding="utf-8") + runtime_core_source = Path(runtime._core.__file__).read_text(encoding="utf-8") + self.assertEqual(server_source.count("def get_job(job_id: str):"), 1) + self.assertNotIn("def get_job(job_id", runtime_source) + self.assertNotIn("def get_job(job_id", runtime_core_source) + status = runtime.build_status() self.assertNotIn("task_detail_view", status) self.assertNotIn("completed_tasks_hidden_by_default", status) @@ -243,7 +252,7 @@ def decoded_rows(result) -> list[dict]: try: import postmaster.runtime as runtime - runtime.scheduler.cache_clear() + runtime._base.scheduler.cache_clear() except Exception: pass if old_scheduler_db is None: From 4f9ce403121bb3979fd2e4da3afbb03318c776a4 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:32:10 +0200 Subject: [PATCH 3/4] Restore README history for v9.4.4 --- README.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38501e4..d796d3c 100644 --- a/README.md +++ b/README.md @@ -617,4 +617,114 @@ v9 keeps the same practical deployment goal — **paste one YAML into Portainer* Persistent data remains under `/data`; migrating an existing installation should preserve the data volume and its encryption keys. -Before replacing a working v8.7 deployment, back up the persistent data volume and test v9 against your real mailboxes and reverse-proxy configuration. \ No newline at end of file +Before replacing a working v8.7 deployment, back up the persistent data volume and test v9 against your real mailboxes and reverse-proxy configuration. + +--- + +# Privacy and public distribution + +The public repository intentionally contains no mailbox credentials, private recipient allowlists, personal domains, private project context or conversation data. + +The compact semantic model is derived only from a public Apache-2.0 source model and contains no user-specific training data. + +Deployment-specific secrets belong in your private Portainer stack or secret-management layer, not in the public repository. + +--- + +# License + +Apache License 2.0. See: + +```text +LICENSE +NOTICE +``` + + +## v9.1 small-file store + +v9.1 adds a private persistent store for small reference files. Metadata is kept in SQLite while file bytes are stored as SHA-256-addressed blobs under `/data/files`, so user-provided filenames never become filesystem paths. The default public stack limits individual files to 1 MiB, the logical store to 100 MiB and 1000 records; hard application caps prevent accidentally configuring unbounded values. + +MCP clients can save UTF-8 text directly or binary data as base64, list scoped metadata, read text with a character budget, retrieve binary content as base64, update metadata and delete files. Owner/project scopes reuse the scheduler registry. The WebGUI has a Files tab for upload, download and deletion. Downloads are forced as attachments with `X-Content-Type-Options: nosniff`; Postmaster never executes stored content and does not expose public file URLs. + +The file store is intentionally separate from Knowledge in v9.1. Uploading a document does not automatically inject it into semantic context; a later version can add explicit opt-in document extraction/indexing without making arbitrary uploads part of prompts by default. + +--- + +# Versioning and updates + +Stable Postmaster releases use Semantic Versioning and are recorded in `CHANGELOG.md`. The repository `VERSION` file contains the application version, while GitHub release tags use `vX.Y.Z`. + +For a Portainer deployment: + +```text +POSTMASTER_VERSION=latest -> follow the latest stable GitHub Release on restart +POSTMASTER_VERSION=v9.2.0 -> stay pinned to that exact release +POSTMASTER_VERSION= -> stay pinned to an immutable commit +``` + +`build_status` reports the application `version`, the resolved running `build`, and the `requested_version` policy so an MCP client can distinguish `latest` from the concrete release actually running. + +# Native ChatGPT file upload (v9.2) + +The portable MCP `save_file(content_base64=...)` tool remains available. ChatGPT clients can instead use `save_uploaded_file` or `save_uploaded_files`; those tools declare `_meta["openai/fileParams"]`, so ChatGPT passes temporary authorized file download objects rather than forcing large Base64 strings through model context. + +Remote downloads are HTTPS-only, bounded by the same per-file store limit while streaming, limited in redirects and timeout, checked against non-public address resolution, and then stored through the same SHA-256 content-addressed `FileStore`. Uploaded content is never executed or automatically added to semantic Knowledge. + +# Native Postmaster file handoff (v9.3) + +v9.3 completes the reverse path from Postmaster to MCP clients. `get_stored_file_resource(file_id, transport="auto")` returns a real MCP `ResourceLink` content block using the canonical FileStore `file_id`; constructing the link reads metadata only and does not serialize the link into text or load the stored blob. + +The preferred hierarchy is native ResourceLink/file reference, signed HTTPS streaming, MCP `resources/read`, Base64 fallback, and inline Base64 only as a last resort. `postmaster://files/{file_id}` is registered as a resource template, and the SDK turns returned bytes into protocol `BlobResourceContents` when a client follows the MCP resource. + +`GET` and `HEAD /files/{file_id}` provide temporary HMAC-signed HTTPS capabilities with byte-range support. The HTTP path streams the original content-addressed blob directly: it does not resize, recompress, transcode, Base64-encode, or create a second transfer copy. + +The existing `PUBLIC_MCP_HOST` is reused as the normal HTTPS base for the same service, so the public `postmaster-mcp.yml` does not need new required variables. Advanced deployments may optionally override the file base or signing behavior with `FILE_STORE_PUBLIC_BASE_URL`, `FILE_STORE_DOWNLOAD_SECRET`, and `FILE_STORE_DOWNLOAD_URL_TTL_SECONDS`; otherwise the signing secret is generated once and persisted at `/data/file-store-download.secret` and the TTL defaults to 900 seconds. + +An existing stack with `POSTMASTER_VERSION=latest` can therefore receive v9.3 by restarting after the stable release is published. If the external access layer protects the full app, ensure the signed `/files/*` route is reachable according to the deployment's proxy policy without weakening protection for `/mcp`, the dashboard, or unrelated routes. + +See `docs/FILE_HANDOFF.md` for the handoff hierarchy, security model, signed URL behavior and deployment details. + +# Per-link tracking and clean Sent copies (v9.4) + +v9.4 adds per-link HTTP/HTTPS click telemetry to the existing tracked-delivery pipeline while leaving the existing `/track/open/*` pixel behavior unchanged. Eligible recipient HTML anchors are rewritten to opaque URLs under: + +```text +/t/c/ +``` + +The random token identifies a server-side link occurrence; recipient data and destination URLs are not encoded into it. The click endpoint resolves the stored record, records a `link` event with the existing country/source/browser/OS/User-Agent/client-fingerprint enrichment and immediately redirects to the exact stored `original_url`. Query parameters supplied to `/t/c/` are never accepted as redirect destinations. + +The v9.4 unique-click definition is: + +```text +delivery_id + link_id + client_fingerprint +``` + +Analytics expose total clicks, unique clicks, unique recipients, first/last click, destination host, per-campaign/per-delivery/per-link filtering and top links. Existing `tracking_status` and `get_tracking_campaign` are extended, while `get_tracking_summary`, `list_tracking_links` and `list_tracking_events` provide read-only link/event queries. Opaque tracking tokens are not returned by list/dashboard APIs. + +Recipient and Sent MIME are now generated separately from the same canonical body and attachment inputs. The recipient copy keeps the existing tracking pixel and tracked URLs; the archived Sent copy keeps original URLs and contains no active recipient pixel or `/t/c/`. `Message-ID`, Date and normal threading headers are preserved and attachments reuse the same original bytes. This prevents sender self-opens/self-clicks from being attributed to the recipient for messages generated by v9.4+. + +The single-YAML bootstrap is unchanged. With `POSTMASTER_VERSION=latest` and `POSTMASTER_CHECK_UPDATES_ON_START=true`, restart the stack after the stable release is published. + +Cloudflare Access is external to the container. Keep the existing public bypasses for `/track/open/*` and `/api/amp/*`, and add exactly one new public bypass required by v9.4: + +```text +/t/c/* +``` + +Do not expose `/mcp`, dashboard/admin/private APIs, mail/task/memory/skill/file-management endpoints or tracking analytics. The pre-existing v9.3 signed `/files/*` handoff remains a separate deployment-policy concern and is not added automatically as part of v9.4. + +See `docs/LINK_TRACKING.md` for architecture, schema, Sent-clean behavior, analytics and the live Cloudflare preflight. + +# Explicit reply/follow-up semantics (v9.4.2) + +v9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered. + +Tracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required. + +# Task list/detail UX (v9.4.3) + +v9.4.3 makes task reads behave more like persistent Memory and Skill reads. `list_jobs()` is now the scannable list view and hides stored `completed` tasks by default; callers can opt back into all records with `include_completed=true` or request completed tasks directly with `status="completed"`. The persisted database value remains `completed` and is never renamed to `done`. + +`get_job(job_id)` is the full read-only detail view and remains able to open a completed task directly. The list response is a single structured `{ok, count, jobs}` envelope while each job record preserves its existing fields. Completed rows remain in the registry and remain part of `scheduler_status` counts. No task-state migration or deployment configuration change is required. From 956d800cdef34285e1619c91d166cf60018131ee Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:35:53 +0200 Subject: [PATCH 4/4] Fix v9.4.2 MCP single-row serialization test --- tests/test_v9_4_4_webgui_task_correction.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_v9_4_4_webgui_task_correction.py b/tests/test_v9_4_4_webgui_task_correction.py index f0355c8..17e09da 100644 --- a/tests/test_v9_4_4_webgui_task_correction.py +++ b/tests/test_v9_4_4_webgui_task_correction.py @@ -216,8 +216,10 @@ def decoded_rows(result) -> list[dict]: self.assertTrue(texts) if len(texts) == 1: payload = json.loads(texts[0]) - self.assertIsInstance(payload, list) - return payload + if isinstance(payload, list): + return payload + self.assertIsInstance(payload, dict) + return [payload] rows = [json.loads(text) for text in texts] self.assertTrue(all(isinstance(row, dict) for row in rows)) return rows