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"
}