feat: lazy per-folder email sync + IMAP/SMTP/notification fixes#195
feat: lazy per-folder email sync + IMAP/SMTP/notification fixes#195Systemsaholic merged 37 commits intomainfrom
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughImplements 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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>
There was a problem hiding this comment.
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 invalidmodevalues 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.,
```httpor```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
📒 Files selected for processing (7)
apps/admin/src/app/emails/inbox/_components/email-list.tsxapps/admin/src/app/emails/inbox/page.tsxapps/admin/src/hooks/use-emails.tsapps/api/src/email-accounts/email-accounts.controller.tsapps/api/src/email-accounts/imap-sync.service.tsdocs/superpowers/plans/2026-04-12-email-lazy-sync.mddocs/superpowers/specs/2026-04-12-email-lazy-sync-design.md
| 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 }) | ||
| }, | ||
| }) | ||
| } |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
apps/admin/src/app/emails/inbox/page.tsxapps/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
| // 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 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/admin/src/app/emails/inbox/page.tsx | head -150Repository: 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.tsxRepository: 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.
| // Manual refresh — folder-scoped | ||
| function handleRefresh() { | ||
| setSyncErrorFolder(null) | ||
| syncFolder.mutate({ folder: activeFolder, mode: 'incremental' }, { | ||
| onSuccess: () => { | ||
| setFolderSyncState(prev => ({ | ||
| ...prev, | ||
| [activeFolder]: { ...prev[activeFolder], lastSyncAt: Date.now() }, | ||
| })) | ||
| }, | ||
| }) |
There was a problem hiding this comment.
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.
| // 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>
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>
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
{ uid: true }as 3rd arg to client.fetch()lastUid+1:lastUid+batchSizeprevents unbounded pullsoldestSyncedUid <= 1), not row-countBackend — SMTP & Notifications
INBOX.Sent(wasSent, mismatched Dovecot hierarchy)forceChannels: ['platform']— prevents circular email-about-emailBackend — Validation & Config
media.phoenixvoyages.ca/media-stg.phoenixvoyages.cain DopplerFrontend
{ fetched, folder }shapeInfrastructure
Test plan
🤖 Generated with Claude Code