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
14 changes: 14 additions & 0 deletions BUGFIX_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 标签

**错误**
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 9 additions & 2 deletions jobradar/email_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
55 changes: 55 additions & 0 deletions tests/test_application_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading