From f7ee2042626c4bb9bef7fe09f5ac2771dfaf5eff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:45:37 +0100 Subject: [PATCH] feat: add support for marking emails as answered or forwarded and update UI accordingly --- app/[locale]/page.tsx | 27 +++++++++++++++++++++++ components/email/email-list-item.tsx | 16 +++++++++++++- components/email/thread-email-item.tsx | 16 +++++++++++++- components/email/thread-list-item.tsx | 30 ++++++++++++++++++++++++-- lib/__tests__/thread-utils.test.ts | 24 +++++++++++++++++++++ lib/demo/demo-client.ts | 5 +++++ lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 13 +++++++++++ lib/jmap/types.ts | 2 ++ lib/thread-utils.ts | 8 +++++++ package.json | 5 ++++- 11 files changed, 142 insertions(+), 5 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 5f4ca016..def07bca 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -444,9 +444,27 @@ export default function Home() { if (!client) return; try { + const effectiveMode = pendingDraft?.mode ?? composerMode; + const originalEmailId = selectedEmail?.id; + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); setShowComposer(false); + // Mark the original email with $answered or $forwarded keyword + if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { + try { + await client.setKeyword(originalEmailId, '$answered'); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } + } else if (originalEmailId && effectiveMode === 'forward') { + try { + await client.setKeyword(originalEmailId, '$forwarded'); + } catch (e) { + debug.error('Failed to set $forwarded keyword:', e); + } + } + // Refresh the current mailbox to update the UI await fetchEmails(client, selectedMailbox); } catch (error) { @@ -861,6 +879,8 @@ export default function Home() { // Append signature from the primary identity const finalBody = appendPlainTextSignature(body, primaryIdentity); + const originalEmailId = selectedEmail.id; + // Send reply with just the body text await sendEmail( client, @@ -875,6 +895,13 @@ export default function Home() { primaryIdentity?.name || undefined ); + // Mark the original email as answered + try { + await client.setKeyword(originalEmailId, '$answered'); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } + // Refresh emails to show the sent reply await fetchEmails(client, selectedMailbox); }; diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 2b69a663..0ddded47 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react"; +import { Paperclip, Star, Circle, CheckSquare, Square, Tag, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -41,6 +41,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isImportant = email.keywords?.["$important"]; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; // Resolve color tag using keyword definitions from settings @@ -175,6 +177,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index e9c9a782..4c927028 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react"; +import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; @@ -29,6 +29,8 @@ export function ThreadEmailItem({ }: ThreadEmailItemProps) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -151,6 +153,18 @@ export function ThreadEmailItem({ {isStarred && ( )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 311c13cf..b2e5d13b 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react"; +import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -53,6 +53,8 @@ const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const emailKeywords = useSettingsStore((state) => state.emailKeywords); @@ -182,6 +184,18 @@ const SingleEmailItem = React.forwardRef( {isStarred && ( )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} @@ -267,7 +281,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const isMobile = useUIStore((state) => state.isMobile); - const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread; + const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); @@ -482,6 +496,18 @@ export const ThreadListItem = React.forwardRef )} + {hasAnswered && !hasForwarded && ( + + )} + {hasForwarded && !hasAnswered && ( + + )} + {hasAnswered && hasForwarded && ( + <> + + + + )} {hasAttachment && ( )} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index d0b413c2..bde3a6b5 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -89,6 +89,22 @@ describe('groupEmailsByThread', () => { expect(groupEmailsByThread(emails)[0].hasAttachment).toBe(true); }); + it('detects hasAnswered when an email has $answered', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { $seen: true } }), + makeEmail({ id: 'e2', keywords: { $seen: true, $answered: true } }), + ]; + expect(groupEmailsByThread(emails)[0].hasAnswered).toBe(true); + }); + + it('detects hasForwarded when an email has $forwarded', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { $seen: true } }), + makeEmail({ id: 'e2', keywords: { $seen: true, $forwarded: true } }), + ]; + expect(groupEmailsByThread(emails)[0].hasForwarded).toBe(true); + }); + it('returns empty array for empty input', () => { expect(groupEmailsByThread([])).toEqual([]); }); @@ -110,6 +126,8 @@ describe('sortThreadGroups', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }, { @@ -120,6 +138,8 @@ describe('sortThreadGroups', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }, ]; @@ -169,6 +189,8 @@ describe('mergeThreadEmails', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 2, }; const fetched = [ @@ -189,6 +211,8 @@ describe('mergeThreadEmails', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }; const fetched = [ diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 2a8df588..f9f3a706 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -216,6 +216,11 @@ export class DemoJMAPClient implements IJMAPClient { if (email) email.keywords = { ...email.keywords, ...keywords }; } + async setKeyword(emailId: string, keyword: string): Promise { + const email = this.data.emails.find(e => e.id === emailId); + if (email) email.keywords[keyword] = true; + } + async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { let count = 0; for (const email of this.data.emails) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0bcfb1a5..777875c9 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -72,6 +72,7 @@ export interface IJMAPClient { batchMarkAsRead(emailIds: string[], read?: boolean): Promise; toggleStar(emailId: string, starred: boolean): Promise; updateEmailKeywords(emailId: string, keywords: Record): Promise; + setKeyword(emailId: string, keyword: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c2395464..92addd39 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -763,6 +763,19 @@ export class JMAPClient implements IJMAPClient { ]); } + async setKeyword(emailId: string, keyword: string): Promise { + await this.request([ + ["Email/set", { + accountId: this.accountId, + update: { + [emailId]: { + [`keywords/${keyword}`]: true, + }, + }, + }, "0"], + ]); + } + async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { // Query all email IDs that have the old keyword const allIds: string[] = []; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c4854ee5..b7e3f5f6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -142,6 +142,8 @@ export interface ThreadGroup { hasUnread: boolean; // Any unread emails in thread hasStarred: boolean; // Any starred emails in thread hasAttachment: boolean; // Any email has attachment + hasAnswered: boolean; // Any email has been replied to + hasForwarded: boolean; // Any email has been forwarded emailCount: number; // Total emails in thread } diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 18681278..7a7de164 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -38,6 +38,8 @@ export function groupEmailsByThread(emails: Email[]): ThreadGroup[] { const hasUnread = sortedEmails.some(e => !e.keywords?.$seen); const hasStarred = sortedEmails.some(e => e.keywords?.$flagged); const hasAttachment = sortedEmails.some(e => e.hasAttachment); + const hasAnswered = sortedEmails.some(e => e.keywords?.$answered); + const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded); threadGroups.push({ threadId, @@ -47,6 +49,8 @@ export function groupEmailsByThread(emails: Email[]): ThreadGroup[] { hasUnread, hasStarred, hasAttachment, + hasAnswered, + hasForwarded, emailCount: sortedEmails.length, }); } @@ -123,6 +127,8 @@ export function mergeThreadEmails( const hasUnread = mergedEmails.some(e => !e.keywords?.$seen); const hasStarred = mergedEmails.some(e => e.keywords?.$flagged); const hasAttachment = mergedEmails.some(e => e.hasAttachment); + const hasAnswered = mergedEmails.some(e => e.keywords?.$answered); + const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded); return { threadId: existingGroup.threadId, @@ -132,6 +138,8 @@ export function mergeThreadEmails( hasUnread, hasStarred, hasAttachment, + hasAnswered, + hasForwarded, emailCount: mergedEmails.length, }; } diff --git a/package.json b/package.json index 7324819a..2ee97157 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,10 @@ "vitest": "^4.0.16" }, "overrides": { - "elliptic": "^6.6.1", + "elliptic": { + ".": "^6.6.1", + "webcrypto-liner": "$elliptic" + }, "flatted": "^3.4.2", "undici": "^7.24.0" }