From 34dd5122b3e20aebb80557ac03842999cccc9ebb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:30:34 +0200 Subject: [PATCH] feat: logging to include categories for better log management --- app/[locale]/calendar/page.tsx | 2 +- app/[locale]/page.tsx | 22 ++-- components/email/email-viewer.tsx | 48 ++++----- components/settings/advanced-settings.tsx | 27 ++++- lib/debug.ts | 81 +++++++++----- lib/jmap/client.ts | 124 +++++++++++----------- lib/notification-sound.ts | 4 +- lib/sieve/generator.ts | 2 +- lib/sieve/parser.ts | 2 +- lib/tnef.ts | 54 +++++----- lib/utils.ts | 15 ++- locales/en/common.json | 17 +++ stores/auth-store.ts | 8 +- stores/calendar-store.ts | 34 +++--- stores/filter-store.ts | 10 +- stores/settings-store.ts | 23 ++++ stores/task-store.ts | 10 +- 17 files changed, 289 insertions(+), 194 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 0b1e2238..4f56a171 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -765,7 +765,7 @@ export default function CalendarPage() { return; } - debug.log('Calendar visibility summary', { + debug.log('calendar', 'Calendar visibility summary', { totalEvents: events.length, visibleEvents: visibleEvents.length, hiddenEvents: hiddenEvents.length, diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 94af300d..8c047226 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -352,13 +352,13 @@ export default function Home() { if (pushEnabled) { setPushConnected(true); - debug.log('[Push] Push notifications successfully enabled'); + debug.log('push', '[Push] Push notifications successfully enabled'); } else { - debug.log('[Push] Push notifications not available on this server'); + debug.log('push', '[Push] Push notifications not available on this server'); } } catch (error) { // Push notifications are optional - don't break the app if they fail - debug.log('[Push] Failed to setup push notifications:', error); + debug.log('push', '[Push] Failed to setup push notifications:', error); } } catch (error) { console.error('Error loading email data:', error); @@ -392,7 +392,7 @@ export default function Home() { useEffect(() => { // Clear any existing timeout when email changes if (markAsReadTimeoutRef.current) { - debug.log('[Mark as Read] Clearing previous timeout'); + debug.log('email', '[Mark as Read] Clearing previous timeout'); clearTimeout(markAsReadTimeoutRef.current); markAsReadTimeoutRef.current = null; } @@ -404,20 +404,20 @@ export default function Home() { // Get current setting value const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; - debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id); + debug.log('email', '[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id); if (markAsReadDelay === -1) { // Never mark as read automatically - debug.log('[Mark as Read] Never mode - email will stay unread'); + debug.log('email', '[Mark as Read] Never mode - email will stay unread'); } else if (markAsReadDelay === 0) { // Mark as read instantly - debug.log('[Mark as Read] Instant mode - marking as read now'); + debug.log('email', '[Mark as Read] Instant mode - marking as read now'); markAsRead(client, selectedEmail.id, true); } else { // Mark as read after delay - debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms'); + debug.log('email', '[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms'); markAsReadTimeoutRef.current = setTimeout(() => { - debug.log('[Mark as Read] Timeout fired - marking as read now'); + debug.log('email', '[Mark as Read] Timeout fired - marking as read now'); markAsRead(client, selectedEmail.id, true); markAsReadTimeoutRef.current = null; }, markAsReadDelay); @@ -426,7 +426,7 @@ export default function Home() { // Cleanup on unmount or when dependencies change return () => { if (markAsReadTimeoutRef.current) { - debug.log('[Mark as Read] Cleanup - clearing timeout'); + debug.log('email', '[Mark as Read] Cleanup - clearing timeout'); clearTimeout(markAsReadTimeoutRef.current); markAsReadTimeoutRef.current = null; } @@ -441,7 +441,7 @@ export default function Home() { if (emailNotificationsEnabled && emailNotificationSound) { playNotificationSound(notificationSoundChoice); } - debug.log('New email received:', newEmailNotification.subject); + debug.log('email', 'New email received:', newEmailNotification.subject); clearNewEmailNotification(); } }, [newEmailNotification, clearNewEmailNotification]); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 68af0db0..d0e8ce5b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1820,12 +1820,12 @@ export function EmailViewer({ const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); if (!tnefAtt?.blobId) { - debug.log('TNEF: No winmail.dat attachment found in email', email?.id); + debug.log('email', 'TNEF: No winmail.dat attachment found in email', email?.id); return; } - debug.group('TNEF Processing'); - debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); + debug.group('TNEF Processing', 'email'); + debug.log('email', 'Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); // Check if the email already has a usable HTML body with real content // Outlook often forwards TNEF emails with an HTML body that's just Word @@ -1835,46 +1835,46 @@ export function EmailViewer({ let hasRealHtmlBody = !!htmlValue; if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) { hasRealHtmlBody = false; - debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body'); + debug.log('email', 'TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body'); } if (hasRealHtmlBody) { - debug.log('TNEF: Email has real HTML body, will extract attachments only'); + debug.log('email', 'TNEF: Email has real HTML body, will extract attachments only'); } else { - debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); + debug.log('email', 'TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); } let cancelled = false; async function processTnef() { try { - debug.time('TNEF fetch blob'); + debug.time('TNEF fetch blob', 'email'); const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); - debug.timeEnd('TNEF fetch blob'); - debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); + debug.timeEnd('TNEF fetch blob', 'email'); + debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); if (cancelled) { - debug.log('TNEF: Processing cancelled after fetch'); + debug.log('email', 'TNEF: Processing cancelled after fetch'); debug.groupEnd(); return; } if (blobBytes.byteLength === 0) { - debug.warn('TNEF: Fetched blob is empty (0 bytes)'); + debug.warn('email', 'TNEF: Fetched blob is empty (0 bytes)'); debug.groupEnd(); return; } const tnefData = new Uint8Array(blobBytes); - debug.time('TNEF parse'); + debug.time('TNEF parse', 'email'); const parsed = parseTnef(tnefData); - debug.timeEnd('TNEF parse'); + debug.timeEnd('TNEF parse', 'email'); if (cancelled) { - debug.log('TNEF: Processing cancelled after parse'); + debug.log('email', 'TNEF: Processing cancelled after parse'); debug.groupEnd(); return; } - debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); + debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); if (parsed.htmlBody && !hasRealHtmlBody) { setTnefHtml(parsed.htmlBody); @@ -1884,11 +1884,11 @@ export function EmailViewer({ } if (parsed.attachments.length > 0) { setTnefAttachments(parsed.attachments); - debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', ')); + debug.log('email', 'TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', ')); } if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) { - debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format'); + debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format'); } debug.groupEnd(); @@ -1926,13 +1926,13 @@ export function EmailViewer({ const hasRealText = !!textValue; if (hasRealHtml || hasRealText) { - debug.log('Embedded RFC822: Outer email has real body content, not unwrapping'); + debug.log('email', 'Embedded RFC822: Outer email has real body content, not unwrapping'); return; } - debug.group('Embedded RFC822 Unwrapping'); - debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); - debug.log('Outer email body is empty, will unwrap embedded email'); + debug.group('Embedded RFC822 Unwrapping', 'email'); + debug.log('email', 'Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); + debug.log('email', 'Outer email body is empty, will unwrap embedded email'); let cancelled = false; @@ -1941,7 +1941,7 @@ export function EmailViewer({ const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); if (cancelled) { debug.groupEnd(); return; } if (blobBytes.byteLength === 0) { - debug.warn('Embedded RFC822: Fetched blob is empty'); + debug.warn('email', 'Embedded RFC822: Fetched blob is empty'); debug.groupEnd(); return; } @@ -1951,7 +1951,7 @@ export function EmailViewer({ const parsed = await parser.parse(new Uint8Array(blobBytes)); if (cancelled) { debug.groupEnd(); return; } - debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)', + debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)', ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments?.length ?? 0); @@ -1963,7 +1963,7 @@ export function EmailViewer({ } if (parsed.attachments && parsed.attachments.length > 0) { setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); - debug.log('Embedded RFC822 attachments:', parsed.attachments.map( + debug.log('email', 'Embedded RFC822 attachments:', parsed.attachments.map( a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')' ).join(', ')); } diff --git a/components/settings/advanced-settings.tsx b/components/settings/advanced-settings.tsx index 0dd634c8..aad81b22 100644 --- a/components/settings/advanced-settings.tsx +++ b/components/settings/advanced-settings.tsx @@ -7,11 +7,12 @@ import { useConfig } from '@/hooks/use-config'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { usePolicyStore } from '@/stores/policy-store'; +import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store'; export function AdvancedSettings() { const t = useTranslations('settings.advanced'); const tCommon = useTranslations('common'); - const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = + const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = useSettingsStore(); const { settingsSyncEnabled } = useConfig(); const [showResetConfirm, setShowResetConfirm] = useState(false); @@ -72,6 +73,30 @@ export function AdvancedSettings() { )} + {/* Debug Categories */} + {debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( +
+

{t('debug_categories.description')}

+ {ALL_DEBUG_CATEGORIES.map((cat) => ( + + { + updateSetting('debugCategories', { + ...debugCategories, + [cat.id]: checked, + }); + }} + /> + + ))} +
+ )} + {/* Settings Sync */} {settingsSyncEnabled && ( diff --git a/lib/debug.ts b/lib/debug.ts index 4846b97f..116235b2 100644 --- a/lib/debug.ts +++ b/lib/debug.ts @@ -1,25 +1,53 @@ import { useSettingsStore } from '@/stores/settings-store'; +import type { DebugCategory } from '@/stores/settings-store'; /** - * Debug logger that respects the debugMode setting. + * Check if debug logging is enabled, optionally for a specific category. + * When a category is provided, both debugMode AND that category must be enabled. + */ +function isEnabled(category?: DebugCategory): boolean { + const state = useSettingsStore.getState(); + if (!state.debugMode) return false; + if (!category) return true; + return state.debugCategories?.[category] !== false; +} + +/** + * Debug logger that respects the debugMode setting and category filters. * Use this instead of console.log for conditional debug output. + * + * Each method accepts an optional category as the first argument. + * When a category is provided, the message only logs if that category is enabled + * in Settings > Advanced > Debug Categories. + * + * Usage: + * debug.log('calendar', 'Event created', event); // Only logs when 'calendar' category is on + * debug.log('Uncategorized message'); // Logs whenever debugMode is on */ export const debug = { /** - * Log a debug message (only when debugMode is enabled) + * Log a debug message (only when debugMode is enabled and category is active) */ - log: (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.log('[DEBUG]', ...args); + log: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => { + if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) { + if (isEnabled(categoryOrMsg)) { + console.log(`[DEBUG:${categoryOrMsg}]`, ...args); + } + } else if (isEnabled()) { + console.log('[DEBUG]', categoryOrMsg, ...args); } }, /** - * Log a warning message (only when debugMode is enabled) + * Log a warning message (only when debugMode is enabled and category is active) */ - warn: (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.warn('[DEBUG]', ...args); + warn: (categoryOrMsg: DebugCategory | unknown, ...args: unknown[]) => { + if (typeof categoryOrMsg === 'string' && isCategoryKey(categoryOrMsg)) { + if (isEnabled(categoryOrMsg)) { + console.warn(`[DEBUG:${categoryOrMsg}]`, ...args); + } + } else if (isEnabled()) { + console.warn('[DEBUG]', categoryOrMsg, ...args); } }, @@ -31,11 +59,11 @@ export const debug = { }, /** - * Start a collapsed console group (only when debugMode is enabled) + * Start a collapsed console group (only when debugMode is enabled and category is active) */ - group: (label: string) => { - if (useSettingsStore.getState().debugMode) { - console.group(`[DEBUG] ${label}`); + group: (label: string, category?: DebugCategory) => { + if (isEnabled(category)) { + console.group(`[DEBUG${category ? ':' + category : ''}] ${label}`); } }, @@ -43,35 +71,40 @@ export const debug = { * End a console group (only when debugMode is enabled) */ groupEnd: () => { - if (useSettingsStore.getState().debugMode) { + if (isEnabled()) { console.groupEnd(); } }, /** - * Start a performance timer (only when debugMode is enabled) + * Start a performance timer (only when debugMode is enabled and category is active) */ - time: (label: string) => { - if (useSettingsStore.getState().debugMode) { - console.time(`[DEBUG] ${label}`); + time: (label: string, category?: DebugCategory) => { + if (isEnabled(category)) { + console.time(`[DEBUG${category ? ':' + category : ''}] ${label}`); } }, /** * End a performance timer (only when debugMode is enabled) */ - timeEnd: (label: string) => { - if (useSettingsStore.getState().debugMode) { - console.timeEnd(`[DEBUG] ${label}`); + timeEnd: (label: string, category?: DebugCategory) => { + if (isEnabled(category)) { + console.timeEnd(`[DEBUG${category ? ':' + category : ''}] ${label}`); } }, /** - * Log a table (only when debugMode is enabled) + * Log a table (only when debugMode is enabled and category is active) */ - table: (data: unknown) => { - if (useSettingsStore.getState().debugMode) { + table: (data: unknown, category?: DebugCategory) => { + if (isEnabled(category)) { console.table(data); } } }; + +const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']); +function isCategoryKey(value: string): value is DebugCategory { + return CATEGORY_KEYS.has(value); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c1a0a0d5..db2c6868 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -670,12 +670,12 @@ export class JMAPClient implements IJMAPClient { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; - debug.log(`[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`); + debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`); // Warn if response might be truncated const maxObjects = this.getMaxObjectsInGet(); if (rawMailboxes.length >= maxObjects) { - debug.warn( + debug.warn('jmap', `[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `Some mailboxes may be missing — nested folders could appear orphaned at root level.` ); @@ -685,7 +685,7 @@ export class JMAPClient implements IJMAPClient { const returnedIds = new Set(rawMailboxes.map(mb => mb.id)); const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId)); if (missingParents.length > 0) { - debug.warn( + debug.warn('jmap', `[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`, missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId })) ); @@ -755,12 +755,12 @@ export class JMAPClient implements IJMAPClient { if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; - debug.log(`[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`); + debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`); // Warn if response might be truncated const maxObjects = this.getMaxObjectsInGet(); if (rawMailboxes.length >= maxObjects) { - debug.warn( + debug.warn('jmap', `[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `Some mailboxes may be missing.` ); @@ -1995,7 +1995,7 @@ export class JMAPClient implements IJMAPClient { lines.push('END:VCALENDAR'); const icsContent = lines.join('\r\n') + '\r\n'; - debug.log('[iMIP] Generated ICS:\n' + icsContent); + debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent); const statusLabels: Record = { ACCEPTED: 'Accepted', @@ -2005,7 +2005,7 @@ export class JMAPClient implements IJMAPClient { const statusLabel = statusLabels[opts.status] || opts.status; const subject = `${statusLabel}: ${opts.summary || 'Event'}`; - debug.log('[iMIP] identityId:', finalIdentityId); + debug.log('calendar', '[iMIP] identityId:', finalIdentityId); const emailId = `imip-reply-${Date.now()}`; const emailCreate: Record = { @@ -2038,12 +2038,12 @@ export class JMAPClient implements IJMAPClient { }, "1"], ]; - debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); - debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); + debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); + debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); const response = await this.request(methodCalls); - debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); + debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { @@ -2058,7 +2058,7 @@ export class JMAPClient implements IJMAPClient { } } } - debug.log('[iMIP] sendImipReply completed successfully'); + debug.log('calendar', '[iMIP] sendImipReply completed successfully'); } /** @@ -2222,7 +2222,7 @@ export class JMAPClient implements IJMAPClient { async sendImipCancellation(event: CalendarEvent): Promise { if (!event.participants) return; if (event.status && event.status !== 'cancelled') { - debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status); + debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status); } const mailboxes = await this.getMailboxes(); @@ -3361,8 +3361,8 @@ export class JMAPClient implements IJMAPClient { const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; cleanRecurrenceRules(cleanEvent as unknown as Record); - debug.group('CalendarEvent/create'); - debug.log('CalendarEvent/create outgoing payload', { + debug.group('CalendarEvent/create', 'calendar'); + debug.log('calendar', 'CalendarEvent/create outgoing payload', { accountId, sendSchedulingMessages, eventKeys: Object.keys(cleanEvent), @@ -3382,40 +3382,40 @@ export class JMAPClient implements IJMAPClient { ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); - debug.log('CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null); + debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-event"]) { const error = result.notCreated["new-event"]; - debug.warn('CalendarEvent/create notCreated', error); - debug.warn('CalendarEvent/create invalid properties', error.properties); - debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent)); + debug.warn('calendar', 'CalendarEvent/create notCreated', error); + debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties); + debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent)); debug.groupEnd(); throw new Error(error.description || "Failed to create calendar event"); } const createdId = result.created?.["new-event"]?.id; - debug.log('CalendarEvent/create server acknowledged created id', { + debug.log('calendar', 'CalendarEvent/create server acknowledged created id', { createdId, created: result.created?.['new-event'] || null, }); if (createdId) { const created = await this.getCalendarEvent(createdId, targetAccountId); - debug.log('CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created)); + debug.log('calendar', 'CalendarEvent/create fetched created event', getCalendarEventDebugSnapshot(created)); if (created?.uid) { try { const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId); - debug.log('CalendarEvent/create verification query by uid', { + debug.log('calendar', 'CalendarEvent/create verification query by uid', { uid: created.uid, matchCount: verificationMatches.length, matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)), }); } catch (verificationError) { - debug.warn('CalendarEvent/create verification query failed', verificationError); + debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError); } } @@ -3424,7 +3424,7 @@ export class JMAPClient implements IJMAPClient { return created; } - debug.warn('CalendarEvent/create server returned created id but CalendarEvent/get returned null', { + debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', { createdId, targetAccountId, }); @@ -3455,7 +3455,7 @@ export class JMAPClient implements IJMAPClient { createMap[`new-${i}`] = clean; } - debug.log('CalendarEvent/batchCreate', { count: events.length, accountId }); + debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); const response = await this.request([ ["CalendarEvent/set", { accountId, create: createMap }, "0"] @@ -3471,7 +3471,7 @@ export class JMAPClient implements IJMAPClient { if (result.created?.[key]?.id) { createdIds.push(result.created[key].id); } else if (result.notCreated?.[key]) { - debug.warn(`CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); + debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); failed.push(key); } } @@ -3496,7 +3496,7 @@ export class JMAPClient implements IJMAPClient { createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)); } - debug.log('CalendarEvent/batchCreate result', { + debug.log('calendar', 'CalendarEvent/batchCreate result', { requested: events.length, created: createdEvents.length, failed: failed.length, @@ -3527,7 +3527,7 @@ export class JMAPClient implements IJMAPClient { setArgs.sendSchedulingMessages = sendSchedulingMessages; } - debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages }); + debug.log('calendar', 'CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages }); const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] @@ -3549,7 +3549,7 @@ export class JMAPClient implements IJMAPClient { debug.error('CalendarEvent/set notUpdated', { eventId, error }); throw new Error(error.description || "Failed to update calendar event"); } - debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null }); + debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null }); return; } @@ -3600,7 +3600,7 @@ export class JMAPClient implements IJMAPClient { setArgs.sendSchedulingMessages = sendSchedulingMessages; } - debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages }); + debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages }); const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] @@ -3622,7 +3622,7 @@ export class JMAPClient implements IJMAPClient { debug.error('CalendarEvent/set notDestroyed', { eventId, error }); throw new Error(error.description || "Failed to delete calendar event"); } - debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed }); + debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed }); return; } @@ -3654,8 +3654,8 @@ export class JMAPClient implements IJMAPClient { async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); - debug.group('CalendarTask/fetch'); - debug.log('CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); + debug.group('CalendarTask/fetch', 'tasks'); + debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); try { // Strategy 1: query with types filter (JMAP spec compliant) @@ -3664,7 +3664,7 @@ export class JMAPClient implements IJMAPClient { filter.inCalendars = calendarIds; } - debug.log('CalendarTask/fetch query filter', filter); + debug.log('tasks', 'CalendarTask/fetch query filter', filter); const response = await this.request([ ["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"], @@ -3678,13 +3678,13 @@ export class JMAPClient implements IJMAPClient { const queryResponse = response.methodResponses?.[0]; const getResponse = response.methodResponses?.[1]; - debug.log('CalendarTask/fetch query method', queryResponse?.[0]); - debug.log('CalendarTask/fetch query result', queryResponse?.[1]); + debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]); + debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]); if (queryResponse?.[0] === "error") { - debug.warn('CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]); + debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]); const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); - debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks'); + debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks'); debug.groupEnd(); return tasks; } @@ -3692,22 +3692,22 @@ export class JMAPClient implements IJMAPClient { if (getResponse?.[0] === "CalendarEvent/get") { const list = (getResponse[1].list || []) as CalendarTask[]; const queryIds = queryResponse?.[1]?.ids || []; - debug.log('CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds); - debug.log('CalendarTask/fetch get returned', list.length, 'objects'); + debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds); + debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects'); // If the types filter returned 0 results, the server may have silently // ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to // a full scan so we can detect tasks by their properties. if (queryIds.length === 0) { - debug.warn('CalendarTask/fetch types filter returned 0 results, falling back to full scan'); + debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan'); const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); - debug.log('CalendarTask/fetch fallback returned', tasks.length, 'tasks'); + debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks'); debug.groupEnd(); return tasks; } list.forEach((task, i) => { - debug.log(`CalendarTask/fetch [${i}]`, { + debug.log('tasks', `CalendarTask/fetch [${i}]`, { id: task.id, uid: task.uid, '@type': task['@type'], @@ -3724,12 +3724,12 @@ export class JMAPClient implements IJMAPClient { ...task, '@type': 'Task' as const, })); - debug.log('CalendarTask/fetch complete,', results.length, 'tasks'); + debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks'); debug.groupEnd(); return results; } - debug.warn('CalendarTask/fetch unexpected response shape', response.methodResponses); + debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses); debug.groupEnd(); return []; } catch (error) { @@ -3746,7 +3746,7 @@ export class JMAPClient implements IJMAPClient { */ private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); - debug.log('CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects'); + debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects'); // CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type const response = await this.request([ @@ -3758,12 +3758,12 @@ export class JMAPClient implements IJMAPClient { ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") { - debug.warn('CalendarTask/fallback unexpected response', response.methodResponses?.[0]); + debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]); return []; } const allObjects = (response.methodResponses[0][1].list || []) as Record[]; - debug.log('CalendarTask/fallback total calendar objects returned:', allObjects.length); + debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length); const tasks: CalendarTask[] = []; const calendarIdSet = calendarIds ? new Set(calendarIds) : null; @@ -3779,7 +3779,7 @@ export class JMAPClient implements IJMAPClient { || ('percentComplete' in obj); const isCalDavTask = type !== 'Event' && hasTaskFields; - debug.log('CalendarTask/fallback scan', { + debug.log('tasks', 'CalendarTask/fallback scan', { id: obj.id, '@type': type, title: obj.title, @@ -3796,7 +3796,7 @@ export class JMAPClient implements IJMAPClient { if (calendarIdSet) { const objCalendarIds = obj.calendarIds as Record | undefined; if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) { - debug.log('CalendarTask/fallback skipping task (not in requested calendars)', obj.id); + debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id); return; } } @@ -3804,9 +3804,9 @@ export class JMAPClient implements IJMAPClient { tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask); }); - debug.log('CalendarTask/fallback detected', tasks.length, 'tasks'); + debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks'); tasks.forEach((t, i) => { - debug.log(`CalendarTask/fallback [${i}]`, { + debug.log('tasks', `CalendarTask/fallback [${i}]`, { id: t.id, uid: t.uid, title: t.title, @@ -3825,9 +3825,9 @@ export class JMAPClient implements IJMAPClient { const { '@type': _type, ...taskData } = task; const cleanTask = { ...taskData, '@type': 'Task' }; - debug.group('CalendarTask/create'); - debug.log('CalendarTask/create accountId', accountId); - debug.log('CalendarTask/create outgoing payload', cleanTask); + debug.group('CalendarTask/create', 'tasks'); + debug.log('tasks', 'CalendarTask/create accountId', accountId); + debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask); const response = await this.request([ ["CalendarEvent/set", { @@ -3838,27 +3838,27 @@ export class JMAPClient implements IJMAPClient { ], this.calendarUsing()); const result = response.methodResponses?.[0]?.[1]; - debug.log('CalendarTask/create raw set response', result); + debug.log('tasks', 'CalendarTask/create raw set response', result); if (result?.notCreated?.["new-task"]) { const error = result.notCreated["new-task"]; - debug.warn('CalendarTask/create REJECTED by server', error); + debug.warn('tasks', 'CalendarTask/create REJECTED by server', error); debug.groupEnd(); throw new Error(error.description || "Failed to create task"); } const createdId = result?.created?.["new-task"]?.id; const serverCreated = result?.created?.["new-task"]; - debug.log('CalendarTask/create server acknowledged', { createdId, serverCreated }); + debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated }); if (!createdId) { - debug.warn('CalendarTask/create no id in server response'); + debug.warn('tasks', 'CalendarTask/create no id in server response'); debug.groupEnd(); throw new Error("Failed to create task — no id returned"); } // Fetch back with task-specific properties - debug.log('CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); + debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, @@ -3870,10 +3870,10 @@ export class JMAPClient implements IJMAPClient { if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const list = getResponse.methodResponses[0][1].list || []; const notFound = getResponse.methodResponses[0][1].notFound || []; - debug.log('CalendarTask/create get response', { found: list.length, notFound }); + debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound }); if (list[0]) { const created = { ...list[0], '@type': 'Task' as const } as CalendarTask; - debug.log('CalendarTask/create final task object', { + debug.log('tasks', 'CalendarTask/create final task object', { id: created.id, uid: created.uid, '@type': created['@type'], @@ -3889,7 +3889,7 @@ export class JMAPClient implements IJMAPClient { } } - debug.warn('CalendarTask/create re-fetch returned nothing for id', createdId); + debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId); debug.groupEnd(); throw new Error("Failed to fetch created task"); } diff --git a/lib/notification-sound.ts b/lib/notification-sound.ts index 02d87de8..13a21a34 100644 --- a/lib/notification-sound.ts +++ b/lib/notification-sound.ts @@ -31,7 +31,7 @@ function playFile(file: string) { const audio = new Audio(file); audio.volume = 0.3; audio.play().catch((e) => { - debug.log('Could not play audio file, falling back to beep:', e); + debug.log('push', 'Could not play audio file, falling back to beep:', e); playBeep(); }); } @@ -47,6 +47,6 @@ export function playNotificationSound(sound?: NotificationSoundChoice) { playBeep(); } } catch (e) { - debug.log('Could not play notification sound:', e); + debug.log('push', 'Could not play notification sound:', e); } } diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index ad705e30..6a494c2d 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -147,7 +147,7 @@ export function generateScript(rules: FilterRule[], vacation?: VacationSieveConf for (const rule of enabledRules) { if (rule.conditions.length === 0 || rule.actions.length === 0) { - debug.warn(`Skipping rule "${rule.name}": empty conditions or actions`); + debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`); continue; } diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index 381510c3..a2533809 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -109,7 +109,7 @@ export function parseScript(content: string): ParseResult { try { metadata = JSON.parse(jsonStr); } catch (e) { - debug.warn('Failed to parse Sieve metadata JSON:', e); + debug.warn('filters', 'Failed to parse Sieve metadata JSON:', e); return OPAQUE; } diff --git a/lib/tnef.ts b/lib/tnef.ts index 42200e3f..2d679e30 100644 --- a/lib/tnef.ts +++ b/lib/tnef.ts @@ -236,11 +236,11 @@ export function parseTnef(data: Uint8Array): TnefResult { attachments: [], }; - debug.group('TNEF Parser'); - debug.log('Input data size:', data.byteLength, 'bytes'); + debug.group('TNEF Parser', 'email'); + debug.log('email', 'Input data size:', data.byteLength, 'bytes'); if (data.byteLength < 6) { - debug.warn('TNEF data too small (< 6 bytes), skipping'); + debug.warn('email', 'TNEF data too small (< 6 bytes), skipping'); debug.groupEnd(); return result; } @@ -249,11 +249,11 @@ export function parseTnef(data: Uint8Array): TnefResult { const signature = r.readUint32LE(); if (signature !== TNEF_SIGNATURE) { - debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)'); + debug.warn('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)'); debug.groupEnd(); return result; } - debug.log('TNEF signature valid'); + debug.log('email', 'TNEF signature valid'); r.skip(2); // legacy key @@ -268,7 +268,7 @@ export function parseTnef(data: Uint8Array): TnefResult { attrCount++; if (attrLen > r.remaining - 2) { - debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available'); + debug.warn('email', 'Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available'); break; } @@ -276,17 +276,17 @@ export function parseTnef(data: Uint8Array): TnefResult { r.skip(2); // checksum const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')'; - debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen); + debug.log('email', 'Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen); if (level === LVL_MESSAGE) { if (attrID === attBody) { result.body = new TextDecoder('utf-8').decode(attrData); - debug.log(' → Extracted plain text body (' + result.body.length + ' chars)'); + debug.log('email', ' → Extracted plain text body (' + result.body.length + ' chars)'); } else if (attrID === attMAPIProps) { const props = parseMAPIProps(attrData); - debug.log(' → Parsed', props.size, 'MAPI properties from message'); + debug.log('email', ' → Parsed', props.size, 'MAPI properties from message'); props.forEach((val, propID) => { - debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); + debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); }); // HTML body @@ -298,9 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult { } else { result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value); } - debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)'); + debug.log('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)'); } else { - debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props'); + debug.log('email', ' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props'); } // Plain text body from MAPI props (fallback) @@ -308,9 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult { const bodyProp = props.get(PR_BODY); if (bodyProp?.value instanceof Uint8Array) { result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF); - debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)'); + debug.log('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)'); } else { - debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props'); + debug.log('email', ' → No plain text body property (PR_BODY 0x1000) found in MAPI props'); } } } @@ -318,7 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult { if (attrID === attAttachRenddata) { // Start of a new attachment — flush previous if (curAttach?.data) { - debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); + debug.log('email', ' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, @@ -326,40 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult { }); } curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null }; - debug.log(' → New attachment started'); + debug.log('email', ' → New attachment started'); } else if (attrID === attAttachTitle && curAttach) { let len = attrData.byteLength; if (len > 0 && attrData[len - 1] === 0) len--; curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len)); - debug.log(' → Attachment short name:', curAttach.name); + debug.log('email', ' → Attachment short name:', curAttach.name); } else if (attrID === attAttachData && curAttach) { curAttach.data = attrData; - debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes'); + debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes'); } else if (attrID === attAttachment && curAttach) { const props = parseMAPIProps(attrData); - debug.log(' → Parsed', props.size, 'MAPI properties from attachment'); + debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment'); props.forEach((val, propID) => { - debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); + debug.log('email', ' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); }); const longName = props.get(PR_ATTACH_LONG_FILENAME); if (longName?.value instanceof Uint8Array) { curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF); - debug.log(' → Attachment long filename:', curAttach.name); + debug.log('email', ' → Attachment long filename:', curAttach.name); } const mimeTag = props.get(PR_ATTACH_MIME_TAG); if (mimeTag?.value instanceof Uint8Array) { curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF); - debug.log(' → Attachment MIME type:', curAttach.mimeType); + debug.log('email', ' → Attachment MIME type:', curAttach.mimeType); } const attachData = props.get(PR_ATTACH_DATA_BIN); if (attachData?.value instanceof Uint8Array) { curAttach.data = attachData.value; - debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes'); + debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes'); } else { - debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props'); + debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props'); } } } @@ -367,7 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult { // Flush last attachment if (curAttach?.data) { - debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); + debug.log('email', 'Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, @@ -375,9 +375,9 @@ export function parseTnef(data: Uint8Array): TnefResult { }); } - debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length); + debug.log('email', 'TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length); if (result.attachments.length > 0) { - debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength }))); + debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })), 'email'); } debug.groupEnd(); diff --git a/lib/utils.ts b/lib/utils.ts index 47f59d2a..f6130fd4 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -152,8 +152,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] { removed.push({ id: mb.id, name: mb.name, matchedRole: matchedRole!.name, parentId: mb.parentId }); // Warn if this removed mailbox is a parent of other mailboxes (orphan risk) if (referencedParentIds.has(mb.id)) { - debug.warn( - `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` + + debug.warn('jmap', `[Mailbox Tree] Deduplication removed mailbox "${mb.name}" (id: ${mb.id}) which is a parent of other mailboxes. ` + `Matched role mailbox: "${matchedRole!.name}" (role: ${matchedRole!.role}). ` + `Children referencing parentId "${mb.id}" will be orphaned to root level.` ); @@ -162,7 +161,7 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] { }); if (removed.length > 0) { - debug.log(`[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed); + debug.log('jmap', `[Mailbox Tree] Deduplication removed ${removed.length} mailbox(es):`, removed); } return result; @@ -170,13 +169,13 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] { // Build a hierarchical tree structure from flat mailbox array export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { - debug.log(`[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`); + debug.log('jmap', `[Mailbox Tree] Building tree from ${mailboxes.length} mailboxes`); // Deduplicate mailboxes first const deduplicated = deduplicateMailboxes(mailboxes); if (deduplicated.length !== mailboxes.length) { - debug.log(`[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`); + debug.log('jmap', `[Mailbox Tree] After deduplication: ${deduplicated.length} mailboxes (removed ${mailboxes.length - deduplicated.length})`); } // Separate own and shared mailboxes @@ -223,8 +222,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { }); if (orphanedMailboxes.length > 0) { - debug.warn( - `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`, + debug.warn('jmap', `[Mailbox Tree] ${orphanedMailboxes.length} orphaned mailbox(es) moved to root level (missing parent):`, orphanedMailboxes ); } @@ -241,8 +239,7 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { } return max; }; - debug.log( - `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` + + debug.log('jmap', `[Mailbox Tree] Built tree: ${rootMailboxes.length} root nodes, ` + `max depth: ${maxDepth(rootMailboxes)}, ` + `total own: ${ownMailboxes.length}, shared: ${sharedMailboxes.length}` ); diff --git a/locales/en/common.json b/locales/en/common.json index ea2d03b4..82403163 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1164,6 +1164,23 @@ "label": "Debug Mode", "description": "Enable detailed logging for troubleshooting" }, + "debug_categories": { + "description": "Select which categories to log. Disable categories you don't need to reduce console noise.", + "jmap": "JMAP Client", + "jmap_description": "Mailbox operations, email fetching, and JMAP protocol requests", + "calendar": "Calendar", + "calendar_description": "Calendar events, imports, and scheduling messages", + "tasks": "Tasks", + "tasks_description": "Calendar task creation, fetching, and updates", + "auth": "Authentication", + "auth_description": "Login, TOTP, token exchange, and session management", + "filters": "Filters", + "filters_description": "Sieve filter rules and vacation scripts", + "email": "Email Viewing", + "email_description": "Email rendering, TNEF processing, and mark-as-read", + "push": "Push Notifications", + "push_description": "Push notification setup and delivery" + }, "settings_sync": { "label": "Settings Sync", "description": "Sync your settings across browsers and devices" diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 4b9de063..8cafb9ca 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -393,13 +393,13 @@ export const useAuthStore = create()( oauthAccessToken = access_token; oauthExpiresIn = expires_in; upgradedToOAuth = true; - debug.log('TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')'); + debug.log('auth', 'TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')'); } else { const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' })); - debug.warn('TOTP token exchange failed:', tokenRes.status, errorBody); + debug.warn('auth', 'TOTP token exchange failed:', tokenRes.status, errorBody); } } catch (err) { - debug.warn('TOTP token exchange error:', err); + debug.warn('auth', 'TOTP token exchange error:', err); } // If token exchange failed, enable TOTP re-auth prompt so the @@ -407,7 +407,7 @@ export const useAuthStore = create()( if (!upgradedToOAuth) { const { useTotpReauthStore } = await import('@/stores/totp-reauth-store'); client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp()); - debug.log('TOTP re-auth enabled — user will be prompted for fresh codes on session expiry'); + debug.log('auth', 'TOTP re-auth enabled — user will be prompted for fresh codes on session expiry'); } } diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 27168f89..58f592f1 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -197,7 +197,7 @@ export const useCalendarStore = create()( // Expand recurring events client-side (Stalwart doesn't support // mutations on synthetic IDs from server-side expandRecurrences) const events = expandRecurringEvents(validEvents, start, end); - debug.log('Calendar fetchEvents completed', { + debug.log('calendar', 'Calendar fetchEvents completed', { start, end, rawCount: rawEvents.length, @@ -206,7 +206,7 @@ export const useCalendarStore = create()( droppedEvents, }); if (droppedEvents > 0) { - debug.warn('Calendar fetchEvents dropped malformed events without a start field', { droppedEvents }); + debug.warn('calendar', 'Calendar fetchEvents dropped malformed events without a start field', { droppedEvents }); } set({ events, isLoadingEvents: false, dateRange: { start, end } }); } catch (error) { @@ -237,7 +237,7 @@ export const useCalendarStore = create()( if (event.originalCalendarIds) { cleanEvent.calendarIds = event.originalCalendarIds; } - debug.log('Calendar createEvent request', { + debug.log('calendar', 'Calendar createEvent request', { event: getStoreEventDebugSnapshot(cleanEvent), sendSchedulingMessages, targetAccountId, @@ -256,7 +256,7 @@ export const useCalendarStore = create()( ? mappedCreated.start >= currentDateRange.start && mappedCreated.start <= currentDateRange.end : null; - debug.log('Calendar createEvent response', { + debug.log('calendar', 'Calendar createEvent response', { created: getStoreEventDebugSnapshot(created), mappedCreated: getStoreEventDebugSnapshot(mappedCreated), isVisible, @@ -265,21 +265,21 @@ export const useCalendarStore = create()( }); if (!isVisible) { - debug.warn('Created event is hidden by current calendar filters', { + debug.warn('calendar', 'Created event is hidden by current calendar filters', { selectedCalendarIds, createdCalendarIds, }); } if (inCurrentDateRange === false) { - debug.warn('Created event is outside the currently loaded date range', { + debug.warn('calendar', 'Created event is outside the currently loaded date range', { currentDateRange, createdStart: mappedCreated.start, }); } if (mappedCreated.showWithoutTime && mappedCreated.timeZone !== null) { - debug.warn('Created all-day event came back with a non-null timeZone', { + debug.warn('calendar', 'Created all-day event came back with a non-null timeZone', { timeZone: mappedCreated.timeZone, event: getStoreEventDebugSnapshot(mappedCreated), }); @@ -301,7 +301,7 @@ export const useCalendarStore = create()( const storeEvent = get().events.find(e => e.id === id); const realId = storeEvent?.originalId || id; const targetAccountId = storeEvent?.accountId; - debug.log('Calendar updateEvent', { + debug.log('calendar', 'Calendar updateEvent', { storeId: id, realId, uid: storeEvent?.uid, @@ -445,20 +445,20 @@ export const useCalendarStore = create()( await client.updateCalendarEvent(eventId, { calendarIds } as Partial, undefined, targetAccountId); linked++; } catch (err) { - debug.warn(`Import: failed to link event ${eventId} to target calendar:`, err); + debug.warn('calendar', `Import: failed to link event ${eventId} to target calendar:`, err); } } if (linked > 0) { - debug.log(`Import: linked ${linked} existing events to target calendar`); + debug.log('calendar', `Import: linked ${linked} existing events to target calendar`); } const skipped = eventsToProcess.length - newEvents.length - eventsToLink.length; if (skipped > 0) { - debug.log(`Import: skipped ${skipped} events already in target calendar`); + debug.log('calendar', `Import: skipped ${skipped} events already in target calendar`); } eventsToProcess = newEvents; } catch (error) { - debug.warn('Could not fetch existing events for deduplication, proceeding without:', error); + debug.warn('calendar', 'Could not fetch existing events for deduplication, proceeding without:', error); } // Prepare all events for batch creation @@ -543,7 +543,7 @@ export const useCalendarStore = create()( const { created, failed } = await client.batchCreateCalendarEvents(batch, targetAccountId); imported += created.length; if (failed.length > 0) { - debug.warn(`Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`); + debug.warn('calendar', `Import batch ${i / BATCH_SIZE + 1}: ${failed.length} events failed`); } } catch (error) { debug.error(`Import batch ${i / BATCH_SIZE + 1} failed:`, error); @@ -577,7 +577,7 @@ export const useCalendarStore = create()( debug.error('Failed to send cancellation emails:', e); } } - debug.log('Calendar deleteEvent', { + debug.log('calendar', 'Calendar deleteEvent', { storeId: id, realId, uid: storeEvent?.uid, @@ -675,7 +675,7 @@ export const useCalendarStore = create()( // If we couldn't destroy any events, stop to avoid infinite loop if (destroyed.length === 0) { - debug.warn('Could not delete any events, stopping clear loop. Not destroyed:', ids.length); + debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length); break; } @@ -744,7 +744,7 @@ export const useCalendarStore = create()( await get().refreshICalSubscription(client, subscription.id); } catch { // Subscription created, initial fetch failed - user can retry - debug.warn('Initial subscription fetch failed for:', name); + debug.warn('calendar', 'Initial subscription fetch failed for:', name); } return subscription; @@ -892,7 +892,7 @@ export const useCalendarStore = create()( try { await get().refreshICalSubscription(client, sub.id); } catch { - debug.warn('Failed to refresh subscription:', sub.name); + debug.warn('calendar', 'Failed to refresh subscription:', sub.name); } } } diff --git a/stores/filter-store.ts b/stores/filter-store.ts index eced8d15..ef828356 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -53,7 +53,7 @@ export const useFilterStore = create()((set, get) => ({ set({ sieveCapabilities: capabilities }); const allScripts = await client.getSieveScripts(); - debug.log('Sieve scripts fetched:', allScripts.length); + debug.log('filters', 'Sieve scripts fetched:', allScripts.length); // Skip the server-managed 'vacation' script (RFC 9661 §4) — it can only // be modified via VacationResponse/set, not SieveScript/set. @@ -73,10 +73,10 @@ export const useFilterStore = create()((set, get) => ({ const result = parseScript(content); if (result.isOpaque) { - debug.log('Sieve script is opaque (hand-edited)'); + debug.log('filters', 'Sieve script is opaque (hand-edited)'); set({ isLoading: false, isOpaque: true, rules: [], vacationSettings: result.vacation || null }); } else { - debug.log('Parsed', result.rules.length, 'filter rules'); + debug.log('filters', 'Parsed', result.rules.length, 'filter rules'); set({ isLoading: false, isOpaque: false, rules: result.rules, vacationSettings: result.vacation || null }); } } catch (error) { @@ -108,7 +108,7 @@ export const useFilterStore = create()((set, get) => ({ } set({ isSaving: false, rawScript: content }); - debug.log('Filters saved successfully'); + debug.log('filters', 'Filters saved successfully'); } catch (error) { debug.error('Failed to save filters:', error); set({ @@ -212,7 +212,7 @@ export const useFilterStore = create()((set, get) => ({ }); } - debug.log('Vacation synced to sieve script'); + debug.log('filters', 'Vacation synced to sieve script'); } catch (error) { debug.error('Failed to sync vacation to sieve script:', error); } diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 8af5efd4..ed981d7c 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -49,6 +49,18 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [ { id: 'spam', labelKey: 'spam' }, ]; +export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push'; + +export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [ + { id: 'jmap', labelKey: 'jmap' }, + { id: 'calendar', labelKey: 'calendar' }, + { id: 'tasks', labelKey: 'tasks' }, + { id: 'auth', labelKey: 'auth' }, + { id: 'filters', labelKey: 'filters' }, + { id: 'email', labelKey: 'email' }, + { id: 'push', labelKey: 'push' }, +]; + export interface KeywordDefinition { id: string; // Used as JMAP keyword suffix: $label: label: string; // Display name @@ -175,6 +187,7 @@ interface SettingsState { // Advanced debugMode: boolean; + debugCategories: Record; settingsSyncDisabled: boolean; // Actions @@ -299,6 +312,15 @@ const DEFAULT_SETTINGS = { // Advanced debugMode: false, + debugCategories: { + jmap: true, + calendar: true, + tasks: true, + auth: true, + filters: true, + email: true, + push: true, + } as Record, settingsSyncDisabled: false, }; @@ -383,6 +405,7 @@ export const useSettingsStore = create()( sidebarApps: state.sidebarApps, keepAppsLoaded: state.keepAppsLoaded, debugMode: state.debugMode, + debugCategories: state.debugCategories, settingsSyncDisabled: state.settingsSyncDisabled, // Cross-store settings theme: useThemeStore.getState().theme, diff --git a/stores/task-store.ts b/stores/task-store.ts index 5382282a..a2f7d070 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -37,13 +37,13 @@ export const useTaskStore = create((set, get) => ({ setShowCompleted: (show) => set({ showCompleted: show }), fetchTasks: async (client, calendarIds) => { - debug.log('TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' }); + debug.log('tasks', 'TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' }); set({ isLoading: true, error: null }); try { const tasks = await client.getCalendarTasks(calendarIds); - debug.log('TaskStore/fetchTasks received', tasks.length, 'tasks'); + debug.log('tasks', 'TaskStore/fetchTasks received', tasks.length, 'tasks'); tasks.forEach((t, i) => { - debug.log(`TaskStore/fetchTasks [${i}]`, { + debug.log('tasks', `TaskStore/fetchTasks [${i}]`, { id: t.id, uid: t.uid, '@type': t['@type'], title: t.title, due: t.due, progress: t.progress, showWithoutTime: t.showWithoutTime, calendarIds: t.calendarIds, @@ -57,9 +57,9 @@ export const useTaskStore = create((set, get) => ({ }, createTask: async (client, task) => { - debug.log('TaskStore/createTask', task); + debug.log('tasks', 'TaskStore/createTask', task); const created = await client.createCalendarTask(task); - debug.log('TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); + debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); set({ tasks: [...get().tasks, created] }); return created; },