Where should the approval boundary sit?

Put the approval boundary between a stored proposal and the component allowed to send it. A model-generated “approved” flag must never be enough to cross that boundary.

For this LangGraph email approval workflow, use an existing test mailbox and a recipient you control. Design separate read, draft, review, and send stages. The read stage uses Mailera MCP; the final executor uses REST so it can attach an Idempotency-Key.

ComponentResponsibility
LangGraph workflowCoordinate reading, drafting, waiting, and handing off an approved operation
Persistent application storagePreserve draft revisions, review decisions, and send-operation records
MaileraProvide access to the connected mailbox through MCP and REST
Reviewer applicationAuthenticate reviewers, enforce mailbox authorization, and record decisions
Send executorRecheck approval, submit the stored payload, and reconcile send status

The review screen and its approval endpoint belong to your application. Keep them separate from Mailera's mailbox and API-key screens.

Read-only Mailera MCP feeds a draft workflow, which waits for external review before a separate executor sends and checks status.
The model proposes a reply. Application-owned components authorize and submit it.

For the broader mailbox architecture, see email for AI agents using existing mailboxes. This workflow focuses on the narrower question of who can turn a particular draft into a send operation. The guide to building an email agent with approval boundaries covers the framework-neutral design behind that separation.

1. Give the drafting agent read-only MCP access

Start with a connected mailbox that can send. For Outlook or Microsoft 365, follow the Microsoft mailbox connection guide and use Sign in with Microsoft.

Before wiring up the agent, inspect the selected mailbox from your server:

curl --fail-with-body "https://mailera.app/api/public/v1/mailboxes/$MAILBOX_ID" \
  -H "Authorization: Bearer $MAILERA_API_KEY"

Check data.capabilities.send. A mailbox that can receive isn't necessarily ready for the final send stage.

Create the model-facing key under Settings → API key tab → Create key:

  1. Set a Descriptive name that identifies the drafting workflow.
  2. Restrict Mailbox scope to the intended group.
  3. Enable Read, without Send messages.
  4. Store the key in server-side secret storage.
Mailera API-key creation with one mailbox group selected and Read permission enabled without Send messages.
The drafting connection gets Read permission. The executor uses a separate send-scoped key.

Use the Mailera MCP endpoint, https://mailera.app/api/public/v1/mcp, with bearer authentication. Keep the credential in the connection layer, not in prompts or graph state.

For this workflow, allowlist only list_mailboxes, list_messages, and get_message. All three use the read scope. Don't expose every discovered tool to the model just because the connection can access it.

The MCP tool discovery specification defines tools/list for discovery and tools/call for invocation. Use each discovered tool's inputSchema to construct its arguments; don't assume REST parameter names are also MCP arguments.

Create a separate key with Send messages for the executor. Do not attach that key to the model-facing MCP connection. Mailera's send_email, reply_to_email, and get_send_status tools require the send scope, so they don't belong in this drafting toolset.

2. Persist the proposal before requesting review

Treat the proposed reply as application data, not as an already-authorized action. Store it before exposing a review task or allowing a resume request.

Keep these values together:

Application recordWhat to preserve
Workflow identityStable thread identifier and owning user or tenant
SourceMailbox identifier and source-message identifier
Proposed replyComplete recipient lists, subject, and body
RevisionDraft revision and canonical payload hash
ReviewPending, approved, or rejected; reviewer identity and expiry
Send operationStable operation identifier, idempotency key, and send-attempt identifier, when available

These are application-owned fields, not Mailera request fields. Keep the draft in your application until the executor is ready to submit it.

Make durability a requirement of the LangGraph implementation: the review must survive a worker restart, and the application must locate the same workflow afterward. Don't use a browser session or an in-memory dictionary as the only copy of the pending decision.

Keep the approval stage free of irreversible side effects before the pause. In particular, don't submit an email while preparing the review payload. Put submission behind the executor's authorization check instead.

Freeze the proposal presented for review. If the model runs again, treat its output as a new revision rather than silently replacing the version already on screen.

3. Bind external approval to the exact reply

An approval callback should identify a stored decision. It should not supply arbitrary replacement content for the executor to send.

Use this application-side sequence:

  1. Load the pending review from storage and display the sending mailbox, complete recipients, subject, and outgoing body.
  2. Authenticate the reviewer and check their authorization for that mailbox and workflow.
  3. Compare the displayed revision with the current stored revision.
  4. Record approval or rejection, including the reviewer identity and expiry.
  5. Atomically associate an approval with one durable send operation.
  6. Request continuation of the corresponding workflow through the application's server-side resume handler.

Don't trust a thread identifier from the browser on its own. Resolve it through an authorized review record. Reject expired decisions, mismatched workflow identities, stale revisions, and attempts to reuse a decision for another operation.

A payload hash can help detect changes. For a JSON-compatible application proposal, this Python helper produces a deterministic hash from its serialized content:

import hashlib
import json

def proposal_hash(proposal: dict) -> str:
    encoded = json.dumps(
        proposal,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
        allow_nan=False,
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()

Include the mailbox, source message, recipients, subject, body, and revision in that proposal. Apply the same normalization rules before display, hashing, and storage. A hash detects a mismatch; it does not authenticate a reviewer or authorize a send.

Any edit requires new approval. That includes recipient changes and edits made by a human reviewer. Rejection ends the operation without sending. An edit returns it to review.

Review states show approval leading to one stored send operation, rejection ending the workflow, and edits requiring another review.
Application-owned review states: edits invalidate approval, and repeated callbacks resolve to the same send operation.

This flow uses an authenticated external application, not a reply email as authorization. Email-based human approval is a separate application pattern.

4. Send the approved version, not a regenerated reply

The executor should load the approved proposal from storage and recheck its revision, hash, authorization, and operation identity. Don't ask the model to “finalize” the message after approval: that creates a different payload.

Use POST /mailboxes/{id}/send for submission. It requires the send scope and accepts the Idempotency-Key header. Keep request construction inside the executor, following the Mailera developer API documentation.

Persist one idempotency key per approved send operation before making the request. Reuse that key when retrying the same operation; don't generate a fresh key for every HTTP attempt.

Mailera's idempotency window is 24 hours. Retain your own operation record beyond that window and require reconciliation before any later resubmission. See preventing duplicate sends with Idempotency-Key for the retry pattern.

Protect against concurrent approval callbacks with an atomic database transition or unique operation constraint. Both callbacks should resolve to the same stored operation, not create two executor jobs with different keys. Record that association durably so a worker crash doesn't discard the work after consuming the approval.

When a send-attempt identifier is available, inspect GET /sends/{id} using the executor’s send-scoped credential.

StatusApplication handling
queuedPreserve the operation and continue status checks
sendingKeep the operation in progress; don't create a replacement
sentRecord the outcome without treating it as proof of inbox placement
failedInvestigate before authorizing another submission
unknownReconcile the existing attempt; don't assume nothing was sent

A timeout after submission is an ambiguous outcome. If a send-attempt identifier is available, inspect that attempt. If the submission response is lost, recover using the stored operation and its stable idempotency key within the window. After the window, route unresolved attempts for reconciliation rather than automatically issuing a new send.

What commonly breaks, and how should you test it?

Run acceptance tests against your own mailbox and controlled recipient. The results below are requirements for the implementation, not reasons to relax the approval boundary when something fails.

SymptomLikely causeCheck
Pending review disappears after restartReview or workflow state exists only in memoryRestart while waiting; recover the same proposal and review identity
Approval reaches the wrong taskBrowser-supplied thread identity isn't bound to the reviewSubmit a mismatched identity; require rejection
Read tools failWrong key, missing read scope, or wrong mailbox groupCheck the read key and its mailbox restriction
Reading works but sending doesn'tMailbox is receive-onlyInspect capabilities.send before starting the workflow
Executor cannot submitMissing send permission or revoked keyCheck the executor's credential, not the drafting key

Test the decision paths separately:

  • Approval: submit exactly the stored, approved version.
  • Rejection: finish without a send operation.
  • Edit and reapprove: invalidate the old decision and display the new revision.
  • Stale, expired, or unauthorized approval: reject without submission.
  • Duplicate and concurrent callbacks: resolve to one durable operation.
  • Restart while waiting: recover the same review without approving it implicitly.
  • Timeout after submission: reconcile the existing attempt instead of creating a fresh operation.

Also test a crash between recording approval and starting the executor. The durable operation must remain discoverable by the worker.

What safety checks belong around the agent?

Treat incoming email, quoted text, and attachments as untrusted data. Instructions inside a message must not grant permissions, approve a draft, change recipients outside your policy, or select a different executor credential.

Use a test email that asks the agent to skip review and send information elsewhere. Require the workflow to remain unable to send without application-authorized approval, regardless of what the draft says.

Human review isn't automatic protection against data leakage. Show reviewers the complete outgoing content and every recipient. The MCP tool safety guidance recommends human control over tool invocation and showing tool inputs before server calls.

The MCP security best practices also prohibit servers from accepting tokens that weren't explicitly issued for that MCP server.

Keep credentials out of prompts, persisted workflow state, screenshots, browser responses, and logs. Restrict access to stored message content and define its retention period. Review Mailera's security information alongside your application's own controls; email-agent connection safety covers the broader access questions.

What Mailera does—and doesn't—handle

Mailera connects mailboxes you already have; it doesn't create mailboxes or email addresses. Implement reviewer authentication, approval storage, and enforcement in your application.

Mailera does not provide bulk or marketing sending, and it does not guarantee inbox placement. Its 24-hour send-idempotency window is one part of retry handling, not an exactly-once guarantee for the complete workflow.

Let AI agents use your mailbox via MCP

Add Mailera's MCP server to Claude, Cursor or VS Code so agents can read, categorize and reply to email messages.

Learn how to use MCP