From 4f0e3be917bb403d407443e7b4d5aa575823f6e4 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:48:04 +0000 Subject: [PATCH] fix(reminders): support OAuth SMTP accounts --- routes/note/note_routes.py | 10 ++-- static/js/settings.js | 9 +++- tests/test_note_reminder_email_oauth.py | 62 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/test_note_reminder_email_oauth.py diff --git a/routes/note/note_routes.py b/routes/note/note_routes.py index ec45072646..5677840f73 100644 --- a/routes/note/note_routes.py +++ b/routes/note/note_routes.py @@ -301,7 +301,7 @@ async def dispatch_reminder( email_error = "" if channel == "email": try: - from routes.email_routes import _get_email_config + from routes.email_routes import _get_email_config, _smtp_ready from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from datetime import datetime as _dt @@ -311,7 +311,7 @@ async def dispatch_reminder( # account when no explicit choice is saved. _acc_id = (settings.get("reminder_email_account_id") or "").strip() or None cfg = _get_email_config(account_id=_acc_id, owner=owner or "") - if not (cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")): + if not _smtp_ready(cfg): try: from core.database import SessionLocal as _SL, EmailAccount as _EA from sqlalchemy import and_, or_ @@ -324,7 +324,7 @@ async def dispatch_reminder( q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox))) for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all(): trial = _get_email_config(account_id=row.id, owner=owner or "") - if trial.get("smtp_host") and trial.get("smtp_user") and trial.get("smtp_password"): + if _smtp_ready(trial): cfg = trial break finally: @@ -347,8 +347,8 @@ async def dispatch_reminder( missing.append("SMTP host") if not cfg.get("smtp_user"): missing.append("SMTP user") - if not cfg.get("smtp_password"): - missing.append("SMTP password") + if not (cfg.get("smtp_password") or cfg.get("oauth_provider")): + missing.append("SMTP credentials") if not from_addr: missing.append("from address") if not recipient: diff --git a/static/js/settings.js b/static/js/settings.js index ad2950f513..c201916cb0 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2412,11 +2412,16 @@ async function initReminderSettings() { // what the Integrations panel manages. Treat the email channel as // configured if there's at least one account with SMTP set. let emailAccounts = []; + const smtpAccountReady = (account) => !!( + account.smtp_host + && account.smtp_user + && (account.has_smtp_password || account.oauth_provider === 'google') + ); try { const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); if (res.ok) { const d = await res.json(); - emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); + emailAccounts = (d.accounts || []).filter(smtpAccountReady); } } catch (_) {} let smtpConfigured = emailAccounts.length > 0; @@ -2520,7 +2525,7 @@ async function initReminderSettings() { const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); if (res.ok) { const d = await res.json(); - emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); + emailAccounts = (d.accounts || []).filter(smtpAccountReady); } } catch (_) {} smtpConfigured = emailAccounts.length > 0; diff --git a/tests/test_note_reminder_email_oauth.py b/tests/test_note_reminder_email_oauth.py new file mode 100644 index 0000000000..b3b0d04158 --- /dev/null +++ b/tests/test_note_reminder_email_oauth.py @@ -0,0 +1,62 @@ +"""Regression coverage for passwordless Google OAuth reminder senders.""" + +import asyncio +from pathlib import Path +from unittest.mock import patch + +from routes.note_routes import dispatch_reminder + + +_REPO = Path(__file__).resolve().parents[1] + + +def test_dispatch_reminder_sends_with_google_oauth_without_smtp_password(): + cfg = { + "account_name": "Workspace", + "smtp_host": "smtp.gmail.com", + "smtp_port": 587, + "smtp_security": "starttls", + "smtp_user": "alice@example.edu", + "smtp_password": "", + "from_address": "alice@example.edu", + "oauth_provider": "google", + } + sent = [] + + def fake_send(actual_cfg, sender, recipients, message): + sent.append((actual_cfg, sender, recipients, message)) + + with ( + patch("src.settings.load_settings", return_value={}), + patch("routes.email_routes._get_email_config", return_value=cfg), + patch("routes.email_helpers._send_smtp_message", side_effect=fake_send), + patch("core.database.SessionLocal", side_effect=AssertionError("fallback lookup is not needed")), + ): + result = asyncio.run(dispatch_reminder( + "Reminder: Submit report", + "The report is due today.", + note_id="", + owner="alice@example.edu", + queue_browser=False, + settings_override={ + "reminder_channel": "email", + "reminder_llm_synthesis": False, + }, + )) + + assert result["email_sent"] is True + assert result["email_error"] == "" + assert len(sent) == 1 + actual_cfg, sender, recipients, message = sent[0] + assert actual_cfg is cfg + assert sender == "alice@example.edu" + assert recipients == ["alice@example.edu"] + assert "Subject: Reminder (Odysseus): Submit report" in message + + +def test_reminder_settings_offer_oauth_smtp_accounts(): + source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8") + helper = source[source.index("const smtpAccountReady"):source.index("const smtpAccountReady") + 260] + + assert "account.has_smtp_password || account.oauth_provider === 'google'" in helper + assert source.count(".filter(smtpAccountReady)") == 2