diff --git a/CHANGELOG.md b/CHANGELOG.md index a222f96..cdcb5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [Unreleased] + +### Fixed + +- Worker tasks: use a fresh DB session for `send_user_notification` calls and move notifications after `db.commit()` to prevent the post-rollback `greenlet_spawn` SQLAlchemy error. +- Worker tasks: ensure `last_check_at` and error status are always committed before notifications, fixing accounts being endlessly re-queued after IMAP auth failures. + ## v0.3.2 (2026-03-28) ### Bug Fixes diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index d07eb31..1520caa 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -285,13 +285,14 @@ async def process_mail_account(account_id: int): "User must re-authorise." ) try: - await send_user_notification( - db=db, - user_id=int(account.user_id), - title="InboxRescue: Gmail Authorization Expired", - body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.", - notify_on_error=True, - ) + async with async_session_maker() as notif_db: + await send_user_notification( + db=notif_db, + user_id=int(account.user_id), + title="InboxRescue: Gmail Authorization Expired", + body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.", + notify_on_error=True, + ) except Exception as notify_exc: logger.warning( f"Failed to send revocation notification: {notify_exc}" @@ -364,19 +365,25 @@ async def process_mail_account(account_id: int): account.status = AccountStatus.ERROR # type: ignore[assignment] account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment] account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment] - try: - await send_user_notification( - db=db, - user_id=int(account.user_id), - title="InboxRescue: Mail Forwarding Failures", - body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.", - notify_on_error=True, - ) - except Exception as notify_exc: - logger.warning(f"Failed to send notification: {notify_exc}") await db.commit() + # Send failure notification after the commit so the status is + # persisted even if the notification fails. Use a fresh session + # to avoid interfering with the (now-committed) main transaction. + if emails_failed > 0: + try: + async with async_session_maker() as notif_db: + await send_user_notification( + db=notif_db, + user_id=int(account.user_id), + title="InboxRescue: Mail Forwarding Failures", + body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.", + notify_on_error=True, + ) + except Exception as notify_exc: + logger.warning(f"Failed to send notification: {notify_exc}") + # Record Prometheus metrics for this completed run _run_status = "completed" if emails_failed == 0 else "partial_failure" MAIL_PROCESSING_RUNS_TOTAL.labels(status=_run_status).inc() @@ -437,20 +444,6 @@ async def process_mail_account(account_id: int): # throttles re-dispatch instead of queuing a new task every cycle. account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment] - # Notify user about the error - try: - await send_user_notification( - db=db, - user_id=int(account.user_id), - title="InboxRescue: Mail Processing Error", - body=f"Error processing mail account '{account.name}': {e}", - notify_on_error=True, - ) - except Exception as notify_exc: - logger.warning( - f"Failed to send error notification: {notify_exc}" - ) - try: await db.commit() except Exception as commit_exc: @@ -459,6 +452,24 @@ async def process_mail_account(account_id: int): f"{account_id}: {commit_exc}" ) + # Send error notification after the commit (and outside the run/account + # guards) so the status is always persisted first. Use a fresh session + # to avoid the post-rollback session's broken greenlet context causing + # the notification query itself to fail with "greenlet_spawn has not + # been called". + if "account" in locals() and account is not None: + try: + async with async_session_maker() as notif_db: + await send_user_notification( + db=notif_db, + user_id=int(account.user_id), + title="InboxRescue: Mail Processing Error", + body=f"Error processing mail account '{account.name}': {e}", + notify_on_error=True, + ) + except Exception as notify_exc: + logger.warning(f"Failed to send error notification: {notify_exc}") + @celery_app.task(base=AsyncTask, name="app.workers.tasks.process_all_enabled_accounts") async def process_all_enabled_accounts(): diff --git a/docs/TODO.md b/docs/TODO.md index 82bca22..b5ada15 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,7 @@ Comprehensive task breakdown for repository improvements and production readines ## ✅ Recently Completed +- [x] Fixed worker `send_user_notification` using rolled-back DB session causing `greenlet_spawn has not been called` errors; status/`last_check_at` now always committed before sending notifications via a fresh session. - [x] **Pull Now**: Added "Pull Now" button on Accounts page that immediately queues a `process_mail_account` Celery task via `POST /mail-accounts/{id}/pull-now`. Button shows spinner while in flight and is disabled for inactive accounts. - [x] Fixed 21 mypy type errors: `Column[T]` vs native type mismatches in `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py` (`lifespan` parameter rename). - [x] **Provider logos rework**: Logos now displayed as full-width banner strips at the top of each account card using `next/image fill + object-contain`. Handles all aspect ratios (1:1 square to 6:1 wordmark) without distortion. Proton Mail added.