refactor: mail manager changes

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-20 00:50:34 -04:00
parent dfe308c046
commit 3aef72b05c
4 changed files with 72 additions and 95 deletions
+60 -89
View File
@@ -1,6 +1,6 @@
/**
* Background mail synchronization composable
*
*
* Periodically checks for changes in mailboxes using the delta method
*/
@@ -8,12 +8,10 @@ import { ref, onMounted, onUnmounted } from 'vue';
import type { Ref } from 'vue';
import { useEntitiesStore } from '../stores/entitiesStore';
import { useCollectionsStore } from '../stores/collectionsStore';
import type { CollectionIdentifier, EntityIdentifier } from '../types/common';
export interface SyncSource {
provider: string;
service: string | number;
collections: (string | number)[];
}
/** A sync source is a collection identifier (provider:service:collection) to monitor */
export type SyncSource = CollectionIdentifier;
interface SyncOptions {
/** Polling interval in milliseconds (default: 30000 = 30 seconds) */
@@ -28,9 +26,9 @@ export interface MailSyncController {
isRunning: Ref<boolean>;
lastSync: Ref<Date | null>;
error: Ref<string | null>;
sources: Ref<SyncSource[]>;
addSource: (source: SyncSource) => void;
removeSource: (source: SyncSource) => void;
sources: Ref<CollectionIdentifier[]>;
addSource: (source: CollectionIdentifier) => void;
removeSource: (source: CollectionIdentifier) => void;
clearSources: () => void;
sync: () => Promise<void>;
start: () => void;
@@ -47,26 +45,21 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
const entitiesStore = useEntitiesStore();
const collectionsStore = useCollectionsStore();
const isRunning = ref(false);
const lastSync = ref<Date | null>(null);
const error = ref<string | null>(null);
const sources = ref<SyncSource[]>([]);
const signatures = ref<Record<string, Record<string, Record<string, string>>>>({});
const sources = ref<CollectionIdentifier[]>([]);
// Last known signature per collection identifier (updated by delta)
const signatures = ref<Record<string, string>>({});
let syncInterval: ReturnType<typeof setInterval> | null = null;
/**
* Add a source to sync (mailbox to monitor)
*/
function addSource(source: SyncSource) {
const exists = sources.value.some(
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
if (!exists) {
function addSource(source: CollectionIdentifier) {
if (!sources.value.includes(source)) {
sources.value.push(source);
}
}
@@ -74,13 +67,8 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
/**
* Remove a source from sync
*/
function removeSource(source: SyncSource) {
const index = sources.value.findIndex(
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
function removeSource(source: CollectionIdentifier) {
const index = sources.value.indexOf(source);
if (index !== -1) {
sources.value.splice(index, 1);
}
@@ -104,91 +92,74 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
try {
error.value = null;
// Build sources structure for delta request
const deltaSources: any = {};
sources.value.forEach(source => {
if (!deltaSources[source.provider]) {
deltaSources[source.provider] = {};
}
if (!deltaSources[source.provider][source.service]) {
deltaSources[source.provider][source.service] = {};
}
// Add collections to check with their signatures
source.collections.forEach(collection => {
// Look up signature from local tracking (updated by delta)
let signature = signatures.value[source.provider]?.[String(source.service)]?.[String(collection)];
// Fallback to collection signature if not yet synced
if (!signature) {
const collectionData = collectionsStore.collection(source.provider, source.service, collection);
signature = collectionData?.signature || '';
}
// Build flat identifier list for the delta request, embedding the last known
// signature for each collection as the entity slot (provider:service:collection:signature)
const requestSignatures: Record<string, string> = {};
const targets: (CollectionIdentifier | EntityIdentifier)[] = [];
console.log(`[Sync] Collection ${source.provider}/${source.service}/${collection} signature: "${signature}"`);
// Map collection identifier to signature string
deltaSources[source.provider][source.service][collection] = signature || '';
});
sources.value.forEach(collectionId => {
// Look up signature from local tracking (updated by delta), falling back to the
// collection's own signature if it has not been synced yet
let signature = signatures.value[collectionId];
if (!signature) {
const collectionData = collectionsStore.collection(collectionId);
signature = collectionData?.signature || '';
}
requestSignatures[collectionId] = signature;
targets.push(signature ? `${collectionId}:${signature}` as EntityIdentifier : collectionId);
});
// Get delta changes
const deltaResponse = await entitiesStore.delta(deltaSources);
const deltaResponse = await entitiesStore.delta(targets);
// If fetchDetails is enabled, fetch full entity data for additions and modifications
if (fetchDetails) {
const fetchPromises: Promise<any>[] = [];
Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => {
if (providerData === false) {
return;
}
Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => {
if (serviceData === false) {
return;
}
Object.entries(serviceData).forEach(([collection, collectionData]: [string, any]) => {
// Skip if no changes (server returns false or string signature)
// Skip if no changes (server returns false or a bare signature string)
if (collectionData === false || typeof collectionData === 'string') {
return;
}
const collectionId = `${provider}:${service}:${collection}` as CollectionIdentifier;
// Update signature tracking
if (collectionData.signature) {
if (!signatures.value[provider]) {
signatures.value[provider] = {};
}
if (!signatures.value[provider][service]) {
signatures.value[provider][service] = {};
}
signatures.value[provider][service][collection] = collectionData.signature;
console.log(`[Sync] Updated signature for ${provider}/${service}/${collection}: "${collectionData.signature}"`);
signatures.value[collectionId] = collectionData.signature;
}
// Check if signature actually changed (if not, skip fetching)
const oldSignature = deltaSources[provider]?.[service]?.[collection];
// Skip fetching when the signature did not actually change
const oldSignature = requestSignatures[collectionId];
const newSignature = collectionData.signature;
if (oldSignature && newSignature && oldSignature === newSignature) {
// Signature unchanged - server bug returning additions anyway, skip fetch
console.log(`[Sync] Skipping fetch for ${provider}/${service}/${collection} - signature unchanged (${newSignature})`);
return;
}
const identifiersToFetch = [
const changedIds = [
...(collectionData.additions || []),
...(collectionData.modifications || []),
];
if (identifiersToFetch.length > 0) {
console.log(`[Sync] Fetching ${identifiersToFetch.length} entities for ${provider}/${service}/${collection}`);
fetchPromises.push(
entitiesStore.fetch(
provider,
service,
collection,
identifiersToFetch
)
if (changedIds.length > 0) {
const entityTargets = changedIds.map(
(id: string | number) => `${collectionId}:${id}` as EntityIdentifier
);
fetchPromises.push(entitiesStore.fetch(entityTargets));
}
});
});
});
// Fetch all in parallel
await Promise.allSettled(fetchPromises);
}
@@ -209,10 +180,10 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
}
isRunning.value = true;
// Do initial sync
sync();
// Set up periodic sync
syncInterval = setInterval(() => {
sync();
@@ -228,7 +199,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
}
isRunning.value = false;
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
@@ -260,7 +231,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
lastSync,
error,
sources,
// Methods
addSource,
removeSource,
+6 -4
View File
@@ -173,14 +173,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
/**
* Retrieve delta changes for entities
*
* @param sources - source selector for delta check
*
* @param targets - collection identifiers (provider:service:collection), optionally
* suffixed with a known signature (provider:service:collection:signature)
* to request a delta relative to that signature
*
* @returns Promise with delta changes (additions, modifications, deletions)
*
*
* Note: Delta returns only identifiers, not full entities.
* Caller should fetch full entities for additions/modifications separately.
*/
async function delta(targets: CollectionIdentifier[]) {
async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]) {
transceiving.value = true
try {
const response = await entityService.delta({ targets })
+3 -1
View File
@@ -87,7 +87,9 @@ export type ApiStreamResponse<T = any> =
/**
* Identifiers for targeting specific providers, services, collections, or entities in list or extant operations.
*
* Operations accept flat arrays of colon-separated identifier strings, e.g.
* ["imap:account1:INBOX", "imap:account1:Sent:1001"].
*/
export type ProviderIdentifier = `${string}`;
export type ServiceIdentifier = `${string}:${string}`;
export type CollectionIdentifier = `${string}:${string}:${string | number}`;
+3 -1
View File
@@ -92,7 +92,9 @@ export interface EntityExtantResponse {
* Entity delta
*/
export interface EntityDeltaRequest {
targets: CollectionIdentifier[];
// Each target is provider:service:collection, or provider:service:collection:signature
// to request a delta relative to a known signature (the signature is the entity slot).
targets: (CollectionIdentifier | EntityIdentifier)[];
}
export interface EntityDeltaResponse {