Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/digest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions announce.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down
19 changes: 19 additions & 0 deletions notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions tests/test_notify_auth.py
Original file line number Diff line number Diff line change
@@ -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()
Loading