From b77b8e8bc72cbd022d17b1f976af363228346cba Mon Sep 17 00:00:00 2001 From: sangowu <34704289+sangowu@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:43:20 +0100 Subject: [PATCH] fix: advance Gmail cursor past missing messages --- BUGFIX_LOG.md | 14 ++++++++ CHANGELOG.md | 3 ++ jobradar/email_sync.py | 11 ++++-- tests/test_application_tracking.py | 55 ++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/BUGFIX_LOG.md b/BUGFIX_LOG.md index e464fad..0152ce4 100644 --- a/BUGFIX_LOG.md +++ b/BUGFIX_LOG.md @@ -9,6 +9,20 @@ --- ## 2026-08-11 +### BUG · 已删除 Gmail 邮件导致增量同步游标永久停滞 + +**错误** +Gmail history 返回一封随后已不存在的邮件时,`messages.get` 每 15 分钟重复返回 404。该邮件不会被标记为已处理,同一个 WARNING 持续出现。 + +**原因** +正文读取阶段把所有异常都计入 `failed_messages`,而同步逻辑只有在失败数为零时才保存新的 `history_id`。永久缺失的单封邮件因此阻止整个游标前进,形成无法自行恢复的重试循环。 + +**解决方案** +将 `messages.get` 的 HTTP 404 识别为终态“邮件已消失”:记录 INFO 后跳过,不计为可重试失败,从而允许保存最新 Gmail 游标。500、认证、限流和网络异常仍保持失败并阻止游标推进。 + +**结果** +新增两个回归测试,分别验证 404 推进游标、500 保留旧游标。邮件追踪测试 63 项、完整 pytest 247 项通过;Ruff、Python 编译、前端静态检查和 diff 检查均通过。 + ### BUG · 切换 CV 后职位卡片出现重复 Indeed 标签 **错误** diff --git a/CHANGELOG.md b/CHANGELOG.md index ec47d3c..4f1505c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Bug Fixes +- **Deleted Gmail messages no longer stall incremental sync** (`email_sync.py`) + When Gmail history references a message that has already disappeared, a `messages.get` 404 is now treated as a terminal missing-message condition rather than a retryable fetch failure. The sync skips that message and saves Gmail's latest `historyId`, while authentication, rate-limit, server, and network errors still hold the cursor for a later retry. + - **Duplicate Indeed source badges after CV changes** (`search_assessment_stage.py` / `cache.py`) Re-assessing a cached job for a different CV now preserves its original `sources`, `raw_sources`, and posting date instead of deriving a second source name from the URL. Cache reads and merges also collapse hostname aliases from the same provider, so existing rows containing both `indeed.ie` and `ie.indeed.com` render as one Indeed source while retaining distinct LinkedIn sources. diff --git a/jobradar/email_sync.py b/jobradar/email_sync.py index a68c1cc..4222946 100644 --- a/jobradar/email_sync.py +++ b/jobradar/email_sync.py @@ -233,8 +233,15 @@ def emit_progress(stage: str) -> None: pending.append(future.result()) fetched += 1 except Exception as exc: - metrics["failed_messages"] += 1 - logger.warning("Gmail message fetch failed | id=%s error=%s", message_id, exc) + if ( + isinstance(exc, requests.HTTPError) + and exc.response is not None + and exc.response.status_code == 404 + ): + logger.info("Gmail message no longer exists; skipping | id=%s", message_id) + else: + metrics["failed_messages"] += 1 + logger.warning("Gmail message fetch failed | id=%s error=%s", message_id, exc) emit_progress("fetching") ordered_pending = sorted(pending, key=lambda value: value["received_at"]) analysis_workers = _analysis_worker_count() diff --git a/tests/test_application_tracking.py b/tests/test_application_tracking.py index 046cf54..e9a1b0d 100644 --- a/tests/test_application_tracking.py +++ b/tests/test_application_tracking.py @@ -7,6 +7,7 @@ from datetime import datetime import pytest +import requests from jobradar.email_classifier import classify_application_email from jobradar.schemas import ApplicationEmailAnalysis @@ -660,6 +661,60 @@ def fetch_message(credentials, message_id): assert result["failed_messages"] == 0 +def test_missing_history_message_advances_cursor(store, monkeypatch): + import jobradar.email_sync as email_sync + + store.set_history_id("100") + monkeypatch.setattr(email_sync, "email_sync_configured", lambda: True) + monkeypatch.setattr(email_sync, "_load_credentials", lambda: object()) + monkeypatch.setattr( + email_sync, + "_list_history_message_ids", + lambda credentials, start_history_id, limit: (["missing-message"], "102", 1), + ) + response = requests.Response() + response.status_code = 404 + response.url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/missing-message" + + def fetch_missing_message(credentials, message_id): + raise requests.HTTPError("message not found", response=response) + + monkeypatch.setattr(email_sync, "_fetch_message", fetch_missing_message) + + result = email_sync.sync_email(limit=10) + + assert result["failed_messages"] == 0 + assert result["scanned"] == 0 + assert store.get_sync_state()["history_id"] == "102" + + +def test_retryable_fetch_failure_holds_cursor(store, monkeypatch): + import jobradar.email_sync as email_sync + + store.set_history_id("100") + monkeypatch.setattr(email_sync, "email_sync_configured", lambda: True) + monkeypatch.setattr(email_sync, "_load_credentials", lambda: object()) + monkeypatch.setattr( + email_sync, + "_list_history_message_ids", + lambda credentials, start_history_id, limit: (["unavailable-message"], "102", 1), + ) + response = requests.Response() + response.status_code = 500 + response.url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/unavailable-message" + + def fetch_unavailable_message(credentials, message_id): + raise requests.HTTPError("temporary server error", response=response) + + monkeypatch.setattr(email_sync, "_fetch_message", fetch_unavailable_message) + + result = email_sync.sync_email(limit=10) + + assert result["failed_messages"] == 1 + assert result["scanned"] == 0 + assert store.get_sync_state()["history_id"] == "100" + + def test_full_sync_analyses_messages_concurrently(store, monkeypatch): import jobradar.email_sync as email_sync