From 6210e3da55ecbf44652ea03719a80bfbc1d12ad9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 18:02:20 +0000 Subject: [PATCH] fix: abort Sunday digest immediately on Gmail auth failure SMTP 535 (bad GMAIL_APP_PASSWORD) was retried across all 17 batches with 6-minute pauses, so the weekly digest burned ~90 minutes and still delivered 0/510 emails. Abort on the first auth error, and keep the Telegram digest running even if Gmail fails. Co-authored-by: Ephraim Iyanda --- .github/workflows/digest.yml | 4 +++- CHANGELOG.md | 4 ++++ announce.py | 8 ++++++++ notify.py | 19 ++++++++++++++++++ tests/test_notify_auth.py | 38 ++++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/test_notify_auth.py diff --git a/.github/workflows/digest.yml b/.github/workflows/digest.yml index 21653e9..355838f 100644 --- a/.github/workflows/digest.yml +++ b/.github/workflows/digest.yml @@ -73,8 +73,10 @@ jobs: if: github.event_name != 'push' run: python run.py --notify + # Still post to Telegram if Gmail SMTP fails — otherwise a bad + # GMAIL_APP_PASSWORD secret silently skips the channel too. - name: Send Telegram digest - if: github.event_name != 'push' + if: always() && github.event_name != 'push' run: python telegram_notify.py - name: Upload logs and preview diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6bedb..4a49549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Fixed +- Weekly digest now aborts immediately when Gmail returns SMTP 535 (bad app password) instead of waiting ~90 minutes across all batches +- Telegram digest still sends if the Gmail step fails, so a bad `GMAIL_APP_PASSWORD` secret no longer skips the channel + ### Added - International Google News RSS feeds — Commonwealth, UK, UN, World Bank opportunities now populate the International tab automatically - `docs/AI_IMPLEMENTATION.md` — full technical write-up of the Gemini AI pipeline diff --git a/announce.py b/announce.py index 662fa28..793eaad 100644 --- a/announce.py +++ b/announce.py @@ -301,6 +301,14 @@ def send_announcement(recipients): logger.info(f"announce: Batch {i}/{len(batches)} — {ok}/{len(batch)} sent.") except Exception as exc: logger.error(f"announce: Batch {i} SMTP error — {exc}") + if isinstance(exc, smtplib.SMTPAuthenticationError) or ( + "535" in str(exc) and "username and password" in str(exc).lower() + ): + logger.error( + "announce: Gmail rejected SENDER_EMAIL / GMAIL_APP_PASSWORD. " + "Aborting remaining batches." + ) + return False if i < len(batches): logger.info(f"announce: Pausing {EMAIL_BATCH_PAUSE_SEC}s...") diff --git a/notify.py b/notify.py index 705a805..147dad8 100644 --- a/notify.py +++ b/notify.py @@ -66,6 +66,17 @@ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]{2,}$") + +def _is_smtp_auth_error(exc): + """True when Gmail rejected SENDER_EMAIL / GMAIL_APP_PASSWORD.""" + if isinstance(exc, smtplib.SMTPAuthenticationError): + return True + msg = str(exc).lower() + return "535" in msg and ( + "username and password" in msg or "badcredentials" in msg + ) + + _INVALID_DOMAINS = { "example.com", "test.com", "mailinator.com", "guerrillamail.com", "sharklasers.com", "guerrillamailblock.com", "grr.la", "yopmail.com", @@ -448,6 +459,14 @@ def send_email(nigeria_opps, intl_opps, recipients): logger.info(f"notify: Batch {i}/{total_batches} — {batch_ok}/{len(batch)} sent.") except Exception as exc: logger.error(f"notify: Batch {i}/{total_batches} SMTP error — {exc}") + if _is_smtp_auth_error(exc): + logger.error( + "notify: Gmail rejected SENDER_EMAIL / GMAIL_APP_PASSWORD. " + "Aborting remaining batches instead of waiting through them. " + "Rotate the GMAIL_APP_PASSWORD GitHub secret, then re-run " + "'ScoutBot — Weekly Sunday Digest' from the Actions tab." + ) + return False if i < total_batches: logger.info(f"notify: Pausing {EMAIL_BATCH_PAUSE_SEC}s...") time.sleep(EMAIL_BATCH_PAUSE_SEC) diff --git a/tests/test_notify_auth.py b/tests/test_notify_auth.py new file mode 100644 index 0000000..c1a8ce7 --- /dev/null +++ b/tests/test_notify_auth.py @@ -0,0 +1,38 @@ +"""Fail-fast when Gmail rejects the app password (SMTP 535).""" + +from unittest.mock import MagicMock, patch + +import smtplib + +import notify + + +def test_is_smtp_auth_error_detects_gmail_535(): + exc = smtplib.SMTPAuthenticationError( + 535, + b"5.7.8 Username and Password not accepted. BadCredentials", + ) + assert notify._is_smtp_auth_error(exc) is True + assert notify._is_smtp_auth_error(RuntimeError("temporary timeout")) is False + + +def test_send_email_aborts_remaining_batches_on_auth_error(): + recipients = [f"user{i}@example.com" for i in range(60)] # 2 batches of 30 + auth_error = smtplib.SMTPAuthenticationError( + 535, b"5.7.8 Username and Password not accepted" + ) + with ( + patch.object(notify, "SENDER_EMAIL", "bot@gmail.com"), + patch.object(notify, "GMAIL_APP_PASSWORD", "bad-pass"), + patch("notify.smtplib.SMTP_SSL") as smtp_cls, + patch("notify.time.sleep") as sleep, + ): + server = MagicMock() + smtp_cls.return_value.__enter__.return_value = server + server.login.side_effect = auth_error + + ok = notify.send_email([], [], recipients) + + assert ok is False + assert server.login.call_count == 1 + sleep.assert_not_called()