diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index b807937cd..305225db0 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -885,8 +885,109 @@ def _smtp_connect(account=None, cfg=None): return conn +def _read_agent_email_confirm_setting() -> bool: + """True if the user wants agent send_email/reply_to_email calls to be + queued for manual approval instead of SMTPed immediately. Defaults to + True so a fresh install is safe — agents have been observed inventing + signatures and sending to real recipients without the user's review.""" + try: + from src.settings import get_setting + return bool(get_setting("agent_email_confirm", True)) + except Exception: + return True + + +def _stash_agent_draft(*, to, subject, body, in_reply_to=None, references=None, + cc=None, bcc=None, account=None) -> dict: + """Insert the composed email into scheduled_emails with status + 'agent_draft' and a far-future send_at so the scheduled-send poller + never picks it up. Returns the pending payload the model surfaces to + the user (and that the chat UI can render as an approval card).""" + try: + from src.constants import SCHEDULED_EMAILS_DB + except Exception: + return {"success": False, "error": "Pending-email storage unavailable"} + pending_id = uuid.uuid4().hex[:16] + far_future = "9999-12-31T00:00:00" + now = datetime.utcnow().isoformat() + try: + conn = sqlite3.connect(SCHEDULED_EMAILS_DB) + # Touch the schema in case the email-routes init hasn't run yet + # (MCP server can boot independently). + conn.execute(""" + CREATE TABLE IF NOT EXISTS scheduled_emails ( + id TEXT PRIMARY KEY, + to_addr TEXT NOT NULL, + cc TEXT, + bcc TEXT, + subject TEXT, + body TEXT NOT NULL, + in_reply_to TEXT, + references_hdr TEXT, + attachments TEXT, + send_at TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + owner TEXT DEFAULT '', + account_id TEXT, + odysseus_kind TEXT + ) + """) + conn.execute(""" + INSERT INTO scheduled_emails + (id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, + attachments, send_at, created_at, status, account_id, odysseus_kind, owner) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent_draft', ?, ?, ?) + """, ( + pending_id, + to if isinstance(to, str) else ", ".join(to), + cc if isinstance(cc, str) else (", ".join(cc) if cc else None), + bcc if isinstance(bcc, str) else (", ".join(bcc) if bcc else None), + subject or "", + body or "", + in_reply_to or None, + references if isinstance(references, str) else (" ".join(references) if references else None), + "[]", + far_future, + now, + account or None, + "agent_draft", + "", + )) + conn.commit() + conn.close() + except Exception as e: + return {"success": False, "error": f"Failed to stash draft: {e}"} + return { + "success": True, + "pending": True, + "pending_id": pending_id, + "to": to if isinstance(to, str) else ", ".join(to), + "subject": subject or "", + "body": body or "", + "message": ( + "✋ Draft staged for your approval — nothing has been sent yet.\n" + "Review the To/Subject/Body above. Reply 'send' to deliver, or " + "'cancel' to discard." + ), + } + + def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, bcc=None, account=None): - """Send an email via SMTP. Returns dict with status.""" + """Send an email via SMTP. Returns dict with status. + + When the `agent_email_confirm` setting is on (the default), the email + is NOT SMTPed — instead it lands in scheduled_emails as an + `agent_draft` row and the user reviews + approves it from the chat + UI. This closes the auto-send hole that let earlier models invent + signatures and ship them to real recipients without confirmation.""" + if _read_agent_email_confirm_setting(): + return _stash_agent_draft( + to=to, subject=subject, body=body, + in_reply_to=in_reply_to, references=references, + cc=cc, bcc=bcc, account=account, + ) send_account, cfg = _resolve_send_config(account) msg = EmailMessage() msg["From"] = _clean_header_value(cfg["from_address"]) diff --git a/routes/email_routes.py b/routes/email_routes.py index 797a142f2..11e1c3255 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -2071,6 +2071,79 @@ def setup_email_routes(): logger.error(f"cancel_scheduled {sid!r} failed: {e}") return {"success": False, "error": "Mail operation failed"} + # ── Agent send-confirm: list/approve/cancel ────────────────────────── + # When `agent_email_confirm` is on, the MCP send_email tool drops the + # composed email into scheduled_emails with status='agent_draft' (a + # far-future send_at so the poller never picks it up). These endpoints + # let the chat UI surface them for the user and either approve (flip + # to status='pending' with send_at=now so the poller delivers it) or + # cancel (status='cancelled'). + @router.get("/pending") + async def list_pending_agent_drafts(owner: str = Depends(require_owner)): + import sqlite3 + try: + conn = sqlite3.connect(SCHEDULED_DB) + conn.row_factory = sqlite3.Row + # The MCP server can't easily set owner, so it stores '' — fall + # back to those rows in addition to the caller's owner. + rows = conn.execute( + """SELECT id, to_addr, subject, body, created_at, account_id + FROM scheduled_emails + WHERE status = 'agent_draft' AND (owner = ? OR owner = '') + ORDER BY created_at DESC""", + (owner or "",), + ).fetchall() + conn.close() + return {"pending": [dict(r) for r in rows]} + except Exception as e: + logger.error(f"list_pending_agent_drafts failed: {e}") + return {"pending": [], "error": "Mail operation failed"} + + @router.post("/pending/{sid}/approve") + async def approve_agent_draft(sid: str, owner: str = Depends(require_owner)): + """Approve a draft staged by the agent: flip status → pending and + backdate send_at so the scheduled-send poller picks it up + immediately.""" + import sqlite3 + try: + conn = sqlite3.connect(SCHEDULED_DB) + cur = conn.execute( + """UPDATE scheduled_emails + SET status = 'pending', send_at = ? + WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""", + (datetime.utcnow().isoformat(), sid, owner or ""), + ) + conn.commit() + affected = cur.rowcount + conn.close() + if not affected: + return {"success": False, "error": "Draft not found or already handled"} + return {"success": True} + except Exception as e: + logger.error(f"approve_agent_draft {sid!r} failed: {e}") + return {"success": False, "error": "Mail operation failed"} + + @router.delete("/pending/{sid}") + async def cancel_agent_draft(sid: str, owner: str = Depends(require_owner)): + """Discard a draft the agent staged for approval.""" + import sqlite3 + try: + conn = sqlite3.connect(SCHEDULED_DB) + cur = conn.execute( + """UPDATE scheduled_emails SET status = 'cancelled' + WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""", + (sid, owner or ""), + ) + conn.commit() + affected = cur.rowcount + conn.close() + if not affected: + return {"success": False, "error": "Draft not found or already handled"} + return {"success": True} + except Exception as e: + logger.error(f"cancel_agent_draft {sid!r} failed: {e}") + return {"success": False, "error": "Mail operation failed"} + @router.get("/resolve-contact") async def resolve_contact(name: str = Query(..., description="Name to search for"), owner: str = Depends(require_owner)): """Search Sent folder for a contact by name. Returns matching email addresses.""" diff --git a/src/agent_loop.py b/src/agent_loop.py index 7a70c1453..f884df421 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -416,7 +416,9 @@ Notes, checklists, AND user reminders. Use this for "create/add/write a note", t ```send_email {"to": "recipient@example.com", "subject": "Re: Your question", "body": "Hi, ...", "account": "gmail"} ``` -Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`.""", +Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`. + +CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""", "list_emails": """\ ```list_emails {"folder": "INBOX", "max_results": 20, "unread_only": false, "account": "gmail"} @@ -427,7 +429,9 @@ List recent emails from a folder, newest first, including read messages by defau ```reply_to_email {"uid": "1234", "body": "Sounds good — talk Friday.", "account": "gmail"} ``` -SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).""", +SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled). + +CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""", "bulk_email": """\ ```bulk_email {"action": "delete", "uids": ["10997", "10998"], "folder": "INBOX", "account": "Gmail"} diff --git a/src/settings.py b/src/settings.py index c0ce8ab54..c6f2cdd1a 100644 --- a/src/settings.py +++ b/src/settings.py @@ -29,6 +29,14 @@ def _invalidate_caches(): # ── Default values ── DEFAULT_SETTINGS = { + # Agent email safety: when True, the MCP send_email / reply_to_email + # tools don't SMTP directly. They stage the composed message into the + # scheduled_emails table with status='agent_draft' and return a + # pending_id + the rendered email so the user can review and approve + # (or cancel) before it actually goes out. Default ON because models + # have been observed inventing signatures and sending to real + # recipients without confirmation. + "agent_email_confirm": True, "image_gen_enabled": False, "image_model": "", "image_quality": "medium", diff --git a/static/index.html b/static/index.html index cbdb17d5f..c673b5987 100644 --- a/static/index.html +++ b/static/index.html @@ -1547,6 +1547,10 @@ working for anyone who wired it via `manage_settings` / settings backup. Re-add this card to surface the toggle again once the core experience is faster. --> +
+

Email Safety

+
When on, agent send_email / reply_to_email tools stage a draft for your approval (in the chat) instead of SMTPing immediately. Stops models from inventing a signature and sending it to a real recipient before you can review.
+
diff --git a/static/js/settings.js b/static/js/settings.js index 2dbfd3f4b..5d23e1f91 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1699,6 +1699,25 @@ async function initAgentSettings() { msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + (curR != null ? ' · ' + curR + ' steps/message' : '') + (supInput && supInput.checked ? ' · supervisor on' : ''); + + // Standalone Email Safety toggle (separate card on the AI Defaults tab). + // Default to ON if the setting isn't present so a fresh install is safe. + var emailConfirm = el('set-agentEmailConfirm'); + if (emailConfirm) { + try { + var s = await fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()); + emailConfirm.checked = s.agent_email_confirm !== false; + } catch (_) {} + emailConfirm.addEventListener('change', async () => { + try { + await fetch('/api/auth/settings', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent_email_confirm: !!emailConfirm.checked }), + }); + } catch (_) {} + }); + } } /* ═══════════════════════════════════════════