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 * Background mail synchronization composable
* *
* Periodically checks for changes in mailboxes using the delta method * 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 type { Ref } from 'vue';
import { useEntitiesStore } from '../stores/entitiesStore'; import { useEntitiesStore } from '../stores/entitiesStore';
import { useCollectionsStore } from '../stores/collectionsStore'; import { useCollectionsStore } from '../stores/collectionsStore';
import type { CollectionIdentifier, EntityIdentifier } from '../types/common';
export interface SyncSource { /** A sync source is a collection identifier (provider:service:collection) to monitor */
provider: string; export type SyncSource = CollectionIdentifier;
service: string | number;
collections: (string | number)[];
}
interface SyncOptions { interface SyncOptions {
/** Polling interval in milliseconds (default: 30000 = 30 seconds) */ /** Polling interval in milliseconds (default: 30000 = 30 seconds) */
@@ -28,9 +26,9 @@ export interface MailSyncController {
isRunning: Ref<boolean>; isRunning: Ref<boolean>;
lastSync: Ref<Date | null>; lastSync: Ref<Date | null>;
error: Ref<string | null>; error: Ref<string | null>;
sources: Ref<SyncSource[]>; sources: Ref<CollectionIdentifier[]>;
addSource: (source: SyncSource) => void; addSource: (source: CollectionIdentifier) => void;
removeSource: (source: SyncSource) => void; removeSource: (source: CollectionIdentifier) => void;
clearSources: () => void; clearSources: () => void;
sync: () => Promise<void>; sync: () => Promise<void>;
start: () => void; start: () => void;
@@ -47,26 +45,21 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
const entitiesStore = useEntitiesStore(); const entitiesStore = useEntitiesStore();
const collectionsStore = useCollectionsStore(); const collectionsStore = useCollectionsStore();
const isRunning = ref(false); const isRunning = ref(false);
const lastSync = ref<Date | null>(null); const lastSync = ref<Date | null>(null);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const sources = ref<SyncSource[]>([]); const sources = ref<CollectionIdentifier[]>([]);
const signatures = ref<Record<string, Record<string, Record<string, string>>>>({}); // Last known signature per collection identifier (updated by delta)
const signatures = ref<Record<string, string>>({});
let syncInterval: ReturnType<typeof setInterval> | null = null; let syncInterval: ReturnType<typeof setInterval> | null = null;
/** /**
* Add a source to sync (mailbox to monitor) * Add a source to sync (mailbox to monitor)
*/ */
function addSource(source: SyncSource) { function addSource(source: CollectionIdentifier) {
const exists = sources.value.some( if (!sources.value.includes(source)) {
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
if (!exists) {
sources.value.push(source); sources.value.push(source);
} }
} }
@@ -74,13 +67,8 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
/** /**
* Remove a source from sync * Remove a source from sync
*/ */
function removeSource(source: SyncSource) { function removeSource(source: CollectionIdentifier) {
const index = sources.value.findIndex( const index = sources.value.indexOf(source);
s => s.provider === source.provider
&& s.service === source.service
&& JSON.stringify(s.collections) === JSON.stringify(source.collections)
);
if (index !== -1) { if (index !== -1) {
sources.value.splice(index, 1); sources.value.splice(index, 1);
} }
@@ -104,91 +92,74 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
try { try {
error.value = null; error.value = null;
// Build sources structure for delta request // Build flat identifier list for the delta request, embedding the last known
const deltaSources: any = {}; // signature for each collection as the entity slot (provider:service:collection:signature)
const requestSignatures: Record<string, string> = {};
sources.value.forEach(source => { const targets: (CollectionIdentifier | EntityIdentifier)[] = [];
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 || '';
}
console.log(`[Sync] Collection ${source.provider}/${source.service}/${collection} signature: "${signature}"`); sources.value.forEach(collectionId => {
// Look up signature from local tracking (updated by delta), falling back to the
// Map collection identifier to signature string // collection's own signature if it has not been synced yet
deltaSources[source.provider][source.service][collection] = signature || ''; 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 // 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 is enabled, fetch full entity data for additions and modifications
if (fetchDetails) { if (fetchDetails) {
const fetchPromises: Promise<any>[] = []; const fetchPromises: Promise<any>[] = [];
Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => { Object.entries(deltaResponse).forEach(([provider, providerData]: [string, any]) => {
if (providerData === false) {
return;
}
Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => { Object.entries(providerData).forEach(([service, serviceData]: [string, any]) => {
if (serviceData === false) {
return;
}
Object.entries(serviceData).forEach(([collection, collectionData]: [string, any]) => { 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') { if (collectionData === false || typeof collectionData === 'string') {
return; return;
} }
const collectionId = `${provider}:${service}:${collection}` as CollectionIdentifier;
// Update signature tracking // Update signature tracking
if (collectionData.signature) { if (collectionData.signature) {
if (!signatures.value[provider]) { signatures.value[collectionId] = collectionData.signature;
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}"`);
} }
// Check if signature actually changed (if not, skip fetching) // Skip fetching when the signature did not actually change
const oldSignature = deltaSources[provider]?.[service]?.[collection]; const oldSignature = requestSignatures[collectionId];
const newSignature = collectionData.signature; const newSignature = collectionData.signature;
if (oldSignature && newSignature && oldSignature === newSignature) { 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; return;
} }
const identifiersToFetch = [ const changedIds = [
...(collectionData.additions || []), ...(collectionData.additions || []),
...(collectionData.modifications || []), ...(collectionData.modifications || []),
]; ];
if (identifiersToFetch.length > 0) { if (changedIds.length > 0) {
console.log(`[Sync] Fetching ${identifiersToFetch.length} entities for ${provider}/${service}/${collection}`); const entityTargets = changedIds.map(
fetchPromises.push( (id: string | number) => `${collectionId}:${id}` as EntityIdentifier
entitiesStore.fetch(
provider,
service,
collection,
identifiersToFetch
)
); );
fetchPromises.push(entitiesStore.fetch(entityTargets));
} }
}); });
}); });
}); });
// Fetch all in parallel // Fetch all in parallel
await Promise.allSettled(fetchPromises); await Promise.allSettled(fetchPromises);
} }
@@ -209,10 +180,10 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
} }
isRunning.value = true; isRunning.value = true;
// Do initial sync // Do initial sync
sync(); sync();
// Set up periodic sync // Set up periodic sync
syncInterval = setInterval(() => { syncInterval = setInterval(() => {
sync(); sync();
@@ -228,7 +199,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
} }
isRunning.value = false; isRunning.value = false;
if (syncInterval) { if (syncInterval) {
clearInterval(syncInterval); clearInterval(syncInterval);
syncInterval = null; syncInterval = null;
@@ -260,7 +231,7 @@ export function useMailSync(options: SyncOptions = {}): MailSyncController {
lastSync, lastSync,
error, error,
sources, sources,
// Methods // Methods
addSource, addSource,
removeSource, removeSource,
+6 -4
View File
@@ -173,14 +173,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
/** /**
* Retrieve delta changes for entities * 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) * @returns Promise with delta changes (additions, modifications, deletions)
* *
* Note: Delta returns only identifiers, not full entities. * Note: Delta returns only identifiers, not full entities.
* Caller should fetch full entities for additions/modifications separately. * Caller should fetch full entities for additions/modifications separately.
*/ */
async function delta(targets: CollectionIdentifier[]) { async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]) {
transceiving.value = true transceiving.value = true
try { try {
const response = await entityService.delta({ targets }) 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. * 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 ProviderIdentifier = `${string}`;
export type ServiceIdentifier = `${string}:${string}`; export type ServiceIdentifier = `${string}:${string}`;
export type CollectionIdentifier = `${string}:${string}:${string | number}`; export type CollectionIdentifier = `${string}:${string}:${string | number}`;
+3 -1
View File
@@ -92,7 +92,9 @@ export interface EntityExtantResponse {
* Entity delta * Entity delta
*/ */
export interface EntityDeltaRequest { 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 { export interface EntityDeltaResponse {