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() {
+
+
+
Google OAuth2 — required for Workspace / .edu accounts
+
${a.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}
+
+
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() {
+
+
+
Google OAuth2 — required for Workspace / .edu accounts
+
${existing && existing.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}
+
+
-
+
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