Skip to content

feat: lazy per-folder email sync + IMAP/SMTP/notification fixes#195

Merged
Systemsaholic merged 37 commits intomainfrom
feature/email-lazy-sync
Apr 13, 2026
Merged

feat: lazy per-folder email sync + IMAP/SMTP/notification fixes#195
Systemsaholic merged 37 commits intomainfrom
feature/email-lazy-sync

Conversation

@Systemsaholic
Copy link
Copy Markdown
Owner

@Systemsaholic Systemsaholic commented Apr 12, 2026

Summary

Complete email system overhaul: lazy per-folder IMAP sync, SMTP send fixes, background sync with notifications, email UX improvements, and R2 custom domain for deliverability.

Changes (36 commits, 16 files, +1117/-141)

Backend — IMAP Sync

  • Lazy sync architecture: 3 modes (incremental/hydrate_recent/hydrate_older) with bounded UID windows
  • BigInt fix: ImapFlow uidValidity→Number() before JSONB storage
  • UID FETCH fix: { uid: true } as 3rd arg to client.fetch()
  • Incremental cap: lastUid+1:lastUid+batchSize prevents unbounded pulls
  • historyExhausted: Cursor-based (oldestSyncedUid <= 1), not row-count
  • Error enrichment: IMAP server responseText in Sentry errors

Backend — SMTP & Notifications

  • Sent folder: Saves to INBOX.Sent (was Sent, mismatched Dovecot hierarchy)
  • Background sync: setInterval (2 min) + direct syncAccount() — BullMQ scheduler doesn't work on Upstash Redis
  • New email notifications: forceChannels: ['platform'] — prevents circular email-about-email
  • Notification channel: Added platform to client_care defaults

Backend — Validation & Config

  • PlatformPreferencesDto: Added trustedImageDomains, emailPaneWidths with decorators
  • R2 custom domains: media.phoenixvoyages.ca / media-stg.phoenixvoyages.ca in Doppler
  • Signature avatar: HTML width/height attributes for email client compatibility
  • Body-signature divider: Light gray border-top between content and signature

Frontend

  • Infinite scroll: useInfiniteEmails + useSyncFolder hooks
  • Folder sync on change: hydrate_recent/incremental based on staleness
  • Optimistic cache: Both regular + infinite caches for flag/move/delete
  • Trusted sender images: Sender domain check (not image URL domain)
  • Links in new tab: target="_blank" rel="noopener noreferrer" on all email links
  • Loading state: Spinner instead of premature "No emails" during sync
  • Sync error tracking: Prevents hydrate_older after folder sync failure
  • useSyncEmails response: Updated to match new { fetched, folder } shape

Infrastructure

  • Railway upgraded to Pro plan (SMTP egress)
  • R2 custom domains for email deliverability (eliminates URIBL spam scoring)
  • Signature avatar URLs backfilled from r2.dev to custom domain

Test plan

  • Verified on tf-demo.phoenixvoyages.ca
  • IMAP sync (hydrate_recent, incremental, hydrate_older)
  • SMTP send working (port 465, Railway Pro)
  • Background sync every 2 min with notifications
  • Trusted image domains persisting
  • Email deliverability (custom R2 domain, no spam flags)
  • Codex review — merge blockers addressed
  • Verify on production after merge
  • Backfill production DB signatures with media.phoenixvoyages.ca

🤖 Generated with Claude Code

Systemsaholic and others added 10 commits April 12, 2026 14:17
Two pipelines: INBOX incremental freshness (background 2min) +
per-folder historical hydration (on-demand). Bounded UID windows,
per-folder sync state with oldestSyncedUid + historyExhausted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8 tasks: refactor syncFolder (3 modes), folder-scoped endpoint,
INBOX-only background sync, frontend folder-click sync, scroll
hydration, manual refresh, rename migration, testing.

CRM contact matching preserved via existing matchContacts() in
upsertEmailFromImap — all sync modes use the same upsert path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…te_older modes

Adds three sync modes to the private syncFolder method:
- incremental (default): fetches UIDs from lastUid+1 onwards, preserving existing behavior
- hydrate_recent: fetches the newest batchSize messages from the tail of the mailbox
- hydrate_older: walks backwards from oldestSyncedUid in batches

Also adds UIDVALIDITY change detection (resets cursors and wipes stale data),
tracks oldestSyncedUid/historyExhausted in folder sync state, and caps
batchSize at 100. The upsertEmailFromImap path is unchanged — all modes
go through the same CRM contact matching pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exposes a public `syncFolderOnDemand` method on ImapSyncService that wraps
the private `syncFolder` with connection lifecycle management, and updates
`POST :id/sync` to accept folder, mode (incremental/hydrate_recent/hydrate_older),
and batchSize params — returning { fetched, folder, historyExhausted }.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove Sent folder sync from the background job — INBOX now syncs
incrementally with a 100-message cap. Sent and other folders will
only sync on-demand when the user opens them (Task 2 syncFolderOnDemand).
Removes unused findSentFolder private method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds useSyncFolder mutation hook that calls the folder-scoped sync endpoint.
When the active folder changes, a useEffect triggers sync if the folder
hasn't been synced in the last 60 seconds. First visit uses hydrate_recent
mode, subsequent visits use incremental mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When infinite scroll reaches the end of DB results but IMAP may have
more history, automatically triggers hydrate_older sync. Adds loading
indicator ("Loading older emails from server...") and "All emails loaded"
end-of-list message to EmailList component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the old useSyncEmails-based refresh button with handleRefresh
that calls syncFolder with incremental mode for the active folder.
Removes unused useSyncEmails import from inbox page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserve folder cursors (lastUid, oldestSyncedUid, etc.) under the new
path in syncState.folders after a successful IMAP mailboxRename call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tailfire-client Ready Ready Preview, Comment Apr 13, 2026 0:50am
tailfire-ota Ready Ready Preview, Comment Apr 13, 2026 0:50am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements folder-scoped lazy email synchronization: backend adds a folder-scoped sync endpoint and per-folder sync logic/cursors; frontend gains a folder-scoped sync hook, triggers syncs on folder change or infinite-scroll exhaustion, and surfaces distinct loading/exhausted UI states.

Changes

Cohort / File(s) Summary
Email List UI
apps/admin/src/app/emails/inbox/_components/email-list.tsx
Added optional props isSyncingFolder? and folderExhausted?; render "Loading older emails from server..." when DB is exhausted but folder is syncing, and "All emails loaded" when folder is exhausted.
Inbox Page / Frontend Flow
apps/admin/src/app/emails/inbox/page.tsx
Replaced inbox-wide useSyncEmails with useSyncFolder; added per-folder lastSyncAt/historyExhausted state and effects to auto-trigger folder sync on folder change/staleness and to hydrate older messages when DB pagination exhausts; updated manual refresh and EmailList prop wiring.
Frontend Hooks
apps/admin/src/hooks/use-emails.ts
Added exported useSyncFolder(accountId) mutation (POST /email-accounts/{id}/sync with {folder,mode,batchSize}) returning {fetched,folder,historyExhausted?} and invalidating ['emails-infinite']/emailKeys.all; updated useDeleteEmail and useMoveEmail to cancel queries, optimistically remove items from infinite pages[*].emails, and invalidate on error/settle.
Backend Controller
apps/api/src/email-accounts/email-accounts.controller.ts
POST /email-accounts/:id/sync now accepts optional {folder, mode, batchSize}, calls folder-scoped sync, returns {fetched, folder, historyExhausted?}, and increased throttle from 3→5 per 60000ms.
IMAP Sync Service
apps/api/src/email-accounts/imap-sync.service.ts
Constrained background syncAccount to INBOX incremental; expanded syncFolder to support `incremental
HTML Sanitizer
apps/admin/src/lib/sanitize-email-html.ts
Always parse DOM and rewrite a[href] to include target="_blank" and rel="noopener noreferrer"; preserve conditional remote image blocking when allowAllImages is false.
Documentation
docs/superpowers/plans/2026-04-12-email-lazy-sync.md, docs/superpowers/specs/2026-04-12-email-lazy-sync-design.md
Added plan and architecture spec describing lazy folder-scoped sync modes, API contract, cursor semantics, frontend trigger points, and rollout/testing checklist.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Frontend as Frontend (Inbox Page)
    participant Hook as Hook (useSyncFolder)
    participant API as Backend API (Controller)
    participant Service as ImapSyncService
    participant IMAP as IMAP Server

    User->>Frontend: Click folder tab
    Frontend->>Frontend: Check folderSyncState (stale > 60s?)
    alt Sync needed
        Frontend->>Hook: mutate({folder, mode, batchSize?})
        Hook->>API: POST /email-accounts/{id}/sync {folder, mode, batchSize?}
        API->>Service: syncFolderOnDemand(id, folder, mode, batchSize)
        Service->>IMAP: Connect & fetch UID range per mode
        IMAP-->>Service: Return messages
        Service->>Service: Upsert emails, update cursors (lastUid, oldestSyncedUid, historyExhausted)
        Service-->>API: {fetched, folder, historyExhausted?}
        API-->>Hook: response
        Hook->>Frontend: invalidate `emails-infinite`, return result
        Frontend->>Frontend: update folderSyncState[folder]
    end
Loading
sequenceDiagram
    participant Frontend as Frontend (Inbox)
    participant Hook as Hook (useSyncFolder)
    participant API as Backend API
    participant Service as ImapSyncService
    participant IMAP as IMAP Server
    participant DB as Database

    Frontend->>Frontend: Scroll → hasNextPage = false
    alt history not exhausted
        Frontend->>Hook: call mutate({folder, mode:'hydrate_older'})
        Hook->>API: POST /email-accounts/{id}/sync {folder, mode:'hydrate_older'}
        API->>Service: syncFolderOnDemand(...)
        Service->>IMAP: Fetch older UID range
        IMAP-->>Service: Return older messages
        Service->>DB: Upsert older emails, update oldestSyncedUid / historyExhausted
        Service-->>API: {fetched, folder, historyExhausted?}
        API-->>Hook: response → invalidate caches
        Frontend->>Frontend: render newly loaded emails or "All emails loaded"
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hop through folders, one UID at a time,
Hydrate recent, older — fetch without a crime,
Cursors remember where I last did peep,
Load more as you scroll, then rest—no bulk to sweep,
A rabbit's cheer for lazy sync — nibble, leap! ✨📧

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: lazy per-folder email sync + IMAP/SMTP/notification fixes' accurately captures the primary change: implementing lazy, per-folder email synchronization with bounded batches. It is concise, specific, and clearly summarizes the main feature being introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/email-lazy-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

useMoveEmail and useDeleteEmail now optimistically remove emails from
both the regular emailKeys cache AND the emails-infinite cache. Without
this, drag-and-drop moves and deletes required a page refresh to see
the change in the infinite scroll inbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/api/src/email-accounts/email-accounts.controller.ts (1)

131-139: Consider adding input validation for sync request body.

The inline type { folder?: string; mode?: 'incremental' | 'hydrate_recent' | 'hydrate_older'; batchSize?: number } won't be validated by NestJS class-validator. A malicious client could send invalid mode values that would propagate to the service layer.

Consider creating a DTO class with validation decorators:

Proposed DTO
// dto/sync-folder.dto.ts
import { IsOptional, IsString, IsIn, IsInt, Max, Min } from 'class-validator'

export class SyncFolderDto {
  `@IsOptional`()
  `@IsString`()
  folder?: string

  `@IsOptional`()
  `@IsIn`(['incremental', 'hydrate_recent', 'hydrate_older'])
  mode?: 'incremental' | 'hydrate_recent' | 'hydrate_older'

  `@IsOptional`()
  `@IsInt`()
  `@Min`(1)
  `@Max`(100)
  batchSize?: number
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/email-accounts/email-accounts.controller.ts` around lines 131 -
139, Replace the inline body type in the controller method with a validated DTO:
create a SyncFolderDto class (e.g., dto/sync-folder.dto.ts) that uses
class-validator decorators (IsOptional, IsString for folder; IsOptional,
IsIn(['incremental','hydrate_recent','hydrate_older']) for mode; IsOptional,
IsInt, Min, Max for batchSize), import and use SyncFolderDto in the
email-accounts controller parameter (the method calling
emailAccountsService.findOne and imapSyncService.syncFolderOnDemand), and ensure
the app/global ValidationPipe or a method-level/usePipes(ValidationPipe) is
active so invalid mode/batchSize values are rejected before hitting
imapSyncService.
docs/superpowers/specs/2026-04-12-email-lazy-sync-design.md (1)

69-81: Add language specifier to fenced code block.

The API endpoint example lacks a language specifier. Adding one (e.g., ```http or ```text) improves rendering and consistency with other code blocks in the document.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/specs/2026-04-12-email-lazy-sync-design.md` around lines 69
- 81, The fenced code block showing the POST /email-accounts/:id/sync API
example is missing a language specifier; update the opening fence to include a
language (for example use ```http or ```text) so the block renders consistently
with other docs and preserves HTTP syntax highlighting for the request/response
example.
docs/superpowers/plans/2026-04-12-email-lazy-sync.md (1)

1-13: Heading structure skips a level.

The document jumps from # Email Lazy Sync Implementation Plan (H1) directly to ### Task 1 (H3), skipping H2. Consider adding an H2 section header (e.g., ## Tasks) before the task list, or change tasks to H2 headings for consistent document structure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-04-12-email-lazy-sync.md` around lines 1 - 13,
The heading hierarchy skips a level by jumping from H1 ("Email Lazy Sync
Implementation Plan") directly to H3 ("### Task 1"); update the document
structure by inserting an H2 section (for example "## Tasks" or "##
Implementation Tasks") before the task list or by promoting each "### Task X" to
H2 ("## Task X") so headings are properly nested and the outline/TOC tools work
correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin/src/app/emails/inbox/page.tsx`:
- Around line 86-96: The syncFolder.mutate call lacks error handling; update the
call to include an onError callback for syncFolder.mutate that captures the
exception with Sentry (e.g., Sentry.captureException(err)) and surfaces user
feedback (for example via a toast or by updating setFolderSyncState to mark an
error state for activeFolder). Ensure the onError receives the error and context
(result or variables), logs enough context (activeFolder, mode), and does not
swallow the error so callers can react appropriately; reference the existing
syncFolder.mutate invocation, setFolderSyncState updater, and activeFolder to
locate where to add the onError handler.

In `@apps/admin/src/hooks/use-emails.ts`:
- Around line 400-416: The hook useSyncFolder is missing an onError handler; add
an onError callback to the useMutation options (next to mutationFn and
onSuccess) that mirrors the other hooks (e.g., useSendEmail/useDeleteEmail) by
showing a toast/error notification with the error message when the API POST to
`/email-accounts/${accountId}/sync` fails, and include the thrown error details
so users see why sync failed; keep existing queryClient.invalidateQueries
behavior in onSuccess and do not change mutationFn or emailKeys.all references.

---

Nitpick comments:
In `@apps/api/src/email-accounts/email-accounts.controller.ts`:
- Around line 131-139: Replace the inline body type in the controller method
with a validated DTO: create a SyncFolderDto class (e.g.,
dto/sync-folder.dto.ts) that uses class-validator decorators (IsOptional,
IsString for folder; IsOptional,
IsIn(['incremental','hydrate_recent','hydrate_older']) for mode; IsOptional,
IsInt, Min, Max for batchSize), import and use SyncFolderDto in the
email-accounts controller parameter (the method calling
emailAccountsService.findOne and imapSyncService.syncFolderOnDemand), and ensure
the app/global ValidationPipe or a method-level/usePipes(ValidationPipe) is
active so invalid mode/batchSize values are rejected before hitting
imapSyncService.

In `@docs/superpowers/plans/2026-04-12-email-lazy-sync.md`:
- Around line 1-13: The heading hierarchy skips a level by jumping from H1
("Email Lazy Sync Implementation Plan") directly to H3 ("### Task 1"); update
the document structure by inserting an H2 section (for example "## Tasks" or "##
Implementation Tasks") before the task list or by promoting each "### Task X" to
H2 ("## Task X") so headings are properly nested and the outline/TOC tools work
correctly.

In `@docs/superpowers/specs/2026-04-12-email-lazy-sync-design.md`:
- Around line 69-81: The fenced code block showing the POST
/email-accounts/:id/sync API example is missing a language specifier; update the
opening fence to include a language (for example use ```http or ```text) so the
block renders consistently with other docs and preserves HTTP syntax
highlighting for the request/response example.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3448744-6b38-48e6-8b72-c417d75b3a0e

📥 Commits

Reviewing files that changed from the base of the PR and between 0e050f9 and 87d2f85.

📒 Files selected for processing (7)
  • apps/admin/src/app/emails/inbox/_components/email-list.tsx
  • apps/admin/src/app/emails/inbox/page.tsx
  • apps/admin/src/hooks/use-emails.ts
  • apps/api/src/email-accounts/email-accounts.controller.ts
  • apps/api/src/email-accounts/imap-sync.service.ts
  • docs/superpowers/plans/2026-04-12-email-lazy-sync.md
  • docs/superpowers/specs/2026-04-12-email-lazy-sync-design.md

Comment thread apps/admin/src/app/emails/inbox/page.tsx
Comment on lines +400 to +416
export function useSyncFolder(accountId: string | null) {
const queryClient = useQueryClient()

return useMutation({
mutationFn: (params: { folder?: string; mode?: 'incremental' | 'hydrate_recent' | 'hydrate_older'; batchSize?: number }) => {
if (!accountId) throw new Error('No account selected')
return api.post<{ fetched: number; folder: string; historyExhausted?: boolean }>(
`/email-accounts/${accountId}/sync`,
params,
)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['emails-infinite'] })
queryClient.invalidateQueries({ queryKey: emailKeys.all })
},
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add error handling for consistency with other mutation hooks.

The useSyncFolder hook lacks an onError handler, unlike similar hooks in this file (useSendEmail, useDeleteEmail, useMoveEmail, useSyncEmails) which all display toast notifications on failure. Silent failures will leave users confused when sync doesn't work.

Proposed fix
 export function useSyncFolder(accountId: string | null) {
   const queryClient = useQueryClient()
+  const { toast } = useToast()

   return useMutation({
     mutationFn: (params: { folder?: string; mode?: 'incremental' | 'hydrate_recent' | 'hydrate_older'; batchSize?: number }) => {
       if (!accountId) throw new Error('No account selected')
       return api.post<{ fetched: number; folder: string; historyExhausted?: boolean }>(
         `/email-accounts/${accountId}/sync`,
         params,
       )
     },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['emails-infinite'] })
       queryClient.invalidateQueries({ queryKey: emailKeys.all })
     },
+    onError: (error: any) => {
+      const isAuthFailed = error?.code === 'IMAP_AUTH_FAILED'
+      toast({
+        title: isAuthFailed ? 'Email authentication failed' : 'Sync failed',
+        description: isAuthFailed
+          ? 'Your email password may have changed. Update it in Profile > Email.'
+          : error.message || 'Could not sync folder.',
+        variant: 'destructive',
+      })
+    },
   })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/hooks/use-emails.ts` around lines 400 - 416, The hook
useSyncFolder is missing an onError handler; add an onError callback to the
useMutation options (next to mutationFn and onSuccess) that mirrors the other
hooks (e.g., useSendEmail/useDeleteEmail) by showing a toast/error notification
with the error message when the API POST to `/email-accounts/${accountId}/sync`
fails, and include the thrown error details so users see why sync failed; keep
existing queryClient.invalidateQueries behavior in onSuccess and do not change
mutationFn or emailKeys.all references.

…op on sync failure

- Wrap IMAP connect in try/catch with handleImapAuthFailure
- Remove duplicate updateSyncState call (syncFolder already persists)
- Track sync errors per folder to prevent hydrate_older from firing after failure
- Clear error state on manual refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ImapFlow returns uidValidity as BigInt, which JSON.stringify cannot serialize.
This caused 500 errors when saving sync state to the JSONB column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…isibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin/src/app/emails/inbox/page.tsx`:
- Around line 155-165: The handleRefresh function clears the folder error flag
but never restores it when syncFolder.mutate fails; add an onError handler to
the syncFolder.mutate call that sets the error flag back (call
setSyncErrorFolder(activeFolder) or similar) and optionally preserve any error
info, while keeping the existing onSuccess behavior (update setFolderSyncState).
This ensures the hydrate guard remains effective for the failing folder.
- Around line 79-126: Update both useEffect dependency arrays to include the
gating state variables so effects re-run when pending/loading change: add
syncFolder.isPending to the first effect's deps (the effect that computes
isStale and calls syncFolder.mutate) and add syncFolder.isPending and
emailsLoading to the second effect's deps (the hydrate_older effect that checks
hasNextPage/folderExhausted/loaded). Also modify handleRefresh to supply an
onError callback to the syncFolder.mutate call (or equivalent) that restores
setSyncErrorFolder(activeFolder) when the refresh fails so the syncErrorFolder
guard remains correct; reference the syncFolder.mutate usages, syncErrorFolder
state, and handleRefresh function when making these edits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 878a8959-1d26-4771-b413-c6e3f53669fb

📥 Commits

Reviewing files that changed from the base of the PR and between 3400304 and cf2d808.

📒 Files selected for processing (2)
  • apps/admin/src/app/emails/inbox/page.tsx
  • apps/api/src/email-accounts/imap-sync.service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/email-accounts/imap-sync.service.ts

Comment on lines +79 to +126
// Sync folder on change if stale (>60s since last sync)
useEffect(() => {
if (!accountId || !activeFolder || syncFolder.isPending) return
const state = folderSyncState[activeFolder]
const isStale = !state?.lastSyncAt || Date.now() - state.lastSyncAt > 60000
if (!isStale) return

const mode = state?.lastSyncAt ? 'incremental' : 'hydrate_recent'
setSyncErrorFolder(null)
syncFolder.mutate({ folder: activeFolder, mode, batchSize: 50 }, {
onSuccess: (result) => {
setFolderSyncState(prev => ({
...prev,
[activeFolder]: {
lastSyncAt: Date.now(),
historyExhausted: result.historyExhausted ?? prev[activeFolder]?.historyExhausted,
},
}))
},
onError: () => {
setSyncErrorFolder(activeFolder)
},
})
}, [activeFolder, accountId]) // eslint-disable-line react-hooks/exhaustive-deps

// Hydrate older emails from IMAP when scroll exhausts DB pages
useEffect(() => {
if (hasNextPage || folderExhausted || !accountId || syncFolder.isPending || emailsLoading) return
if (syncErrorFolder === activeFolder) return // Don't hydrate if folder sync already failed
// All DB pages loaded but IMAP may have more — hydrate older batch
const loaded = infiniteData?.pages.flatMap(p => p.emails).length ?? 0
if (loaded === 0) return // Don't hydrate if nothing loaded yet

syncFolder.mutate({ folder: activeFolder, mode: 'hydrate_older', batchSize: 50 }, {
onSuccess: (result) => {
setFolderSyncState(prev => ({
...prev,
[activeFolder]: {
...prev[activeFolder],
historyExhausted: result.historyExhausted ?? false,
},
}))
},
onError: () => {
setSyncErrorFolder(activeFolder)
},
})
}, [hasNextPage, folderExhausted]) // eslint-disable-line react-hooks/exhaustive-deps
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/admin/src/app/emails/inbox/page.tsx | head -150

Repository: Systemsaholic/tailfire

Length of output: 7495


🏁 Script executed:

cat -n apps/admin/src/app/emails/inbox/page.tsx | sed -n '150,250p'

Repository: Systemsaholic/tailfire

Length of output: 3970


🏁 Script executed:

cat -n apps/admin/src/app/emails/inbox/page.tsx | sed -n '250,350p'

Repository: Systemsaholic/tailfire

Length of output: 5515


🏁 Script executed:

rg -A 5 "onError.*syncErrorFolder" apps/admin/src/app/emails/inbox/page.tsx

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

cat -n apps/admin/src/app/emails/inbox/page.tsx | sed -n '155,167p'

Repository: Systemsaholic/tailfire

Length of output: 513


Add gating state to effect dependencies and restore error state on manual refresh failure.

The first effect (lines 80–102) returns early when syncFolder.isPending is true (line 81) but excludes this from dependencies [activeFolder, accountId]. When a user switches folders during a pending sync, the effect returns early and never re-evaluates after the mutation settles, leaving the new folder unsynced.

The second effect (lines 105–126) similarly returns early on syncFolder.isPending or emailsLoading (line 106) but excludes both from dependencies [hasNextPage, folderExhausted]. State changes in these gating variables do not trigger re-evaluation, so hydrate_older may never run after loading completes.

Additionally, handleRefresh() (line 157) unconditionally clears syncErrorFolder but provides no onError handler. If the mutation fails, syncErrorFolder remains null, and the guard on line 107 (if (syncErrorFolder === activeFolder) return) is bypassed on subsequent hydrate attempts.

Either add the gating variables to dependencies or use an explicit queue mechanism to trigger the next sync/hydrate after mutations settle. Restore syncErrorFolder in onError callback of handleRefresh.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/emails/inbox/page.tsx` around lines 79 - 126, Update both
useEffect dependency arrays to include the gating state variables so effects
re-run when pending/loading change: add syncFolder.isPending to the first
effect's deps (the effect that computes isStale and calls syncFolder.mutate) and
add syncFolder.isPending and emailsLoading to the second effect's deps (the
hydrate_older effect that checks hasNextPage/folderExhausted/loaded). Also
modify handleRefresh to supply an onError callback to the syncFolder.mutate call
(or equivalent) that restores setSyncErrorFolder(activeFolder) when the refresh
fails so the syncErrorFolder guard remains correct; reference the
syncFolder.mutate usages, syncErrorFolder state, and handleRefresh function when
making these edits.

Comment on lines +155 to +165
// Manual refresh — folder-scoped
function handleRefresh() {
setSyncErrorFolder(null)
syncFolder.mutate({ folder: activeFolder, mode: 'incremental' }, {
onSuccess: () => {
setFolderSyncState(prev => ({
...prev,
[activeFolder]: { ...prev[activeFolder], lastSyncAt: Date.now() },
}))
},
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Restore the folder error flag when manual refresh fails.

Line 157 clears syncErrorFolder, but this mutate call never sets it back on failure. If the refresh request errors, the guard in the hydrate effect no longer applies and the page can immediately fire a follow-up hydrate_older request for the same broken folder.

Suggested fix
 function handleRefresh() {
   setSyncErrorFolder(null)
   syncFolder.mutate({ folder: activeFolder, mode: 'incremental' }, {
     onSuccess: () => {
       setFolderSyncState(prev => ({
         ...prev,
         [activeFolder]: { ...prev[activeFolder], lastSyncAt: Date.now() },
       }))
     },
+    onError: () => {
+      setSyncErrorFolder(activeFolder)
+    },
   })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Manual refresh — folder-scoped
function handleRefresh() {
setSyncErrorFolder(null)
syncFolder.mutate({ folder: activeFolder, mode: 'incremental' }, {
onSuccess: () => {
setFolderSyncState(prev => ({
...prev,
[activeFolder]: { ...prev[activeFolder], lastSyncAt: Date.now() },
}))
},
})
// Manual refresh — folder-scoped
function handleRefresh() {
setSyncErrorFolder(null)
syncFolder.mutate({ folder: activeFolder, mode: 'incremental' }, {
onSuccess: () => {
setFolderSyncState(prev => ({
...prev,
[activeFolder]: { ...prev[activeFolder], lastSyncAt: Date.now() },
}))
},
onError: () => {
setSyncErrorFolder(activeFolder)
},
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/emails/inbox/page.tsx` around lines 155 - 165, The
handleRefresh function clears the folder error flag but never restores it when
syncFolder.mutate fails; add an onError handler to the syncFolder.mutate call
that sets the error flag back (call setSyncErrorFolder(activeFolder) or similar)
and optionally preserve any error info, while keeping the existing onSuccess
behavior (update setFolderSyncState). This ensures the hydrate guard remains
effective for the failing folder.

ImapFlow's fetch(range, query, options) requires options.uid=true to use
UID FETCH instead of sequence FETCH. Having uid:true in the query only
requests UID in the response. Without this, UID ranges like "787:*" are
interpreted as sequence numbers, causing "Invalid messageset" when the
mailbox has fewer messages than the range start.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces setInterval with the same BullMQ repeat pattern used by
AutomationService for birthday checks, payment scans, and other
recurring jobs. This pattern is proven on Railway + Upstash Redis.

- Uses queue.add(..., { repeat: { every: 120000 } })
- Worker concurrency 5 handles per-account syncs in parallel
- Durable across restarts/deploys
- No duplicates across replicas (BullMQ repeat deduplication)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstash Redis (serverless) doesn't reliably promote delayed/repeat jobs.
Use setInterval as the timer, but dispatch into the BullMQ queue so we
get worker concurrency, job deduplication, and history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BullMQ deduplicates by jobId. The fixed `sync-${accountId}` jobId meant
the completed job lingered in Redis, causing all subsequent sync adds to
be silently skipped as duplicates. Removing the fixed jobId lets each
cycle enqueue fresh sync jobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sync

Upstash Redis doesn't process queued jobs added from within processors.
Call syncAccount() directly with overlap guard. Notifications are sent
by syncAccount when new messages are found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
client_care category only sent via email, not in-app platform notifications.
New email alerts now appear in the bell icon notification dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Email notifications about received emails are circular — the user already
has the email in their inbox. Using forceChannels: ['platform'] ensures
only in-app bell notifications, preventing notification loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Systemsaholic Systemsaholic changed the title feat: lazy per-folder email sync + IMAP/SMTP fixes feat: lazy per-folder email sync + IMAP/SMTP/notification fixes Apr 13, 2026
Codex merge blockers:
1. Sent folder was hardcoded to INBOX.Sent — now resolves from existing
   outbound emails, sync state, or falls back to INBOX.Sent
2. Sync endpoint was throttled to 5 req/min but frontend auto-triggers
   on folder change + scroll — relaxed to 30 req/min

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant