diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 9278a56e15..1ee705804b 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -95,7 +95,7 @@ const draft: ActionFunction<{ to?: string | null; cc?: string | null; bcc?: string | null; -}> = async ({ client, email, args, executedRule }) => { +}> = async ({ client, email, args, userEmail, executedRule }) => { const draftArgs = { to: args.to ?? undefined, subject: args.subject ?? undefined, @@ -119,6 +119,7 @@ const draft: ActionFunction<{ attachments: email.attachments, }, draftArgs, + userEmail, executedRule, ); return { draftId: result.draftId }; diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 2c3d59dbe9..6abf102625 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -278,12 +278,13 @@ export class GmailProvider implements EmailProvider { async draftEmail( email: ParsedMessage, args: { to?: string; subject?: string; content: string }, + userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }> { if (executedRule) { // Run draft creation and previous draft deletion in parallel const [result] = await Promise.all([ - draftEmail(this.client, email, args), + draftEmail(this.client, email, args, userEmail), handlePreviousDraftDeletion({ client: this, executedRule, @@ -292,7 +293,7 @@ export class GmailProvider implements EmailProvider { ]); return { draftId: result.data.id || "" }; } else { - const result = await draftEmail(this.client, email, args); + const result = await draftEmail(this.client, email, args, userEmail); return { draftId: result.data.id || "" }; } } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 4f858abef2..7c42ef9cf3 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -283,12 +283,13 @@ export class OutlookProvider implements EmailProvider { async draftEmail( email: ParsedMessage, args: { to?: string; subject?: string; content: string }, + userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }> { if (executedRule) { // Run draft creation and previous draft deletion in parallel const [result] = await Promise.all([ - draftEmail(this.client, email, args), + draftEmail(this.client, email, args, userEmail), handlePreviousDraftDeletion({ client: this, executedRule, @@ -297,7 +298,7 @@ export class OutlookProvider implements EmailProvider { ]); return { draftId: result.id || "" }; } else { - const result = await draftEmail(this.client, email, args); + const result = await draftEmail(this.client, email, args, userEmail); return { draftId: result.id || "" }; } } diff --git a/apps/web/utils/email/reply-all.test.ts b/apps/web/utils/email/reply-all.test.ts new file mode 100644 index 0000000000..4387946fff --- /dev/null +++ b/apps/web/utils/email/reply-all.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect } from "vitest"; +import { buildReplyAllRecipients, formatCcList } from "./reply-all"; +import type { ParsedMessageHeaders } from "@/utils/types"; + +describe("buildReplyAllRecipients", () => { + it("should handle simple reply-all with TO and CC", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com, colleague@company.com", + cc: "manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toHaveLength(3); + }); + + it("should use reply-to header when available", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + "reply-to": "noreply@example.com", + to: "user@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("noreply@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).not.toContain("noreply@example.com"); + }); + + it("should handle no CC recipients", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com, colleague@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should handle single recipient (no CC needed)", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "sender@example.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toHaveLength(0); + }); + + it("should remove duplicates from CC list", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com, colleague@company.com", + cc: "colleague@company.com, manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toHaveLength(3); + }); + + it("should handle override TO parameter", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com", + cc: "manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + "override@example.com", + "myemail@example.com", + ); + + expect(result.to).toBe("override@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).not.toContain("override@example.com"); + }); + + it("should handle addresses with extra spaces", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: " user@company.com , colleague@company.com ", + cc: " manager@company.com ", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toHaveLength(3); + }); + + it("should filter out empty addresses", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com, , colleague@company.com", + cc: ", manager@company.com, ", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toHaveLength(3); + }); + + it("should exclude the reply-to address from CC", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "sender@example.com, user@company.com", + cc: "manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).not.toContain("sender@example.com"); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should handle email addresses with display names", () => { + const headers: ParsedMessageHeaders = { + from: '"John Doe" ', + to: '"Alice Smith" , "Bob Jones" ', + cc: '"Charlie Brown" ', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe('"John Doe" '); + expect(result.cc).toContain("alice@company.com"); + expect(result.cc).toContain("bob@company.com"); + expect(result.cc).toContain("charlie@company.com"); + expect(result.cc).toHaveLength(3); + }); + + it("should deduplicate emails with different display names", () => { + const headers: ParsedMessageHeaders = { + from: '"John Doe" ', + to: '"Alice" , "Alice Smith" ', + cc: 'alice@company.com, "Ms. Alice" ', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe('"John Doe" '); + expect(result.cc).toContain("alice@company.com"); + expect(result.cc).toHaveLength(1); // All duplicates should be removed + }); + + it("should exclude sender with display name from CC", () => { + const headers: ParsedMessageHeaders = { + from: '"John Doe" ', + to: 'john@example.com, "Alice" ', + cc: '"John Doe" , bob@company.com', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe('"John Doe" '); + expect(result.cc).not.toContain("john@example.com"); + expect(result.cc).toContain("alice@company.com"); + expect(result.cc).toContain("bob@company.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should handle mixed email formats", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: '"Alice" , bob@company.com', + cc: 'charlie@company.com, "David Lee" ', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("alice@company.com"); + expect(result.cc).toContain("bob@company.com"); + expect(result.cc).toContain("charlie@company.com"); + expect(result.cc).toContain("david@company.com"); + expect(result.cc).toHaveLength(4); + }); + + it("should handle override TO with display name format", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "user@company.com", + cc: "manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + '"Override User" ', + "myemail@example.com", + ); + + expect(result.to).toBe('"Override User" '); + expect(result.cc).toContain("user@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).not.toContain("override@example.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should handle malformed email addresses gracefully", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: '"Invalid" <>, valid@company.com', + cc: "not-an-email, real@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).toContain("valid@company.com"); + expect(result.cc).toContain("real@company.com"); + expect(result.cc).not.toContain(""); // Empty strings should be filtered out + expect(result.cc).toHaveLength(2); + }); + + it("should exclude current user from CC", () => { + const headers: ParsedMessageHeaders = { + from: "sender@example.com", + to: "me@mycompany.com, colleague@company.com", + cc: "manager@company.com", + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "me@mycompany.com", + ); + + expect(result.to).toBe("sender@example.com"); + expect(result.cc).not.toContain("me@mycompany.com"); + expect(result.cc).toContain("colleague@company.com"); + expect(result.cc).toContain("manager@company.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should exclude current user with display name from CC", () => { + const headers: ParsedMessageHeaders = { + from: '"Alice" ', + to: '"Me" , "Bob" ', + cc: 'me@mycompany.com, "Charlie" ', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + '"My Name" ', + ); + + expect(result.to).toBe('"Alice" '); + expect(result.cc).not.toContain("me@mycompany.com"); + expect(result.cc).toContain("bob@company.com"); + expect(result.cc).toContain("charlie@company.com"); + expect(result.cc).toHaveLength(2); + }); + + it("should handle display names with commas correctly", () => { + const headers: ParsedMessageHeaders = { + from: '"Smith, John" ', + to: '"Doe, Jane" , "Johnson, Bob" ', + cc: '"Williams, Mary" , simple@company.com', + subject: "Test", + date: "2024-01-01", + }; + + const result = buildReplyAllRecipients( + headers, + undefined, + "myemail@example.com", + ); + + expect(result.to).toBe('"Smith, John" '); + expect(result.cc).toContain("jane@company.com"); + expect(result.cc).toContain("bob@company.com"); + expect(result.cc).toContain("mary@company.com"); + expect(result.cc).toContain("simple@company.com"); + expect(result.cc).toHaveLength(4); + }); +}); + +describe("formatCcList", () => { + it("should format array of addresses as comma-separated string", () => { + const addresses = ["user1@example.com", "user2@example.com"]; + const result = formatCcList(addresses); + expect(result).toBe("user1@example.com, user2@example.com"); + }); + + it("should return undefined for empty array", () => { + const result = formatCcList([]); + expect(result).toBeUndefined(); + }); + + it("should handle single address", () => { + const result = formatCcList(["user@example.com"]); + expect(result).toBe("user@example.com"); + }); +}); diff --git a/apps/web/utils/email/reply-all.ts b/apps/web/utils/email/reply-all.ts new file mode 100644 index 0000000000..21825ccf6c --- /dev/null +++ b/apps/web/utils/email/reply-all.ts @@ -0,0 +1,69 @@ +import type { ParsedMessageHeaders } from "@/utils/types"; +import { extractEmailAddress } from "@/utils/email"; + +export interface ReplyAllRecipients { + to: string; + cc: string[]; +} + +/** + * Builds reply-all recipients by including original TO and CC recipients. + * The reply goes to the original sender, and CC includes all other recipients. + * + * @param headers - Original email headers + * @param overrideTo - Optional override for the TO field (e.g., for drafts) + * @param currentUserEmail - Current user's email to exclude from CC + * @returns Object with TO and CC recipients for reply-all + */ +export function buildReplyAllRecipients( + headers: ParsedMessageHeaders, + overrideTo: string | undefined, + currentUserEmail: string, +): ReplyAllRecipients { + // Determine the primary recipient (TO field) + const replyToRaw = overrideTo || headers["reply-to"] || headers.from; + const replyTo = extractEmailAddress(replyToRaw); + + // Extract current user's email + const currentUser = extractEmailAddress(currentUserEmail); + + // Build CC list for reply-all behavior + const ccSet = new Set(); + + // Add original CC recipients if they exist + if (headers.cc) { + const originalCcAddresses = headers.cc + .split(",") + .map((addr) => extractEmailAddress(addr.trim())) + .filter((addr) => addr && addr !== replyTo && addr !== currentUser); + + for (const addr of originalCcAddresses) { + ccSet.add(addr); + } + } + + // Add original TO recipients to CC (excluding the reply-to address and current user) + if (headers.to) { + const originalToAddresses = headers.to + .split(",") + .map((addr) => extractEmailAddress(addr.trim())) + .filter((addr) => addr && addr !== replyTo && addr !== currentUser); + + for (const addr of originalToAddresses) { + ccSet.add(addr); + } + } + + return { + to: replyToRaw, // Keep the original format for the TO field + cc: Array.from(ccSet), + }; +} + +/** + * Converts array of CC recipients to a comma-separated string. + * Returns undefined if the array is empty. + */ +export function formatCcList(ccList: string[]): string | undefined { + return ccList.length > 0 ? ccList.join(", ") : undefined; +} diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 2f892d1332..f3394a0598 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -74,6 +74,7 @@ export interface EmailProvider { draftEmail( email: ParsedMessage, args: { to?: string; subject?: string; content: string }, + userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }>; replyToEmail(email: ParsedMessage, content: string): Promise; diff --git a/apps/web/utils/gmail/mail.ts b/apps/web/utils/gmail/mail.ts index 3712f05cc9..300139b343 100644 --- a/apps/web/utils/gmail/mail.ts +++ b/apps/web/utils/gmail/mail.ts @@ -17,6 +17,7 @@ import { createReplyContent } from "@/utils/gmail/reply"; import type { EmailForAction } from "@/utils/ai/types"; import { createScopedLogger } from "@/utils/logger"; import { withGmailRetry } from "@/utils/gmail/retry"; +import { buildReplyAllRecipients, formatCcList } from "@/utils/email/reply-all"; const logger = createScopedLogger("gmail/mail"); @@ -145,6 +146,7 @@ export async function replyToEmail( message, }); + // Only replying to the original sender const raw = await createRawMailMessage( { to: message.headers["reply-to"] || message.headers.from, @@ -242,19 +244,22 @@ export async function draftEmail( content: string; attachments?: Attachment[]; }, + userEmail: string, ) { const { text, html } = createReplyContent({ textContent: args.content, message: originalEmail, }); + const recipients = buildReplyAllRecipients( + originalEmail.headers, + args.to, + userEmail, + ); + const raw = await createRawMailMessage({ - to: - args.to || - originalEmail.headers["reply-to"] || - originalEmail.headers.from, - cc: originalEmail.headers.cc, - bcc: originalEmail.headers.bcc, + to: recipients.to, + cc: formatCcList(recipients.cc), subject: args.subject || originalEmail.headers.subject, messageHtml: html, messageText: text, diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 25a8385ebe..66c183a1e6 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -6,6 +6,7 @@ import type { ParsedMessage } from "@/utils/types"; import type { EmailForAction } from "@/utils/ai/types"; import { createReplyContent } from "@/utils/gmail/reply"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; +import { buildReplyAllRecipients } from "@/utils/email/reply-all"; interface OutlookMessageRequest { subject: string; @@ -72,6 +73,7 @@ export async function replyToEmail( message, }); + // Only replying to the original sender const replyMessage = { subject: `Re: ${message.headers.subject}`, body: { @@ -166,12 +168,24 @@ export async function draftEmail( content: string; attachments?: Attachment[]; }, + userEmail: string, ) { const { html } = createReplyContent({ textContent: args.content, message: originalEmail, }); + const recipients = buildReplyAllRecipients( + originalEmail.headers, + args.to, + userEmail, + ); + + // Convert CC addresses to Outlook format + const ccRecipients = recipients.cc.map((addr) => ({ + emailAddress: { address: addr }, + })); + const draft = { subject: args.subject || originalEmail.headers.subject, body: { @@ -181,27 +195,11 @@ export async function draftEmail( toRecipients: [ { emailAddress: { - address: - args.to || - originalEmail.headers["reply-to"] || - originalEmail.headers.from, + address: recipients.to, }, }, ], - ...(originalEmail.headers.cc - ? { - ccRecipients: [ - { emailAddress: { address: originalEmail.headers.cc } }, - ], - } - : {}), - ...(originalEmail.headers.bcc - ? { - bccRecipients: [ - { emailAddress: { address: originalEmail.headers.bcc } }, - ], - } - : {}), + ...(ccRecipients.length > 0 ? { ccRecipients } : {}), conversationId: originalEmail.threadId, isDraft: true, }; diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 3f7d47b131..bab75e3d92 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -51,7 +51,7 @@ export async function generateDraft({ } // 2. Create draft - await client.draftEmail(message, { content: result }); + await client.draftEmail(message, { content: result }, emailAccount.email); logger.info("Draft created"); }