mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-29 16:12:06 -04:00
Merge origin/dev into main
This commit is contained in:
+149
-13
@@ -13,7 +13,9 @@ handlers need. The split is mechanical — no behavior change.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3 as _sql3
|
||||
import time
|
||||
import email as email_mod
|
||||
import email.header
|
||||
import email.utils
|
||||
@@ -43,6 +45,7 @@ from routes.email_helpers import (
|
||||
_load_settings, _save_settings, _get_email_config,
|
||||
_send_smtp_message, _smtp_security_mode,
|
||||
_IMAP_TIMEOUT_SECONDS, _open_imap_connection,
|
||||
make_oauth_state, verify_oauth_state,
|
||||
_imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder,
|
||||
_extract_attachment_text, _list_attachments_from_msg, _has_visible_attachments, _is_likely_signature_image_attachment,
|
||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||
@@ -77,15 +80,16 @@ def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[st
|
||||
cfg.get("smtp_user") or "",
|
||||
cfg.get("from_address") or "",
|
||||
])
|
||||
except Exception:
|
||||
except Exception as _e:
|
||||
logger.warning("Failed to resolve email account alias", exc_info=_e)
|
||||
resolved_account_id = None
|
||||
row = db.get(_EA, resolved_account_id) if resolved_account_id else None
|
||||
if row:
|
||||
aliases.extend([row.owner or "", row.imap_user or "", row.from_address or ""])
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.warning("Failed to load email aliases", exc_info=_e)
|
||||
out = []
|
||||
for a in aliases:
|
||||
a = (a or "").strip()
|
||||
@@ -301,7 +305,9 @@ def _group_uid_fetch_records(msg_data) -> list:
|
||||
|
||||
|
||||
def _smtp_ready(cfg: dict) -> bool:
|
||||
return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password"))
|
||||
if not cfg.get("smtp_host") or not cfg.get("smtp_user"):
|
||||
return False
|
||||
return bool(cfg.get("smtp_password") or cfg.get("oauth_provider"))
|
||||
|
||||
|
||||
def _resolve_send_config(account_id: str | None = None, owner: str = "") -> dict:
|
||||
@@ -2165,7 +2171,7 @@ def setup_email_routes():
|
||||
to = _normalize_addr_field(to or "")
|
||||
cc = _normalize_addr_field(cc or "")
|
||||
bcc = _normalize_addr_field(bcc or "")
|
||||
outer["From"] = cfg["from_address"]
|
||||
outer["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||
outer["To"] = to
|
||||
if cc:
|
||||
outer["Cc"] = cc
|
||||
@@ -2309,12 +2315,10 @@ def setup_email_routes():
|
||||
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 = '')
|
||||
WHERE status = 'agent_draft' AND owner = ?
|
||||
ORDER BY created_at DESC""",
|
||||
(owner or "",),
|
||||
).fetchall()
|
||||
@@ -2335,7 +2339,7 @@ def setup_email_routes():
|
||||
cur = conn.execute(
|
||||
"""UPDATE scheduled_emails
|
||||
SET status = 'pending', send_at = ?
|
||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
||||
WHERE id = ? AND status = 'agent_draft' AND owner = ?""",
|
||||
(datetime.utcnow().isoformat(), sid, owner or ""),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -2356,7 +2360,7 @@ def setup_email_routes():
|
||||
conn = sqlite3.connect(SCHEDULED_DB)
|
||||
cur = conn.execute(
|
||||
"""UPDATE scheduled_emails SET status = 'cancelled'
|
||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
||||
WHERE id = ? AND status = 'agent_draft' AND owner = ?""",
|
||||
(sid, owner or ""),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -2429,6 +2433,7 @@ def setup_email_routes():
|
||||
try:
|
||||
cfg = _resolve_send_config(req.account_id, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning(f"No SMTP-capable account resolved: {e}")
|
||||
return {"success": False, "error": str(e) or "No SMTP-capable email account configured"}
|
||||
|
||||
# Use 'mixed' if we have attachments, 'alternative' otherwise
|
||||
@@ -2444,7 +2449,7 @@ def setup_email_routes():
|
||||
req.to = _normalize_addr_field(req.to or "")
|
||||
req.cc = _normalize_addr_field(req.cc or "")
|
||||
req.bcc = _normalize_addr_field(req.bcc or "")
|
||||
outer["From"] = cfg["from_address"]
|
||||
outer["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||
outer["To"] = req.to
|
||||
if req.cc:
|
||||
outer["Cc"] = req.cc
|
||||
@@ -2495,6 +2500,10 @@ def setup_email_routes():
|
||||
|
||||
_account_id = cfg.get("account_id") or req.account_id # capture for the IMAP append in the closure
|
||||
_in_reply_to = (req.in_reply_to or "").strip()
|
||||
_oauth_provider = cfg.get("oauth_provider") or ""
|
||||
_oauth_access_token = cfg.get("oauth_access_token") or ""
|
||||
_oauth_refresh_token = cfg.get("oauth_refresh_token") or ""
|
||||
_oauth_token_expiry = cfg.get("oauth_token_expiry") or ""
|
||||
|
||||
def _deliver():
|
||||
try:
|
||||
@@ -2505,6 +2514,11 @@ def setup_email_routes():
|
||||
"smtp_security": _smtp_security,
|
||||
"smtp_user": _smtp_user,
|
||||
"smtp_password": _smtp_pw,
|
||||
"account_id": _account_id,
|
||||
"oauth_provider": _oauth_provider,
|
||||
"oauth_access_token": _oauth_access_token,
|
||||
"oauth_refresh_token": _oauth_refresh_token,
|
||||
"oauth_token_expiry": _oauth_token_expiry,
|
||||
},
|
||||
_from,
|
||||
_recipients,
|
||||
@@ -2617,7 +2631,7 @@ def setup_email_routes():
|
||||
msg.attach(MIMEText(_draft_html, "html", "utf-8"))
|
||||
else:
|
||||
msg = MIMEText(req.body, "plain", "utf-8")
|
||||
msg["From"] = cfg["from_address"]
|
||||
msg["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||
msg["To"] = req.to
|
||||
if req.cc:
|
||||
msg["Cc"] = req.cc
|
||||
@@ -3269,6 +3283,8 @@ def setup_email_routes():
|
||||
"from_address": r.from_address or "",
|
||||
"has_imap_password": bool(r.imap_password),
|
||||
"has_smtp_password": bool(r.smtp_password),
|
||||
"oauth_provider": r.oauth_provider or "",
|
||||
"display_name": r.display_name or "",
|
||||
})
|
||||
return {"accounts": out}
|
||||
finally:
|
||||
@@ -3301,6 +3317,7 @@ def setup_email_routes():
|
||||
smtp_user=(data.get("smtp_user") or "").strip(),
|
||||
smtp_password=_enc(data.get("smtp_password") or ""),
|
||||
from_address=(data.get("from_address") or "").strip(),
|
||||
display_name=(data.get("display_name") or "").strip(),
|
||||
# SECURITY: stamp the creator so all subsequent reads / mutations
|
||||
# can filter by user. Without this every new account leaks to
|
||||
# every other user.
|
||||
@@ -3335,7 +3352,7 @@ def setup_email_routes():
|
||||
if not row:
|
||||
return {"ok": False, "error": "Account not found"}
|
||||
# Simple fields
|
||||
for key in ("name", "imap_host", "imap_user", "smtp_host", "smtp_user", "from_address"):
|
||||
for key in ("name", "imap_host", "imap_user", "smtp_host", "smtp_user", "from_address", "display_name"):
|
||||
if key in data:
|
||||
setattr(row, key, (data[key] or "").strip())
|
||||
for key in ("imap_port", "smtp_port"):
|
||||
@@ -3524,4 +3541,123 @@ def setup_email_routes():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── Google OAuth2 routes ──
|
||||
|
||||
@router.get("/oauth/google/authorize")
|
||||
async def google_oauth_authorize(account_id: str = Query(...), request: Request = None, owner: str = Depends(require_user)):
|
||||
import urllib.parse
|
||||
_assert_owns_account(account_id, owner)
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
if not client_id:
|
||||
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
state = make_oauth_state(account_id, owner)
|
||||
params = urllib.parse.urlencode({
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "https://mail.google.com/ email",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": state,
|
||||
})
|
||||
from fastapi.responses import RedirectResponse as _RR
|
||||
return _RR(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
|
||||
|
||||
@router.get("/oauth/google/callback")
|
||||
async def google_oauth_callback(
|
||||
code: str = Query(None),
|
||||
state: str = Query(None),
|
||||
error: str = Query(None),
|
||||
request: Request = None,
|
||||
):
|
||||
import urllib.parse
|
||||
from fastapi.responses import RedirectResponse as _RR
|
||||
if error:
|
||||
return _RR("/?section=integrations&email_oauth_error=google_error")
|
||||
if not code or not state:
|
||||
return _RR("/?section=integrations&email_oauth_error=missing_code")
|
||||
state_data = verify_oauth_state(state)
|
||||
if not state_data:
|
||||
return _RR("/?section=integrations&email_oauth_error=invalid_state")
|
||||
account_id = state_data.get("a", "")
|
||||
owner = state_data.get("o", "")
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
import httpx as _httpx
|
||||
try:
|
||||
resp = _httpx.post("https://oauth2.googleapis.com/token", data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.warning("Google token exchange failed")
|
||||
return _RR("/?section=integrations&email_oauth_error=token_exchange_failed")
|
||||
access_token = data.get("access_token", "")
|
||||
refresh_token = data.get("refresh_token", "")
|
||||
expiry = str(int(time.time()) + data.get("expires_in", 3600))
|
||||
# Fetch the email address from userinfo so we can auto-fill imap_user.
|
||||
email_addr = ""
|
||||
display_name = ""
|
||||
try:
|
||||
ui = _httpx.get("https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"}, timeout=10)
|
||||
if ui.is_success:
|
||||
ui_data = ui.json()
|
||||
email_addr = ui_data.get("email", "")
|
||||
display_name = ui_data.get("name", "")
|
||||
except Exception:
|
||||
pass
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
from src.secret_storage import encrypt as _enc
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(EmailAccount).filter(EmailAccount.id == account_id).first()
|
||||
if not row:
|
||||
return _RR("/?section=integrations&email_oauth_error=account_not_found")
|
||||
# SECURITY: verify the account belongs to the initiating user.
|
||||
if owner and row.owner and row.owner != owner:
|
||||
logger.warning("OAuth callback owner mismatch — rejecting token write")
|
||||
return _RR("/?section=integrations&email_oauth_error=ownership_error")
|
||||
row.oauth_provider = "google"
|
||||
row.oauth_access_token = _enc(access_token)
|
||||
if refresh_token:
|
||||
row.oauth_refresh_token = _enc(refresh_token)
|
||||
row.oauth_token_expiry = expiry
|
||||
# Auto-fill Google IMAP/SMTP settings if not already configured.
|
||||
if not row.imap_host:
|
||||
row.imap_host = "imap.gmail.com"
|
||||
row.imap_port = 993
|
||||
row.imap_starttls = False
|
||||
if not row.smtp_host:
|
||||
row.smtp_host = "smtp.gmail.com"
|
||||
row.smtp_port = 587
|
||||
if email_addr:
|
||||
if not row.imap_user:
|
||||
row.imap_user = email_addr
|
||||
if not row.smtp_user:
|
||||
row.smtp_user = email_addr
|
||||
if not row.from_address:
|
||||
row.from_address = email_addr
|
||||
if not row.name or row.name == row.id:
|
||||
row.name = email_addr
|
||||
if display_name and not row.display_name:
|
||||
row.display_name = display_name
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return _RR("/?section=integrations&email_oauth_success=1")
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user