Skip to content

feat: v0.3.0 — continue_on_error, email connectors, browser automation#45

Merged
michaelmcnees merged 36 commits into
mainfrom
feature/v030-release
Mar 26, 2026
Merged

feat: v0.3.0 — continue_on_error, email connectors, browser automation#45
michaelmcnees merged 36 commits into
mainfrom
feature/v030-release

Conversation

@michaelmcnees

@michaelmcnees michaelmcnees commented Mar 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • continue_on_error step option — Steps can now set continue_on_error: true to allow workflow execution to continue after failure. Failed step error and partial output are exposed to downstream steps via steps['name'].error in CEL expressions. Includes checkpoint recovery, distributed worker support, audit events, and Prometheus metrics.

  • Email connectors & trigger — Four new IMAP connectors (email/receive, email/move, email/delete, email/flag) plus a persistent IMAP polling trigger that fires workflow executions on new emails. Supports folder selection, message filtering, custom tags/keywords, and connection monitoring via Prometheus metrics.

  • Browser automationbrowser/run connector wraps Playwright scripts (JavaScript, TypeScript, Python) inside Docker containers. Delegates to the existing Docker connector for container lifecycle, security hardening, and artifact collection.

Additional changes

Test plan

  • All 19 packages pass (go test ./... -short)
  • go vet ./... clean
  • go build ./cmd/mantle/ succeeds
  • All example YAML files validate
  • Integration tests for continue_on_error (sequential + distributed)
  • Integration tests for email connectors (GreenMail testcontainer)
  • Unit tests for browser wrapper generation
  • CodeRabbit review — all findings addressed
  • 5-agent release review — all blockers/warnings resolved
  • Release review report: .release-reviews/RELEASE-REVIEW-2026-03-25-v030.md

Release review verdict

CONDITIONAL GO — all conditions addressed. See full report for details.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Browser automation step for scripted Playwright runs; new IMAP email actions (receive, move, delete, flag); email triggers with mailbox polling and per-step continue_on_error.
  • Examples

    • Added browser form-submit/scrape, email inbox-triage, error-notification, and email/backup workflow updates for resilient S3 uploads and notifications.
  • Documentation

    • Guides for email triggers, connectors, and error-handling (steps[].error / continue_on_error).
  • Database & Metrics

    • Migration to track continue_on_error; new email and continued-on-error metrics.

michaelmcnees and others added 25 commits March 25, 2026 13:05
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…int recovery

When a step with continue_on_error: true fails after exhausting retries, workflow
execution continues instead of halting. The failed step's error and partial output
are exposed to downstream steps via CEL (steps['name'].error / steps['name'].output).
Checkpoint recovery re-populates failed-continued steps so resume is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add AdvanceExecution to Orchestrator — takes the workflow step definitions,
remaps failed-but-continued steps to "completed" in the DAG status view, and
schedules downstream steps instead of cascading cancellations. Includes
TestDistributed_ContinueOnError to verify the end-to-end behaviour with a
real Postgres instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces parseIMAPCredential and imapDial helpers in internal/connector/imap.go,
using go-imap/v2 (imapclient). Includes unit tests for credential parsing and a
testcontainers-based integration test using GreenMail (skipped under -short).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements EmailReceiveConnector that selects a folder, searches for
messages by filter (unseen/all/flagged/recent), fetches envelopes and
body text, optionally marks messages as \Seen, and returns structured
output. Registered as email/receive in the connector registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the Trigger struct with email-specific fields (Mailbox, Folder,
Filter, PollInterval) and adds validation ensuring mailbox is required,
filter is one of the allowed values, and poll_interval is a valid duration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… and Prometheus metrics

- Add EmailTriggerPoller (internal/server/email_trigger.go): one goroutine
  per enabled email trigger, persistent IMAP connection with exponential
  backoff (5s→5min), configurable poll_interval, marks seen after fire,
  enforces maxConnections=5 cap, Reload() for hot-reload on apply
- Wire into Server: constructor creates poller, Start() calls poller.Start(),
  Shutdown() calls poller.Stop()
- Extend TriggerRecord/TriggerInput with Mailbox/Folder/Filter/PollInterval;
  SyncTriggers INSERT now includes the four email columns; add
  ListEmailTriggers + scanEmailTriggers (dedicated scan, not reusing
  scanTriggers); add nullableString helper
- Add email trigger Prometheus metrics: connections_active gauge,
  poll_duration histogram, poll_errors/triggers/connection_errors counters
- Add audit actions: email.trigger.fired, email.connection.established,
  email.connection.failed
- Add 10 unit tests covering start/stop, reload bookkeeping, search criteria,
  input building, body extraction, and address formatting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements BrowserRunConnector that accepts a user Playwright script,
wraps it in language-appropriate boilerplate (JS/TS or Python), passes
it via stdin to the official Microsoft Playwright Docker image, and
delegates execution to DockerRunConnector. Supports output_format=json
to parse stdout as JSON. Registered as browser/run in the connector
registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nstream CEL error access

Exercises the three-step scenario: http/request fails with continue_on_error:true,
email/send mock receives the error message via CEL interpolation of steps['step-1'].error,
and a conditional step is skipped because the error is non-null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…error handling, bounds checking

- Enforce TLS 1.2 minimum in imap.go and email_trigger.go dialIMAP
- Add --experimental-strip-types to TypeScript browser/run containerCmd
- Handle initial poll error in pollLoop (was silently ignored)
- Add overflow/NaN/Inf bounds checking in extractUID for float64/int/int64
- Fix Python browser/run docs example to use sync Playwright API (no await)
- Correct scanEmailTriggers comment from "12 columns" to "10 columns"
- Add log.Printf warning when falling back from UIDExpunge to Expunge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… limits, metrics

- MakeGlobalStepExecutor now loads failed-continued steps into CEL context,
  matching the sequential resumeExecution path so downstream expressions
  can reference error/output from continue_on_error steps
- email/receive: cap limit param at 200 to prevent unbounded fetches
- email_receive fetchMessages: remove redundant defer cmd.Close() that
  caused a double-close after the explicit close at end of function
- metrics: add EmailTriggersSkippedTotal counter; increment in
  EmailTriggerPoller.startPoller when maxConnections limit is reached

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…il delivery semantics

- Fix email/move output: success → moved
- Fix email/delete output: success → deleted
- Fix email/flag output: success → updated, add action field
- Fix email/receive and trigger filter values: remove seen, add recent, change default all → unseen
- Remove fictional engine.email config keys; document compile-time default of 5 connections
- Add email trigger type fields (mailbox, folder, filter, poll_interval) to workflow-reference/index.md
- Remove _credential params from email/receive, email/move, email/delete, email/flag; add step-level credential notes
- Fix Python browser/run example to use pre-created page object instead of calling browser.new_page()
- Add at-least-once delivery section explaining crash-recovery re-trigger risk and idempotency guidance
- Clarify steps.error is only practically useful when continue_on_error: true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@michaelmcnees michaelmcnees added this to the v0.3.0 milestone Mar 25, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mantle with  Cloudflare Pages  Cloudflare Pages

Latest commit: 107cc62
Status: ✅  Deploy successful!
Preview URL: https://23874f09.mantle-cxy.pages.dev
Branch Preview URL: https://feature-v030-release.mantle-cxy.pages.dev

View logs

@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds IMAP-backed email triggers/connectors, a containerized Playwright browser runner, per-step continue_on_error support with DB migration and engine/orchestrator changes, metrics and audit entries, validation/tests, example workflows, and documentation updates.

Changes

Cohort / File(s) Summary
Example Workflows
examples/browser-form-submit.yaml, examples/browser-scrape.yaml, examples/email-inbox-triage.yaml, examples/error-notification.yaml, examples/docker-volume-backup.yaml
New browser and email-trigger example workflows; updated backup/error workflows to use continue_on_error and conditional Slack notifications.
Browser Connector
internal/connector/browser.go, internal/connector/browser_test.go
New browser/run connector that builds JS/TS/Python Playwright wrappers, runs them in pinned container images via DockerRunConnector, parses JSON/text outputs, and includes unit + Docker integration tests.
Email Connectors
internal/connector/email_receive.go, internal/connector/email_move.go, internal/connector/email_delete.go, internal/connector/email_flag.go, internal/connector/*_test.go
Added IMAP-backed connectors (email/receive, email/move, email/delete, email/flag) with credential parsing, UID/flag handling, and extensive unit + GreenMail integration tests.
IMAP Helpers & Tests
internal/connector/imap.go, internal/connector/imap_test.go
IMAP credential parsing and dial helpers (TLS/non‑TLS) with tests and retry semantics.
Connector Registry
internal/connector/connector.go
Registered new connectors: email/receive, email/move, email/delete, email/flag, and browser/run.
Server Email Triggering
internal/server/email_trigger.go, internal/server/email_trigger_test.go, internal/server/server.go, internal/server/trigger.go
New EmailTriggerPoller (Start/Reload/Stop), per-trigger goroutines, IMAP polling/fetch, conversion to trigger inputs, mark-as-seen behavior, DB listing of email triggers, and tests; integrated into Server lifecycle.
Engine & Orchestrator
internal/engine/engine.go, internal/engine/orchestrator.go, internal/engine/*_test.go, internal/engine/distributed_test.go
Persist/load failed-but-continued steps, seed CEL with steps[...].error, emit audit/metrics for continued steps, add AdvanceExecution DAG readiness/cancellation logic, and add comprehensive continue_on_error tests.
Workflow Schema & Validation
internal/workflow/workflow.go, internal/workflow/validate.go, internal/workflow/validate_test.go
Added email trigger fields and Step.ContinueOnError; validation for email triggers and browser/run params with tests.
DB Migrations
internal/db/migrations/014_continue_on_error.sql, internal/db/migrations/015_email_triggers.sql
Added continue_on_error column to step_executions; added email trigger columns (mailbox, folder, filter, poll_interval) and partial index.
Metrics & Audit
internal/metrics/metrics.go, internal/audit/audit.go
New metric StepsContinuedOnErrorTotal and email-trigger metrics; new audit action constants for continued steps and email-trigger events.
Server Trigger Records
internal/server/trigger.go
Extended TriggerRecord/TriggerInput with email fields, persisted email fields, and ListEmailTriggers.
Docs
site/src/content/docs/concepts/expressions.md, site/src/content/docs/server-guide/triggers.md, site/src/content/docs/workflow-reference/connectors.md, site/src/content/docs/workflow-reference/index.md
Documented steps.<name>.error, continue_on_error, email trigger setup, and new connector/action docs including browser/run.
Dependencies
go.mod
Added/reshuffled Docker and IMAP/message/SASL libraries.
Misc tests
internal/engine/*_test.go, internal/server/*_test.go, internal/connector/*_test.go, internal/workflow/*_test.go
Extensive new unit and integration tests across engine, orchestrator, server email trigger, connectors, IMAP helpers, and validation.

Sequence Diagram(s)

sequenceDiagram
    participant Poller as Email Trigger Poller
    participant IMAP as IMAP Server
    participant Engine as Workflow Engine
    participant DB as Database

    Poller->>IMAP: Dial & Login (persistent connection)
    activate IMAP

    loop Poll Interval
        Poller->>IMAP: Search for messages (filter)
        IMAP-->>Poller: UIDs

        alt Messages Found
            Poller->>IMAP: Fetch envelope, flags, body
            IMAP-->>Poller: Message details

            Poller->>Engine: Fire workflow with message inputs
            activate Engine
            Engine->>DB: Record execution / create steps
            Engine-->>Poller: Execution started
            deactivate Engine

            Poller->>IMAP: Mark messages as \Seen
            IMAP-->>Poller: Confirmed
        else No Messages
            Poller->>Poller: Wait until next tick
        end
    end

    deactivate IMAP
Loading
sequenceDiagram
    participant Orch as Orchestrator
    participant DB as Database
    participant Executor as Step Executor

    Orch->>DB: Read step statuses
    Orch->>Orch: Remap failed steps with continue_on_error -> treat as completed
    Orch->>Orch: Build DAG & compute ready steps
    Orch->>Executor: Create pending executions for ready steps
    Executor-->>DB: Update step status (failed or completed)
    Orch->>Orch: Cascade cancellations for non-continued failures
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through mailboxes and spun up a headless rhyme,
I launched browsers in boxes to fetch and click in time,
When steps fell and tumbled, I whispered "carry on",
Now errors can be captured, noted, and then gone,
Little confirmations logged — a rabbit's careful song.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the three main features (continue_on_error, email connectors, browser automation) and aligns with the v0.3.0 version release, clearly reflecting the primary changes in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/v030-release

Comment @coderabbitai help to get the list of available commands and usage tips.

@michaelmcnees michaelmcnees self-assigned this Mar 25, 2026
… retry IMAP connections

- browser/run: change JS/TS and Python container commands to install the
  playwright npm/pip package inline before executing the script. The
  Microsoft Playwright Docker images ship browsers but not the Node API
  package, causing MODULE_NOT_FOUND errors in CI.

- TestIMAPDial: add a 5-attempt retry loop (2s sleep) around imapDial.
  GreenMail may not accept logins immediately after the "Starting
  GreenMail" log line appears in CI.

- fetchFirstUID: replace the single-shot call with a 5-attempt retry
  loop (2s sleep) so that SMTP-to-IMAP visibility delay in CI doesn't
  cause TestEmailDelete_DeletesMessage and TestEmailMove_MovesMessage
  to fail with "no messages found".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/engine/engine.go (1)

724-726: ⚠️ Potential issue | 🟡 Minor

Missing "error": nil key in completed steps CEL context.

In MakeGlobalStepExecutor, completed steps are seeded with only "output", but the sequential execution path (line 143) and failed-continued steps (lines 734-737) both include the "error" key. This inconsistency could cause CEL expressions like steps['name'].error to fail for completed steps in the distributed worker path.

🐛 Proposed fix for consistency
 		for name, output := range completedSteps {
-			celCtx.Steps[name] = map[string]any{"output": output}
+			celCtx.Steps[name] = map[string]any{"output": output, "error": nil}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/engine.go` around lines 724 - 726, In MakeGlobalStepExecutor
the completedSteps seeding only sets {"output": output} which is inconsistent
with other paths that include an "error" key; update the loop that populates
celCtx.Steps from completedSteps to set celCtx.Steps[name] =
map[string]any{"output": output, "error": nil} so every step has both keys
(reference the completedSteps variable and celCtx.Steps map in
MakeGlobalStepExecutor).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/connector/browser.go`:
- Around line 21-30: The wrapper scripts assume the Playwright package is
available but the container images only include browsers; update the wrapper
generation to ensure Playwright is installed before requiring/importing it: in
buildJSWrapper, run an installation step (e.g., execute "npm install
playwright") or ensure the generated script invokes an install command or uses a
base image with playwright preinstalled before the "require('playwright')" call;
apply the same fix to the Python wrapper(s) that produce "from
playwright.sync_api import sync_playwright" (install via "pip install
playwright" or use a prebuilt image) as referenced around the other wrappers at
the noted lines so runtime require/imports succeed.

In `@internal/connector/email_delete_test.go`:
- Around line 112-117: The test currently uses unchecked type assertions on
result["deleted"] and result["uid"] which can panic; update the assertions in
email_delete_test.go to use the comma-ok form (e.g., check value, ok :=
result["deleted"].(bool) and value, ok := result["uid"].(uint32)), fail the test
with a clear message (t.Fatalf or t.Errorf) if the type assertion fails, and
then compare the extracted values to expected (deleted == true and retUID ==
uid) to produce actionable failures instead of panics.

In `@internal/connector/email_delete.go`:
- Line 6: Replace the standard "log" import and any uses of log.* in this file
with the project's structured logger (do not use package "log"); remove the
"log" import and instead use the existing project logger instance (e.g.,
processLogger or the package's logger) in the email_delete.go functions that
currently call log.* so that logs include structured fields like workflowID and
executionID (attach them as fields/context on each log call) and follow the
project's logging pattern for consistency and observability.
- Around line 58-67: The current fallback to client.Expunge() in the UID
deletion path is unsafe because UIDExpunge failure will cause Expunge to remove
all messages marked \Deleted; update the deletion flow (the block calling
client.UIDExpunge(uidSet).Close()) to instead return a clear error stating
UIDExpunge is required (do not call client.Expunge()), and add an audit/metric
emission when UIDExpunge is not supported so the event is observable (e.g., emit
an audit log/metric in the same conditional where you currently log the
fallback). Also add a short comment in the EmailDeleteConnector/UID delete logic
documenting that UIDExpunge is required and that callers should not rely on
Expunge fallback.

In `@internal/connector/email_move.go`:
- Around line 69-95: Move the extractUID helper into the shared IMAP utilities
(e.g., imap.go) and extend its type switch to accept uint and uint64 (in
addition to uint32, float64, int, int64) validating non-zero and <=
math.MaxUint32 for all integer types and rejecting NaN/Inf for float64; keep the
same error messages and return uint32, and update callers (e.g., email_move.go
and email_delete.go) to call the relocated extractUID from the shared IMAP
utilities.

In `@internal/connector/email_receive.go`:
- Around line 126-135: The code is calling client.Store(...) with a UIDSet
(uidSet), which operates on sequence numbers; replace these calls with
client.UIDStore(...) so the STORE command is sent using UIDs: for example change
client.Store(uidSet, storeFlags, nil).Close() to client.UIDStore(uidSet,
storeFlags, nil).Close(); apply the same replacement for any other places where
client.Store is invoked with a UIDSet (look for variables named uidSet,
storeFlags, markSeen or similar) so flag, delete and trigger flows use UIDStore
instead of Store.

In `@internal/connector/imap.go`:
- Around line 46-68: The imapDial function lacks a context parameter and so
cannot enforce timeouts/cancellations; change imapDial to accept ctx
context.Context and use a net.Dialer (e.g., &net.Dialer{Timeout:...}) passed
into imapclient.Options.Dialer so you call imapclient.DialTLS/DialInsecure with
those options (or perform dialer.DialContext(ctx, ...) and wrap the net.Conn
with imapclient.New(conn, opts)); ensure Login uses the returned client and that
the dial and error handling remain the same but are context-aware.

In `@internal/engine/distributed_test.go`:
- Around line 294-362: The test currently only verifies step-2 is scheduled and
completed but doesn't assert that the failed step's persisted error/partial
output is written and visible to downstream steps; update the test so the Worker
StepExecutor (the executor closure used by Worker) makes step-2 read the
persisted state for step-1 (via the database row in step_executions for execID
and step_name "step-1" or via the same mechanism the runtime uses to surface
errors/outputs) and assert that the error string and/or partial output fields
contain the expected values (e.g., "deliberate failure" and any partial output)
before returning success; ensure you reference and read the same columns the
production code persists and keep the check inside the executor for step-2 so
the test fails if the distributed checkpoint did not persist
step-1.error/output.

In `@internal/engine/engine.go`:
- Around line 902-927: The current loadFailedContinuedSteps function can receive
output::text == "null" (a JSON null) which causes json.Unmarshal to fail when
expecting an object; after scanning outputStr in loadFailedContinuedSteps, guard
against JSON null by normalizing it before unmarshalling (e.g., if
strings.TrimSpace(outputStr) == "null" || outputStr == "" then set outputStr =
"{}"), then proceed to json.Unmarshal into the map; this fixes cases where
output is JSON null while preserving existing handling of SQL NULL via COALESCE.

In `@internal/server/email_trigger_test.go`:
- Around line 187-200: Remove the unused imapclientFetchBufferStub type and
simplify the wrapper: delete the imapclientFetchBufferStub declaration and
update buildEmailTriggerInputsFromStub to no longer accept or ignore a parameter
(or remove the wrapper entirely and call
buildEmailTriggerInputs(&imapclient.FetchMessageBuffer{}, "INBOX") at the test
call sites); locate symbols imapclientFetchBufferStub and
buildEmailTriggerInputsFromStub and update callers to use
buildEmailTriggerInputs(&imapclient.FetchMessageBuffer{}, "INBOX") or the new
parameterless helper that returns the same map from buildEmailTriggerInputs.

In `@internal/server/email_trigger.go`:
- Around line 368-382: The warning log when Store fails after firing the
workflow lacks the message UID, making it hard to trace duplicates; update the
log in the error branch where client.Store(uidSet, storeFlags, nil).Close() is
checked to include the UID(s) from uidSet (or the specific UID used to fetch the
message) alongside existing fields like "trigger_id" (t.ID) and "workflow"
(t.WorkflowName), so the call to e.server.Logger.Warn includes a "uid" or "uids"
key with the identifier(s) to aid debugging duplicate executions.
- Around line 388-427: In fireWorkflow, the audit emission is using the original
ctx instead of the team-scoped context teamCtx; change the call to
e.server.Auditor.Emit to pass teamCtx so context-aware auditor middleware
receives the TeamID; specifically update the Auditor.Emit invocation (currently
e.server.Auditor.Emit(ctx, audit.Event{...})) to use teamCtx and keep the rest
of the audit.Event (Actor, Action, Resource, Metadata, TeamID) unchanged.
- Around line 312-322: The current code builds an imap.UIDSet from
searchData.AllUIDs() without a cap, which can exhaust memory for large
mailboxes; limit the number of UIDs before constructing uidSet by introducing a
maxFetch constant (e.g., 200) and truncating the uids slice to the most recent
maxFetch entries (e.g., if len(uids) > maxFetch, set uids =
uids[len(uids)-maxFetch:]) before the loop that adds them to uidSet; update any
variable references (uids, uidSet) used by the subsequent fetch logic in
email_trigger.go so the fetch operates only on the capped set while keeping
metrics.EmailPollDuration and t.WorkflowName/folder usage unchanged.

In `@internal/server/server.go`:
- Around line 287-291: After successfully calling s.emailPoller.Start(ctx) you
must ensure s.emailPoller.Stop(ctx) is called on all early returns; add a
rollback cleanup (e.g., a deferred stop or an explicit stop before each return)
immediately after Start succeeds to guarantee the poller is stopped if later
code returns (for example in the errCh/http bind failure branch). Locate the
Start/Stop calls on s.emailPoller (s.emailPoller.Start and s.emailPoller.Stop)
and either add defer s.emailPoller.Stop(ctx) right after Start or ensure every
non-shutdown return path calls s.emailPoller.Stop(ctx) before returning so the
poller does not leak when initialization fails later.

In `@internal/workflow/validate_test.go`:
- Around line 700-724: TestValidate_ContinueOnError currently uses
steps['risky-step'].error but the implicit-dependency extractor tests only cover
.output; update the test-suite to ensure ExtractImplicitDeps/Validate recognizes
steps.*.error as creating a dependency on that step. Add a unit case (or extend
existing implicit-deps table) that includes an expression referencing
steps['name'].error and assert the resulting dependency list for the consumer
step includes the producer step; ensure the extractor logic referenced by
ExtractImplicitDeps is exercised so .error is treated like .output and the
notify step cannot run before risky-step.

In `@internal/workflow/validate.go`:
- Around line 219-256: The branch that validates the "browser/run" step
currently skips checks when step.Params is nil and silently accepts non-string
types for language/output_format; update the validation block in validate.go so
that if step.Action == "browser/run" and step.Params == nil you append a
ValidationError for prefix + ".params" (and/or ".params.script") indicating
params/script are required, and change the language/output_format checks to
explicitly assert types (e.g., lang, ok := step.Params["language"].(string); if
!ok && step.Params["language"] != nil { append a type ValidationError for prefix
+ ".params.language" } else if ok { validate against validLanguages }) and
similarly for output_format (use format, ok :=
step.Params["output_format"].(string) with a type error when present but not a
string, then validate values), keeping the existing script presence/emptiness
checks on step.Params["script"].(string) unchanged but ensuring they run even
when step.Params was originally nil by handling that nil case first.
- Around line 104-152: The trigger validation loop in Validate (iterating
w.Triggers / trig) only enforces type and email fields, so cron and webhook
triggers lack required field checks; update the switch over trig.Type to add
case "cron" that ensures trig.Schedule (or similarly named field) is present and
non-empty and that its schedule parses/validates, and add case "webhook" that
ensures trig.Path (and any required method or auth fields) is present and valid;
alternatively call the JSON Schema validation for triggers here so all kinds
(cron, webhook, email) are validated consistently before returning errs.

In `@site/src/content/docs/workflow-reference/connectors.md`:
- Around line 383-390: The email connector examples use the `trigger.uid`
namespace but the rest of the PR documents trigger data as `trigger.payload.*`;
update the samples (e.g., the `archive-message` example and the delete/flag
examples) to use the documented namespace such as `trigger.payload.uid` (or
alternatively update the expressions.md to document `trigger.uid` if you prefer
that naming) so the trigger field names are consistent across docs; ensure the
YAML `params` entries reference `trigger.payload.*` everywhere to match
`expressions.md`.
- Around line 743-751: The example uses page.goto() and page.pdf() without
creating a Page object; insert a call to create the page from the injected
browser (e.g., page = browser.new_page()) before invoking page.goto() and
page.pdf() so the script initializes a Page instance prior to navigation and PDF
generation.

---

Outside diff comments:
In `@internal/engine/engine.go`:
- Around line 724-726: In MakeGlobalStepExecutor the completedSteps seeding only
sets {"output": output} which is inconsistent with other paths that include an
"error" key; update the loop that populates celCtx.Steps from completedSteps to
set celCtx.Steps[name] = map[string]any{"output": output, "error": nil} so every
step has both keys (reference the completedSteps variable and celCtx.Steps map
in MakeGlobalStepExecutor).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 20c29e75-f1d9-40f4-a5ed-3ace2af64719

📥 Commits

Reviewing files that changed from the base of the PR and between acad9f3 and 107cc62.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (38)
  • examples/browser-form-submit.yaml
  • examples/browser-scrape.yaml
  • examples/docker-volume-backup.yaml
  • examples/email-inbox-triage.yaml
  • examples/error-notification.yaml
  • go.mod
  • internal/audit/audit.go
  • internal/connector/browser.go
  • internal/connector/browser_test.go
  • internal/connector/connector.go
  • internal/connector/email_delete.go
  • internal/connector/email_delete_test.go
  • internal/connector/email_flag.go
  • internal/connector/email_flag_test.go
  • internal/connector/email_move.go
  • internal/connector/email_move_test.go
  • internal/connector/email_receive.go
  • internal/connector/email_receive_test.go
  • internal/connector/imap.go
  • internal/connector/imap_test.go
  • internal/db/migrations/014_continue_on_error.sql
  • internal/db/migrations/015_email_triggers.sql
  • internal/engine/distributed_test.go
  • internal/engine/engine.go
  • internal/engine/engine_test.go
  • internal/engine/orchestrator.go
  • internal/metrics/metrics.go
  • internal/server/email_trigger.go
  • internal/server/email_trigger_test.go
  • internal/server/server.go
  • internal/server/trigger.go
  • internal/workflow/validate.go
  • internal/workflow/validate_test.go
  • internal/workflow/workflow.go
  • site/src/content/docs/concepts/expressions.md
  • site/src/content/docs/server-guide/triggers.md
  • site/src/content/docs/workflow-reference/connectors.md
  • site/src/content/docs/workflow-reference/index.md

Comment thread internal/connector/email_delete_test.go
import (
"context"
"fmt"
"log"

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider structured logging instead of log package.

Using the standard log package loses structured context (workflow ID, execution ID, etc.). Consider using the project's logging pattern for consistency and observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/email_delete.go` at line 6, Replace the standard "log"
import and any uses of log.* in this file with the project's structured logger
(do not use package "log"); remove the "log" import and instead use the existing
project logger instance (e.g., processLogger or the package's logger) in the
email_delete.go functions that currently call log.* so that logs include
structured fields like workflowID and executionID (attach them as fields/context
on each log call) and follow the project's logging pattern for consistency and
observability.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Tracked as #39 in the v0.3.1 milestone (The Locksmith Update).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, thanks for tracking this! I'll look forward to seeing the structured logging improvements land in #39 as part of the v0.3.1 milestone. 👍

Comment thread internal/connector/email_delete.go
Comment on lines +69 to +95
// extractUID extracts a uint32 UID value from params, accepting uint32 or float64.
func extractUID(params map[string]any, key string) (uint32, error) {
switch v := params[key].(type) {
case uint32:
if v == 0 {
return 0, fmt.Errorf("%s must be a non-zero UID", key)
}
return v, nil
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 || v > math.MaxUint32 || v != math.Floor(v) {
return 0, fmt.Errorf("%s must be a non-zero UID (valid uint32 integer)", key)
}
return uint32(v), nil
case int:
if v <= 0 || v > math.MaxUint32 {
return 0, fmt.Errorf("%s must be a non-zero UID (valid uint32)", key)
}
return uint32(v), nil
case int64:
if v <= 0 || v > math.MaxUint32 {
return 0, fmt.Errorf("%s must be a non-zero UID (valid uint32)", key)
}
return uint32(v), nil
default:
return 0, fmt.Errorf("%s is required and must be a uint32", key)
}
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

extractUID should be in a shared file and handle additional integer types.

This helper is used by multiple connectors (email_move.go, email_delete.go). Consider moving it to imap.go with other shared IMAP utilities. Also, uint and uint64 types are not handled, which could cause unexpected errors if params originate from different sources.

♻️ Extended type handling
 func extractUID(params map[string]any, key string) (uint32, error) {
 	switch v := params[key].(type) {
 	case uint32:
 		if v == 0 {
 			return 0, fmt.Errorf("%s must be a non-zero UID", key)
 		}
 		return v, nil
+	case uint:
+		if v == 0 || v > math.MaxUint32 {
+			return 0, fmt.Errorf("%s must be a non-zero UID (valid uint32)", key)
+		}
+		return uint32(v), nil
+	case uint64:
+		if v == 0 || v > math.MaxUint32 {
+			return 0, fmt.Errorf("%s must be a non-zero UID (valid uint32)", key)
+		}
+		return uint32(v), nil
 	case float64:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/email_move.go` around lines 69 - 95, Move the extractUID
helper into the shared IMAP utilities (e.g., imap.go) and extend its type switch
to accept uint and uint64 (in addition to uint32, float64, int, int64)
validating non-zero and <= math.MaxUint32 for all integer types and rejecting
NaN/Inf for float64; keep the same error messages and return uint32, and update
callers (e.g., email_move.go and email_delete.go) to call the relocated
extractUID from the shared IMAP utilities.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on consolidation. Tracked as part of #37 (Consolidate duplicated IMAP config and dial logic) in v0.3.1.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, sounds good! Consolidating extractUID alongside the other shared IMAP utilities in v0.3.1 makes sense — grouping it with parseIMAPCredential and imapDial in imap.go will keep the shared logic in one place. The uint/uint64 type extensions can be included at the same time.

Comment on lines +126 to +135
if markSeen && len(uids) > 0 {
storeFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
Silent: true,
}
if err := client.Store(uidSet, storeFlags, nil).Close(); err != nil {
return nil, fmt.Errorf("email/receive: marking messages as seen: %w", err)
}
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check go-imap/v2 API for Store vs UIDStore
rg -n 'func.*Store\(' --type go
ast-grep --pattern 'client.Store($_, $_, $_)'
# Check if UIDStore exists in the library
rg -n 'UIDStore' internal/connector/

Repository: dvflw/mantle

Length of output: 998


🌐 Web query:

go-imap v2 imapclient Store vs UIDStore UID operations

💡 Result:

In the go-imap v2 library (github.com/emersion/go-imap/v2), the IMAPClient provides methods for STORE operations following standard IMAP conventions for sequence numbers vs UIDs. The Client.Store(numSet imap.NumSet, store *imap.StoreFlags, options *imap.StoreOptions) method sends a STORE command using message sequence numbers (NumSet). Sequence numbers are temporary indices that change as messages are added, expunged, or modified. For UID-based operations, which use permanent unique identifiers (UIDs) that remain stable across sessions (per UIDPLUS RFC 4315), use Client.UIDStore(uidSet imap.UIDSet, store *imap.StoreFlags, options *imap.StoreOptions). This sends a UID STORE command, interpreting the set as UIDs instead of sequence numbers. UID operations are preferred for reliable message targeting, especially in concurrent or changing mailboxes. Examples from docs and issues show patterns like c.Fetch(seqset or uidset, items...) and c.UIDFetch(uidset, items...), confirming UID-prefixed methods for UID sets. Store follows this: use UIDStore for UID operations to avoid sequence number shifts. Note: v2 is in beta (latest v2.0.0-beta.8 as of 2026); always check pkg.go.dev/github.com/emersion/go-imap/v2/imapclient for exact signatures. Other libraries like github.com/demisto/go-imap explicitly have UIDStore vs Store.

Citations:


Replace client.Store(uidSet, ...) with client.UIDStore(uidSet, ...).

The code uses client.Store() with a UIDSet, but go-imap v2's Store() method expects sequence numbers. For UID-based operations, use Client.UIDStore() instead, which sends the UID STORE command. Using the wrong method will operate on incorrect messages based on temporary sequence numbers rather than permanent UIDs.

This same issue appears in internal/connector/email_flag.go:77, internal/connector/email_delete.go:54, and internal/server/email_trigger.go:374.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/email_receive.go` around lines 126 - 135, The code is
calling client.Store(...) with a UIDSet (uidSet), which operates on sequence
numbers; replace these calls with client.UIDStore(...) so the STORE command is
sent using UIDs: for example change client.Store(uidSet, storeFlags,
nil).Close() to client.UIDStore(uidSet, storeFlags, nil).Close(); apply the same
replacement for any other places where client.Store is invoked with a UIDSet
(look for variables named uidSet, storeFlags, markSeen or similar) so flag,
delete and trigger flows use UIDStore instead of Store.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this is a false positive. go-imap v2's client.Store(numSet, ...) method dispatches UID STORE vs plain STORE automatically based on the runtime type of numSet — it checks imapwire.NumSetKind. Since all call sites pass imap.UIDSet (not imap.SeqSet), the wire protocol already sends UID STORE. There is no separate UIDStore method in go-imap v2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, thanks for the detailed verification. You're correct — go-imap v2's Client.Store accepts imap.NumSet as its parameter, and the library dispatches UID STORE vs plain STORE on the wire based on the runtime type of the numSet argument. There is no separate UIDStore method in go-imap v2's imapclient — the unified Store method handles both cases. My comment was incorrectly based on the go-imap v1 API, which did have a separate UidStore method. Apologies for the noise.


✏️ Learnings added
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/connector/email_receive.go:126-135
Timestamp: 2026-03-25T19:44:56.827Z
Learning: In go-imap v2 (github.com/emersion/go-imap/v2/imapclient), the `Client.Store(numSet imap.NumSet, store *imap.StoreFlags, options *imap.StoreOptions)` method automatically dispatches `UID STORE` vs plain `STORE` on the wire based on the runtime type of `numSet`: passing `imap.UIDSet` results in `UID STORE`, and passing `imap.SeqSet` results in plain `STORE`. There is no separate `UIDStore` method in go-imap v2 (unlike v1, which had `UidStore`). Do not flag uses of `client.Store(uidSet, ...)` as incorrect when the variable is of type `imap.UIDSet`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +700 to +724
func TestValidate_ContinueOnError(t *testing.T) {
result := mustParse(t, `
name: test-continue
steps:
- name: risky-step
action: http/request
continue_on_error: true
params:
url: https://example.com
- name: notify
action: slack/send
if: "steps['risky-step'].error != null"
params:
channel: "#alerts"
text: "Step failed"
`)
errs := Validate(result)
assertNoErrors(t, errs)
if result.Workflow.Steps[0].ContinueOnError != true {
t.Errorf("expected ContinueOnError to be true for steps[0], got %v", result.Workflow.Steps[0].ContinueOnError)
}
if result.Workflow.Steps[1].ContinueOnError != false {
t.Errorf("expected ContinueOnError to be false for steps[1], got %v", result.Workflow.Steps[1].ContinueOnError)
}
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add implicit-dependency coverage for steps.*.error.

TestValidate_ContinueOnError parses steps['risky-step'].error, but the implicit-dependency tests in this file only cover .output references. If ExtractImplicitDeps misses .error, the notify step can run too early and this new test still passes.

🧪 Minimal case to pin the extractor
+		{
+			name: "if condition references step error",
+			step: Step{
+				If: "steps['risky-step'].error != null",
+			},
+			want: []string{"risky-step"},
+		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/workflow/validate_test.go` around lines 700 - 724,
TestValidate_ContinueOnError currently uses steps['risky-step'].error but the
implicit-dependency extractor tests only cover .output; update the test-suite to
ensure ExtractImplicitDeps/Validate recognizes steps.*.error as creating a
dependency on that step. Add a unit case (or extend existing implicit-deps
table) that includes an expression referencing steps['name'].error and assert
the resulting dependency list for the consumer step includes the producer step;
ensure the extractor logic referenced by ExtractImplicitDeps is exercised so
.error is treated like .output and the notify step cannot run before risky-step.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. The existing stepRefPattern regex detects steps.X references for dependency validation. Adding a test that verifies steps['x'].error is detected would improve coverage. Deferring to v0.3.1.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, understood — good to know stepRefPattern already covers the steps.X pattern broadly. Would you like me to open a follow-up GitHub issue to track adding the steps['x'].error implicit-dependency test case for v0.3.1?

Comment on lines +104 to +152
// Validate triggers.
validTriggerTypes := map[string]bool{"cron": true, "webhook": true, "email": true}
validEmailFilters := map[string]bool{"unseen": true, "all": true, "flagged": true, "recent": true}
for i, trig := range w.Triggers {
trigPrefix := fmt.Sprintf("triggers[%d]", i)
if trig.Type == "" {
errs = append(errs, ValidationError{
Field: trigPrefix + ".type",
Message: "trigger type is required",
})
continue
}
if !validTriggerTypes[trig.Type] {
errs = append(errs, ValidationError{
Field: trigPrefix + ".type",
Message: fmt.Sprintf("trigger type must be one of: cron, webhook, email (got %q)", trig.Type),
})
continue
}
switch trig.Type {
case "email":
if trig.Mailbox == "" {
errs = append(errs, ValidationError{
Field: trigPrefix + ".mailbox",
Message: "mailbox is required for email triggers",
})
}
if trig.Filter != "" && !validEmailFilters[trig.Filter] {
errs = append(errs, ValidationError{
Field: trigPrefix + ".filter",
Message: fmt.Sprintf("filter must be one of: unseen, all, flagged, recent (got %q)", trig.Filter),
})
}
if trig.PollInterval != "" {
d, err := time.ParseDuration(trig.PollInterval)
if err != nil {
errs = append(errs, ValidationError{
Field: trigPrefix + ".poll_interval",
Message: fmt.Sprintf("invalid duration: %v", err),
})
} else if d <= 0 {
errs = append(errs, ValidationError{
Field: trigPrefix + ".poll_interval",
Message: "poll_interval must be a positive duration",
})
}
}
}
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate still accepts malformed cron and webhook triggers.

This block only enforces type and email-specific fields, so a cron trigger with no schedule or a webhook trigger with no path still passes validation and fails later at runtime. Please add the missing type-specific checks here, or move trigger validation behind the workflow JSON Schema so all trigger kinds stay aligned. As per coding guidelines, "Validate workflow YAML against JSON Schema in the validate command."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/workflow/validate.go` around lines 104 - 152, The trigger validation
loop in Validate (iterating w.Triggers / trig) only enforces type and email
fields, so cron and webhook triggers lack required field checks; update the
switch over trig.Type to add case "cron" that ensures trig.Schedule (or
similarly named field) is present and non-empty and that its schedule
parses/validates, and add case "webhook" that ensures trig.Path (and any
required method or auth fields) is present and valid; alternatively call the
JSON Schema validation for triggers here so all kinds (cron, webhook, email) are
validated consistently before returning errs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pre-existing gap — cron and webhook trigger validation was not part of the v0.3.0 scope. We added email trigger validation in this PR. Adding required-field checks for cron (schedule) and webhook (path) is a good improvement but out of scope for this release. Will create a separate issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, understood — that's a reasonable scoping decision. Would you like me to open a GitHub issue to track adding required-field validation for cron (schedule) and webhook (path) triggers in internal/workflow/validate.go? I can file it now so it's ready for a follow-up PR.


🧠 Learnings used
Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T01:18:15.022Z
Learning: Applies to **/*.yaml : Validate workflow YAML against JSON Schema in the validate command

Comment on lines +219 to +256
// Validate params for browser/run steps.
if step.Action == "browser/run" && step.Params != nil {
// Validate script is present and non-empty.
script, hasScript := step.Params["script"].(string)
if !hasScript {
errs = append(errs, ValidationError{
Field: prefix + ".params.script",
Message: "script param is required and must be a string",
})
} else if strings.TrimSpace(script) == "" {
errs = append(errs, ValidationError{
Field: prefix + ".params.script",
Message: "script param must not be empty",
})
}

// Validate language (if present).
if lang, ok := step.Params["language"].(string); ok && lang != "" {
validLanguages := map[string]bool{"javascript": true, "typescript": true, "python": true}
if !validLanguages[lang] {
errs = append(errs, ValidationError{
Field: prefix + ".params.language",
Message: fmt.Sprintf("language must be one of: javascript, typescript, python (got %q)", lang),
})
}
}

// Validate output_format (if present).
if format, ok := step.Params["output_format"].(string); ok && format != "" {
validFormats := map[string]bool{"json": true, "text": true}
if !validFormats[format] {
errs = append(errs, ValidationError{
Field: prefix + ".params.output_format",
Message: fmt.Sprintf("output_format must be one of: json, text (got %q)", format),
})
}
}
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

browser/run can still bypass validation with missing or mistyped params.

Because the whole branch is gated on step.Params != nil, { action: browser/run } passes validation even though the connector requires params.script. Also, language and output_format are only checked after a successful .(string), so values like language: 123 or output_format: false are silently accepted and then defaulted at execution time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/workflow/validate.go` around lines 219 - 256, The branch that
validates the "browser/run" step currently skips checks when step.Params is nil
and silently accepts non-string types for language/output_format; update the
validation block in validate.go so that if step.Action == "browser/run" and
step.Params == nil you append a ValidationError for prefix + ".params" (and/or
".params.script") indicating params/script are required, and change the
language/output_format checks to explicitly assert types (e.g., lang, ok :=
step.Params["language"].(string); if !ok && step.Params["language"] != nil {
append a type ValidationError for prefix + ".params.language" } else if ok {
validate against validLanguages }) and similarly for output_format (use format,
ok := step.Params["output_format"].(string) with a type error when present but
not a string, then validate values), keeping the existing script
presence/emptiness checks on step.Params["script"].(string) unchanged but
ensuring they run even when step.Params was originally nil by handling that nil
case first.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation runs at mantle validate time against the static YAML. Dynamic CEL expressions ({{ }}) are resolved at runtime, so static validation can't catch a missing script that comes from a CEL expression. This is consistent with how all other connectors work — params are validated structurally at parse time, not at execution time. The runtime connector already validates script is non-empty and returns a clear error. No change needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, that makes sense — thanks for the clarification. Static validation can only reason about literal YAML values, and requiring script at validate-time would break workflows that supply it dynamically via a CEL expression. The runtime connector's own guard is the right place for that check. I'll note this design pattern for future reviews.

(^_^) ∫


✏️ Learnings added
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/workflow/validate.go:219-256
Timestamp: 2026-03-25T19:46:50.273Z
Learning: In dvflw/mantle, `mantle validate` performs static structural validation against literal YAML values only. Connector params (e.g., `script` for `browser/run`) are not required to be present at validate time because they can be supplied via CEL expressions (`{{ }}`), which are resolved at runtime. Runtime connectors are responsible for validating their own required params and returning clear errors. This is consistent across all connectors and intentional by design.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-24T01:18:15.022Z
Learning: Applies to **/*.yaml : Validate workflow YAML against JSON Schema in the validate command

Comment on lines +383 to +390
```yaml
- name: archive-message
action: email/move
credential: company-inbox
params:
uid: "{{ trigger.uid }}"
source_folder: INBOX
target_folder: Archive

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align the email examples with the documented trigger namespace.

These samples use trigger.uid, but site/src/content/docs/concepts/expressions.md in the same PR still documents trigger data as trigger.payload.*. Please make the namespace consistent or document the email-trigger shape here. The same mismatch repeats in the delete/flag examples below.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@site/src/content/docs/workflow-reference/connectors.md` around lines 383 -
390, The email connector examples use the `trigger.uid` namespace but the rest
of the PR documents trigger data as `trigger.payload.*`; update the samples
(e.g., the `archive-message` example and the delete/flag examples) to use the
documented namespace such as `trigger.payload.uid` (or alternatively update the
expressions.md to document `trigger.uid` if you prefer that naming) so the
trigger field names are consistent across docs; ensure the YAML `params` entries
reference `trigger.payload.*` everywhere to match `expressions.md`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will review and align in the docs fix push.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees — Sounds good! When you do the fix push, the places to update are:

  1. email/move exampletrigger.uid → consistent namespace
  2. email/delete example — same
  3. email/flag example — same

Whether you settle on trigger.uid (flat email-trigger shape) or trigger.payload.uid (generic payload shape), just make sure site/src/content/docs/concepts/expressions.md and site/src/content/docs/server-guide/triggers.md all agree with whichever convention is chosen. Happy to re-check once the push lands!

Comment on lines +743 to +751
script: |
import os
import json

page.goto('https://example.com/report')
page.pdf(path='/mantle/artifacts/report.pdf')

file_size = os.path.getsize('/mantle/artifacts/report.pdf')
print(json.dumps({'generated': True, 'size_bytes': file_size}))

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Playwright Python, given a Browser instance, should a docs example create a page with page = browser.new_page()before callingpage.goto()andpage.pdf()?

💡 Result:

Yes, in Playwright Python, given a Browser instance, documentation examples consistently create a page with page = browser.new_page before calling page.goto and page.pdf. The official Browser API docs show examples like: browser = chromium.launch; page = browser.new_page; page.goto("https://example.com"). Similar patterns appear in Page, Playwright intro, and PDF generation tutorials from sources like pdforge.com (2025). browser.new_page is a convenience method that creates a new page in a new context, suitable for single-page scenarios, though production code may prefer explicit context.new_page for lifecycle control.

Citations:


Add page creation before navigation and PDF operations.

The example calls page.goto() and page.pdf() without first creating a page instance. Add page = browser.new_page() to initialize the page from the injected browser instance.

Fix
       import os
       import json
 
+      page = browser.new_page()
       page.goto('https://example.com/report')
       page.pdf(path='/mantle/artifacts/report.pdf')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
script: |
import os
import json
page.goto('https://example.com/report')
page.pdf(path='/mantle/artifacts/report.pdf')
file_size = os.path.getsize('/mantle/artifacts/report.pdf')
print(json.dumps({'generated': True, 'size_bytes': file_size}))
script: |
import os
import json
page = browser.new_page()
page.goto('https://example.com/report')
page.pdf(path='/mantle/artifacts/report.pdf')
file_size = os.path.getsize('/mantle/artifacts/report.pdf')
print(json.dumps({'generated': True, 'size_bytes': file_size}))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@site/src/content/docs/workflow-reference/connectors.md` around lines 743 -
751, The example uses page.goto() and page.pdf() without creating a Page object;
insert a call to create the page from the injected browser (e.g., page =
browser.new_page()) before invoking page.goto() and page.pdf() so the script
initializes a Page instance prior to navigation and PDF generation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in commit 107cc62 — the Python example was updated to use the pre-created page object from the wrapper.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, thanks for the follow-up! That makes sense — since the Python wrapper in BrowserRunConnector already pre-creates a page instance (analogous to how browser is injected for JS/TS), the example correctly reflects the actual runtime context. Acknowledged!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
internal/server/email_trigger.go (2)

411-426: ⚠️ Potential issue | 🟡 Minor

Audit event uses original ctx instead of team-scoped teamCtx.

The fireWorkflow method creates teamCtx (line 390) but the audit event emission at line 412 uses ctx. While TeamID is explicitly set, any context-aware auditor middleware won't see the team context.

🐛 Proposed fix
 	if e.server.Auditor != nil {
-		_ = e.server.Auditor.Emit(ctx, audit.Event{
+		_ = e.server.Auditor.Emit(teamCtx, audit.Event{
 			Actor:  "email-poller",
 			Action: audit.ActionEmailTriggerFired,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 411 - 426, In fireWorkflow,
the audit Emit call uses the original ctx instead of the team-scoped teamCtx;
update the call to use teamCtx (i.e., call e.server.Auditor.Emit(teamCtx,
audit.Event{...})) so any context-aware auditor middleware receives the team
context; keep the existing e.server.Auditor != nil guard and the audit.Event
payload (WorkflowName/ID/TeamID) unchanged.

312-322: ⚠️ Potential issue | 🟠 Major

Unbounded UID fetch may cause memory/performance issues with large mailboxes.

The code fetches all UIDs from searchData.AllUIDs() without applying the 200-message cap mentioned in the PR summary. Large mailboxes (e.g., after an outage) could cause memory exhaustion or timeouts.

🛡️ Proposed fix to cap fetched messages
+const maxEmailsPerPoll = 200
+
 	uids := searchData.AllUIDs()
 	if len(uids) == 0 {
 		metrics.EmailPollDuration.WithLabelValues(t.WorkflowName, folder).Observe(time.Since(start).Seconds())
 		return nil
 	}

+	// Cap the number of messages processed per poll to avoid memory exhaustion.
+	if len(uids) > maxEmailsPerPoll {
+		e.server.Logger.Warn("email poller: truncating UID list",
+			"trigger_id", t.ID,
+			"total_uids", len(uids),
+			"processing", maxEmailsPerPoll)
+		uids = uids[:maxEmailsPerPoll]
+	}
+
 	// Fetch envelope + body text for each message.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 312 - 322, The code currently
builds a UID set from searchData.AllUIDs() without limiting count; change it to
cap to the most recent 200 UIDs before creating uidSet to avoid unbounded memory
usage. After calling uids := searchData.AllUIDs(), if len(uids) > 200 slice it
to keep only the last 200 entries (e.g., uids = uids[len(uids)-200:]) and then
iterate those to populate uidSet (uidSet.AddNum). Keep existing
metrics.EmailPollDuration behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/server/email_trigger.go`:
- Around line 461-497: The imapDialConfig struct and dialIMAP function duplicate
connector.imapConfig/connector.imapDial which will drift; refactor by moving the
shared definitions into a single internal/shared package or by exporting the
connector types so both packages import the same symbols (e.g., replace local
imapDialConfig and dialIMAP with a single exported type and dial function in a
common package and update callers to use
connector.ImapDialConfig/connector.DialIMAP or
internal.ImapDialConfig/internal.DialIMAP), ensuring the implementation (TLS
options, Login/Close behavior and error wrapping) remains identical.

---

Duplicate comments:
In `@internal/server/email_trigger.go`:
- Around line 411-426: In fireWorkflow, the audit Emit call uses the original
ctx instead of the team-scoped teamCtx; update the call to use teamCtx (i.e.,
call e.server.Auditor.Emit(teamCtx, audit.Event{...})) so any context-aware
auditor middleware receives the team context; keep the existing e.server.Auditor
!= nil guard and the audit.Event payload (WorkflowName/ID/TeamID) unchanged.
- Around line 312-322: The code currently builds a UID set from
searchData.AllUIDs() without limiting count; change it to cap to the most recent
200 UIDs before creating uidSet to avoid unbounded memory usage. After calling
uids := searchData.AllUIDs(), if len(uids) > 200 slice it to keep only the last
200 entries (e.g., uids = uids[len(uids)-200:]) and then iterate those to
populate uidSet (uidSet.AddNum). Keep existing metrics.EmailPollDuration
behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 759c01d1-3c9f-4a0c-9aa6-c5a0b3c348c9

📥 Commits

Reviewing files that changed from the base of the PR and between 107cc62 and bafd5b5.

📒 Files selected for processing (4)
  • internal/connector/browser.go
  • internal/connector/email_move_test.go
  • internal/connector/imap_test.go
  • internal/server/email_trigger.go

Comment on lines +461 to +497
// imapDialConfig holds parameters for dialling an IMAP server.
// It mirrors connector.imapConfig but is defined here to avoid a cross-package
// dependency on an unexported type.
type imapDialConfig struct {
Host string
Port string
Username string
Password string
UseTLS bool
}

// dialIMAP connects to an IMAP server and authenticates.
func dialIMAP(cfg *imapDialConfig) (*imapclient.Client, error) {
addr := net.JoinHostPort(cfg.Host, cfg.Port)
var (
client *imapclient.Client
err error
)
if cfg.UseTLS {
client, err = imapclient.DialTLS(addr, &imapclient.Options{
TLSConfig: &tls.Config{
ServerName: cfg.Host,
MinVersion: tls.VersionTLS12,
},
})
} else {
client, err = imapclient.DialInsecure(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("connecting to IMAP server %s: %w", addr, err)
}
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("IMAP login failed: %w", err)
}
return client, nil
}

@coderabbitai coderabbitai Bot Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting shared IMAP dial logic to avoid drift.

The imapDialConfig struct and dialIMAP function duplicate connector.imapConfig and connector.imapDial. While the comment at line 462 acknowledges this is to avoid cross-package dependency on an unexported type, consider exporting the connector types or extracting to a shared internal package to prevent future drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 461 - 497, The imapDialConfig
struct and dialIMAP function duplicate connector.imapConfig/connector.imapDial
which will drift; refactor by moving the shared definitions into a single
internal/shared package or by exporting the connector types so both packages
import the same symbols (e.g., replace local imapDialConfig and dialIMAP with a
single exported type and dial function in a common package and update callers to
use connector.ImapDialConfig/connector.DialIMAP or
internal.ImapDialConfig/internal.DialIMAP), ensuring the implementation (TLS
options, Login/Close behavior and error wrapping) remains identical.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Tracked as #37 in v0.3.1 (The Locksmith Update).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, noted — I'll avoid flagging this duplication in future reviews of these files since the refactor is already tracked for v0.3.1.


✏️ Learnings added
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/server/email_trigger.go:472-508
Timestamp: 2026-03-25T19:46:51.137Z
Learning: In `internal/server/email_trigger.go`, the `imapDialConfig` struct and `dialIMAP` function intentionally duplicate `connector.imapConfig` and `connector.imapDial` from `internal/connector/imap.go` to avoid a cross-package dependency on unexported types. The refactor (exporting shared types or extracting to a common internal package) is tracked in issue `#37` for v0.3.1 (The Locksmith Update). Do not flag this duplication in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/connector/imap.go:46-68
Timestamp: 2026-03-25T19:45:51.680Z
Learning: In `internal/connector/imap.go`, the `imapDial` function intentionally omits a `context.Context` parameter for v0.3.0. The default go-imap v2 dialer already enforces a 30-second timeout, which is considered adequate. Context-aware dialing for cancellation support is deferred to v0.3.1 and tracked in issue `#37`. Do not flag the missing context parameter in future reviews of this file.

Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/connector/email_delete.go:58-67
Timestamp: 2026-03-25T19:44:44.878Z
Learning: In `internal/connector/email_delete.go`, the fallback from `UIDExpunge` to `Expunge` is intentional for v0.3.0 to maintain compatibility with older IMAP servers that do not support the UIDPLUS extension. A loud warning log is emitted when the fallback is taken. A `require_uidplus` config flag is planned for v0.3.1 to allow users to opt into stricter behavior. Do not flag this fallback as a bug in future reviews.

Cap UIDs fetched per poll cycle to 200 to prevent memory exhaustion on
large mailboxes. Fix audit event in fireWorkflow to use teamCtx (which
carries TeamID) instead of the bare ctx.

Note: go-imap v2 client.Store() already issues UID STORE automatically
when passed an imap.UIDSet (via uidCmdName dispatch) — no UIDStore method
exists in this API version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/connector/browser.go`:
- Around line 33-46: The Python wrapper (buildPythonWrapper) currently creates a
page variable via browser.new_page(), but the JavaScript/TypeScript wrapper only
exposes browser, causing API inconsistency; update the JS/TS wrapper that
constructs the Playwright script to also pre-create and expose a page variable
(using browser.newPage()) alongside browser (so both language wrappers provide
browser and page), ensuring the JS wrapper's boilerplate mirrors
buildPythonWrapper's structure and names.
- Around line 10-13: The Docker images referenced by playwrightNodeImage and
playwrightPythonImage only include browsers and system deps, not the Playwright
npm package, so relying on NODE_PATH to find the module will fail; update the
connector to either (A) use a custom image that installs the Playwright npm
package (build an image FROM mcr.microsoft.com/playwright:v1.52.0-noble and run
npm install playwright@1.52.0 during build) and point playwrightNodeImage to
that custom image, or (B) change runtime startup to install the npm package
before requiring it (e.g., run npm ci/npm install playwright into the container
at init), and remove the NODE_PATH workaround in the code that attempts to load
Playwright so the module can be resolved reliably.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c917be31-1ccd-4b86-817a-9cdcbbcf08d4

📥 Commits

Reviewing files that changed from the base of the PR and between bafd5b5 and 491070f.

📒 Files selected for processing (1)
  • internal/connector/browser.go

Comment thread internal/connector/browser.go
Comment on lines +33 to +46
// buildPythonWrapper wraps the user's Python Playwright script with boilerplate
// that launches Chromium and tears it down.
func buildPythonWrapper(userScript string) string {
// Indent each line of the user script by 8 spaces to sit inside the try block.
indented := indentLines(userScript, " ")
return "from playwright.sync_api import sync_playwright\n" +
"with sync_playwright() as p:\n" +
" browser = p.chromium.launch()\n" +
" try:\n" +
" page = browser.new_page()\n" +
indented + "\n" +
" finally:\n" +
" browser.close()\n"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider API consistency between language wrappers.

The Python wrapper pre-creates a page variable (line 42), but the JavaScript wrapper (lines 21-31) only provides browser. This means:

  • Python users: Have browser and page available
  • JS/TS users: Have only browser, must create page themselves

This asymmetry could confuse users switching between languages or following documentation.

Option 1: Add page to JS wrapper for consistency
 func buildJSWrapper(userScript string) string {
 	return "const { chromium } = require('playwright');\n" +
 		"(async () => {\n" +
 		"    const browser = await chromium.launch();\n" +
 		"    try {\n" +
+		"        const page = await browser.newPage();\n" +
 		"        " + userScript + "\n" +
 		"    } finally {\n" +
 		"        await browser.close();\n" +
 		"    }\n" +
 		"})();\n"
 }
Option 2: Remove page from Python wrapper
 func buildPythonWrapper(userScript string) string {
 	indented := indentLines(userScript, "        ")
 	return "from playwright.sync_api import sync_playwright\n" +
 		"with sync_playwright() as p:\n" +
 		"    browser = p.chromium.launch()\n" +
 		"    try:\n" +
-		"        page = browser.new_page()\n" +
 		indented + "\n" +
 		"    finally:\n" +
 		"        browser.close()\n"
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// buildPythonWrapper wraps the user's Python Playwright script with boilerplate
// that launches Chromium and tears it down.
func buildPythonWrapper(userScript string) string {
// Indent each line of the user script by 8 spaces to sit inside the try block.
indented := indentLines(userScript, " ")
return "from playwright.sync_api import sync_playwright\n" +
"with sync_playwright() as p:\n" +
" browser = p.chromium.launch()\n" +
" try:\n" +
" page = browser.new_page()\n" +
indented + "\n" +
" finally:\n" +
" browser.close()\n"
}
// buildPythonWrapper wraps the user's Python Playwright script with boilerplate
// that launches Chromium and tears it down.
func buildPythonWrapper(userScript string) string {
// Indent each line of the user script by 8 spaces to sit inside the try block.
indented := indentLines(userScript, " ")
return "from playwright.sync_api import sync_playwright\n" +
"with sync_playwright() as p:\n" +
" browser = p.chromium.launch()\n" +
" try:\n" +
indented + "\n" +
" finally:\n" +
" browser.close()\n"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser.go` around lines 33 - 46, The Python wrapper
(buildPythonWrapper) currently creates a page variable via browser.new_page(),
but the JavaScript/TypeScript wrapper only exposes browser, causing API
inconsistency; update the JS/TS wrapper that constructs the Playwright script to
also pre-create and expose a page variable (using browser.newPage()) alongside
browser (so both language wrappers provide browser and page), ensuring the JS
wrapper's boilerplate mirrors buildPythonWrapper's structure and names.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/server/email_trigger.go`:
- Around line 93-100: The loop that checks e.pollers under e.mu and then
releases the lock before calling startPoller can race and start the same trigger
twice; change this by moving the check-and-start under a single lock: add a
helper (e.g. startPollerIfNotRunning) that acquires e.mu, checks
e.pollers[t.ID], and if absent performs the start logic (or calls an internal
start routine that does not re-acquire the lock) so the existence check and
insertion are atomic; update the loop to call startPollerIfNotRunning(ctx, t)
instead of unlocking then calling startPoller.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2160427c-2681-44e8-accb-b7e1493ac0e2

📥 Commits

Reviewing files that changed from the base of the PR and between 491070f and 200c362.

📒 Files selected for processing (1)
  • internal/server/email_trigger.go

Comment thread internal/server/email_trigger.go
@michaelmcnees

Copy link
Copy Markdown
Collaborator Author

Race condition in Reload() — fixed in d61a2c9

The check-and-start in Reload() had a TOCTOU race: the existence check (e.pollers[t.ID]) and the start were under separate lock acquisitions. Two concurrent Reload() calls could both see running=false and spawn duplicate pollers.

Fixed by splitting into three methods:

  • startPollerLocked() — core logic, caller must hold e.mu
  • startPoller() — acquires lock, calls startPollerLocked (used by Start())
  • startPollerIfNotRunning() — acquires lock, checks existence atomically, calls startPollerLocked if absent (used by Reload())

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
internal/server/email_trigger.go (1)

93-100: ⚠️ Potential issue | 🟡 Minor

Race condition between checking and starting pollers persists.

The lock is released at line 96 before calling startPoller at line 98. If Reload is called concurrently, multiple threads could pass the check and call startPoller for the same trigger ID. Since startPoller doesn't verify the trigger ID is absent before inserting at line 129, it will overwrite the cancel function, orphaning the first goroutine.

🛡️ Proposed fix: consolidate check-and-start under single lock
 	// Start pollers for newly added triggers.
 	for _, t := range triggers {
-		e.mu.Lock()
-		_, running := e.pollers[t.ID]
-		e.mu.Unlock()
-		if !running {
-			e.startPoller(ctx, t)
-		}
+		e.startPollerIfAbsent(ctx, t)
 	}

Add a helper that holds the lock for both check and start:

func (e *EmailTriggerPoller) startPollerIfAbsent(ctx context.Context, t TriggerRecord) {
	e.mu.Lock()
	defer e.mu.Unlock()

	if _, exists := e.pollers[t.ID]; exists {
		return // already running
	}
	if len(e.pollers) >= e.maxConnections {
		e.server.Logger.Warn("email poller: maxConnections reached, skipping trigger",
			"trigger_id", t.ID,
			"workflow", t.WorkflowName,
			"max", e.maxConnections)
		metrics.EmailTriggersSkippedTotal.Inc()
		return
	}

	pollCtx, cancel := context.WithCancel(ctx)
	e.pollers[t.ID] = cancel

	e.wg.Add(1)
	go func() {
		defer e.wg.Done()
		defer func() {
			e.mu.Lock()
			delete(e.pollers, t.ID)
			e.mu.Unlock()
		}()
		e.runPoller(pollCtx, t)
	}()
}

Also applies to: 115-141

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 93 - 100, The current
check-then-start in Reload risks races because the lock is released before
calling startPoller; create a helper (e.g., startPollerIfAbsent) on
EmailTriggerPoller that holds e.mu for the whole check-and-start sequence: under
the lock verify e.pollers[t.ID] is absent and enforce maxConnections, create the
pollCtx and store the cancel in e.pollers[t.ID], then call e.wg.Add and launch
the goroutine that calls runPoller; ensure the goroutine deletes e.pollers[t.ID]
under e.mu when it exits. Replace the existing unlock-then-start pattern in
Reload (the loop around startPoller) with a call to this new startPollerIfAbsent
to eliminate the race.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/server/email_trigger.go`:
- Around line 593-613: In parseRawHeaders, you redundantly call
textproto.CanonicalMIMEHeaderKey(key) even though tp.ReadMIMEHeader() already
returns canonicalized keys; remove the extra canonicalization and assign
headers[key] = values[0] (or otherwise use the key directly) when populating the
headers map to simplify the code while preserving behavior (locate this in
function parseRawHeaders where textproto.CanonicalMIMEHeaderKey(key) is used).

In `@internal/server/server.go`:
- Around line 287-291: If s.emailPoller.Start(ctx) fails, stop the previously
started cron scheduler to avoid leaking goroutines: after the failed call to
s.emailPoller.Start(ctx) and before returning the error, call the cron shutdown
method (e.g. s.cron.Stop() or s.cron.Stop(ctx) depending on the cron API) and
handle/propagate any stop error appropriately (log or wrap) so the cron is
always stopped on startup failure.

---

Duplicate comments:
In `@internal/server/email_trigger.go`:
- Around line 93-100: The current check-then-start in Reload risks races because
the lock is released before calling startPoller; create a helper (e.g.,
startPollerIfAbsent) on EmailTriggerPoller that holds e.mu for the whole
check-and-start sequence: under the lock verify e.pollers[t.ID] is absent and
enforce maxConnections, create the pollCtx and store the cancel in
e.pollers[t.ID], then call e.wg.Add and launch the goroutine that calls
runPoller; ensure the goroutine deletes e.pollers[t.ID] under e.mu when it
exits. Replace the existing unlock-then-start pattern in Reload (the loop around
startPoller) with a call to this new startPollerIfAbsent to eliminate the race.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8049cae-7a15-41a1-b814-bc53cbc7a6ae

📥 Commits

Reviewing files that changed from the base of the PR and between 200c362 and ccda97b.

📒 Files selected for processing (2)
  • internal/server/email_trigger.go
  • internal/server/server.go

Comment thread internal/server/email_trigger.go
Comment thread internal/server/server.go
@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Note

Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/server/server.go (1)

311-318: ⚠️ Potential issue | 🟡 Minor

Consider stopping email poller before cron on context cancellation path.

On context cancellation (line 313), the code proceeds to graceful shutdown which stops cron first (line 324), then email poller (line 327). However, on the errCh path (lines 315-317), only the email poller is stopped before returning. For consistency and to ensure cron is also stopped on HTTP bind failures:

♻️ Proposed fix
 	case err := <-errCh:
+		s.cron.Stop()
 		s.emailPoller.Stop()
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/server.go` around lines 311 - 318, On shutdown paths, ensure
the email poller is stopped before the cron and that both are stopped on the
errCh path: in the select, when ctx.Done() is received call s.emailPoller.Stop()
before proceeding to the cron shutdown call (the cron stop/shutdown invocation),
and in the case handling err := <-errCh also call the cron shutdown method in
addition to s.emailPoller.Stop() before returning err (use the existing cron
stop/shutdown symbol in the file).
♻️ Duplicate comments (1)
internal/connector/browser.go (1)

22-32: 🧹 Nitpick | 🔵 Trivial

API inconsistency: Python wrapper pre-creates page, but JavaScript wrapper does not.

The Python wrapper (line 43) creates a page variable via browser.new_page(), while the JavaScript wrapper only exposes browser. Users switching between languages or following shared documentation may expect both wrappers to provide the same variables.

Consider aligning the wrappers for consistency:

♻️ Option: Add page to JS wrapper
 func buildJSWrapper(userScript string) string {
 	return "const { chromium } = require('playwright');\n" +
 		"(async () => {\n" +
 		"    const browser = await chromium.launch();\n" +
 		"    try {\n" +
+		"        const page = await browser.newPage();\n" +
 		"        " + userScript + "\n" +
 		"    finally {\n" +
 		"        await browser.close();\n" +
 		"    }\n" +
 		"})();\n"
 }

Also applies to: 36-47

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser.go` around lines 22 - 32, The JavaScript wrapper
returned by buildJSWrapper currently exposes only browser, causing an API
mismatch with the Python wrapper which pre-creates a page; update buildJSWrapper
to create a page variable (e.g., const page = await browser.newPage() or await
browser.newPage(); depending on Playwright API in use) inside the async block
before injecting userScript so userScript can use page just like in the Python
wrapper, and ensure the page is closed in the finally block (await page.close())
alongside await browser.close().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/connector/browser.go`:
- Around line 22-31: The buildJSWrapper function inserts userScript inline
without indenting subsequent lines, which breaks readability and stack traces
for multi-line scripts; update buildJSWrapper to indent every line of userScript
(e.g., using the same indentLines helper used by the Python wrapper or implement
a similar line-by-line prefixing) before concatenation so each line is prefixed
with the wrapper indentation (match the existing "        " spacing) and ensure
the final wrapped string preserves trailing newline/closing braces.
- Around line 163-174: The JSON parsing block should be skipped when the script
exited with a non-zero code to avoid misleading "stdout is not valid JSON"
errors; update the logic around outputFormat == "json" to first extract and
normalize the exit code from result (e.g., result["exit_code"] as int/float64)
and only attempt json.Unmarshal on stdout when exitCode == 0, otherwise skip
parsing (or attach raw stdout) and return the actual exit failure instead of a
JSON parse error; refer to the variables result, outputFormat, stdout, output
and the json.Unmarshal call to locate and change the code.

In `@internal/connector/email_receive_test.go`:
- Around line 97-110: The test currently only checks the first element of c.Flag
and c.NotFlag, allowing extra unexpected flags; update the assertions in the
test loop to also assert the flag slice length is exactly 1 when tt.wantFlag or
tt.wantNotFlag is non-empty and exactly 0 when they are empty, and still assert
that c.Flag[0] == tt.wantFlag and c.NotFlag[0] == tt.wantNotFlag; modify the
checks around tt.wantFlag, c.Flag, tt.wantNotFlag, and c.NotFlag so they
validate both length and value rather than only the first element.
- Around line 161-168: The current retry loop in the test exits on the first
successful c.Execute(context.Background(), params) even if the mailbox is still
empty; update the loop in email_receive_test.go to continue retrying until the
returned result contains the expected message_count (not just until err == nil).
After each successful Execute, check result["message_count"] (or the appropriate
field on the returned type) against the expected value and only break when it
matches; otherwise log the current count and continue retrying with the same
attempt limit and 2s sleep. Ensure you still surface the final result and error
after the loop.

In `@internal/server/email_trigger.go`:
- Around line 365-383: The loop currently calls e.fireWorkflow as each msgData
is fetched, which causes partial processing if a fetch error occurs; change the
logic in the Fetch handling (the block using client.Fetch, cmd.Next,
msgData.Collect) to first collect all successful message buffers into a slice
(e.g., gather results from msgData.Collect into a []bufList) while still
tracking fetchErr and handling cmd.Close, and only after the fetch loop
completes and fetchErr is nil iterate over that collected slice to call
buildEmailTriggerInputs and e.fireWorkflow for each message; ensure you still
propagate/return fetchErr when present and preserve existing closeErr handling
so no workflows are fired when a fetch failed partway.
- Around line 571-577: The current fallback uses buf.BodySection[0].Bytes which
may be the HEADER section; change the logic in the block that looks up the TEXT
specifier (using imap.FetchItemBodySection and buf.FindBodySection) so that if
FindBodySection returns nil you do NOT fall back to
buf.BodySection[0].Bytes—instead leave trigger["body"] empty (or unset) to avoid
placing headers into the body; keep using extractEmailBody(rawText) when rawText
is non-nil and reference buf.FindBodySection, buf.BodySection, extractEmailBody,
and imap.FetchItemBodySection to locate the code to modify.
- Around line 526-538: The "recent" case in buildEmailSearchCriteria (case
"recent" returning imap.Flag(`\Recent`)) lacks unit test coverage and needs
documentation about RFC 9051 deprecation; add a unit test exercising
buildEmailSearchCriteria("recent") and asserting it returns a SearchCriteria
with Flag containing imap.Flag(`\Recent`), and update comments or README near
buildEmailSearchCriteria to note RFC 9051 warnings (session-scoped, cleared on
select, inconsistent across servers) and recommend using SINCE or "unseen"
(UNSEEN/NotFlag: imap.FlagSeen) or time-based searches for production.

---

Outside diff comments:
In `@internal/server/server.go`:
- Around line 311-318: On shutdown paths, ensure the email poller is stopped
before the cron and that both are stopped on the errCh path: in the select, when
ctx.Done() is received call s.emailPoller.Stop() before proceeding to the cron
shutdown call (the cron stop/shutdown invocation), and in the case handling err
:= <-errCh also call the cron shutdown method in addition to
s.emailPoller.Stop() before returning err (use the existing cron stop/shutdown
symbol in the file).

---

Duplicate comments:
In `@internal/connector/browser.go`:
- Around line 22-32: The JavaScript wrapper returned by buildJSWrapper currently
exposes only browser, causing an API mismatch with the Python wrapper which
pre-creates a page; update buildJSWrapper to create a page variable (e.g., const
page = await browser.newPage() or await browser.newPage(); depending on
Playwright API in use) inside the async block before injecting userScript so
userScript can use page just like in the Python wrapper, and ensure the page is
closed in the finally block (await page.close()) alongside await
browser.close().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d69c0571-5e7c-461e-bdf9-74fd851215bf

📥 Commits

Reviewing files that changed from the base of the PR and between 200c362 and 59adbd8.

📒 Files selected for processing (5)
  • internal/connector/browser.go
  • internal/connector/browser_test.go
  • internal/connector/email_receive_test.go
  • internal/server/email_trigger.go
  • internal/server/server.go

Comment on lines +22 to +31
func buildJSWrapper(userScript string) string {
return "const { chromium } = require('playwright');\n" +
"(async () => {\n" +
" const browser = await chromium.launch();\n" +
" try {\n" +
" " + userScript + "\n" +
" } finally {\n" +
" await browser.close();\n" +
" }\n" +
"})();\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

User scripts with newlines will break the JS wrapper indentation.

The JS wrapper embeds the user script inline with a single " " + userScript (line 27). Multi-line scripts will have only the first line indented, potentially causing readability issues in error traces. The Python wrapper correctly uses indentLines, but the JS wrapper does not.

♻️ Proposed fix
 func buildJSWrapper(userScript string) string {
+	indented := indentLines(userScript, "        ")
 	return "const { chromium } = require('playwright');\n" +
 		"(async () => {\n" +
 		"    const browser = await chromium.launch();\n" +
 		"    try {\n" +
-		"        " + userScript + "\n" +
+		indented + "\n" +
 		"    } finally {\n" +
 		"        await browser.close();\n" +
 		"    }\n" +
 		"})();\n"
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser.go` around lines 22 - 31, The buildJSWrapper
function inserts userScript inline without indenting subsequent lines, which
breaks readability and stack traces for multi-line scripts; update
buildJSWrapper to indent every line of userScript (e.g., using the same
indentLines helper used by the Python wrapper or implement a similar
line-by-line prefixing) before concatenation so each line is prefixed with the
wrapper indentation (match the existing "        " spacing) and ensure the final
wrapped string preserves trailing newline/closing braces.

Comment thread internal/connector/browser.go Outdated
Comment on lines +97 to +110
if tt.wantFlag != "" {
if len(c.Flag) == 0 || string(c.Flag[0]) != tt.wantFlag {
t.Errorf("filter %q: Flag = %v, want %q", tt.filter, c.Flag, tt.wantFlag)
}
} else if len(c.Flag) != 0 {
t.Errorf("filter %q: unexpected Flag = %v", tt.filter, c.Flag)
}
if tt.wantNotFlag != "" {
if len(c.NotFlag) == 0 || string(c.NotFlag[0]) != tt.wantNotFlag {
t.Errorf("filter %q: NotFlag = %v, want %q", tt.filter, c.NotFlag, tt.wantNotFlag)
}
} else if len(c.NotFlag) != 0 {
t.Errorf("filter %q: unexpected NotFlag = %v", tt.filter, c.NotFlag)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make filter assertions strict on flag count.

Current checks validate only the first element; tests can still pass with extra unexpected flags.

Suggested tightening
-			if tt.wantFlag != "" {
-				if len(c.Flag) == 0 || string(c.Flag[0]) != tt.wantFlag {
+			if tt.wantFlag != "" {
+				if len(c.Flag) != 1 || string(c.Flag[0]) != tt.wantFlag {
 					t.Errorf("filter %q: Flag = %v, want %q", tt.filter, c.Flag, tt.wantFlag)
 				}
 			} else if len(c.Flag) != 0 {
 				t.Errorf("filter %q: unexpected Flag = %v", tt.filter, c.Flag)
 			}
-			if tt.wantNotFlag != "" {
-				if len(c.NotFlag) == 0 || string(c.NotFlag[0]) != tt.wantNotFlag {
+			if tt.wantNotFlag != "" {
+				if len(c.NotFlag) != 1 || string(c.NotFlag[0]) != tt.wantNotFlag {
 					t.Errorf("filter %q: NotFlag = %v, want %q", tt.filter, c.NotFlag, tt.wantNotFlag)
 				}
 			} else if len(c.NotFlag) != 0 {
 				t.Errorf("filter %q: unexpected NotFlag = %v", tt.filter, c.NotFlag)
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if tt.wantFlag != "" {
if len(c.Flag) == 0 || string(c.Flag[0]) != tt.wantFlag {
t.Errorf("filter %q: Flag = %v, want %q", tt.filter, c.Flag, tt.wantFlag)
}
} else if len(c.Flag) != 0 {
t.Errorf("filter %q: unexpected Flag = %v", tt.filter, c.Flag)
}
if tt.wantNotFlag != "" {
if len(c.NotFlag) == 0 || string(c.NotFlag[0]) != tt.wantNotFlag {
t.Errorf("filter %q: NotFlag = %v, want %q", tt.filter, c.NotFlag, tt.wantNotFlag)
}
} else if len(c.NotFlag) != 0 {
t.Errorf("filter %q: unexpected NotFlag = %v", tt.filter, c.NotFlag)
}
if tt.wantFlag != "" {
if len(c.Flag) != 1 || string(c.Flag[0]) != tt.wantFlag {
t.Errorf("filter %q: Flag = %v, want %q", tt.filter, c.Flag, tt.wantFlag)
}
} else if len(c.Flag) != 0 {
t.Errorf("filter %q: unexpected Flag = %v", tt.filter, c.Flag)
}
if tt.wantNotFlag != "" {
if len(c.NotFlag) != 1 || string(c.NotFlag[0]) != tt.wantNotFlag {
t.Errorf("filter %q: NotFlag = %v, want %q", tt.filter, c.NotFlag, tt.wantNotFlag)
}
} else if len(c.NotFlag) != 0 {
t.Errorf("filter %q: unexpected NotFlag = %v", tt.filter, c.NotFlag)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/email_receive_test.go` around lines 97 - 110, The test
currently only checks the first element of c.Flag and c.NotFlag, allowing extra
unexpected flags; update the assertions in the test loop to also assert the flag
slice length is exactly 1 when tt.wantFlag or tt.wantNotFlag is non-empty and
exactly 0 when they are empty, and still assert that c.Flag[0] == tt.wantFlag
and c.NotFlag[0] == tt.wantNotFlag; modify the checks around tt.wantFlag,
c.Flag, tt.wantNotFlag, and c.NotFlag so they validate both length and value
rather than only the first element.

Comment on lines +161 to +168
for attempt := 1; attempt <= 5; attempt++ {
result, err = c.Execute(context.Background(), params)
if err == nil {
break
}
t.Logf("attempt %d: Execute() error: %v (retrying in 2s)", attempt, err)
time.Sleep(2 * time.Second)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retry loop can exit before message visibility, causing flakiness.

The loop stops on first successful Execute(), even when the mailbox is still empty. Retry until message_count reaches the expected value.

Suggested fix
 	var result map[string]any
 	var err error
+	var count int
 	for attempt := 1; attempt <= 5; attempt++ {
 		result, err = c.Execute(context.Background(), params)
-		if err == nil {
-			break
+		if err == nil {
+			var ok bool
+			count, ok = result["message_count"].(int)
+			if ok && count >= 1 {
+				break
+			}
+			err = fmt.Errorf("message not visible in IMAP yet")
 		}
 		t.Logf("attempt %d: Execute() error: %v (retrying in 2s)", attempt, err)
 		time.Sleep(2 * time.Second)
 	}
 	if err != nil {
 		t.Fatalf("Execute() error after retries: %v", err)
 	}
-
-	count, ok := result["message_count"].(int)
+	count, ok := result["message_count"].(int)
 	if !ok {
 		t.Fatalf("message_count is not an int: %T", result["message_count"])
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for attempt := 1; attempt <= 5; attempt++ {
result, err = c.Execute(context.Background(), params)
if err == nil {
break
}
t.Logf("attempt %d: Execute() error: %v (retrying in 2s)", attempt, err)
time.Sleep(2 * time.Second)
}
var result map[string]any
var err error
var count int
for attempt := 1; attempt <= 5; attempt++ {
result, err = c.Execute(context.Background(), params)
if err == nil {
var ok bool
count, ok = result["message_count"].(int)
if ok && count >= 1 {
break
}
err = fmt.Errorf("message not visible in IMAP yet")
}
t.Logf("attempt %d: Execute() error: %v (retrying in 2s)", attempt, err)
time.Sleep(2 * time.Second)
}
if err != nil {
t.Fatalf("Execute() error after retries: %v", err)
}
count, ok := result["message_count"].(int)
if !ok {
t.Fatalf("message_count is not an int: %T", result["message_count"])
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/email_receive_test.go` around lines 161 - 168, The current
retry loop in the test exits on the first successful
c.Execute(context.Background(), params) even if the mailbox is still empty;
update the loop in email_receive_test.go to continue retrying until the returned
result contains the expected message_count (not just until err == nil). After
each successful Execute, check result["message_count"] (or the appropriate field
on the returned type) against the expected value and only break when it matches;
otherwise log the current count and continue retrying with the same attempt
limit and 2s sleep. Ensure you still surface the final result and error after
the loop.

Comment on lines +365 to +383
cmd := client.Fetch(uidSet, fetchOptions)
var fetchErr error
for {
msgData := cmd.Next()
if msgData == nil {
break
}
buf, err := msgData.Collect()
if err != nil {
fetchErr = err
break
}

inputs := buildEmailTriggerInputs(buf, folder)
e.fireWorkflow(ctx, t, inputs, imap.UID(buf.UID))
}
if closeErr := cmd.Close(); closeErr != nil && fetchErr == nil {
fetchErr = closeErr
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider batching workflow executions to avoid partial processing on fetch errors.

The current loop fires workflows one at a time as messages are fetched (lines 367-380). If a fetch error occurs mid-batch (fetchErr set at line 374), some workflows will have already fired but the messages won't be marked as seen (lines 385-391 return early). On reconnect, those messages will be re-fetched and re-triggered.

While the at-least-once semantics are documented, consider collecting all messages first before firing any workflows, or documenting this behavior more explicitly in the logs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 365 - 383, The loop currently
calls e.fireWorkflow as each msgData is fetched, which causes partial processing
if a fetch error occurs; change the logic in the Fetch handling (the block using
client.Fetch, cmd.Next, msgData.Collect) to first collect all successful message
buffers into a slice (e.g., gather results from msgData.Collect into a
[]bufList) while still tracking fetchErr and handling cmd.Close, and only after
the fetch loop completes and fetchErr is nil iterate over that collected slice
to call buildEmailTriggerInputs and e.fireWorkflow for each message; ensure you
still propagate/return fetchErr when present and preserve existing closeErr
handling so no workflows are fired when a fetch failed partway.

Comment on lines +526 to +538
// buildEmailSearchCriteria constructs IMAP search criteria matching the
// connector package's filter semantics.
func buildEmailSearchCriteria(filter string) *imap.SearchCriteria {
switch filter {
case "flagged":
return &imap.SearchCriteria{Flag: []imap.Flag{imap.FlagFlagged}}
case "recent":
return &imap.SearchCriteria{Flag: []imap.Flag{imap.Flag(`\Recent`)}}
case "all":
return &imap.SearchCriteria{}
default: // "unseen"
return &imap.SearchCriteria{NotFlag: []imap.Flag{imap.FlagSeen}}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

IMAP \Recent flag searchable server compatibility

💡 Result:

The IMAP \Recent flag indicates messages that are "recently arrived" in a mailbox for the current session (first session notified about them). It is a standard system flag defined in RFC 3501 (IMAP4rev1) section 2.3.2 and RFC 9051 (IMAP4rev2, where it is deprecated but still supported for compatibility). The flag cannot be set or cleared by clients; it is managed server-side and is session-specific. The SEARCH command (RFC 3501 section 6.4.4) explicitly supports searching for \Recent: "RECENT" matches messages with the \Recent flag set. "NEW" matches \Recent but not \Seen (equivalent to "RECENT UNSEEN"). This is part of core IMAP4rev1, so standards-compliant servers must support it. Server compatibility is generally good for standards-compliant open-source servers: - Dovecot: Fully supports RECENT in SEARCH and doveadm queries. - Cyrus IMAP: Supports core IMAP4rev1 SEARCH including RECENT. - Libraries like ImapFlow, PHP imap_search, Chilkat: Document and use RECENT/NEW searches. However, \Recent is unreliable across servers due to its session-only, multi-client sensitive nature: - Once any session SELECTs the mailbox (read-write), \Recent is cleared for subsequent sessions. - Without IDLE (not always supported), new messages may arrive after SELECT but before SEARCH, affecting results. - Commercial servers often have quirks: - Outlook/Exchange: RECENT/NEW unreliable (node-imap issues); prefer date-based searches. - Gmail: Works but uses custom extensions; RECENT flaky in multi-client scenarios. - IBM Lotus Domino: Broken \Recent handling. - Older Dovecot versions had disappearing \Recent bugs. IMAP4rev2 (RFC 9051) deprecates \Recent/RECENT responses, advising clients to ignore untagged RECENT and use alternatives like SINCE/YOUNGER (RFC 5032 WITHIN), UNSEEN, or UIDNEXT for "new" messages. Recommendation: Avoid relying on SEARCH RECENT/NEW for production due to flakiness. Use time-based searches (SINCE ), UNSEEN, or track UIDNEXT/HIGHESTMODSEQ (CONDSTORE/QRESYNC, RFC 7162).

Citations:


🏁 Script executed:

# Find where buildEmailSearchCriteria is used
rg "buildEmailSearchCriteria" --type go -A 3 -B 3

Repository: dvflw/mantle

Length of output: 3195


🏁 Script executed:

# Check for any documentation or comments about IMAP filters in the file
rg -A 5 -B 5 "\"recent\"" internal/server/email_trigger.go

Repository: dvflw/mantle

Length of output: 486


🏁 Script executed:

# Look for related issues or discussions about IMAP server compatibility
fd -e md -e txt | xargs rg "recent|RECENT" -l 2>/dev/null | head -20

Repository: dvflw/mantle

Length of output: 505


🏁 Script executed:

# Check if "recent" is documented in the docs
rg -i "recent" site/src/content/docs/server-guide/triggers.md site/src/content/docs/workflow-reference/connectors.md

Repository: dvflw/mantle

Length of output: 561


🏁 Script executed:

# Look for any test specifically for the "recent" filter
rg "TestBuildEmailSearchCriteria.*Recent|recent.*filter" internal/server/email_trigger_test.go

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

# Check the full test file to see if there's a recent test
cat -n internal/server/email_trigger_test.go | grep -A 10 -B 2 "TestBuildEmailSearch"

Repository: dvflw/mantle

Length of output: 1437


Add test coverage for the "recent" filter and document RFC 9051 deprecation concerns.

The \Recent flag is documented as a supported filter option, but it lacks unit test coverage unlike the other filters. Additionally, RFC 9051 (IMAP4rev2) deprecates \Recent due to its unreliability: it is session-specific, cleared when any session selects the mailbox, and has inconsistent behavior across servers including commercial implementations (Outlook/Exchange, Gmail). If this filter is intended for production use, consider either (1) adding test coverage to validate its behavior with your supported IMAP servers, or (2) offering more reliable alternatives like time-based searches (SINCE) or the UNSEEN flag per RFC 9051 recommendations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 526 - 538, The "recent" case
in buildEmailSearchCriteria (case "recent" returning imap.Flag(`\Recent`)) lacks
unit test coverage and needs documentation about RFC 9051 deprecation; add a
unit test exercising buildEmailSearchCriteria("recent") and asserting it returns
a SearchCriteria with Flag containing imap.Flag(`\Recent`), and update comments
or README near buildEmailSearchCriteria to note RFC 9051 warnings
(session-scoped, cleared on select, inconsistent across servers) and recommend
using SINCE or "unseen" (UNSEEN/NotFlag: imap.FlagSeen) or time-based searches
for production.

Comment thread internal/server/email_trigger.go
@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Request timed out after 900000ms (requestId=13e1a351-7188-4270-86d6-f4393bb4fe2b)

@michaelmcnees

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 26, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
internal/server/email_trigger.go (3)

571-577: ⚠️ Potential issue | 🟡 Minor

Fallback body extraction may incorrectly return headers.

If FindBodySection fails to locate the TEXT section (line 573), the fallback at lines 575-576 uses buf.BodySection[0].Bytes without checking which section it is. Since the HEADER section is also requested (lines 354-357), the first section could be headers rather than body text.

🐛 Proposed fix: Remove unreliable fallback
 	// Extract body text from the TEXT body section.
 	bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}
 	if rawText := buf.FindBodySection(bodySection); rawText != nil {
 		trigger["body"] = extractEmailBody(rawText)
-	} else if len(buf.BodySection) > 0 {
-		trigger["body"] = extractEmailBody(buf.BodySection[0].Bytes)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 571 - 577, The current
fallback assigns trigger["body"] from buf.BodySection[0].Bytes which can be a
HEADER part; update the logic in the email extraction block so you only set
trigger["body"] when a proper TEXT body part is found: rely on
buf.FindBodySection(bodySection) result and remove the unconditional fallback
that uses buf.BodySection[0].Bytes, or alternatively iterate buf.BodySection to
locate the PartSpecifierText/actual body bytes before calling extractEmailBody;
adjust the code around imap.FetchItemBodySection, FindBodySection,
buf.BodySection and extractEmailBody accordingly so headers are never passed to
extractEmailBody.

526-539: 🧹 Nitpick | 🔵 Trivial

Add test coverage for the "recent" filter and document RFC 9051 deprecation.

The \Recent flag (line 533) is supported but lacks unit test coverage unlike other filters. Per RFC 9051, \Recent is deprecated due to unreliability: it's session-specific, cleared when any session selects the mailbox, and behaves inconsistently across servers (Outlook/Exchange, Gmail). Consider adding test coverage and documenting this caveat for users relying on the "recent" filter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 526 - 539, The
buildEmailSearchCriteria function returns a SearchCriteria for the "recent" case
using imap.Flag(`\Recent`) but lacks unit test coverage and no documentation
about RFC 9051 deprecation; add a unit test that exercises
buildEmailSearchCriteria("recent") and asserts it returns
&imap.SearchCriteria{Flag: []imap.Flag{imap.Flag(`\Recent`)}}, and update the
function comment (or nearby package docs) to note RFC 9051 deprecation and the
caveats (session-specific, cleared on mailbox select, inconsistent across
servers like Exchange/Gmail) so callers know "recent" is unreliable.

365-392: 🧹 Nitpick | 🔵 Trivial

Partial processing on fetch error may cause duplicate workflow executions.

The loop fires workflows as messages are fetched (line 379). If a fetch error occurs mid-batch (lines 373-375), workflows already fired won't be marked as seen (the function returns at line 391), causing re-triggers on reconnect. While at-least-once semantics are documented, consider collecting all messages first before firing workflows to avoid partial processing.

♻️ Optional: Collect-then-fire pattern
+	type fetchedMsg struct {
+		buf *imapclient.FetchMessageBuffer
+		uid imap.UID
+	}
+	var msgs []fetchedMsg
+
 	cmd := client.Fetch(uidSet, fetchOptions)
 	var fetchErr error
 	for {
 		msgData := cmd.Next()
 		if msgData == nil {
 			break
 		}
 		buf, err := msgData.Collect()
 		if err != nil {
 			fetchErr = err
 			break
 		}
-
-		inputs := buildEmailTriggerInputs(buf, folder)
-		e.fireWorkflow(ctx, t, inputs, imap.UID(buf.UID))
+		msgs = append(msgs, fetchedMsg{buf: buf, uid: imap.UID(buf.UID)})
 	}
 	if closeErr := cmd.Close(); closeErr != nil && fetchErr == nil {
 		fetchErr = closeErr
 	}
 
 	if fetchErr != nil {
 		// ... error handling unchanged
 		return fetchErr
 	}
+
+	// Fire workflows only after successful fetch of all messages.
+	for _, m := range msgs {
+		inputs := buildEmailTriggerInputs(m.buf, folder)
+		e.fireWorkflow(ctx, t, inputs, m.uid)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/server/email_trigger.go` around lines 365 - 392, The current loop
uses cmd.Next()/msgData.Collect() and calls e.fireWorkflow immediately (via
buildEmailTriggerInputs and e.fireWorkflow), so if a fetch error occurs
mid-iteration already-fired messages won't be marked seen and will be
reprocessed; change to a collect-then-fire pattern: first iterate over
cmd.Next() collecting each msgData.Collect() result into a local slice (use the
same identifiers buildEmailTriggerInputs and cmd.Next/msgData.Collect to locate
the code), handle and propagate any Collect or cmd.Close() error after
collection, then in a separate loop iterate the collected buffers to call
buildEmailTriggerInputs(...) and e.fireWorkflow(...) so partial fetch failures
do not cause already-fired messages to be re-triggered.
internal/connector/browser.go (1)

163-174: ⚠️ Potential issue | 🟠 Major

Skip JSON decoding after a failed browser run.

If the script exits non-zero, attempting to unmarshal partial stdout turns the real browser failure into “stdout is not valid JSON” and drops the exit code/stderr that continue_on_error consumers need.

♻️ Suggested fix
 	// Optionally parse stdout as JSON.
 	if outputFormat == "json" {
+		switch v := result["exit_code"].(type) {
+		case int64:
+			if v != 0 {
+				return output, nil
+			}
+		case int:
+			if v != 0 {
+				return output, nil
+			}
+		case float64:
+			if v != 0 {
+				return output, nil
+			}
+		}
 		stdout, _ := result["stdout"].(string)
 		stdout = strings.TrimSpace(stdout)
 		if stdout != "" {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser.go` around lines 163 - 174, The code unmarshals
stdout to JSON regardless of whether the browser run failed, which masks the
real failure; in the block that checks outputFormat == "json" (using the local
variables result, stdout and output), first detect a failed run (e.g., non-zero
exit code or a non-empty stderr field in result) and skip JSON decoding when the
run failed so you preserve and return the original exit code/stderr for
continue_on_error consumers; only attempt json.Unmarshal when result indicates
success (zero exit) and stdout is non-empty, then set output["json"] with the
parsed value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/connector/browser_test.go`:
- Around line 126-134: The current TestBuildJSWrapper_TypeScriptPassthrough only
checks string passthrough via buildJSWrapper; add an execution-focused test for
language: "typescript" that verifies the TypeScript execution branch is actually
used (e.g., assert the produced wrapper/command includes the TypeScript runner
flag such as "node --experimental-strip-types" or whatever TS-specific
invocation your code emits) so a broken TS execution path fails the test; update
or add a test that calls buildJSWrapper (and the function that assembles the
runtime command if separate) and checks for the TS-specific command token rather
than only the original TypeScript source string.

In `@internal/connector/browser.go`:
- Line 65: The env map (documented as map[string]string) must be normalized to
map[string]any before being passed to docker/run to avoid silently dropping
variables; update the code that forwards env (the local variable named env) to
create a normalized map[string]any (handle nil safely) and copy each key/value
from the map[string]string into it, then pass that normalized map into
docker/run (the docker/run call sites around the current env usage and the
second occurrence at the later call near lines 145-146). Ensure the conversion
is applied in both places where env is forwarded.
- Around line 115-119: In the case "typescript" branch the containerCmd
currently silences npm install stderr, losing useful diagnostics; update the
containerCmd assignment used with playwrightNodeImage/wrapperScript to run "npm
install --no-save --silent playwright@"+playwrightVersion without redirecting
stderr (remove "2>/dev/null") so npm registry/auth/network errors are preserved,
keeping the rest of the command that pipes into node --experimental-strip-types
intact; modify the containerCmd variable in that case branch accordingly.

---

Duplicate comments:
In `@internal/connector/browser.go`:
- Around line 163-174: The code unmarshals stdout to JSON regardless of whether
the browser run failed, which masks the real failure; in the block that checks
outputFormat == "json" (using the local variables result, stdout and output),
first detect a failed run (e.g., non-zero exit code or a non-empty stderr field
in result) and skip JSON decoding when the run failed so you preserve and return
the original exit code/stderr for continue_on_error consumers; only attempt
json.Unmarshal when result indicates success (zero exit) and stdout is
non-empty, then set output["json"] with the parsed value.

In `@internal/server/email_trigger.go`:
- Around line 571-577: The current fallback assigns trigger["body"] from
buf.BodySection[0].Bytes which can be a HEADER part; update the logic in the
email extraction block so you only set trigger["body"] when a proper TEXT body
part is found: rely on buf.FindBodySection(bodySection) result and remove the
unconditional fallback that uses buf.BodySection[0].Bytes, or alternatively
iterate buf.BodySection to locate the PartSpecifierText/actual body bytes before
calling extractEmailBody; adjust the code around imap.FetchItemBodySection,
FindBodySection, buf.BodySection and extractEmailBody accordingly so headers are
never passed to extractEmailBody.
- Around line 526-539: The buildEmailSearchCriteria function returns a
SearchCriteria for the "recent" case using imap.Flag(`\Recent`) but lacks unit
test coverage and no documentation about RFC 9051 deprecation; add a unit test
that exercises buildEmailSearchCriteria("recent") and asserts it returns
&imap.SearchCriteria{Flag: []imap.Flag{imap.Flag(`\Recent`)}}, and update the
function comment (or nearby package docs) to note RFC 9051 deprecation and the
caveats (session-specific, cleared on mailbox select, inconsistent across
servers like Exchange/Gmail) so callers know "recent" is unreliable.
- Around line 365-392: The current loop uses cmd.Next()/msgData.Collect() and
calls e.fireWorkflow immediately (via buildEmailTriggerInputs and
e.fireWorkflow), so if a fetch error occurs mid-iteration already-fired messages
won't be marked seen and will be reprocessed; change to a collect-then-fire
pattern: first iterate over cmd.Next() collecting each msgData.Collect() result
into a local slice (use the same identifiers buildEmailTriggerInputs and
cmd.Next/msgData.Collect to locate the code), handle and propagate any Collect
or cmd.Close() error after collection, then in a separate loop iterate the
collected buffers to call buildEmailTriggerInputs(...) and e.fireWorkflow(...)
so partial fetch failures do not cause already-fired messages to be
re-triggered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3296fed1-8c14-41d9-91ec-e042ec26f280

📥 Commits

Reviewing files that changed from the base of the PR and between 200c362 and 59adbd8.

📒 Files selected for processing (5)
  • internal/connector/browser.go
  • internal/connector/browser_test.go
  • internal/connector/email_receive_test.go
  • internal/server/email_trigger.go
  • internal/server/server.go

Comment on lines +126 to +134
func TestBuildJSWrapper_TypeScriptPassthrough(t *testing.T) {
// TypeScript uses the same JS wrapper since the Node Playwright image can
// execute TS syntax via the wrapper route.
tsScript := "const page: any = await browser.newPage();"
wrapper := buildJSWrapper(tsScript)
if !strings.Contains(wrapper, tsScript) {
t.Errorf("JS/TS wrapper should contain the user TypeScript script")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add an execution test for language: "typescript".

This only verifies string passthrough. The actual TypeScript branch runs a different command from JavaScript, so a broken node --experimental-strip-types path can ship without any test failing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser_test.go` around lines 126 - 134, The current
TestBuildJSWrapper_TypeScriptPassthrough only checks string passthrough via
buildJSWrapper; add an execution-focused test for language: "typescript" that
verifies the TypeScript execution branch is actually used (e.g., assert the
produced wrapper/command includes the TypeScript runner flag such as "node
--experimental-strip-types" or whatever TS-specific invocation your code emits)
so a broken TS execution path fails the test; update or add a test that calls
buildJSWrapper (and the function that assembles the runtime command if separate)
and checks for the TS-specific command token rather than only the original
TypeScript source string.

Comment thread internal/connector/browser.go Outdated
// - language (string) "javascript" | "typescript" | "python" (default "javascript")
// - script (string, required) the user's Playwright script
// - output_format (string) "json" | "text" (default "text")
// - env (map[string]string) environment variables

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize env before forwarding to docker/run.

The comment documents env as map[string]string, but docker/run only normalizes map[string]any. Passing the documented shape here silently drops every environment variable.

♻️ Suggested fix
-	if envRaw, ok := params["env"]; ok {
-		dockerParams["env"] = envRaw
-	}
+	if envRaw, ok := params["env"]; ok {
+		switch env := envRaw.(type) {
+		case map[string]any:
+			dockerParams["env"] = env
+		case map[string]string:
+			converted := make(map[string]any, len(env))
+			for k, v := range env {
+				converted[k] = v
+			}
+			dockerParams["env"] = converted
+		default:
+			return nil, fmt.Errorf("browser/run: env must be an object")
+		}
+	}

Also applies to: 145-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/connector/browser.go` at line 65, The env map (documented as
map[string]string) must be normalized to map[string]any before being passed to
docker/run to avoid silently dropping variables; update the code that forwards
env (the local variable named env) to create a normalized map[string]any (handle
nil safely) and copy each key/value from the map[string]string into it, then
pass that normalized map into docker/run (the docker/run call sites around the
current env usage and the second occurrence at the later call near lines
145-146). Ensure the conversion is applied in both places where env is
forwarded.

Comment thread internal/connector/browser.go Outdated
@michaelmcnees

Copy link
Copy Markdown
Collaborator Author

Addressing latest review feedback (commit 73c5631):

Fixed:

  • TS branch 2>/dev/null removed — npm stderr now visible for diagnostics
  • JSON parsing skipped on non-zero exit code — failed runs return exit_code/stderr cleanly for continue_on_error consumers
  • Removed unsafe buf.BodySection[0] fallback in email trigger — body now only populated from the TEXT section, preventing header data from leaking into trigger body
  • Added TS execution branch test
  • Fixed env doc comment (map[string]any, not map[string]string)

Duplicate comments — status:

  • recent filter RFC 9051 deprecation — Acknowledged, documented as a known limitation. The filter works on IMAP4rev1 servers (the vast majority). No code change needed.
  • Collect-then-fire pattern — The current fire-as-you-go pattern is intentional. Collecting first doesn't prevent duplicate firings on crash — if you fire 3 of 5 messages and crash, those 3 re-fire regardless of collection strategy. The at-least-once semantics are documented in the trigger guide. Workflows should be idempotent (deduplicate on trigger.message_id).
  • buildEmailSearchCriteria test coverage — Tracked for v0.3.1 alongside the search criteria deduplication ([Code Quality] Consolidate duplicated search criteria builders #38).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant