diff --git a/core/database.py b/core/database.py index e4acc8d54..04ebb374b 100644 --- a/core/database.py +++ b/core/database.py @@ -324,6 +324,13 @@ class EmailAccount(TimestampMixin, Base): smtp_password = Column(String, default="") from_address = Column(String, default="") + display_name = Column(String, nullable=True) # "Hriday Ranka" — used in From: header + + # OAuth2 (Google / Google Workspace). Tokens stored encrypted via secret_storage. + oauth_provider = Column(String, nullable=True) # "google" or None + oauth_access_token = Column(String, nullable=True) # encrypted + oauth_refresh_token = Column(String, nullable=True) # encrypted + oauth_token_expiry = Column(String, nullable=True) # unix timestamp string __table_args__ = ( Index('ix_email_accounts_owner_default', 'owner', 'is_default'), @@ -1427,6 +1434,25 @@ def _migrate_add_task_automation_columns(): except Exception as e: logging.getLogger(__name__).warning(f"task automation migration: {e}") +def _migrate_add_email_oauth_columns(): + """Add Google OAuth and display_name columns to email_accounts if missing.""" + try: + with engine.connect() as conn: + cols = [r[1] for r in conn.execute(text("PRAGMA table_info(email_accounts)"))] + for col, typedef in [ + ("oauth_provider", "TEXT"), + ("oauth_access_token", "TEXT"), + ("oauth_refresh_token", "TEXT"), + ("oauth_token_expiry", "TEXT"), + ("display_name", "TEXT"), + ]: + if col not in cols: + conn.execute(text(f"ALTER TABLE email_accounts ADD COLUMN {col} {typedef}")) + conn.commit() + except Exception as e: + logging.getLogger(__name__).warning(f"email oauth columns migration: {e}") + + def _migrate_add_oauth_config(): """Add oauth_config column to mcp_servers table if missing.""" try: @@ -1771,6 +1797,7 @@ def init_db(): _migrate_add_tidy_verdict() _migrate_add_doc_source_email_cols() _migrate_add_oauth_config() + _migrate_add_email_oauth_columns() _migrate_add_task_automation_columns() _migrate_add_disabled_tools() _migrate_add_mcp_oauth_tokens_column() diff --git a/routes/email_helpers.py b/routes/email_helpers.py index b3df6a560..e33b72182 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -13,6 +13,8 @@ and `email_pollers.py` (the background loops): """ import os +import base64 +import time import imaplib import smtplib import email as email_mod @@ -38,6 +40,106 @@ from src.secret_storage import decrypt as _decrypt logger = logging.getLogger(__name__) +def _xoauth2_raw(user: str, access_token: str) -> str: + """The SASL XOAUTH2 initial-response string (unencoded). + + Both smtplib.SMTP.auth() and imaplib.IMAP4.authenticate() base64-encode + the value their callback returns, so callers pass this raw form — never + pre-encoded — to avoid double base64. + """ + return f"user={user}\x01auth=Bearer {access_token}\x01\x01" + + +def _xoauth2_bytes(user: str, access_token: str) -> bytes: + """Raw XOAUTH2 bytes for imaplib's authenticate() callback.""" + return _xoauth2_raw(user, access_token).encode() + + +def make_oauth_state(account_id: str, owner: str) -> str: + """Return an HMAC-signed, base64-encoded OAuth state token. + + Encodes account_id + owner + a random nonce, signed with the app secret + so the callback can validate that the flow was initiated by an + authenticated, owning user (CSRF / state-forgery protection). + """ + import hmac as _hmac, hashlib as _hl, secrets as _sec + from src.secret_storage import _load_or_create_key + nonce = _sec.token_hex(16) + payload = json.dumps({"a": account_id, "o": owner, "n": nonce}, separators=(",", ":")) + sig = _hmac.new(_load_or_create_key(), payload.encode(), _hl.sha256).hexdigest() + return base64.urlsafe_b64encode(f"{payload}|{sig}".encode()).decode() + + +def verify_oauth_state(state: str) -> dict | None: + """Verify an OAuth state token's HMAC signature. + + Returns the decoded payload dict ({"a", "o", "n"}) on success, or None if + the token is malformed, tampered, or signed with a different key. + """ + import hmac as _hmac, hashlib as _hl + from src.secret_storage import _load_or_create_key + try: + decoded = base64.urlsafe_b64decode(state.encode()).decode() + payload, sig = decoded.rsplit("|", 1) + expected = _hmac.new(_load_or_create_key(), payload.encode(), _hl.sha256).hexdigest() + if not _hmac.compare_digest(sig, expected): + return None + return json.loads(payload) + except Exception: + return None + + +def _refresh_google_token(account_id: str) -> str | None: + """Exchange the stored refresh token for a new access token and persist it.""" + import httpx + from core.database import SessionLocal as _SL, EmailAccount as _EA + from src.secret_storage import encrypt as _enc, decrypt as _dec + client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") + client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") + if not client_id or not client_secret: + return None + db = _SL() + try: + row = db.get(_EA, account_id) + if not row or not row.oauth_refresh_token: + return None + refresh_token = _dec(row.oauth_refresh_token or "") + if not refresh_token: + return None + resp = httpx.post("https://oauth2.googleapis.com/token", data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, timeout=10) + resp.raise_for_status() + data = resp.json() + access_token = data["access_token"] + row.oauth_access_token = _enc(access_token) + row.oauth_token_expiry = str(int(time.time()) + data.get("expires_in", 3600)) + db.commit() + return access_token + except Exception: + logger.warning(f"Google token refresh failed for account {account_id}") + return None + finally: + db.close() + + +def _get_valid_google_token(account_id: str, cfg: dict) -> str | None: + """Return a valid Google access token, refreshing if expired or missing.""" + from src.secret_storage import decrypt as _dec + access_token = _dec(cfg.get("oauth_access_token") or "") + expiry_str = cfg.get("oauth_token_expiry") or "" + if access_token and expiry_str: + try: + if int(expiry_str) - 60 > time.time(): + return access_token + except (ValueError, TypeError): + pass + return _refresh_google_token(account_id) + + def _smtp_security_mode(cfg: dict) -> str: raw = str(cfg.get("smtp_security") or "").strip().lower() if raw in {"ssl", "starttls", "none"}: @@ -54,20 +156,29 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message port = int(cfg.get("smtp_port") or 465) user = cfg.get("smtp_user") or "" password = cfg.get("smtp_password") or "" + + def _auth_smtp(smtp): + if cfg.get("oauth_provider") == "google": + token = _get_valid_google_token(cfg.get("account_id"), cfg) + if not token: + raise RuntimeError("Google OAuth token unavailable — reconnect the account") + smtp.ehlo() + smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(user, token), initial_response_ok=True) + elif user and password: + smtp.login(user, password) + security = _smtp_security_mode(cfg) if security == "ssl": with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: - if user and password: - smtp.login(user, password) + _auth_smtp(smtp) smtp.sendmail(from_addr, recipients, message) return with smtplib.SMTP(host, port, timeout=timeout) as smtp: if security == "starttls": smtp.starttls() - if user and password: - smtp.login(user, password) + _auth_smtp(smtp) smtp.sendmail(from_addr, recipients, message) @@ -701,10 +812,16 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict: "imap_password": _decrypt(row.imap_password or ""), "imap_starttls": bool(row.imap_starttls), "from_address": row.from_address or row.imap_user or "", + "oauth_provider": row.oauth_provider or "", + "oauth_access_token": row.oauth_access_token or "", + "oauth_refresh_token": row.oauth_refresh_token or "", + "oauth_token_expiry": row.oauth_token_expiry or "", + "display_name": row.display_name or "", } - if not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]): + is_oauth = bool(cfg.get("oauth_provider")) + if not is_oauth and not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]): logger.warning(f"SMTP not configured for account {row.name!r}") - if not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]): + if not is_oauth and not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]): logger.warning(f"IMAP not configured for account {row.name!r}") return cfg finally: @@ -825,12 +942,19 @@ def _imap_connect(account_id: str | None = None, owner: str = "", timeout=timeout, ) try: - conn.login(cfg["imap_user"], cfg["imap_password"]) + if cfg.get("oauth_provider") == "google": + token = _get_valid_google_token(cfg.get("account_id"), cfg) + if not token: + raise RuntimeError("Google OAuth token unavailable — reconnect the account in Settings → Integrations") + conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(cfg["imap_user"], token)) + else: + conn.login(cfg["imap_user"], cfg["imap_password"]) except Exception: # A failed AUTHENTICATE (e.g. an Office 365 app password on an - # MFA-enabled tenant, #3174) otherwise orphans the already-connected - # socket; close it before propagating so a misconfigured account - # can't leak one descriptor per retry / background poller pass. + # MFA-enabled tenant, #3174, or an expired/revoked OAuth token) + # otherwise orphans the already-connected socket; close it before + # propagating so a misconfigured account can't leak one descriptor + # per retry / background poller pass. try: conn.shutdown() except Exception: diff --git a/routes/email_routes.py b/routes/email_routes.py index 0871b5656..0f4af19ae 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -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, _extract_attachment_to_disk, _extract_html, _extract_text, @@ -285,7 +288,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: @@ -2021,7 +2026,7 @@ def setup_email_routes(): outer = MIMEMultipart("alternative") body_container = outer - 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 @@ -2285,6 +2290,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 @@ -2297,7 +2303,7 @@ def setup_email_routes(): outer = MIMEMultipart("alternative") body_container = outer - 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 @@ -2348,6 +2354,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: @@ -2358,6 +2368,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, @@ -2470,7 +2485,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 @@ -3122,6 +3137,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: @@ -3154,6 +3171,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. @@ -3188,7 +3206,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"): @@ -3377,4 +3395,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 diff --git a/static/js/document.js b/static/js/document.js index cfa2025d0..20d77788a 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -87,7 +87,8 @@ import * as Modals from './modalManager.js'; } function _accountCanSend(account) { - return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password); + if (!account || !account.smtp_host || !account.smtp_user) return false; + return !!(account.has_smtp_password || account.oauth_provider); } async function _resolveComposeSendAccountId() { diff --git a/static/js/settings.js b/static/js/settings.js index 9b466f6cb..09cc5505d 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2913,13 +2913,14 @@ async function initEmailAccountsSettings() { // IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally // blank because it may live on another machine (DNS, LAN, Tailscale). const PROVIDERS = { - gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, - migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, - icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, - outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, - fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, - yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, - dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, + gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, + google_workspace: { label: 'Google Workspace / .edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' }, + migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, + icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, + outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, + fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, + yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, + dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, }; const _providerOptions = Object.entries(PROVIDERS) .map(([k, v]) => ``) @@ -2932,11 +2933,17 @@ async function initEmailAccountsSettings() {
+
+
IMAP (Receiving)
-
+
SMTP (Sending) — optional, leave blank for read-only
@@ -2959,6 +2966,16 @@ async function initEmailAccountsSettings() { `; + // Show/hide OAuth section and password fields based on provider selection. + function _syncOauthUI(providerKey) { + const p = PROVIDERS[providerKey]; + const isOauth = !!(p && p.oauth); + el('eaf-oauth-section').style.display = isOauth ? '' : 'none'; + formEl.querySelectorAll('.eaf-password-section').forEach(r => { + r.style.display = isOauth ? 'none' : ''; + }); + } + const eafProviderNotes = { outlook: { title: 'Outlook / Office 365 needs OAuth', @@ -2983,13 +3000,41 @@ async function initEmailAccountsSettings() { el('eaf-provider').addEventListener('change', (e) => { _renderEafProviderNote(e.target.value); const p = PROVIDERS[e.target.value]; - if (!p) return; + if (!p) { _syncOauthUI(''); return; } el('eaf-imap-host').value = p.imap.host; el('eaf-imap-port').value = p.imap.port; el('eaf-imap-starttls').checked = !!p.imap.starttls; el('eaf-smtp-host').value = p.smtp.host; el('eaf-smtp-port').value = p.smtp.port; el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl'); + _syncOauthUI(e.target.value); + }); + + // Init OAuth UI for accounts already connected via OAuth. + if (a.oauth_provider === 'google') _syncOauthUI('google_workspace'); + + // "Connect with Google" button — save the account first, then redirect to OAuth. + el('eaf-oauth-btn').addEventListener('click', async () => { + // Must save the account first to get an account_id to pass to the OAuth flow. + const body = { + name: el('eaf-name').value.trim() || el('eaf-from').value.trim(), + from_address: el('eaf-from').value.trim(), + imap_host: el('eaf-imap-host').value.trim(), + imap_port: parseInt(el('eaf-imap-port').value) || 993, + imap_user: el('eaf-imap-user').value.trim(), + imap_starttls: el('eaf-imap-starttls').checked, + smtp_host: el('eaf-smtp-host').value.trim(), + smtp_port: parseInt(el('eaf-smtp-port').value) || 587, + smtp_user: el('eaf-imap-user').value.trim(), + }; + if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; } + const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts'; + const method = isEdit ? 'PUT' : 'POST'; + const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const d = await r.json(); + if (!d.ok) { el('eaf-msg').textContent = d.error || 'Save failed'; el('eaf-msg').style.color = 'var(--red)'; return; } + const accId = isEdit ? a.id : d.id; + window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`; }); el('eaf-smtp-security').value = _smtpSecurity(a); @@ -3009,6 +3054,7 @@ async function initEmailAccountsSettings() { const body = { name: el('eaf-name').value.trim(), from_address: el('eaf-from').value.trim(), + display_name: el('eaf-display-name').value.trim(), imap_host: el('eaf-imap-host').value.trim(), imap_port: parseInt(el('eaf-imap-port').value) || 993, imap_user: el('eaf-imap-user').value.trim(), @@ -4317,6 +4363,7 @@ async function initUnifiedIntegrations() { // it may be remote (DNS, LAN, Tailscale), not localhost. const PROVIDERS = { gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, + google_workspace: { label: 'Google Workspace / .edu', emailEx: 'you@yourschool.edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' }, migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, @@ -4334,6 +4381,7 @@ async function initUnifiedIntegrations() { const PROV_LOGO = { '': _customLogo, gmail: _letterLogo('G', '#ea4335'), + google_workspace: _letterLogo('G', '#ea4335'), migadu: _letterLogo('M', '#3aa39d'), icloud: _letterLogo('i', '#3693f3'), outlook: _letterLogo('O', '#0078d4'), @@ -4362,11 +4410,17 @@ async function initUnifiedIntegrations() {
+
+
IMAP (Receiving)
-
+
SMTP (Sending) — optional, leave blank for read-only
@@ -4491,6 +4545,16 @@ async function initUnifiedIntegrations() { `; }; + // Show/hide the OAuth section and password fields based on provider selection. + function _syncOauthUI(providerKey) { + const p = PROVIDERS[providerKey]; + const isOauth = !!(p && p.oauth); + el('uf-oauth-section').style.display = isOauth ? '' : 'none'; + formEl.querySelectorAll('.uf-password-section').forEach(r => { + r.style.display = isOauth ? 'none' : ''; + }); + } + // Custom dropdown wire-up — the native