From cdc521b693ddb19c7d061fa9f5569a363ec6111e Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 17:02:37 +0100 Subject: [PATCH 01/13] fix: add time-based sorting for events in buildWeekSegments function --- lib/calendar-utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index a7ef8d83..3c2c452e 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -79,6 +79,8 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca if (left.event.showWithoutTime !== right.event.showWithoutTime) { return left.event.showWithoutTime ? -1 : 1; } + const timeDiff = new Date(left.event.start).getTime() - new Date(right.event.start).getTime(); + if (timeDiff !== 0) return timeDiff; return (left.event.title || "").localeCompare(right.event.title || ""); }); From 2edbf379e2b4f2359feb0e12c4119485f8ae93fb Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 17:32:25 +0100 Subject: [PATCH 02/13] fix: implement unwrapping of embedded message/rfc822 attachments and enhance HTML body validation --- components/email/email-viewer.tsx | 159 +++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 13 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 632df8bb..6b93c485 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -390,6 +390,21 @@ function extractNestedSignedDataCandidate( }; } +/** + * Check if an HTML body string is effectively empty (just boilerplate/whitespace). + * Outlook often generates HTML bodies with Word CSS +   but no real text. + */ +function isHtmlBodyEffectivelyEmpty(html: string): boolean { + const textContent = html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, '') + .trim(); + return textContent.length === 0; +} + function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { if (depth > 6) { const trimmed = rawText.trim(); @@ -871,6 +886,12 @@ export function EmailViewer({ const [tnefText, setTnefText] = useState(null); const [tnefAttachments, setTnefAttachments] = useState([]); + // Embedded message/rfc822 unwrapping (Outlook forward-as-attachment) + const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState(null); + const [embeddedEmailText, setEmbeddedEmailText] = useState(null); + const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); + const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); + // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { smimeStore.load(); @@ -1088,6 +1109,10 @@ export function EmailViewer({ setTnefHtml(null); setTnefText(null); setTnefAttachments([]); + setEmbeddedEmailHtml(null); + setEmbeddedEmailText(null); + setEmbeddedEmailAttachments([]); + setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); const prepareSmimeUnlock = useCallback((keyRecordId: string) => { @@ -1675,15 +1700,20 @@ export function EmailViewer({ debug.group('TNEF Processing'); debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); - // Check if the email already has a usable HTML body - const hasHtmlBody = !!( - email.htmlBody?.[0]?.partId && - email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim() - ); - if (hasHtmlBody) { - debug.log('TNEF: Email already has HTML body, will extract attachments only'); + // 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 + // boilerplate (CSS +  ) — treat these as effectively empty. + const htmlPartId = email.htmlBody?.[0]?.partId; + const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; + 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'); + } + if (hasRealHtmlBody) { + debug.log('TNEF: Email has real HTML body, will extract attachments only'); } else { - debug.log('TNEF: Email has no HTML body, proceeding with full TNEF extraction'); + debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); } let cancelled = false; @@ -1719,10 +1749,10 @@ export function EmailViewer({ 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); - if (parsed.htmlBody && !hasHtmlBody) { + if (parsed.htmlBody && !hasRealHtmlBody) { setTnefHtml(parsed.htmlBody); } - if (parsed.body && !hasHtmlBody) { + if (parsed.body && !hasRealHtmlBody) { setTnefText(parsed.body); } if (parsed.attachments.length > 0) { @@ -1746,6 +1776,83 @@ export function EmailViewer({ return () => { cancelled = true; }; }, [email, client]); + // Embedded message/rfc822 unwrapping + // When Outlook forwards an email as an attachment, the outer email body is + // often empty Word boilerplate and the real content is inside a message/rfc822 + // attachment. Detect this pattern and unwrap the embedded email. + useEffect(() => { + if (!email?.attachments || !client) return; + + // Find message/rfc822 attachment + const rfc822Att = email.attachments.find( + att => att.type === 'message/rfc822' && att.blobId + ); + if (!rfc822Att?.blobId) return; + + // Only unwrap if the outer body is effectively empty + const htmlPartId = email.htmlBody?.[0]?.partId; + const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; + const textPartId = email.textBody?.[0]?.partId; + const textValue = textPartId ? email.bodyValues?.[textPartId]?.value?.trim() : ''; + + const hasRealHtml = !!htmlValue && !isHtmlBodyEffectivelyEmpty(htmlValue); + const hasRealText = !!textValue; + + if (hasRealHtml || hasRealText) { + debug.log('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'); + + let cancelled = false; + + async function unwrapEmbedded() { + try { + 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.groupEnd(); + return; + } + + const { default: PostalMime } = await import('postal-mime'); + const parser = new PostalMime(); + 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)', + ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)', + ', attachments:', parsed.attachments?.length ?? 0); + + if (parsed.html) { + setEmbeddedEmailHtml(parsed.html); + } + if (parsed.text) { + setEmbeddedEmailText(parsed.text); + } + if (parsed.attachments && parsed.attachments.length > 0) { + setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); + debug.log('Embedded RFC822 attachments:', parsed.attachments.map( + a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')' + ).join(', ')); + } + setEmbeddedEmailUnwrapped(true); + debug.groupEnd(); + } catch (err) { + debug.error('Embedded RFC822 unwrapping failed:', err); + debug.groupEnd(); + } + } + + unwrapEmbedded(); + + return () => { cancelled = true; }; + }, [email, client]); + // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { let cancelled = false; @@ -1829,6 +1936,8 @@ export function EmailViewer({ const jmapAttachments = (email?.attachments ?? []) // Hide winmail.dat when we have successfully extracted TNEF content or attachments .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) + // Hide message/rfc822 when we have unwrapped the embedded email + .filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822') .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -1847,8 +1956,19 @@ export function EmailViewer({ tnefData: att.data, })); - return [...jmapAttachments, ...tnefExtracted]; - }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments]); + // Append attachments extracted from embedded message/rfc822 + const embeddedExtracted: EffectiveAttachment[] = embeddedEmailAttachments + .filter(att => !att.contentId) // Skip inline CID images + .map((att, index) => ({ + id: `embedded-${index}-${att.filename || att.mimeType}`, + name: att.filename || null, + type: att.mimeType || 'application/octet-stream', + size: getPostalMimeAttachmentSize(att), + decryptedAttachment: att, + })); + + return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted]; + }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]); // Generate email source for viewing const generateEmailSource = (email: Email): string => { @@ -2189,8 +2309,21 @@ export function EmailViewer({ .replace(/(https?:\/\/[^\s<]+)/g, '$1'); return { html: htmlFromText, isHtml: false }; } + // Embedded message/rfc822 unwrapped content + if (embeddedEmailHtml) { + const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG); + return { html: cleanHtml, isHtml: true }; + } + if (embeddedEmailText) { + const htmlFromText = embeddedEmailText + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/(https?:\/\/[^\s<]+)/g, '$1'); + return { html: htmlFromText, isHtml: false }; + } return emailContent; - }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText]); + }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); From 9fdbb62205e0d3ded16076d364e5df0420e39bc1 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 17:55:29 +0100 Subject: [PATCH 03/13] fix: update gender handling to use speakToAs structure and adjust localization keys --- components/contacts/contact-detail.tsx | 13 ++++--- components/contacts/contact-form.tsx | 23 +++++++----- lib/__tests__/vcard.test.ts | 4 +- lib/jmap/types.ts | 5 ++- lib/vcard.ts | 51 ++++++++++++++++++++++---- locales/de/common.json | 10 ++--- locales/en/common.json | 10 ++--- locales/es/common.json | 10 ++--- locales/fr/common.json | 10 ++--- locales/it/common.json | 10 ++--- locales/ja/common.json | 10 ++--- locales/nl/common.json | 10 ++--- locales/pt/common.json | 10 ++--- 13 files changed, 112 insertions(+), 64 deletions(-) diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 9d69d210..ae05be43 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } )} - {contact.gender && (contact.gender.sex || contact.gender.identity) && ( + {contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
- {contact.gender.sex && {t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}} - {contact.gender.identity && ( - {contact.gender.sex ? " — " : ""}{contact.gender.identity} - )} + {contact.speakToAs.grammaticalGender && {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}} + {contact.speakToAs.pronouns && (() => { + const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; + return firstPronoun ? ( + {contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun} + ) : null; + })()}
)} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 657be3a2..7ef14167 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -235,8 +235,10 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { contact?.notes ? Object.values(contact.notes)[0]?.note || "" : "" ); - const [genderSex, setGenderSex] = useState(contact?.gender?.sex || ""); - const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || ""); + const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || ""); + const [genderIdentity, setGenderIdentity] = useState( + contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : "" + ); const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || ""); const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || ""); const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || ""); @@ -371,8 +373,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { notes: note.trim() ? { n0: { note: note.trim() } } : undefined, - gender: (genderSex.trim() || genderIdentity.trim()) - ? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined } + speakToAs: (genderSex.trim() || genderIdentity.trim()) + ? { + grammaticalGender: genderSex.trim() || undefined, + pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined, + } : undefined, calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, @@ -737,11 +742,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index acca5283..ccff3dc7 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -226,7 +226,7 @@ describe("parseVCard", () => { expect(result).toHaveLength(1); const card = result[0]; - expect(card.gender).toEqual({ sex: "F", identity: "Female" }); + expect(card.speakToAs).toEqual({ grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } }); expect(card.media?.m0).toEqual({ kind: "logo", uri: "https://example.com/logo.png", @@ -331,7 +331,7 @@ describe("generateVCard", () => { components: [{ kind: "given", value: "Jane" }], isOrdered: true, }, - gender: { sex: "F", identity: "Female" }, + speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } }, media: { m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" }, m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" }, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index e7457149..c6748c21 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -183,7 +183,10 @@ export interface ContactCard { relatedTo?: Record; keywords?: Record; members?: Record; - gender?: { sex?: string; identity?: string }; + speakToAs?: { + grammaticalGender?: string; + pronouns?: Record }>; + }; calendarUri?: string; schedulingUri?: string; freeBusyUri?: string; diff --git a/lib/vcard.ts b/lib/vcard.ts index 6d0be68f..8fbc819f 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -1,5 +1,29 @@ import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types"; +const VCARD_SEX_TO_GENDER: Record = { + M: "masculine", + F: "feminine", + O: "other", + N: "none", + U: "unknown", +}; + +const GENDER_TO_VCARD_SEX: Record = { + masculine: "M", + feminine: "F", + other: "O", + none: "N", + unknown: "U", +}; + +function vcardSexToGrammaticalGender(sex: string): string { + return VCARD_SEX_TO_GENDER[sex.toUpperCase()] || sex.toLowerCase(); +} + +function grammaticalGenderToVcardSex(gender: string): string { + return GENDER_TO_VCARD_SEX[gender.toLowerCase()] || ""; +} + function unfoldLines(vcf: string): string { return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } @@ -390,9 +414,17 @@ function buildContact(raw: Record): ContactCard | null { case "GENDER": { const gParts = val.split(";"); - card.gender = {}; - if (gParts[0]) card.gender.sex = gParts[0]; - if (gParts[1]) card.gender.identity = gParts[1]; + const sexCode = gParts[0]?.toUpperCase(); + const identityText = gParts[1]; + if (sexCode || identityText) { + card.speakToAs = {}; + if (sexCode) { + card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode); + } + if (identityText) { + card.speakToAs.pronouns = { p0: { pronouns: identityText } }; + } + } break; } @@ -684,10 +716,15 @@ function generateSingleVCard(contact: ContactCard): string { } } - if (contact.gender) { - const sex = contact.gender.sex || ""; - const identity = contact.gender.identity || ""; - lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`); + if (contact.speakToAs) { + const sex = contact.speakToAs.grammaticalGender + ? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender) + : ""; + const pronouns = contact.speakToAs.pronouns; + const identity = pronouns ? Object.values(pronouns)[0]?.pronouns || "" : ""; + if (sex || identity) { + lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`); + } } if (contact.calendarUri) { diff --git a/locales/de/common.json b/locales/de/common.json index 0734ead9..2e1d1da0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Sonstiges", "gender": "Geschlecht", - "gender_M": "Männlich", - "gender_F": "Weiblich", - "gender_O": "Andere", - "gender_N": "Nicht zutreffend", - "gender_U": "Unbekannt", + "gender_masculine": "Männlich", + "gender_feminine": "Weiblich", + "gender_other": "Andere", + "gender_none": "Nicht zutreffend", + "gender_unknown": "Unbekannt", "calendar": "Kalender", "calendar_uri": "Kalender-URL", "scheduling_uri": "Terminplanungs-URL", diff --git a/locales/en/common.json b/locales/en/common.json index cdede18f..4bc6ebb2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1505,11 +1505,11 @@ "personal_interest": "Interest", "personal_other": "Other", "gender": "Gender", - "gender_M": "Male", - "gender_F": "Female", - "gender_O": "Other", - "gender_N": "Not applicable", - "gender_U": "Unknown", + "gender_masculine": "Male", + "gender_feminine": "Female", + "gender_other": "Other", + "gender_none": "Not applicable", + "gender_unknown": "Unknown", "calendar": "Calendar", "calendar_uri": "Calendar URL", "scheduling_uri": "Scheduling URL", diff --git a/locales/es/common.json b/locales/es/common.json index 0213bb17..60051267 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interés", "personal_other": "Otro", "gender": "Género", - "gender_M": "Masculino", - "gender_F": "Femenino", - "gender_O": "Otro", - "gender_N": "No aplicable", - "gender_U": "Desconocido", + "gender_masculine": "Masculino", + "gender_feminine": "Femenino", + "gender_other": "Otro", + "gender_none": "No aplicable", + "gender_unknown": "Desconocido", "calendar": "Calendario", "calendar_uri": "URL del calendario", "scheduling_uri": "URL de programación", diff --git a/locales/fr/common.json b/locales/fr/common.json index 51cfc93b..8419df01 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Intérêt", "personal_other": "Autre", "gender": "Genre", - "gender_M": "Masculin", - "gender_F": "Féminin", - "gender_O": "Autre", - "gender_N": "Non applicable", - "gender_U": "Inconnu", + "gender_masculine": "Masculin", + "gender_feminine": "Féminin", + "gender_other": "Autre", + "gender_none": "Non applicable", + "gender_unknown": "Inconnu", "calendar": "Calendrier", "calendar_uri": "URL du calendrier", "scheduling_uri": "URL de planification", diff --git a/locales/it/common.json b/locales/it/common.json index 010eec90..93d29fd7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Altro", "gender": "Genere", - "gender_M": "Maschile", - "gender_F": "Femminile", - "gender_O": "Altro", - "gender_N": "Non applicabile", - "gender_U": "Sconosciuto", + "gender_masculine": "Maschile", + "gender_feminine": "Femminile", + "gender_other": "Altro", + "gender_none": "Non applicabile", + "gender_unknown": "Sconosciuto", "calendar": "Calendario", "calendar_uri": "URL del calendario", "scheduling_uri": "URL di pianificazione", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d1fd31d..7be34e16 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "興味", "personal_other": "その他", "gender": "性別", - "gender_M": "男性", - "gender_F": "女性", - "gender_O": "その他", - "gender_N": "該当なし", - "gender_U": "不明", + "gender_masculine": "男性", + "gender_feminine": "女性", + "gender_other": "その他", + "gender_none": "該当なし", + "gender_unknown": "不明", "calendar": "カレンダー", "calendar_uri": "カレンダーURL", "scheduling_uri": "スケジュールURL", diff --git a/locales/nl/common.json b/locales/nl/common.json index 775bf993..801877f0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Overig", "gender": "Geslacht", - "gender_M": "Man", - "gender_F": "Vrouw", - "gender_O": "Anders", - "gender_N": "Niet van toepassing", - "gender_U": "Onbekend", + "gender_masculine": "Man", + "gender_feminine": "Vrouw", + "gender_other": "Anders", + "gender_none": "Niet van toepassing", + "gender_unknown": "Onbekend", "calendar": "Kalender", "calendar_uri": "Kalender-URL", "scheduling_uri": "Planning-URL", diff --git a/locales/pt/common.json b/locales/pt/common.json index accd419a..bb0d6af2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Outro", "gender": "Gênero", - "gender_M": "Masculino", - "gender_F": "Feminino", - "gender_O": "Outro", - "gender_N": "Não aplicável", - "gender_U": "Desconhecido", + "gender_masculine": "Masculino", + "gender_feminine": "Feminino", + "gender_other": "Outro", + "gender_none": "Não aplicável", + "gender_unknown": "Desconhecido", "calendar": "Calendário", "calendar_uri": "URL do calendário", "scheduling_uri": "URL de agendamento", From ef562bcaad51f7e253a1b02a0eddcc44a2c8e379 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 18:00:51 +0100 Subject: [PATCH 04/13] fix: add email export/import localization keys for multiple languages --- lib/__tests__/translations.test.ts | 1 + locales/de/common.json | 7 ++++++- locales/es/common.json | 7 ++++++- locales/fr/common.json | 7 ++++++- locales/it/common.json | 7 ++++++- locales/ja/common.json | 7 ++++++- locales/nl/common.json | 7 ++++++- locales/pt/common.json | 7 ++++++- 8 files changed, 43 insertions(+), 7 deletions(-) diff --git a/lib/__tests__/translations.test.ts b/lib/__tests__/translations.test.ts index d4634c89..a91aab8d 100644 --- a/lib/__tests__/translations.test.ts +++ b/lib/__tests__/translations.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import fs from 'fs'; import path from 'path'; import { describe, expect, it } from 'vitest'; diff --git a/locales/de/common.json b/locales/de/common.json index 2e1d1da0..dbea8749 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -191,6 +191,8 @@ "mark_read": "Als gelesen markieren", "print": "Drucken", "view_source": "Quelltext anzeigen", + "export_email": "Als .eml exportieren", + "import_email": ".eml importieren", "keyboard_shortcuts": "Tastaturkürzel (?)", "email_source": "E-Mail-Quelltext", "copy_source": "In Zwischenablage kopieren", @@ -527,7 +529,10 @@ "templates_exported": "Vorlagen erfolgreich exportiert", "templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}", "templates_import_errors": "Einige Vorlagen konnten nicht importiert werden", - "templates_import_empty": "Keine Vorlagen in der Datei gefunden" + "templates_import_empty": "Keine Vorlagen in der Datei gefunden", + "export_email_error": "Fehler beim Exportieren der E-Mail", + "import_email_success": "E-Mail erfolgreich importiert", + "import_email_error": "Fehler beim Importieren der E-Mail" }, "date": { "today": "Heute", diff --git a/locales/es/common.json b/locales/es/common.json index 60051267..aede70eb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -191,6 +191,8 @@ "mark_read": "Marcar como leído", "print": "Imprimir", "view_source": "Ver código fuente", + "export_email": "Exportar como .eml", + "import_email": "Importar .eml", "keyboard_shortcuts": "Atajos de teclado (?)", "email_source": "Código Fuente del Correo", "copy_source": "Copiar al portapapeles", @@ -527,7 +529,10 @@ "templates_exported": "Plantillas exportadas correctamente", "templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}", "templates_import_errors": "Algunas plantillas no se pudieron importar", - "templates_import_empty": "No se encontraron plantillas en el archivo" + "templates_import_empty": "No se encontraron plantillas en el archivo", + "export_email_error": "Error al exportar el correo electrónico", + "import_email_success": "Correo electrónico importado correctamente", + "import_email_error": "Error al importar el correo electrónico" }, "date": { "today": "Hoy", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8419df01..747b22cc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -191,6 +191,8 @@ "mark_read": "Marquer comme lu", "print": "Imprimer", "view_source": "Voir la source", + "export_email": "Exporter en .eml", + "import_email": "Importer un .eml", "keyboard_shortcuts": "Raccourcis clavier (?)", "email_source": "Source de l'email", "copy_source": "Copier dans le presse-papiers", @@ -527,7 +529,10 @@ "templates_exported": "Modèles exportés avec succès", "templates_imported": "{count, plural, one {# modèle importé} other {# modèles importés}}", "templates_import_errors": "Certains modèles n'ont pas pu être importés", - "templates_import_empty": "Aucun modèle trouvé dans le fichier" + "templates_import_empty": "Aucun modèle trouvé dans le fichier", + "export_email_error": "Échec de l'exportation de l'e-mail", + "import_email_success": "E-mail importé avec succès", + "import_email_error": "Échec de l'importation de l'e-mail" }, "date": { "today": "Aujourd'hui", diff --git a/locales/it/common.json b/locales/it/common.json index 93d29fd7..487666ca 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -191,6 +191,8 @@ "mark_read": "Segna come letto", "print": "Stampa", "view_source": "Visualizza sorgente", + "export_email": "Esporta come .eml", + "import_email": "Importa .eml", "keyboard_shortcuts": "Scorciatoie da tastiera (?)", "email_source": "Sorgente del messaggio", "copy_source": "Copia negli appunti", @@ -527,7 +529,10 @@ "templates_exported": "Modelli esportati con successo", "templates_imported": "{count, plural, one {# modello importato} other {# modelli importati}}", "templates_import_errors": "Alcuni modelli non sono stati importati", - "templates_import_empty": "Nessun modello trovato nel file" + "templates_import_empty": "Nessun modello trovato nel file", + "export_email_error": "Impossibile esportare l'e-mail", + "import_email_success": "E-mail importata con successo", + "import_email_error": "Impossibile importare l'e-mail" }, "date": { "today": "Oggi", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7be34e16..6b5594b2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -191,6 +191,8 @@ "mark_read": "既読にする", "print": "印刷", "view_source": "ソースを表示", + "export_email": ".emlとしてエクスポート", + "import_email": ".emlをインポート", "keyboard_shortcuts": "キーボードショートカット (?)", "email_source": "メールソース", "copy_source": "クリップボードにコピー", @@ -527,7 +529,10 @@ "templates_exported": "テンプレートをエクスポートしました", "templates_imported": "{count}件のテンプレートをインポートしました", "templates_import_errors": "一部のテンプレートをインポートできませんでした", - "templates_import_empty": "ファイルにテンプレートが見つかりません" + "templates_import_empty": "ファイルにテンプレートが見つかりません", + "export_email_error": "メールのエクスポートに失敗しました", + "import_email_success": "メールを正常にインポートしました", + "import_email_error": "メールのインポートに失敗しました" }, "date": { "today": "今日", diff --git a/locales/nl/common.json b/locales/nl/common.json index 801877f0..72cb8dbe 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -191,6 +191,8 @@ "mark_read": "Markeren als gelezen", "print": "Afdrukken", "view_source": "Bron bekijken", + "export_email": "Exporteren als .eml", + "import_email": ".eml importeren", "keyboard_shortcuts": "Sneltoetsen (?)", "email_source": "E-mailbron", "copy_source": "Kopiëren naar klembord", @@ -527,7 +529,10 @@ "templates_exported": "Sjablonen succesvol geëxporteerd", "templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen geïmporteerd}}", "templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd", - "templates_import_empty": "Geen sjablonen gevonden in het bestand" + "templates_import_empty": "Geen sjablonen gevonden in het bestand", + "export_email_error": "Kan e-mail niet exporteren", + "import_email_success": "E-mail succesvol geïmporteerd", + "import_email_error": "Kan e-mail niet importeren" }, "date": { "today": "Vandaag", diff --git a/locales/pt/common.json b/locales/pt/common.json index bb0d6af2..036fe539 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -191,6 +191,8 @@ "mark_read": "Marcar como lido", "print": "Imprimir", "view_source": "Ver código-fonte", + "export_email": "Exportar como .eml", + "import_email": "Importar .eml", "keyboard_shortcuts": "Atalhos de teclado (?)", "email_source": "Código-fonte do E-mail", "copy_source": "Copiar para a área de transferência", @@ -527,7 +529,10 @@ "templates_exported": "Modelos exportados com sucesso", "templates_imported": "{count, plural, one {# modelo importado} other {# modelos importados}}", "templates_import_errors": "Alguns modelos não puderam ser importados", - "templates_import_empty": "Nenhum modelo encontrado no arquivo" + "templates_import_empty": "Nenhum modelo encontrado no arquivo", + "export_email_error": "Falha ao exportar o e-mail", + "import_email_success": "E-mail importado com sucesso", + "import_email_error": "Falha ao importar o e-mail" }, "date": { "today": "Hoje", From 6457b271250794aaeef159f51fe1a1e97d212c07 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 18:13:33 +0100 Subject: [PATCH 05/13] fix: enhance calendar event creation with double-click support and modal date handling --- app/[locale]/calendar/page.tsx | 11 ++++++++--- components/calendar/calendar-month-view.tsx | 3 +++ hooks/use-time-grid-interactions.ts | 7 ++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index ed252007..df64b196 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -236,10 +236,12 @@ export default function CalendarPage() { const openCreateModal = useCallback((date?: Date, endDate?: Date) => { setEditEvent(null); - setDefaultModalDate(date || selectedDate); + const d = date || selectedDate; + setDefaultModalDate(d); setDefaultModalEndDate(endDate); + setSelectedDate(d); setShowEventModal(true); - }, [selectedDate]); + }, [selectedDate, setSelectedDate]); const openEditModal = useCallback((event: CalendarEvent) => { setEditEvent(event); @@ -638,6 +640,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} + onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} /> @@ -690,7 +693,7 @@ export default function CalendarPage() { return (
{viewContent} - {isLoadingEvents && calendars.length > 0 && ( + {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
@@ -791,6 +794,7 @@ export default function CalendarPage() { {!isMobile && showEventModal && (
void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; + onCreateAtTime?: (date: Date) => void; firstDayOfWeek?: number; isMobile?: boolean; } @@ -34,6 +35,7 @@ export function CalendarMonthView({ onSelectEvent, onHoverEvent, onHoverLeave, + onCreateAtTime, firstDayOfWeek = 1, isMobile, }: CalendarMonthViewProps) { @@ -165,6 +167,7 @@ export function CalendarMonthView({ aria-selected={selected} aria-label={fullDateLabel} onClick={() => onSelectDate(day)} + onDoubleClick={() => onCreateAtTime?.(day)} onDragOver={(e) => handleCellDragOver(e, key)} onDragLeave={handleCellDragLeave} onDrop={(e) => handleCellDrop(e, day)} diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index fb2f55f2..675628cc 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -239,9 +239,10 @@ export function useTimeGridInteractions({ clearTimeout(clickTimerRef.current); clickTimerRef.current = null; } - const key = format(day, "yyyy-MM-dd"); - setQuickCreate({ dayKey: key, day, hour, top: hour * hourHeight }); - }, [hourHeight]); + const d = new Date(day); + d.setHours(hour, 0, 0, 0); + onCreateRange(d); + }, [onCreateRange]); const handleQuickCreateSubmit = useCallback(async (title: string) => { if (!quickCreate) return; From bb72ac92aee231893f491bdca79fb42d64e0cc21 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 18:39:46 +0100 Subject: [PATCH 06/13] fix: implement draft editing functionality across email components and add localization keys --- app/[locale]/page.tsx | 31 +++++++++++++ components/email/email-context-menu.tsx | 16 +++++++ components/email/email-list.tsx | 3 ++ components/email/email-viewer.tsx | 61 +++++++++++++++++++++++-- locales/de/common.json | 8 +++- locales/en/common.json | 8 +++- locales/es/common.json | 8 +++- locales/fr/common.json | 8 +++- locales/it/common.json | 8 +++- locales/ja/common.json | 8 +++- locales/nl/common.json | 8 +++- locales/pt/common.json | 8 +++- 12 files changed, 156 insertions(+), 19 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 4d76c768..f593f4ea 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -468,6 +468,33 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }; + const handleEditDraft = (email?: Email) => { + const draft = email || selectedEmail; + if (!draft) return; + const bodyText = draft.bodyValues + ? Object.values(draft.bodyValues).map(v => v.value).join('\n') + : ''; + const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] + ? draft.bodyValues[draft.htmlBody[0].partId].value + : undefined; + setPendingDraft({ + to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', + cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', + bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '', + subject: draft.subject || '', + body: htmlBody || bodyText, + showCc: (draft.cc?.length || 0) > 0, + showBcc: (draft.bcc?.length || 0) > 0, + selectedIdentityId: null, + subAddressTag: '', + mode: 'compose', + draftId: draft.id, + }); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + const handleReplyAll = () => { setComposerMode('replyAll'); setShowComposer(true); @@ -1370,6 +1397,9 @@ export default function Home() { selectEmail(email); await handleUndoSpam(); }} + onEditDraft={(email) => { + handleEditDraft(email); + }} className="flex-1 min-h-0" /> @@ -1543,6 +1573,7 @@ export default function Home() { onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} + onEditDraft={handleEditDraft} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index ecf449f2..34c99575 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -28,6 +28,7 @@ import { Folder, ShieldAlert, ShieldCheck, + EditIcon, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -60,6 +61,7 @@ interface EmailContextMenuProps { onMoveToMailbox?: (mailboxId: string) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; + onEditDraft?: () => void; // Batch actions onBatchMarkAsRead?: (read: boolean) => void; onBatchDelete?: () => void; @@ -126,12 +128,14 @@ export function EmailContextMenu({ onBatchMoveToMailbox, onBatchMarkAsSpam, onBatchUndoSpam, + onEditDraft, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tColor = useTranslations("email_viewer.color_tag"); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isDraft = email.keywords?.['$draft'] === true; const currentColor = getCurrentColor(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; @@ -188,6 +192,18 @@ export function EmailContextMenu({ )} + {/* Edit Draft - only for single draft emails */} + {!showBatchActions && isDraft && onEditDraft && ( + <> + handleAction(onEditDraft)} + /> + + + )} + {/* Single email actions - Reply, Reply All, Forward */} {!showBatchActions && ( <> diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5c02d802..095913f2 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -37,6 +37,7 @@ interface EmailListProps { onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; + onEditDraft?: (email: Email) => void; } export function EmailList({ @@ -57,6 +58,7 @@ export function EmailList({ onMarkAsSpam, onUndoSpam, onMoveToMailbox, + onEditDraft, }: EmailListProps) { const t = useTranslations('email_list'); const { client } = useAuthStore(); @@ -467,6 +469,7 @@ export function EmailList({ onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} + onEditDraft={() => onEditDraft?.(contextMenu.data!)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 6b93c485..c3770397 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -64,6 +64,7 @@ import { Upload, Moon, HelpCircle, + EditIcon, } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; @@ -112,6 +113,7 @@ interface EmailViewerProps { onNavigateNext?: () => void; onNavigatePrev?: () => void; onShowShortcuts?: () => void; + onEditDraft?: () => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: string; @@ -815,6 +817,7 @@ export function EmailViewer({ onNavigateNext, onNavigatePrev, onShowShortcuts, + onEditDraft, currentUserEmail, currentUserName, currentMailboxRole, @@ -840,6 +843,9 @@ export function EmailViewer({ // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; + // Detect if the email is a draft + const isDraft = email?.keywords?.['$draft'] === true; + // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ name: kw.label, @@ -2632,6 +2638,19 @@ export function EmailViewer({ )} + {isDraft && onEditDraft && ( + + )} + {!isDraft && (<> + )}
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */} @@ -3988,6 +4008,29 @@ export function EmailViewer({
)} + {/* Draft Banner */} + {isDraft && ( +
+
+
+ + {t('draft_banner')} +
+ {onEditDraft && ( + + )} +
+
+ )} + { @@ -4077,8 +4120,8 @@ export function EmailViewer({ )}
- {/* Quick Reply Section */} -
- + )} @@ -4240,6 +4283,17 @@ export function EmailViewer({ {t('previous')} + {isDraft && onEditDraft ? ( + + ) : ( + <> + )} + )} + {/* Personal address books */} + {personalBooks.length > 0 && ( +
+
+ + {t("address_books.title")} + +
+ {personalBooks.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ )} + {/* Groups section */} {(sortedGroups.length > 0) && (
@@ -82,7 +145,7 @@ export function ContactsSidebar({
{sortedGroups.map((group) => { - const isActive = typeof activeCategory === "object" && activeCategory.groupId === group.id; + const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id; const memberCount = group.members ? Object.values(group.members).filter(Boolean).length : 0; @@ -128,7 +191,94 @@ export function ContactsSidebar({ )} + + {/* Shared accounts with address books */} + {sharedBookGroups.map((group) => ( +
+
+ + + {group.accountName} + +
+ {group.books.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ ))} ); } + +function AddressBookItem({ + book, + isActive, + contactCount, + onSelect, + onDropContacts, +}: { + book: AddressBook; + isActive: boolean; + contactCount: number; + onSelect: () => void; + onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; +}) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = useCallback((e: DragEvent) => { + if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const data = e.dataTransfer.getData("application/x-contact-ids"); + if (!data || !onDropContacts) return; + try { + const contactIds = JSON.parse(data) as string[]; + if (contactIds.length > 0) { + onDropContacts(contactIds, book); + } + } catch { + // ignore invalid data + } + }, [book, onDropContacts]); + + return ( + + ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 09bd9fda..be91e659 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2340,6 +2340,38 @@ export class JMAPClient { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"]; } + private getCalendarCapableAccountIds(): string[] { + const primaryId = this.getCalendarsAccountId(); + const accountIds: string[] = []; + for (const [id, account] of Object.entries(this.accounts)) { + if (id === primaryId) continue; + // Include accounts that either advertise calendar capability + // or are non-personal (shared/group) accounts — Stalwart doesn't + // always advertise capabilities on group accounts even when they + // have calendar resources. + if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) { + accountIds.push(id); + } + } + return [primaryId, ...accountIds]; + } + + private getContactCapableAccountIds(): string[] { + const primaryId = this.getContactsAccountId(); + const accountIds: string[] = []; + for (const [id, account] of Object.entries(this.accounts)) { + if (id === primaryId) continue; + // Include accounts that either advertise contacts capability + // or are non-personal (shared/group) accounts — Stalwart doesn't + // always advertise capabilities on group accounts even when they + // have contact resources. + if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) { + accountIds.push(id); + } + } + return [primaryId, ...accountIds]; + } + async getAddressBooks(): Promise { try { const accountId = this.getContactsAccountId(); @@ -2357,6 +2389,45 @@ export class JMAPClient { } } + async getAllAddressBooks(): Promise { + try { + const allBooks: AddressBook[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["AddressBook/get", { accountId }, "0"] + ], this.contactUsing()); + + if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { + const rawBooks = (response.methodResponses[0][1].list || []) as AddressBook[]; + const books = rawBooks.map((book) => ({ + ...book, + id: isPrimary ? book.id : `${accountId}:${book.id}`, + originalId: book.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allBooks.push(...books); + } + } catch (error) { + console.error(`Failed to fetch address books for account ${accountId}:`, error); + } + } + + return allBooks; + } catch (error) { + console.error('Failed to fetch all address books:', error); + return this.getAddressBooks(); + } + } + async getContacts(addressBookId?: string): Promise { try { const accountId = this.getContactsAccountId(); @@ -2383,12 +2454,55 @@ export class JMAPClient { } } - async getContact(contactId: string): Promise { + async getAllContacts(): Promise { try { - const accountId = this.getContactsAccountId(); + const allContacts: ContactCard[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["ContactCard/query", { accountId, limit: 1000 }, "0"], + ["ContactCard/get", { + accountId, + "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, + }, "1"], + ], this.contactUsing()); + + if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { + const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[]; + const contacts = rawContacts.map((contact) => ({ + ...contact, + id: isPrimary ? contact.id : `${accountId}:${contact.id}`, + originalId: contact.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allContacts.push(...contacts); + } + } catch (error) { + console.error(`Failed to fetch contacts for account ${accountId}:`, error); + } + } + + return allContacts; + } catch (error) { + console.error('Failed to fetch all contacts:', error); + return this.getContacts(); + } + } + + async getContact(contactId: string, accountId?: string): Promise { + try { + const targetAccountId = accountId || this.getContactsAccountId(); const response = await this.request([ ["ContactCard/get", { - accountId, + accountId: targetAccountId, ids: [contactId], }, "0"] ], this.contactUsing()); @@ -2404,8 +2518,8 @@ export class JMAPClient { } } - async createContact(contact: Partial): Promise { - const accountId = this.getContactsAccountId(); + async createContact(contact: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); let addressBookIds = contact.addressBookIds; if (!addressBookIds || Object.keys(addressBookIds).length === 0) { const books = await this.getAddressBooks(); @@ -2415,12 +2529,15 @@ export class JMAPClient { } } + // Strip shared-only fields before sending to JMAP + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard; + const response = await this.request([ ["ContactCard/set", { accountId, create: { "new-contact": { - ...contact, + ...contactData, addressBookIds, } } @@ -2437,7 +2554,7 @@ export class JMAPClient { const createdId = result.created?.["new-contact"]?.id; if (createdId) { - const created = await this.getContact(createdId); + const created = await this.getContact(createdId, accountId); if (created) return created; } } @@ -2445,14 +2562,17 @@ export class JMAPClient { throw new Error("Failed to create contact"); } - async updateContact(contactId: string, updates: Partial): Promise { - const accountId = this.getContactsAccountId(); + async updateContact(contactId: string, updates: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + + // Strip shared-only fields before sending to JMAP + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard; const response = await this.request([ ["ContactCard/set", { accountId, update: { - [contactId]: updates + [contactId]: cleanUpdates } }, "0"] ], this.contactUsing()); @@ -2470,8 +2590,8 @@ export class JMAPClient { throw new Error("Failed to update contact"); } - async deleteContact(contactId: string): Promise { - const accountId = this.getContactsAccountId(); + async deleteContact(contactId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); const response = await this.request([ ["ContactCard/set", { @@ -2495,24 +2615,45 @@ export class JMAPClient { async searchContacts(query: string): Promise { try { - const accountId = this.getContactsAccountId(); + const allResults: ContactCard[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); - const response = await this.request([ - ["ContactCard/query", { - accountId, - filter: { text: query }, - limit: 50, - }, "0"], - ["ContactCard/get", { - accountId, - "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, - }, "1"] - ], this.contactUsing()); + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; - if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { - return (response.methodResponses[1][1].list || []) as ContactCard[]; + try { + const response = await this.request([ + ["ContactCard/query", { + accountId, + filter: { text: query }, + limit: 50, + }, "0"], + ["ContactCard/get", { + accountId, + "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, + }, "1"] + ], this.contactUsing()); + + if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { + const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[]; + const contacts = rawContacts.map((contact) => ({ + ...contact, + id: isPrimary ? contact.id : `${accountId}:${contact.id}`, + originalId: contact.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allResults.push(...contacts); + } + } catch (error) { + console.error(`Failed to search contacts for account ${accountId}:`, error); + } } - return []; + + return allResults; } catch (error) { console.error('Failed to search contacts:', error); return []; @@ -2536,8 +2677,47 @@ export class JMAPClient { } } - async createCalendar(calendar: Partial): Promise { - const accountId = this.getCalendarsAccountId(); + async getAllCalendars(): Promise { + try { + const allCalendars: Calendar[] = []; + const primaryId = this.getCalendarsAccountId(); + const accountIds = this.getCalendarCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["Calendar/get", { accountId }, "0"] + ], this.calendarUsing()); + + if (response.methodResponses?.[0]?.[0] === "Calendar/get") { + const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[]; + const calendars = rawCalendars.map((cal) => ({ + ...cal, + id: isPrimary ? cal.id : `${accountId}:${cal.id}`, + originalId: cal.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allCalendars.push(...calendars); + } + } catch (error) { + console.error(`Failed to fetch calendars for account ${accountId}:`, error); + } + } + + return allCalendars; + } catch (error) { + console.error('Failed to fetch all calendars:', error); + return this.getCalendars(); + } + } + + async createCalendar(calendar: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2558,17 +2738,23 @@ export class JMAPClient { const createdId = result.created?.["new-calendar"]?.id; if (createdId) { - const calendars = await this.getCalendars(); - const created = calendars.find(c => c.id === createdId); - if (created) return created; + // Fetch from the target account to find the created calendar + const fetchAccountId = targetAccountId || this.getCalendarsAccountId(); + const fetchResponse = await this.request([ + ["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"] + ], this.calendarUsing()); + if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") { + const list = fetchResponse.methodResponses[0][1].list || []; + if (list[0]) return list[0] as Calendar; + } } } throw new Error("Failed to create calendar"); } - async updateCalendar(calendarId: string, updates: Partial): Promise { - const accountId = this.getCalendarsAccountId(); + async updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2592,8 +2778,8 @@ export class JMAPClient { throw new Error("Failed to update calendar"); } - async deleteCalendar(calendarId: string): Promise { - const accountId = this.getCalendarsAccountId(); + async deleteCalendar(calendarId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2616,8 +2802,8 @@ export class JMAPClient { throw new Error("Failed to delete calendar"); } - async getCalendarEvents(calendarIds?: string[]): Promise { - const accountId = this.getCalendarsAccountId(); + async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const queryArgs: Record = { accountId, limit: 1000 }; if (calendarIds && calendarIds.length > 0) { @@ -2644,13 +2830,55 @@ export class JMAPClient { return []; } - async queryCalendarEvents( + async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number ): Promise { try { - const accountId = this.getCalendarsAccountId(); + const allEvents: CalendarEvent[] = []; + const primaryId = this.getCalendarsAccountId(); + const accountIds = this.getCalendarCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const events = await this.queryCalendarEvents(filter, sort, limit, accountId); + const mapped = events.map((event) => ({ + ...event, + id: isPrimary ? event.id : `${accountId}:${event.id}`, + originalId: event.id, + originalCalendarIds: event.calendarIds, + calendarIds: isPrimary ? event.calendarIds : Object.fromEntries( + Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v]) + ), + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allEvents.push(...mapped); + } catch (error) { + console.error(`Failed to query calendar events for account ${accountId}:`, error); + } + } + + return allEvents; + } catch (error) { + console.error('Failed to query all calendar events:', error); + return this.queryCalendarEvents(filter, sort, limit); + } + } + + async queryCalendarEvents( + filter: CalendarEventFilter, + sort?: Array<{ property: string; isAscending: boolean }>, + limit?: number, + targetAccountId?: string + ): Promise { + try { + const accountId = targetAccountId || this.getCalendarsAccountId(); const queryArgs: Record = { accountId, @@ -2679,9 +2907,9 @@ export class JMAPClient { } } - async getCalendarEvent(id: string): Promise { + async getCalendarEvent(id: string, targetAccountId?: string): Promise { try { - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/get", { accountId, @@ -2700,13 +2928,16 @@ export class JMAPClient { } } - async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean): Promise { - const accountId = this.getCalendarsAccountId(); + async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + + // Strip client-only shared fields before sending to JMAP + const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; const setArgs: Record = { accountId, create: { - "new-event": event + "new-event": cleanEvent } }; if (sendSchedulingMessages !== undefined) { @@ -2727,7 +2958,7 @@ export class JMAPClient { const createdId = result.created?.["new-event"]?.id; if (createdId) { - const created = await this.getCalendarEvent(createdId); + const created = await this.getCalendarEvent(createdId, targetAccountId); if (created) return created; } } @@ -2738,14 +2969,18 @@ export class JMAPClient { async updateCalendarEvent( eventId: string, updates: Partial, - sendSchedulingMessages?: boolean + sendSchedulingMessages?: boolean, + targetAccountId?: string ): Promise { - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); + + // Strip client-only shared fields before sending to JMAP + const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; const setArgs: Record = { accountId, update: { - [eventId]: updates + [eventId]: cleanUpdates } }; if (sendSchedulingMessages !== undefined) { @@ -2800,8 +3035,8 @@ export class JMAPClient { throw new Error("Failed to parse calendar file"); } - async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise { - const accountId = this.getCalendarsAccountId(); + async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const setArgs: Record = { accountId, @@ -2828,10 +3063,10 @@ export class JMAPClient { throw new Error("Failed to delete calendar event"); } - async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { + async batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }> { if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] }; - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/set", { accountId, destroy: eventIds }, "0"] ], this.calendarUsing()); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c6748c21..8d27c209 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -160,9 +160,13 @@ export interface Identity { export interface ContactCard { id: string; + originalId?: string; uid?: string; addressBookIds: Record; kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application'; + accountId?: string; + accountName?: string; + isShared?: boolean; language?: string; name?: ContactName; nicknames?: Record; @@ -319,12 +323,16 @@ export interface ContactRelation { export interface AddressBook { id: string; + originalId?: string; name: string; description?: string | null; sortOrder?: number; isDefault?: boolean; isSubscribed?: boolean; myRights?: AddressBookRights; + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface AddressBookRights { @@ -370,6 +378,7 @@ export interface DeliveryStatus { export interface Calendar { id: string; + originalId?: string; name: string; description: string | null; color: string | null; @@ -383,6 +392,9 @@ export interface Calendar { timeZone: string | null; shareWith: Record | null; myRights: CalendarRights; + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface CalendarRights { @@ -398,7 +410,12 @@ export interface CalendarRights { export interface CalendarEvent { id: string; + originalId?: string; calendarIds: Record; + originalCalendarIds?: Record; + accountId?: string; + accountName?: string; + isShared?: boolean; isDraft: boolean; isOrigin: boolean; utcStart: string | null; diff --git a/locales/de/common.json b/locales/de/common.json index c67524bd..20a01f71 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1469,6 +1469,16 @@ "all": "Alle", "groups": "Gruppen" }, + "shared": { + "title": "Geteilt" + }, + "address_books": { + "title": "Verzeichnisse", + "moved": "Kontakt verschoben nach {name}", + "moved_plural": "{count} Kontakte verschoben nach {name}", + "move_failed": "Kontakt konnte nicht verschoben werden", + "address_book": "Verzeichnis" + }, "detail": { "emails": "E-Mail-Adressen", "phones": "Telefonnummern", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Neuer Kontakt", "edit_title": "Kontakt bearbeiten", + "section_address_book": "Verzeichnis", + "select_address_book": "Verzeichnis auswählen...", "section_identity": "Name & Identität", "section_work": "Beruf & Organisation", "prefix": "Anrede", diff --git a/locales/en/common.json b/locales/en/common.json index 716b8422..9bd0b09d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1469,6 +1469,16 @@ "all": "All", "groups": "Groups" }, + "shared": { + "title": "Shared" + }, + "address_books": { + "title": "Directories", + "moved": "Contact moved to {name}", + "moved_plural": "{count} contacts moved to {name}", + "move_failed": "Failed to move contact", + "address_book": "Directory" + }, "detail": { "emails": "Email Addresses", "phones": "Phone Numbers", @@ -1524,6 +1534,8 @@ "form": { "create_title": "New Contact", "edit_title": "Edit Contact", + "section_address_book": "Directory", + "select_address_book": "Select a directory...", "section_identity": "Name & Identity", "section_work": "Work & Organization", "prefix": "Prefix", diff --git a/locales/es/common.json b/locales/es/common.json index 3d632113..b61f05bc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1469,6 +1469,16 @@ "all": "Todos", "groups": "Grupos" }, + "shared": { + "title": "Compartidos" + }, + "address_books": { + "title": "Directorios", + "moved": "Contacto movido a {name}", + "moved_plural": "{count} contactos movidos a {name}", + "move_failed": "Error al mover el contacto", + "address_book": "Directorio" + }, "detail": { "emails": "Direcciones de correo", "phones": "Números de teléfono", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nuevo contacto", "edit_title": "Editar contacto", + "section_address_book": "Directorio", + "select_address_book": "Seleccionar un directorio...", "section_identity": "Nombre e identidad", "section_work": "Trabajo y organización", "prefix": "Prefijo", diff --git a/locales/fr/common.json b/locales/fr/common.json index e807de0b..b59f1e53 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1469,6 +1469,16 @@ "all": "Tous", "groups": "Groupes" }, + "shared": { + "title": "Partagés" + }, + "address_books": { + "title": "Répertoires", + "moved": "Contact déplacé vers {name}", + "moved_plural": "{count} contacts déplacés vers {name}", + "move_failed": "Échec du déplacement du contact", + "address_book": "Répertoire" + }, "detail": { "emails": "Adresses e-mail", "phones": "Numéros de téléphone", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nouveau contact", "edit_title": "Modifier le contact", + "section_address_book": "Répertoire", + "select_address_book": "Sélectionner un répertoire...", "section_identity": "Nom et identité", "section_work": "Travail et organisation", "prefix": "Préfixe", diff --git a/locales/it/common.json b/locales/it/common.json index 16cd0f2c..c39b9006 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1469,6 +1469,16 @@ "all": "Tutti", "groups": "Gruppi" }, + "shared": { + "title": "Condivisi" + }, + "address_books": { + "title": "Rubriche", + "moved": "Contatto spostato in {name}", + "moved_plural": "{count} contatti spostati in {name}", + "move_failed": "Impossibile spostare il contatto", + "address_book": "Rubrica" + }, "detail": { "emails": "Indirizzi email", "phones": "Numeri di telefono", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nuovo contatto", "edit_title": "Modifica contatto", + "section_address_book": "Rubrica", + "select_address_book": "Seleziona una rubrica...", "section_identity": "Nome e identità", "section_work": "Lavoro e organizzazione", "prefix": "Prefisso", diff --git a/locales/ja/common.json b/locales/ja/common.json index a1a648b7..cc38ba3b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1469,6 +1469,16 @@ "all": "すべて", "groups": "グループ" }, + "shared": { + "title": "共有" + }, + "address_books": { + "title": "ディレクトリ", + "moved": "連絡先を {name} に移動しました", + "moved_plural": "{count} 件の連絡先を {name} に移動しました", + "move_failed": "連絡先の移動に失敗しました", + "address_book": "ディレクトリ" + }, "detail": { "emails": "メールアドレス", "phones": "電話番号", @@ -1524,6 +1534,8 @@ "form": { "create_title": "新しい連絡先", "edit_title": "連絡先を編集", + "section_address_book": "ディレクトリ", + "select_address_book": "ディレクトリを選択...", "section_identity": "名前と識別情報", "section_work": "職業と組織", "prefix": "敬称", diff --git a/locales/nl/common.json b/locales/nl/common.json index e2aad935..d80069ff 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1469,6 +1469,16 @@ "all": "Alle", "groups": "Groepen" }, + "shared": { + "title": "Gedeeld" + }, + "address_books": { + "title": "Adresboeken", + "moved": "Contact verplaatst naar {name}", + "moved_plural": "{count} contacten verplaatst naar {name}", + "move_failed": "Verplaatsen van contact mislukt", + "address_book": "Adresboek" + }, "detail": { "emails": "E-mailadressen", "phones": "Telefoonnummers", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nieuw contact", "edit_title": "Contact bewerken", + "section_address_book": "Adresboek", + "select_address_book": "Selecteer een adresboek...", "section_identity": "Naam en identiteit", "section_work": "Werk en organisatie", "prefix": "Voorvoegsel", diff --git a/locales/pt/common.json b/locales/pt/common.json index abe7cfc8..0a420620 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1469,6 +1469,16 @@ "all": "Todos", "groups": "Grupos" }, + "shared": { + "title": "Compartilhados" + }, + "address_books": { + "title": "Diretórios", + "moved": "Contato movido para {name}", + "moved_plural": "{count} contatos movidos para {name}", + "move_failed": "Falha ao mover o contato", + "address_book": "Diretório" + }, "detail": { "emails": "Endereços de e-mail", "phones": "Números de telefone", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Novo contato", "edit_title": "Editar contato", + "section_address_book": "Diretório", + "select_address_book": "Selecionar um diretório...", "section_identity": "Nome e identidade", "section_work": "Trabalho e organização", "prefix": "Prefixo", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 6df85ee2..bb1fe825 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -80,6 +80,7 @@ interface ContactStore { clearSelection: () => void; bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise; bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise; + moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise; importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise; } @@ -101,7 +102,7 @@ export const useContactStore = create()( fetchContacts: async (client) => { set({ isLoading: true, error: null }); try { - const contacts = await client.getContacts(); + const contacts = await client.getAllContacts(); set({ contacts, isLoading: false }); } catch (error) { console.error('Failed to fetch contacts:', error); @@ -111,7 +112,7 @@ export const useContactStore = create()( fetchAddressBooks: async (client) => { try { - const addressBooks = await client.getAddressBooks(); + const addressBooks = await client.getAllAddressBooks(); set({ addressBooks }); } catch (error) { console.error('Failed to fetch address books:', error); @@ -122,7 +123,16 @@ export const useContactStore = create()( createContact: async (client, contact) => { set({ isLoading: true, error: null }); try { - const created = await client.createContact(contact); + const accountId = contact.isShared ? contact.accountId : undefined; + const created = await client.createContact(contact, accountId); + // Preserve shared account metadata + if (contact.isShared && contact.accountId) { + created.accountId = contact.accountId; + created.accountName = contact.accountName; + created.isShared = true; + created.id = `${contact.accountId}:${created.id}`; + created.originalId = created.id.includes(':') ? created.id.split(':').slice(1).join(':') : created.id; + } set((state) => ({ contacts: [...state.contacts, created], isLoading: false, @@ -137,7 +147,10 @@ export const useContactStore = create()( updateContact: async (client, id, updates) => { set({ error: null }); try { - await client.updateContact(id, updates); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.updateContact(originalId, updates, accountId); set((state) => ({ contacts: state.contacts.map(c => c.id === id ? { ...c, ...updates } : c @@ -153,7 +166,10 @@ export const useContactStore = create()( deleteContact: async (client, id) => { set({ error: null }); try { - await client.deleteContact(id); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.deleteContact(originalId, accountId); set((state) => ({ contacts: state.contacts.filter(c => c.id !== id), selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, @@ -296,7 +312,10 @@ export const useContactStore = create()( name: { components: [{ kind: 'given', value: name }], isOrdered: true }, }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const group = get().contacts.find(c => c.id === groupId); + const originalId = group?.originalId || groupId; + const accountId = group?.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -313,13 +332,15 @@ export const useContactStore = create()( const newMembers = { ...group.members }; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); - const key = contact?.uid || id; + const key = contact?.uid || contact?.originalId || id; newMembers[key] = true; }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const originalId = group.originalId || groupId; + const accountId = group.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -359,7 +380,9 @@ export const useContactStore = create()( const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const originalId = group.originalId || groupId; + const accountId = group.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -370,7 +393,10 @@ export const useContactStore = create()( deleteGroup: async (client, groupId) => { if (client && get().supportsSync) { - await client.deleteContact(groupId); + const group = get().contacts.find(c => c.id === groupId); + const originalId = group?.originalId || groupId; + const accountId = group?.isShared ? group.accountId : undefined; + await client.deleteContact(originalId, accountId); } set((state) => ({ contacts: state.contacts.filter(c => c.id !== groupId), @@ -410,13 +436,16 @@ export const useContactStore = create()( bulkDeleteContacts: async (client, ids) => { set({ error: null }); - const { supportsSync } = get(); + const { supportsSync, contacts } = get(); const deletedIds = new Set(ids); if (client && supportsSync) { for (const id of ids) { try { - await client.deleteContact(id); + const contact = contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.deleteContact(originalId, accountId); } catch (error) { console.error(`Failed to delete contact ${id}:`, error); deletedIds.delete(id); @@ -439,6 +468,57 @@ export const useContactStore = create()( set({ selectedContactIds: new Set() }); }, + moveContactToAddressBook: async (client, contactIds, addressBook) => { + set({ error: null }); + const { contacts } = get(); + const targetBookOriginalId = addressBook.originalId || addressBook.id; + const targetAccountId = addressBook.accountId; + const primaryAccountId = client.getContactsAccountId(); + + for (const id of contactIds) { + const contact = contacts.find(c => c.id === id); + if (!contact) continue; + + const originalId = contact.originalId || id; + const sourceAccountId = contact.isShared ? contact.accountId : undefined; + + // Same account: just update the addressBookIds + if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) { + await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId); + set((state) => ({ + contacts: state.contacts.map(c => + c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c + ), + })); + } else { + // Cross-account: create in target, delete from source + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact; + const newContact = await client.createContact( + { ...contactData, addressBookIds: { [targetBookOriginalId]: true } }, + targetAccountId + ); + await client.deleteContact(originalId, sourceAccountId); + + // Update local state + const isPrimary = !targetAccountId || targetAccountId === primaryAccountId; + set((state) => ({ + contacts: state.contacts.map(c => { + if (c.id !== id) return c; + return { + ...newContact, + id: isPrimary ? newContact.id : `${targetAccountId}:${newContact.id}`, + originalId: newContact.id, + accountId: targetAccountId, + accountName: addressBook.accountName || targetAccountId, + isShared: !isPrimary, + addressBookIds: { [targetBookOriginalId]: true }, + }; + }), + })); + } + } + }, + importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0; From 96c2ee9e139ca9219c08d6709bf767fc324452a0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:21:36 +0100 Subject: [PATCH 11/13] chore: remove scripts already moved to local-data --- scripts/debug-tnef.ts | 229 ---------------------------------- scripts/generate-test-cert.ts | 160 ------------------------ scripts/test-tnef.ts | 101 --------------- 3 files changed, 490 deletions(-) delete mode 100644 scripts/debug-tnef.ts delete mode 100644 scripts/generate-test-cert.ts delete mode 100644 scripts/test-tnef.ts diff --git a/scripts/debug-tnef.ts b/scripts/debug-tnef.ts deleted file mode 100644 index c6144304..00000000 --- a/scripts/debug-tnef.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Debug script for TNEF parser — dumps raw attribute structure. - */ -import { readFileSync } from 'fs'; -import { resolve } from 'path'; - -const inputPath = process.argv[2]; -if (!inputPath) { - console.error('Usage: npx tsx scripts/debug-tnef.ts '); - process.exit(1); -} - -const data = new Uint8Array(readFileSync(resolve(inputPath))); - -// TNEF attribute ID names -const ATTR_NAMES: Record = { - 0x00069003: 'attMAPIProps', - 0x0002800C: 'attBody', - 0x00069002: 'attAttachRenddata', - 0x0006800F: 'attAttachData', - 0x00018010: 'attAttachTitle', - 0x00069005: 'attAttachment (MAPI)', - 0x00028005: 'attSubject', - 0x00068007: 'attMessageClass', - 0x00078006: 'attDateSent', - 0x00078008: 'attDateModified', - 0x0006900B: 'attRecipTable', - 0x00069001: 'attOwner', - 0x00060001: 'attFrom', - 0x00078004: 'attDateStart', - 0x0001800A: 'attMessageID', - 0x00050008: 'attPriority', - 0x00040009: 'attAidOwner', - 0x00010004: 'attConversationID', - 0x0001800D: 'attParentID', - 0x00018011: 'attAttachCreateDate', - 0x00018012: 'attAttachModifyDate', - 0x00060002: 'attDateRecd', - 0x00060003: 'attAssignedTo', -}; - -const MAPI_PROP_NAMES: Record = { - 0x0037: 'PR_SUBJECT', - 0x1000: 'PR_BODY', - 0x1009: 'PR_RTF_COMPRESSED', - 0x1013: 'PR_BODY_HTML', - 0x1014: 'PR_BODY_CONTENT_ID', - 0x0E1F: 'PR_RTF_IN_SYNC', - 0x3701: 'PR_ATTACH_DATA_BIN', - 0x3702: 'PR_ATTACH_ENCODING', - 0x3703: 'PR_ATTACH_EXTENSION', - 0x3704: 'PR_ATTACH_FILENAME', - 0x3707: 'PR_ATTACH_LONG_FILENAME', - 0x370E: 'PR_ATTACH_MIME_TAG', - 0x3712: 'PR_ATTACH_CONTENT_ID', - 0x0FF9: 'PR_RECORD_KEY', - 0x0FFE: 'PR_OBJECT_TYPE', - 0x3001: 'PR_DISPLAY_NAME', - 0x3002: 'PR_ADDRTYPE', - 0x3003: 'PR_EMAIL_ADDRESS', -}; - -const PROP_TYPE_NAMES: Record = { - 0x0002: 'PT_SHORT', - 0x0003: 'PT_LONG', - 0x000B: 'PT_BOOLEAN', - 0x001E: 'PT_STRING8', - 0x001F: 'PT_UNICODE', - 0x0040: 'PT_SYSTIME', - 0x0048: 'PT_CLSID', - 0x0102: 'PT_BINARY', - 0x0014: 'PT_I8', -}; - -function pad4(len: number): number { - return (4 - (len % 4)) % 4; -} - -const view = new DataView(data.buffer, data.byteOffset, data.byteLength); -let offset = 0; - -function readU8() { return view.getUint8(offset++); } -function readU16() { const v = view.getUint16(offset, true); offset += 2; return v; } -function readU32() { const v = view.getUint32(offset, true); offset += 4; return v; } -function readBytes(n: number) { const s = data.slice(offset, offset + n); offset += n; return s; } - -const sig = readU32(); -console.log(`Signature: 0x${sig.toString(16)} (expected 0x223e9f78: ${sig === 0x223e9f78 ? 'OK' : 'MISMATCH'})`); -const key = readU16(); -console.log(`Key: ${key}\n`); - -let attrIndex = 0; -while (offset + 11 <= data.byteLength) { - const level = readU8(); - const attrId = readU32(); - const attrLen = readU32(); - - if (attrLen > data.byteLength - offset - 2) { - console.log(`[${attrIndex}] TRUNCATED — level=${level} id=0x${attrId.toString(16)} len=${attrLen} (remaining=${data.byteLength - offset})`); - break; - } - - const attrData = readBytes(attrLen); - const checksum = readU16(); - - const levelStr = level === 1 ? 'MESSAGE' : level === 2 ? 'ATTACHMENT' : `LEVEL(${level})`; - const attrName = ATTR_NAMES[attrId] || `0x${attrId.toString(16).padStart(8, '0')}`; - - console.log(`[${attrIndex}] ${levelStr} | ${attrName} | ${attrLen} bytes | checksum=0x${checksum.toString(16)}`); - - // Dump MAPI props if this is a MAPI attr - if (attrId === 0x00069003 || attrId === 0x00069005) { - const propView = new DataView(attrData.buffer, attrData.byteOffset, attrData.byteLength); - let pOff = 0; - if (attrData.byteLength >= 4) { - const count = propView.getUint32(pOff, true); pOff += 4; - console.log(` MAPI props count: ${count}`); - - for (let i = 0; i < count && pOff + 4 <= attrData.byteLength; i++) { - const propType = propView.getUint16(pOff, true); pOff += 2; - const propId = propView.getUint16(pOff, true); pOff += 2; - - const baseType = propType & 0x0FFF; - const isMulti = (propType & 0x1000) !== 0; - const propName = MAPI_PROP_NAMES[propId] || `0x${propId.toString(16).padStart(4, '0')}`; - const typeName = PROP_TYPE_NAMES[baseType] || `0x${baseType.toString(16).padStart(4, '0')}`; - - // Named props - if (propId >= 0x8000) { - if (pOff + 20 > attrData.byteLength) { console.log(` [${i}] ${propName} (${typeName}) — TRUNCATED (named prop)`); break; } - pOff += 16; // GUID - const kind = propView.getUint32(pOff, true); pOff += 4; - if (kind === 0) { - if (pOff + 4 > attrData.byteLength) break; - pOff += 4; - } else { - if (pOff + 4 > attrData.byteLength) break; - const nl = propView.getUint32(pOff, true); pOff += 4; - if (pOff + nl > attrData.byteLength) break; - pOff += nl + pad4(nl); - } - } - - if (isMulti) { - if (pOff + 4 > attrData.byteLength) break; - const vc = propView.getUint32(pOff, true); pOff += 4; - console.log(` [${i}] ${propName} (${typeName} MV x${vc})`); - for (let j = 0; j < vc; j++) { - // skip values - if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) { - if (pOff + 4 > attrData.byteLength) break; - const vl = propView.getUint32(pOff, true); pOff += 4; - pOff += vl + pad4(vl); - } else if (baseType === 0x0040 || baseType === 0x0014) { - pOff += 8; - } else if (baseType === 0x0048) { - pOff += 16; - } else if (baseType === 0x0002) { - pOff += 4; - } else { - pOff += 4; - } - } - } else { - let valuePreview = ''; - const savedOff = pOff; - - if (baseType === 0x0002) { - if (pOff + 4 <= attrData.byteLength) { - valuePreview = `value=${propView.getUint16(pOff, true)}`; - pOff += 4; // padded - } - } else if (baseType === 0x0003 || baseType === 0x000B) { - if (pOff + 4 <= attrData.byteLength) { - valuePreview = `value=${propView.getUint32(pOff, true)}`; - pOff += 4; - } - } else if (baseType === 0x0014 || baseType === 0x0040) { - pOff += 8; - valuePreview = '(8 bytes)'; - } else if (baseType === 0x0048) { - pOff += 16; - valuePreview = '(GUID)'; - } else if (baseType === 0x001E || baseType === 0x001F || baseType === 0x0102) { - if (pOff + 4 <= attrData.byteLength) { - const vl = propView.getUint32(pOff, true); pOff += 4; - if (pOff + vl <= attrData.byteLength) { - const raw = attrData.slice(pOff, pOff + vl); - if (baseType === 0x001F) { - try { valuePreview = `"${new TextDecoder('utf-16le').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; } - } else if (baseType === 0x001E) { - try { valuePreview = `"${new TextDecoder('utf-8').decode(raw).slice(0, 120)}"`; } catch { valuePreview = `(${vl} bytes)`; } - } else { - valuePreview = `(${vl} bytes binary)`; - if (propId === 0x1013) { - try { valuePreview += ` preview="${new TextDecoder('utf-8').decode(raw).slice(0, 200)}"`; } catch { /* ignore decode errors */ } - } - } - pOff += vl + pad4(vl); - } else { - valuePreview = `(${vl} bytes — exceeds data)`; - pOff = savedOff + 4; - } - } - } else { - if (pOff + 4 <= attrData.byteLength) { - pOff += 4; - valuePreview = '(4 bytes fixed)'; - } - } - - console.log(` [${i}] ${propName} (${typeName}) ${valuePreview}`); - } - } - } - } - - // Preview plain text body/attach title - if (attrId === 0x0002800C || attrId === 0x00018010) { - try { - const preview = new TextDecoder('utf-8').decode(attrData.slice(0, Math.min(200, attrData.byteLength))); - console.log(` Preview: "${preview}"`); - } catch { /* ignore decode errors */ } - } - - attrIndex++; -} - -console.log(`\nTotal attributes: ${attrIndex}`); diff --git a/scripts/generate-test-cert.ts b/scripts/generate-test-cert.ts deleted file mode 100644 index 0dad94bc..00000000 --- a/scripts/generate-test-cert.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Generate a self-signed S/MIME test certificate (.p12) using pkijs. - * Usage: npx tsx scripts/generate-test-cert.ts - */ -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import { writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', -}); -pkijs.setEngine('gen', crypto, cryptoEngine); - -function stringToAB(str: string): ArrayBuffer { - const buf = new ArrayBuffer(str.length); - const view = new Uint8Array(buf); - for (let i = 0; i < str.length; i++) view[i] = str.charCodeAt(i); - return buf; -} - -async function main() { - const email = process.argv[2] || 'test@example.com'; - const cn = email.split('@')[0]; - const p12Password = 'test'; - - console.log(`Generating S/MIME certificate for ${email}...`); - - // Generate RSA key pair for signing - const signKeyPair = await crypto.subtle.generateKey( - { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['sign', 'verify'], - ); - - // Build self-signed certificate - const cert = new pkijs.Certificate(); - cert.version = 2; - cert.serialNumber = new asn1js.Integer({ value: Date.now() }); - - // Issuer = Subject (self-signed) - for (const name of [cert.issuer, cert.subject]) { - name.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: cn }) }), - ); - name.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ type: '2.5.4.10', value: new asn1js.Utf8String({ value: 'Test Org' }) }), - ); - } - // Email in subject - cert.subject.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ type: '1.2.840.113549.1.9.1', value: new asn1js.IA5String({ value: email }) }), - ); - - // Validity: 1 year - cert.notBefore.value = new Date(); - const notAfter = new Date(); - notAfter.setFullYear(notAfter.getFullYear() + 1); - cert.notAfter.value = notAfter; - - // Import public key and sign - await cert.subjectPublicKeyInfo.importKey(signKeyPair.publicKey, cryptoEngine); - await cert.sign(signKeyPair.privateKey, 'SHA-256', cryptoEngine); - - // Export private key as PKCS#8 - const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', signKeyPair.privateKey); - - // Build PKCS#12 - const passwordBuf = stringToAB(p12Password); - - const keyBag = new pkijs.PKCS8ShroudedKeyBag({ - parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes), - }); - - await keyBag.makeInternalValues({ - password: passwordBuf, - contentEncryptionAlgorithm: { - name: 'AES-CBC', - length: 256, - } as Parameters[0]['contentEncryptionAlgorithm'], - hmacHashAlgorithm: 'SHA-256', - iterationCount: 100_000, - }); - - const keyBagSafe = new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.2', - bagValue: keyBag, - bagAttributes: [ - new pkijs.Attribute({ - type: '1.2.840.113549.1.9.20', // friendlyName - values: [new asn1js.BmpString({ value: cn })], - }), - ], - }); - - const certBagSafe = new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.3', - bagValue: new pkijs.CertBag({ parsedValue: cert }), - bagAttributes: [ - new pkijs.Attribute({ - type: '1.2.840.113549.1.9.20', - values: [new asn1js.BmpString({ value: cn })], - }), - ], - }); - - const authenticatedSafe = new pkijs.AuthenticatedSafe({ - parsedValue: { - safeContents: [ - { privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) }, - { privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) }, - ], - }, - }); - - await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] }); - - const pfx = new pkijs.PFX({ - parsedValue: { - integrityMode: 0, - authenticatedSafe, - }, - }); - - await pfx.makeInternalValues({ - password: passwordBuf, - iterations: 100_000, - pbkdf2HashAlgorithm: 'SHA-256', - hmacHashAlgorithm: 'SHA-256', - }); - - const p12Bytes = pfx.toSchema().toBER(false); - - // Also export the public cert as PEM - const certDer = cert.toSchema(true).toBER(false); - const certB64 = Buffer.from(certDer).toString('base64'); - const certPem = `-----BEGIN CERTIFICATE-----\n${certB64.match(/.{1,64}/g)!.join('\n')}\n-----END CERTIFICATE-----\n`; - - const slug = email.replace(/[@.]/g, '-'); - const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'local-data'); - const p12Path = join(outDir, `${slug}.p12`); - const pemPath = join(outDir, `${slug}-cert.pem`); - - writeFileSync(p12Path, Buffer.from(p12Bytes)); - writeFileSync(pemPath, certPem); - - console.log(`\nFiles written:`); - console.log(` ${p12Path}`); - console.log(` ${pemPath}`); - console.log(`\nCredentials:`); - console.log(` Email: ${email}`); - console.log(` CN: ${cn}`); - console.log(` Password: ${p12Password}`); - console.log(` Valid until: ${notAfter.toISOString().split('T')[0]}`); -} - -main().catch(console.error); diff --git a/scripts/test-tnef.ts b/scripts/test-tnef.ts deleted file mode 100644 index 33173711..00000000 --- a/scripts/test-tnef.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Test script for TNEF (winmail.dat) parser. - * - * Usage: - * npx tsx scripts/test-tnef.ts - * - * Outputs: - * - tnef-output.html (HTML body or formatted plain text) - * - Any extracted attachments saved alongside - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { resolve, basename } from 'path'; -import { parseTnef } from '../lib/tnef'; - -const inputPath = process.argv[2]; -if (!inputPath) { - console.error('Usage: npx tsx scripts/test-tnef.ts '); - process.exit(1); -} - -const fullPath = resolve(inputPath); -console.log(`Reading: ${fullPath}`); - -const data = new Uint8Array(readFileSync(fullPath)); -console.log(`File size: ${data.byteLength} bytes`); - -const result = parseTnef(data); - -console.log(`\n=== TNEF Parse Results ===`); -console.log(`Plain text body: ${result.body ? `${result.body.length} chars` : 'none'}`); -console.log(`HTML body: ${result.htmlBody ? `${result.htmlBody.length} chars` : 'none'}`); -console.log(`Attachments: ${result.attachments.length}`); - -if (result.attachments.length > 0) { - console.log(`\nAttachments:`); - result.attachments.forEach((att, i) => { - console.log(` [${i + 1}] ${att.name} (${att.mimeType}, ${att.data.byteLength} bytes)`); - }); -} - -// Build output HTML -let htmlContent: string; - -const attachmentsList = result.attachments.length > 0 - ? `

Extracted Attachments (${result.attachments.length})

- - -${result.attachments.map((att, i) => ``).join('\n')} -
#NameMIME TypeSize
${i+1}${att.name}${att.mimeType}${att.data.byteLength} bytes
` - : '

No attachments found.

'; - -if (result.htmlBody) { - htmlContent = ` -TNEF Output - -

TNEF Parse Results

-

Source: ${inputPath} (${data.byteLength} bytes)

-${attachmentsList} -

HTML Body

-
-${result.htmlBody} -
-`; -} else if (result.body) { - const escaped = result.body - .replace(/&/g, '&') - .replace(//g, '>'); - htmlContent = ` -TNEF Output - -

TNEF Parse Results

-

Source: ${inputPath} (${data.byteLength} bytes)

-${attachmentsList} -

Plain Text Body

-
${escaped}
-`; -} else { - htmlContent = ` -TNEF Output - -

TNEF Parse Results

-

Source: ${inputPath} (${data.byteLength} bytes)

-

No body content found in this TNEF file. The email body is likely in the regular MIME text/plain part.

-${attachmentsList} -`; -} - -const outputHtml = resolve('tnef-output.html'); -writeFileSync(outputHtml, htmlContent, 'utf-8'); -console.log(`\nSaved HTML: ${outputHtml}`); - -// Save extracted attachments -result.attachments.forEach((att, i) => { - const attPath = resolve(`tnef-attachment-${i + 1}-${att.name}`); - writeFileSync(attPath, att.data); - console.log(`Saved attachment: ${attPath}`); -}); - -console.log('\nDone.'); From 2793d4b4af222b161d631036a136b1e8384398d9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:21:43 +0100 Subject: [PATCH 12/13] feat: add calendar task list view and shared calendar grouping - Add TaskListView component for displaying calendar tasks - Group shared calendars by account in sidebar panel - Add task view toggle to calendar toolbar - Extend calendar store with task-related state --- .../calendar/calendar-sidebar-panel.tsx | 222 ++++++++++-------- components/calendar/calendar-toolbar.tsx | 45 +++- components/calendar/task-list-view.tsx | 203 ++++++++++++++++ stores/calendar-store.ts | 86 +++++-- 4 files changed, 441 insertions(+), 115 deletions(-) create mode 100644 components/calendar/task-list-view.tsx diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 5e067970..3595ba2a 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; @@ -42,6 +42,20 @@ export function CalendarSidebarPanel({ const colorPickerRef = useRef(null); const contextMenuRef = useRef(null); + const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); + const sharedAccountGroups = useMemo(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + for (const cal of shared) { + const key = cal.accountId!; + if (!groups.has(key)) { + groups.set(key, { accountName: cal.accountName || key, calendars: [] }); + } + groups.get(key)!.calendars.push(cal); + } + return Array.from(groups.values()); + }, [calendars]); + useEffect(() => { if (!colorPickerId && !contextMenuCalId) return; const handleClick = (e: MouseEvent) => { @@ -97,108 +111,122 @@ export function CalendarSidebarPanel({ if (calendars.length === 0 && !onSubscribe) return null; + const renderCalendarItem = (cal: Calendar) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + + return ( +
+ + + {/* Subscription context menu on right-click */} + {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub) return null; + return ( +
+ + + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} +
+ )} +
+ ); + })()} + + {/* Color picker popover on right-click */} + {colorPickerId === cal.id && onColorChange && ( +
+

{t("management.change_color")}

+ { + onColorChange(cal.id, c); + setColorPickerId(null); + }} + allowCustom + /> +
+ )} +
+ ); + }; + return (

{t("my_calendars")}

- {calendars.map((cal) => { - const isVisible = selectedCalendarIds.includes(cal.id); - const color = cal.color || "#3b82f6"; - - return ( -
- - - {/* Subscription context menu on right-click */} - {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { - const sub = getSubscriptionForCalendar(cal.id); - if (!sub) return null; - return ( -
- - - {sub.lastRefreshed && ( -
- {tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })} -
- )} -
- ); - })()} - - {/* Color picker popover on right-click */} - {colorPickerId === cal.id && onColorChange && ( -
-

{t("management.change_color")}

- { - onColorChange(cal.id, c); - setColorPickerId(null); - }} - allowCustom - /> -
- )} -
- ); - })} + {personalCalendars.map(renderCalendarItem)}
+ + {sharedAccountGroups.map((group) => ( +
+

+ + {group.accountName} +

+
+ {group.calendars.map(renderCalendarItem)} +
+
+ ))}
); } diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 2395a1e0..5780eb41 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -153,7 +153,7 @@ export function CalendarToolbar({ {t("my_calendars")}
- {calendars.map((cal) => { + {calendars.filter(c => !c.isShared).map((cal) => { const isVisible = selectedCalendarIds.includes(cal.id); const color = cal.color || "#3b82f6"; return ( @@ -179,6 +179,49 @@ export function CalendarToolbar({ ); })}
+ {(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + for (const c of shared) { + const key = c.accountId!; + if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] }); + groups.get(key)!.cals.push(c); + } + return Array.from(groups.values()).map((group) => ( +
+

+ {group.accountName} +

+
+ {group.cals.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + return ( + + ); + })} +
+
+ )); + })()} )} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx new file mode 100644 index 00000000..4e459408 --- /dev/null +++ b/components/calendar/task-list-view.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useMemo, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns"; +import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CalendarTask, Calendar } from "@/lib/jmap/types"; +import type { TaskViewFilter } from "@/stores/task-store"; +import { useSettingsStore } from "@/stores/settings-store"; + +interface TaskListViewProps { + tasks: CalendarTask[]; + calendars: Calendar[]; + selectedCalendarIds: string[]; + filter: TaskViewFilter; + showCompleted: boolean; + onSelectTask: (task: CalendarTask) => void; + onToggleComplete: (task: CalendarTask) => void; + selectedTaskId?: string | null; +} + +function getTaskPriorityIcon(priority: number) { + if (priority >= 1 && priority <= 4) return ; + if (priority === 5) return ; + if (priority >= 6 && priority <= 9) return ; + return null; +} + +function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType, timeFormat: string): { label: string; className: string } { + const dueDate = parseISO(due); + const overdue = isPast(dueDate) && !isToday(dueDate); + + if (isToday(dueDate)) { + return { + label: t("tasks.due_today"), + className: "text-blue-600 dark:text-blue-400", + }; + } + if (isTomorrow(dueDate)) { + return { + label: t("tasks.due_tomorrow"), + className: "text-muted-foreground", + }; + } + if (overdue) { + return { + label: t("tasks.overdue"), + className: "text-red-600 dark:text-red-400", + }; + } + + const formatted = showWithoutTime + ? format(dueDate, "MMM d") + : format(dueDate, timeFormat === "12h" ? "MMM d, h:mm a" : "MMM d, HH:mm"); + + return { + label: formatted, + className: "text-muted-foreground", + }; +} + +export function TaskListView({ + tasks, + calendars, + selectedCalendarIds, + filter, + showCompleted, + onSelectTask, + onToggleComplete, + selectedTaskId, +}: TaskListViewProps) { + const t = useTranslations("calendar"); + const timeFormat = useSettingsStore((s) => s.timeFormat); + + const filteredTasks = useMemo(() => { + let result = tasks.filter(task => { + const calIds = Object.keys(task.calendarIds); + return calIds.some(id => selectedCalendarIds.includes(id)); + }); + + if (!showCompleted) { + result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled"); + } + + switch (filter) { + case "pending": + result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process"); + break; + case "completed": + result = result.filter(task => task.progress === "completed"); + break; + case "overdue": + result = result.filter(task => { + if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false; + return isPast(parseISO(task.due)) && !isToday(parseISO(task.due)); + }); + break; + } + + // Sort: overdue first, then by due date (no due date last), then by priority + result.sort((a, b) => { + // Completed tasks at the bottom + if (a.progress === "completed" && b.progress !== "completed") return 1; + if (a.progress !== "completed" && b.progress === "completed") return -1; + + // Tasks with due dates before those without + if (a.due && !b.due) return -1; + if (!a.due && b.due) return 1; + if (a.due && b.due) { + const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime(); + if (dateCompare !== 0) return dateCompare; + } + + // Higher priority first (lower number = higher priority, but 0 = no priority goes last) + const aPri = a.priority || 10; + const bPri = b.priority || 10; + return aPri - bPri; + }); + + return result; + }, [tasks, selectedCalendarIds, filter, showCompleted]); + + const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => { + e.stopPropagation(); + onToggleComplete(task); + }, [onToggleComplete]); + + if (filteredTasks.length === 0) { + return ( +
+ +

{t("tasks.no_tasks")}

+
+ ); + } + + return ( +
+
+ {filteredTasks.map(task => { + const cal = calendars.find(c => task.calendarIds[c.id]); + const isCompleted = task.progress === "completed"; + const priorityIcon = getTaskPriorityIcon(task.priority); + const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat) : null; + + return ( +
onSelectTask(task)} + className={cn( + "flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors", + selectedTaskId === task.id && "bg-muted", + )} + > + {/* Checkbox */} + + + {/* Content */} +
+
+ + {task.title || t("tasks.no_title")} + + {priorityIcon} +
+ +
+ {dueDateInfo && ( + + + {dueDateInfo.label} + + )} + {cal && ( + + + {cal.name} + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 954206f4..9a9ed309 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -92,7 +92,7 @@ export const useCalendarStore = create()( fetchCalendars: async (client) => { set({ isLoading: true, error: null }); try { - const calendars = await client.getCalendars(); + const calendars = await client.getAllCalendars(); const { selectedCalendarIds } = get(); const validIds = calendars.map(c => c.id); const stillValid = selectedCalendarIds.filter(id => validIds.includes(id)); @@ -110,7 +110,7 @@ export const useCalendarStore = create()( fetchEvents: async (client, start, end) => { set({ isLoadingEvents: true, error: null }); try { - const events = await client.queryCalendarEvents({ + const events = await client.queryAllCalendarEvents({ after: start, before: end, }); @@ -124,7 +124,23 @@ export const useCalendarStore = create()( createEvent: async (client, event, sendSchedulingMessages) => { set({ error: null }); try { - const created = await client.createCalendarEvent(event, sendSchedulingMessages); + // Resolve shared calendar context from calendarIds + let targetAccountId = event.accountId; + const cleanEvent = { ...event }; + if (event.calendarIds) { + const calId = Object.keys(event.calendarIds)[0]; + if (calId) { + const cal = get().calendars.find(c => c.id === calId); + if (cal?.isShared && cal.originalId) { + targetAccountId = cal.accountId; + cleanEvent.calendarIds = { [cal.originalId]: true }; + } + } + } + if (event.originalCalendarIds) { + cleanEvent.calendarIds = event.originalCalendarIds; + } + const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId); set((state) => ({ events: [...state.events, created] })); if (sendSchedulingMessages && created.participants) { try { @@ -144,13 +160,27 @@ export const useCalendarStore = create()( updateEvent: async (client, id, updates, sendSchedulingMessages) => { set({ error: null }); try { - await client.updateCalendarEvent(id, updates, sendSchedulingMessages); + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || id; + const targetAccountId = storeEvent?.accountId; + // Remap namespaced calendarIds back to original IDs + const cleanUpdates = { ...updates }; + if (cleanUpdates.calendarIds) { + const remapped: Record = {}; + for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) { + const cal = get().calendars.find(c => c.id === calId); + remapped[cal?.originalId || calId] = v; + } + cleanUpdates.calendarIds = remapped; + } + await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.map(e => e.id === id ? { ...e, ...updates } : e), })); if (sendSchedulingMessages) { try { - const updatedEvent = await client.getCalendarEvent(id); + const updatedEvent = await client.getCalendarEvent(realId, targetAccountId); if (updatedEvent?.participants) { await client.sendImipInvitation(updatedEvent); } @@ -174,6 +204,10 @@ export const useCalendarStore = create()( throw new Error('Invalid participant ID'); } try { + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === eventId); + const realId = storeEvent?.originalId || eventId; + const targetAccountId = storeEvent?.accountId; // Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1 const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patchKey = `participants/${escapedId}/participationStatus`; @@ -184,9 +218,10 @@ export const useCalendarStore = create()( patch.replyTo = replyTo; } await client.updateCalendarEvent( - eventId, + realId, patch as unknown as Partial, - true + true, + targetAccountId ); set((state) => ({ events: state.events.map(e => { @@ -209,6 +244,10 @@ export const useCalendarStore = create()( importEvents: async (client, events, calendarId) => { let imported = 0; + // Resolve shared calendar IDs + const cal = get().calendars.find(c => c.id === calendarId); + const realCalendarId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; for (const event of events) { const src = event as Partial; try { @@ -246,7 +285,7 @@ export const useCalendarStore = create()( } const data: Partial = { - calendarIds: { [calendarId]: true }, + calendarIds: { [realCalendarId]: true }, uid: src.uid, title: src.title, description: src.description, @@ -276,7 +315,7 @@ export const useCalendarStore = create()( const v = (data as Record)[k]; if (v === undefined || v === null) delete (data as Record)[k]; }); - const created = await client.createCalendarEvent(data); + const created = await client.createCalendarEvent(data, undefined, targetAccountId); set((state) => ({ events: [...state.events, created] })); imported++; } catch (error) { @@ -289,7 +328,7 @@ export const useCalendarStore = create()( continue; } try { - const all = await client.queryCalendarEvents({}); + const all = await client.queryCalendarEvents({}, undefined, undefined, targetAccountId); const matching = all.filter((e) => e.uid === src.uid); if (matching.length > 0) { const existingIds = new Set(storeEvents.map((e) => e.id)); @@ -313,9 +352,13 @@ export const useCalendarStore = create()( deleteEvent: async (client, id, sendSchedulingMessages) => { set({ error: null }); try { + // Resolve shared event IDs + const storeEvent = get().events.find(e => e.id === id); + const realId = storeEvent?.originalId || id; + const targetAccountId = storeEvent?.accountId; if (sendSchedulingMessages) { try { - const event = await client.getCalendarEvent(id); + const event = await client.getCalendarEvent(realId, targetAccountId); if (event?.participants) { await client.sendImipCancellation(event); } @@ -323,7 +366,7 @@ export const useCalendarStore = create()( debug.error('Failed to send cancellation emails:', e); } } - await client.deleteCalendarEvent(id, sendSchedulingMessages); + await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.filter(e => e.id !== id), selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, @@ -341,7 +384,10 @@ export const useCalendarStore = create()( updateCalendar: async (client, calendarId, updates) => { set({ error: null }); try { - await client.updateCalendar(calendarId, updates); + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.updateCalendar(realId, updates, targetAccountId); set((state) => ({ calendars: state.calendars.map(c => c.id === calendarId ? { ...c, ...updates } : c @@ -373,7 +419,10 @@ export const useCalendarStore = create()( removeCalendar: async (client, calendarId) => { set({ error: null }); try { - await client.deleteCalendar(calendarId); + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; + await client.deleteCalendar(realId, targetAccountId); set((state) => ({ calendars: state.calendars.filter(c => c.id !== calendarId), selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId), @@ -389,18 +438,21 @@ export const useCalendarStore = create()( clearCalendarEvents: async (client, calendarId) => { set({ error: null }); try { + const cal = get().calendars.find(c => c.id === calendarId); + const realCalId = cal?.originalId || calendarId; + const targetAccountId = cal?.accountId; let totalDeleted = 0; // Loop to handle pagination (getCalendarEvents has a 1000 limit) let hasMore = true; while (hasMore) { // Query all events and filter client-side by calendarId // to avoid relying on server-side inCalendars filter support - const allEvents = await client.getCalendarEvents(); - const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]); + const allEvents = await client.getCalendarEvents(undefined, targetAccountId); + const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]); if (calendarEvents.length === 0) break; const ids = calendarEvents.map(e => e.id); - const { destroyed } = await client.batchDeleteCalendarEvents(ids); + const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId); totalDeleted += destroyed.length; // If we couldn't destroy any events, stop to avoid infinite loop From fb8c9db71609c2c5f419252e00c19ae24a739f6e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:38:23 +0100 Subject: [PATCH 13/13] chore: bump version to 1.4.2 --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- app/[locale]/login/page.tsx | 2 +- lib/jmap/types.ts | 26 ++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- stores/task-store.ts | 26 ++++++++++++++++++++++++++ 8 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 stores/task-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2932b92..facf803b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 1.4.2 (2026-03-19) + +### Features + +- **Calendar**: Add task list view for calendar tasks with task details and management +- **Calendar**: Add shared calendar grouping with visual separation in sidebar +- **Calendar**: Support double-click to create events and improve modal date handling +- **Contacts**: Add address book directories with drag-and-drop and editor picker +- **Email**: Add email attachment support in sendEmail functionality +- **Email**: Implement draft editing functionality across email components +- **Email**: Implement unwrapping of embedded message/rfc822 attachments with enhanced HTML body validation +- **Email**: Add email export/import localization keys for multiple languages +- **Contacts**: Update gender handling to use speakToAs structure + +### Fixes + +- **Email**: Resolve default sender to canonical identity on local-part login +- **Email**: Refactor overflow handling in EmailViewer to use hidden priorities and layout effects +- **Email**: Remove debugMode usage from EmailViewer component +- **Calendar**: Enhance IMIP invitation and cancellation handling for calendar events +- **Calendar**: Add time-based sorting for events in buildWeekSegments function +- **Dependencies**: Update dompurify to 3.3.3 and elliptic to 6.6.1, add undici override + ## 1.4.1 (2026-03-18) ### Features diff --git a/README.md b/README.md index 8f16d51a..a622c10a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.1-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.2-green.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 347f5833..9df886c4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 074e4ec4..2ccf540e 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.1"; +const APP_VERSION = "1.4.2"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 8d27c209..1136c57a 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -564,6 +564,32 @@ export interface CalendarRelation { relation: Record | null; } +export interface CalendarTask { + id: string; + calendarIds: Record; + '@type': 'Task'; + uid: string; + title: string; + description: string; + due: string | null; + start: string | null; + duration: string | null; + timeZone: string | null; + showWithoutTime: boolean; + progress: 'needs-action' | 'in-process' | 'completed' | 'cancelled'; + progressUpdated: string | null; + priority: number; + privacy: 'public' | 'private' | 'secret'; + keywords: Record | null; + categories: Record | null; + color: string | null; + created: string | null; + updated: string; + recurrenceRules: CalendarRecurrenceRule[] | null; + alerts: Record | null; + relatedTo: Record | null; +} + export interface CalendarParticipantIdentity { id: string; name: string; diff --git a/package-lock.json b/package-lock.json index e319266a..86bddba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.4.1", + "version": "1.4.2", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.18", diff --git a/package.json b/package.json index 92e7aecd..193f1662 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.1", + "version": "1.4.2", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", diff --git a/stores/task-store.ts b/stores/task-store.ts new file mode 100644 index 00000000..6eb89196 --- /dev/null +++ b/stores/task-store.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import type { CalendarTask } from '@/lib/jmap/types'; + +export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; + +interface TaskStore { + tasks: CalendarTask[]; + selectedTaskId: string | null; + filter: TaskViewFilter; + showCompleted: boolean; + setTasks: (tasks: CalendarTask[]) => void; + setSelectedTaskId: (id: string | null) => void; + setFilter: (filter: TaskViewFilter) => void; + setShowCompleted: (show: boolean) => void; +} + +export const useTaskStore = create((set) => ({ + tasks: [], + selectedTaskId: null, + filter: 'all', + showCompleted: false, + setTasks: (tasks) => set({ tasks }), + setSelectedTaskId: (id) => set({ selectedTaskId: id }), + setFilter: (filter) => set({ filter }), + setShowCompleted: (show) => set({ showCompleted: show }), +}));