mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
Agent email safety: stage drafts for user approval instead of auto-send
Closes the auto-send hole that let earlier models invent signatures
(e.g. signing 'David' for a user named Felix) and SMTP them to real
recipients before the user could review.
New setting: agent_email_confirm (default True).
When on, the MCP send_email and reply_to_email tools no longer SMTP
directly — they write the composed email to scheduled_emails with a new
status 'agent_draft' (far-future send_at so the scheduled-send poller
ignores them) and return a {pending: true, pending_id, to, subject,
body, message: ...} payload. The model surfaces that to the user.
Backend endpoints to approve / cancel:
- GET /api/email/pending → list staged drafts for the owner
- POST /api/email/pending/{id}/approve → flip status to 'pending' +
backdate send_at so the
existing scheduled-send
poller delivers immediately
- DELETE /api/email/pending/{id} → status = 'cancelled'
UI:
- Settings / AI Defaults gets a new 'Email Safety' card with the
toggle, default on.
- Tool descriptions for send_email and reply_to_email now include the
pending behavior + an explicit 'DO NOT invent a signature, do not
type a person's name' guardrail.
Pass 2 (next): inline chat card with Send / Discard buttons so the user
doesn't have to type a confirmation reply. Today's prompt + the listing
endpoint give the model a clean path to surface drafts.
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user