diff --git a/CHANGELOG.md b/CHANGELOG.md index 94020f1..21aa1b6 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 + +- Fix timezone display in Mailbox Activity / Admin Logs pages: timestamps from the server were parsed as local time when no timezone indicator was present, causing relative times ("1h ago") and absolute dates to be shifted by the client's UTC offset. +- 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.4.0 (2026-03-28) ### Features 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 5ab347e..eabb671 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,6 +5,8 @@ Comprehensive task breakdown for repository improvements and production readines ## ✅ Recently Completed - [x] **IMAP reliability: switched to UID-based commands** — `_fetch_imap_emails` now uses `UID SEARCH`, `UID FETCH`, and `UID STORE` throughout. Sequence numbers are volatile (they shift on expunge), causing "Too many invalid IMAP commands" on strict servers (e.g. T-Online). UIDs are stable. The per-message `STORE +FLAGS \Seen` (redundant — RFC822 sets it implicitly) and per-message `STORE +FLAGS \Deleted` are replaced with single batch commands. Stale already-seen UIDs are re-marked `\Seen` in one command. Logout is now in a `finally` block so a mid-session `BYE` is handled gracefully. +- [x] Fixed timezone display bug in Mailbox Activity and Admin Logs pages: ISO timestamps without a `Z` suffix were parsed as local time by JavaScript, shifting "Xm ago" / "Xh ago" displays and absolute dates by the client's UTC offset. +- [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] **Dashboard redesign**: Replaced noisy "Recent Processing Runs" table with a per-account "Mailbox Status" view showing last-check status (OK/Error/Pending), relative timestamp, error messages, and lifetime counters. Stats cards updated to show all-time processed count and accounts-with-errors count. - [x] **Provider logos now saved on account creation**: `provider_name` field added to `MailAccountCreate` and `MailAccountUpdate` schemas (backend and frontend). `ProviderWizard` now passes `provider_name` in its `onSelect` callback; `AddMailAccountModal` stores it so logos are displayed correctly on the accounts page. - [x] **Fetch button UX improvements**: The "fetch emails" button on the accounts page now shows a "Fetch" text label for clarity, a tooltip explaining its purpose, a spinning "Fetching…" state during the API call, and a brief green "Queued!" confirmation after success. diff --git a/frontend/src/app/admin/logs/page.tsx b/frontend/src/app/admin/logs/page.tsx index c2c8c23..a614da9 100644 --- a/frontend/src/app/admin/logs/page.tsx +++ b/frontend/src/app/admin/logs/page.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from '@/store/authStore'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { adminApi, AdminProcessingRun } from '@/lib/api'; +import { parseUTC } from '@/lib/date-utils'; import { Activity, ChevronLeft, @@ -31,7 +32,7 @@ function formatDuration(seconds?: number | null): string { } function formatDate(iso: string): string { - return new Date(iso).toLocaleString(undefined, { + return parseUTC(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium', }); diff --git a/frontend/src/app/logs/page.tsx b/frontend/src/app/logs/page.tsx index 7212f61..9a47161 100644 --- a/frontend/src/app/logs/page.tsx +++ b/frontend/src/app/logs/page.tsx @@ -5,6 +5,7 @@ import { AuthGuard } from '@/components/AuthGuard'; import { DashboardLayout } from '@/components/DashboardLayout'; import { useQuery } from '@tanstack/react-query'; import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api'; +import { parseUTC } from '@/lib/date-utils'; import { Inbox, ChevronLeft, @@ -20,7 +21,7 @@ import { function formatRelative(iso?: string | null): string { if (!iso) return 'Never'; - const diff = Date.now() - new Date(iso).getTime(); + const diff = Date.now() - parseUTC(iso).getTime(); const minutes = Math.floor(diff / 60000); if (minutes < 1) return 'Just now'; if (minutes < 60) return `${minutes}m ago`; @@ -30,7 +31,7 @@ function formatRelative(iso?: string | null): string { } function formatDate(iso: string): string { - return new Date(iso).toLocaleString(undefined, { + return parseUTC(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium', }); diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts new file mode 100644 index 0000000..f41868e --- /dev/null +++ b/frontend/src/lib/date-utils.ts @@ -0,0 +1,16 @@ +/** + * Parse an ISO-8601 datetime string as UTC. + * + * The backend stores all timestamps in UTC. When the serialised string has no + * explicit timezone indicator (no trailing `Z` or `±HH:MM` offset), JavaScript's + * `Date` constructor treats it as *local* time. For clients in UTC+N this + * shifts every timestamp N hours into the past, causing "Xh ago" relative + * labels and `toLocaleString()` absolute dates to be wrong. + * + * This helper appends `Z` to any timezone-naive ISO string so it is always + * interpreted as UTC. Strings that already carry timezone info are passed + * through unchanged. + */ +export function parseUTC(iso: string): Date { + return new Date(/Z|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'); +}