Skip to content

Commit dfcb74f

Browse files
committed
fix sync bug
1 parent 2f5846a commit dfcb74f

8 files changed

Lines changed: 292 additions & 67 deletions

File tree

apps/api/src/gmail-imap.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type ImapFlowClient = {
2828
flags: string[],
2929
): Promise<boolean>;
3030
fetch(
31-
range: string,
31+
range: string | { since: Date },
3232
query: {
3333
uid: true;
3434
flags: true;
@@ -160,11 +160,15 @@ export function createGmailImapMailboxSyncClient(
160160

161161
const afterUid = requestedMailbox?.afterUid;
162162
const incremental = afterUid !== undefined;
163-
const range = incremental ? `${afterUid + 1}:*` : `${Math.max(1, messageCount - 9)}:*`;
163+
const range = incremental
164+
? `${afterUid + 1}:*`
165+
: requestedMailbox?.since
166+
? { since: requestedMailbox.since }
167+
: "1:*";
164168
logInfo("gmail.messages.mailbox.fetch", {
165169
accountId: account.id,
166170
mailboxId: mailbox.path,
167-
range,
171+
range: typeof range === "string" ? range : `since:${requestedMailbox?.since?.toISOString()}`,
168172
messageCount,
169173
mode: incremental ? "incremental" : "backfill",
170174
});

apps/api/src/persistence.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,11 @@ export class MailDatabase {
405405
});
406406
}
407407

408-
saveMessage(message: StoredMessage): void {
408+
saveMessage(message: StoredMessage): { inserted: boolean } {
409+
const existing = this.database
410+
.prepare("SELECT 1 FROM messages WHERE id = ?")
411+
.get(message.id);
412+
409413
this.database
410414
.prepare(`
411415
INSERT INTO messages (
@@ -457,6 +461,8 @@ export class MailDatabase {
457461
JSON.stringify(message.attachments),
458462
);
459463
this.saveInlineMessageResources(message.id, message.inlineResources ?? []);
464+
465+
return { inserted: existing === undefined };
460466
}
461467

462468
private saveInlineMessageResources(messageId: string, resources: InlineMessageResource[]): void {
@@ -471,7 +477,7 @@ export class MailDatabase {
471477

472478
for (const resource of resources) {
473479
statement.run(
474-
resource.id,
480+
`${messageId}:${resource.id}`,
475481
messageId,
476482
resource.contentId,
477483
resource.mimeType,
@@ -681,9 +687,9 @@ export class MailDatabase {
681687
.prepare(`
682688
SELECT id, content_id, mime_type, size_bytes, bytes
683689
FROM inline_message_resources
684-
WHERE message_id = ? AND id = ?
690+
WHERE message_id = ? AND id IN (?, ?)
685691
`)
686-
.get(messageId, resourceId) as
692+
.get(messageId, resourceId, `${messageId}:${resourceId}`) as
687693
| {
688694
id: string;
689695
content_id: string;
@@ -1039,7 +1045,9 @@ export class MailDatabase {
10391045
};
10401046

10411047
return {
1042-
id: resource.id,
1048+
id: resource.id.startsWith(`${messageId}:`)
1049+
? resource.id.slice(messageId.length + 1)
1050+
: resource.id,
10431051
contentId: resource.content_id,
10441052
mimeType: resource.mime_type,
10451053
sizeBytes: resource.size_bytes,

apps/api/src/sync.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export async function syncRecentMessages({
271271
continue;
272272
}
273273

274-
mailDatabase.saveMessage({
274+
const saveResult = mailDatabase.saveMessage({
275275
id: message.id,
276276
stableIdentity: message.stableIdentity,
277277
threadId: message.threadId,
@@ -298,8 +298,10 @@ export async function syncRecentMessages({
298298
sizeBytes,
299299
})),
300300
});
301-
storedMessageCount += 1;
302-
result.storedMessageCount = (result.storedMessageCount ?? 0) + 1;
301+
if (saveResult.inserted) {
302+
storedMessageCount += 1;
303+
result.storedMessageCount = (result.storedMessageCount ?? 0) + 1;
304+
}
303305
mailDatabase.removeStaleMailboxEntries(message.id, syncedMailboxIds, message.mailboxIds);
304306

305307
for (const mailboxId of message.mailboxIds) {
@@ -380,7 +382,7 @@ export async function syncRecentReconciliation({
380382
continue;
381383
}
382384

383-
mailDatabase.saveMessage({
385+
const saveResult = mailDatabase.saveMessage({
384386
id: message.id,
385387
stableIdentity: message.stableIdentity,
386388
threadId: message.threadId,
@@ -407,7 +409,9 @@ export async function syncRecentReconciliation({
407409
sizeBytes,
408410
})),
409411
});
410-
result.storedMessageCount = (result.storedMessageCount ?? 0) + 1;
412+
if (saveResult.inserted) {
413+
result.storedMessageCount = (result.storedMessageCount ?? 0) + 1;
414+
}
411415

412416
for (const mailboxId of message.mailboxIds.filter((mailboxId) =>
413417
mailboxIds.includes(mailboxId),

apps/web/src/App.vue

Lines changed: 130 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const collapsedAccounts = ref(new Set(savedReaderLayout.collapsedAccounts));
5555
const collapsedMailboxGroups = ref(new Set(savedReaderLayout.collapsedMailboxGroups));
5656
const activeResize = ref<"nav" | "list" | null>(null);
5757
const syncJobsOpen = ref(false);
58+
const customSyncDialogOpen = ref(false);
59+
const customSyncAccountId = ref("");
60+
const customSyncDays = ref(90);
5861
const documentVisible = ref(
5962
typeof document === "undefined" ? true : document.visibilityState !== "hidden",
6063
);
@@ -124,6 +127,15 @@ const activeSyncJobs = computed(() =>
124127
syncJobs.value.filter((job) => job.state === "pending" || job.state === "running"),
125128
);
126129
const syncingAccountIds = computed(() => new Set(activeSyncJobs.value.map((job) => job.accountId)));
130+
const customSyncAccount = computed(() =>
131+
mailAccounts.value.find((account) => account.id === customSyncAccountId.value),
132+
);
133+
const customSyncRangeOptions = [
134+
{ label: "Last 30 days", value: 30 },
135+
{ label: "Last 90 days", value: 90 },
136+
{ label: "Last 365 days", value: 365 },
137+
{ label: "Last 10 years", value: 3650 },
138+
];
127139
128140
const messageListQuery = useQuery({
129141
queryKey: computed(() => ["message-list", readerRoute.value]),
@@ -202,7 +214,7 @@ const logoutMutation = useMutation({
202214
});
203215
204216
const syncJobMutation = useMutation({
205-
mutationFn: (accountId: string) => scheduleSyncJob({ accountId }),
217+
mutationFn: (request: { accountId: string; days?: number }) => scheduleSyncJob(request),
206218
onSuccess: async () => {
207219
await queryClient.invalidateQueries({ queryKey: ["sync-jobs"] });
208220
},
@@ -375,6 +387,32 @@ function syncJobsPollingInterval(query: { state: { data: SyncJobsResponse | unde
375387
return hasActiveJob ? 1_000 : 15_000;
376388
}
377389
390+
function accountContextMenuItems(account: MailAccountMailboxTree) {
391+
return [
392+
{
393+
label: "Custom sync...",
394+
icon: "i-lucide-calendar-clock",
395+
disabled: syncingAccountIds.value.has(account.id) || syncJobMutation.isPending.value,
396+
onSelect: () => openCustomSyncDialog(account),
397+
},
398+
];
399+
}
400+
401+
function openCustomSyncDialog(account: MailAccountMailboxTree) {
402+
customSyncAccountId.value = account.id;
403+
customSyncDays.value = 90;
404+
customSyncDialogOpen.value = true;
405+
}
406+
407+
function submitCustomSync() {
408+
if (!customSyncAccountId.value) {
409+
return;
410+
}
411+
412+
syncJobMutation.mutate({ accountId: customSyncAccountId.value, days: customSyncDays.value });
413+
customSyncDialogOpen.value = false;
414+
}
415+
378416
function syncJobSummary(job: SyncJobRecord): string {
379417
if (job.state === "failed") {
380418
return job.error ?? "Sync job failed";
@@ -614,12 +652,14 @@ function participantsLabel(participants: Array<{ address: string; displayName?:
614652
}
615653
616654
function formatDate(value: string): string {
617-
return new Intl.DateTimeFormat(undefined, {
618-
month: "short",
619-
day: "numeric",
620-
hour: "numeric",
621-
minute: "2-digit",
622-
}).format(new Date(value));
655+
const date = new Date(value);
656+
const year = date.getFullYear();
657+
const month = String(date.getMonth() + 1).padStart(2, "0");
658+
const day = String(date.getDate()).padStart(2, "0");
659+
const hour = String(date.getHours()).padStart(2, "0");
660+
const minute = String(date.getMinutes()).padStart(2, "0");
661+
662+
return `${year}-${month}-${day} ${hour}:${minute}`;
623663
}
624664
625665
function mailboxLabel(account: MailAccountMailboxTree, mailboxId: string): string {
@@ -748,6 +788,42 @@ async function selectAccountDefault(account: MailAccountMailboxTree) {
748788
</div>
749789
</header>
750790

791+
<UModal
792+
v-model:open="customSyncDialogOpen"
793+
title="Custom sync"
794+
:description="customSyncAccount?.emailAddress ?? customSyncAccountId"
795+
>
796+
<template #body>
797+
<form class="space-y-4" @submit.prevent="submitCustomSync">
798+
<div class="space-y-2">
799+
<label class="block text-sm font-medium text-slate-700" for="custom-sync-range">
800+
Message range
801+
</label>
802+
<USelect
803+
id="custom-sync-range"
804+
v-model="customSyncDays"
805+
:items="customSyncRangeOptions"
806+
value-key="value"
807+
label-key="label"
808+
class="w-full"
809+
/>
810+
</div>
811+
<div class="flex justify-end gap-2">
812+
<UButton color="neutral" variant="ghost" @click="customSyncDialogOpen = false">
813+
Cancel
814+
</UButton>
815+
<button
816+
class="h-8 rounded-md bg-slate-900 px-3 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
817+
type="submit"
818+
:disabled="!customSyncAccountId || syncJobMutation.isPending.value"
819+
>
820+
{{ syncJobMutation.isPending.value ? "Starting..." : "Start sync" }}
821+
</button>
822+
</div>
823+
</form>
824+
</template>
825+
</UModal>
826+
751827
<div v-if="mailAccounts.length === 0" class="grid flex-1 place-items-center px-6 text-center">
752828
<div>
753829
<h2 class="text-xl font-semibold">No mail accounts synced yet</h2>
@@ -766,51 +842,55 @@ async function selectAccountDefault(account: MailAccountMailboxTree) {
766842
<div class="flex h-full flex-col">
767843
<div class="min-h-0 flex-1 overflow-y-auto p-2">
768844
<div v-for="account in mailAccounts" :key="account.id" class="mb-3">
769-
<div class="flex items-start justify-between gap-1">
770-
<button
771-
class="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded text-slate-500 hover:bg-stone-200"
772-
type="button"
773-
:aria-label="
774-
accountCollapsed(account.id) ? 'Expand account' : 'Collapse account'
775-
"
776-
@click="toggleAccount(account.id)"
777-
>
778-
<span class="text-[10px]">{{ accountCollapsed(account.id) ? ">" : "v" }}</span>
779-
</button>
780-
<button
781-
class="min-w-0 flex-1 text-left"
782-
type="button"
783-
@click="selectAccountDefault(account)"
784-
>
785-
<span class="block min-w-0 truncate text-xs font-semibold">
786-
{{ account.id }}
787-
</span>
788-
<span class="block truncate text-[11px] text-slate-500">{{
789-
account.emailAddress
790-
}}</span>
791-
</button>
792-
<div class="flex shrink-0 items-center gap-1">
793-
<UBadge
794-
v-if="account.unreadCount > 0"
795-
color="neutral"
796-
size="sm"
797-
variant="subtle"
798-
>{{ account.unreadCount }}</UBadge
799-
>
800-
<UButton
801-
color="neutral"
802-
icon="i-lucide-refresh-cw"
803-
:loading="syncingAccountIds.has(account.id)"
804-
:disabled="
805-
syncingAccountIds.has(account.id) || syncJobMutation.isPending.value
845+
<UContextMenu :items="accountContextMenuItems(account)">
846+
<div class="flex items-start justify-between gap-1">
847+
<button
848+
class="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded text-slate-500 hover:bg-stone-200"
849+
type="button"
850+
:aria-label="
851+
accountCollapsed(account.id) ? 'Expand account' : 'Collapse account'
806852
"
807-
square
808-
variant="ghost"
809-
aria-label="Refresh account"
810-
@click="syncJobMutation.mutate(account.id)"
811-
/>
853+
@click="toggleAccount(account.id)"
854+
>
855+
<span class="text-[10px]">{{
856+
accountCollapsed(account.id) ? ">" : "v"
857+
}}</span>
858+
</button>
859+
<button
860+
class="min-w-0 flex-1 text-left"
861+
type="button"
862+
@click="selectAccountDefault(account)"
863+
>
864+
<span class="block min-w-0 truncate text-xs font-semibold">
865+
{{ account.id }}
866+
</span>
867+
<span class="block truncate text-[11px] text-slate-500">{{
868+
account.emailAddress
869+
}}</span>
870+
</button>
871+
<div class="flex shrink-0 items-center gap-1">
872+
<UBadge
873+
v-if="account.unreadCount > 0"
874+
color="neutral"
875+
size="sm"
876+
variant="subtle"
877+
>{{ account.unreadCount }}</UBadge
878+
>
879+
<UButton
880+
color="neutral"
881+
icon="i-lucide-refresh-cw"
882+
:loading="syncingAccountIds.has(account.id)"
883+
:disabled="
884+
syncingAccountIds.has(account.id) || syncJobMutation.isPending.value
885+
"
886+
square
887+
variant="ghost"
888+
aria-label="Refresh account"
889+
@click="syncJobMutation.mutate({ accountId: account.id })"
890+
/>
891+
</div>
812892
</div>
813-
</div>
893+
</UContextMenu>
814894
<div v-if="!accountCollapsed(account.id)" class="mt-1 space-y-0.5">
815895
<div
816896
v-for="row in visibleMailboxRows(account)"

tests/gmail-imap.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe("Gmail IMAP mailbox sync client", () => {
128128
expect(mailboxOpen).toHaveBeenCalledWith("[Gmail]/All Mail");
129129
expect(mailboxOpen).not.toHaveBeenCalledWith("[Gmail]");
130130
expect(fetch).toHaveBeenCalledWith(
131-
"33:*",
131+
{ since: new Date("2026-05-01T00:00:00.000Z") },
132132
{
133133
uid: true,
134134
flags: true,

0 commit comments

Comments
 (0)