diff --git a/.cursor/commands/address-pr-comments.md b/.cursor/commands/address-pr-comments.md index 1eb7e3f28a..dd449e2a15 100644 --- a/.cursor/commands/address-pr-comments.md +++ b/.cursor/commands/address-pr-comments.md @@ -1,5 +1,7 @@ Resolve all active PR comments (conversation + code review). Use `gh` CLI. +Important: don't use sandbox mode as the commands won't work in sandbox. + ## Critical Rules 1. **ALWAYS reply to the specific comment** - use replies API, not new PR comment diff --git a/.cursor/commands/create-pr.md b/.cursor/commands/create-pr.md index cac46c02ae..0841b63362 100644 --- a/.cursor/commands/create-pr.md +++ b/.cursor/commands/create-pr.md @@ -1,5 +1,7 @@ # Open a PR +Important: don't use sandbox mode as the commands won't work in sandbox. + ## Step 1: Check state (ONE command) ```bash diff --git a/.cursor/commands/next-step.md b/.cursor/commands/next-step.md new file mode 100644 index 0000000000..90f7425bd6 --- /dev/null +++ b/.cursor/commands/next-step.md @@ -0,0 +1,2 @@ +Continue to the next step. + diff --git a/.cursor/commands/step-by-step.md b/.cursor/commands/step-by-step.md new file mode 100644 index 0000000000..95177044d3 --- /dev/null +++ b/.cursor/commands/step-by-step.md @@ -0,0 +1,7 @@ +# Step-by-Step Execution + +Execute **one step at a time**. After each step, stop and wait for my approval before continuing. + +After each step, show: +- ✅ What was completed +- ➡️ What's next diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 797b51e1a6..751fe1cd84 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Testing Framework - `vitest` is used for testing +- Run tests using `pnpm test` (not `npx vitest`) - Tests are colocated next to the tested file - Example: `dir/format.ts` and `dir/format.test.ts` - AI tests are placed in the `__tests__` directory and are not run by default (they use a real LLM) diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 3f460dad9d..a06555e410 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -159,6 +159,8 @@ const draft: ActionFunction<{ to: args.to ?? undefined, subject: args.subject ?? undefined, content: args.content ?? "", + cc: args.cc ?? undefined, + bcc: args.bcc ?? undefined, }; const result = await client.draftEmail( diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 4c286c17b3..989822a4d1 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -628,7 +628,13 @@ export class GmailProvider implements EmailProvider { async draftEmail( email: ParsedMessage, - args: { to?: string; subject?: string; content: string }, + args: { + to?: string; + subject?: string; + content: string; + cc?: string; + bcc?: string; + }, userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }> { diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index f666178e82..355e00f29c 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -485,7 +485,13 @@ export class OutlookProvider implements EmailProvider { async draftEmail( email: ParsedMessage, - args: { to?: string; subject?: string; content: string }, + args: { + to?: string; + subject?: string; + content: string; + cc?: string; + bcc?: string; + }, userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }> { diff --git a/apps/web/utils/email/reply-all.test.ts b/apps/web/utils/email/reply-all.test.ts index 4387946fff..73327c73f5 100644 --- a/apps/web/utils/email/reply-all.test.ts +++ b/apps/web/utils/email/reply-all.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildReplyAllRecipients, formatCcList } from "./reply-all"; +import { buildReplyAllRecipients, formatCcList, mergeAndDedupeRecipients } from "./reply-all"; import type { ParsedMessageHeaders } from "@/utils/types"; describe("buildReplyAllRecipients", () => { @@ -408,3 +408,26 @@ describe("formatCcList", () => { expect(result).toBe("user@example.com"); }); }); + +describe("mergeAndDedupeRecipients", () => { + it("should handle display names correctly", () => { + const existing = ["john@example.com"]; + const manual = "John Doe , jane@example.com"; + const result = mergeAndDedupeRecipients(existing, manual); + expect(result).toEqual(["john@example.com", "jane@example.com"]); + }); + + it("should be case-insensitive", () => { + const existing = ["john@example.com"]; + const manual = "JOHN@example.com"; + const result = mergeAndDedupeRecipients(existing, manual); + expect(result).toEqual(["john@example.com"]); + }); + + it("should sanitize empty and invalid entries", () => { + const existing = ["john@example.com"]; + const manual = " , , invalid-email, jane@example.com"; + const result = mergeAndDedupeRecipients(existing, manual); + expect(result).toEqual(["john@example.com", "jane@example.com"]); + }); +}); diff --git a/apps/web/utils/email/reply-all.ts b/apps/web/utils/email/reply-all.ts index 21825ccf6c..ee2db64f4f 100644 --- a/apps/web/utils/email/reply-all.ts +++ b/apps/web/utils/email/reply-all.ts @@ -29,16 +29,26 @@ export function buildReplyAllRecipients( // Build CC list for reply-all behavior const ccSet = new Set(); + const seenEmails = 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); + .map((addr) => ({ + raw: addr.trim(), + email: extractEmailAddress(addr.trim()), + })) + .filter( + ({ email }) => email && email !== replyTo && email !== currentUser, + ); - for (const addr of originalCcAddresses) { - ccSet.add(addr); + for (const { raw, email } of originalCcAddresses) { + const key = email.toLowerCase(); + if (!seenEmails.has(key)) { + seenEmails.add(key); + ccSet.add(email); + } } } @@ -46,11 +56,20 @@ export function buildReplyAllRecipients( if (headers.to) { const originalToAddresses = headers.to .split(",") - .map((addr) => extractEmailAddress(addr.trim())) - .filter((addr) => addr && addr !== replyTo && addr !== currentUser); + .map((addr) => ({ + raw: addr.trim(), + email: extractEmailAddress(addr.trim()), + })) + .filter( + ({ email }) => email && email !== replyTo && email !== currentUser, + ); - for (const addr of originalToAddresses) { - ccSet.add(addr); + for (const { raw, email } of originalToAddresses) { + const key = email.toLowerCase(); + if (!seenEmails.has(key)) { + seenEmails.add(key); + ccSet.add(email); + } } } @@ -67,3 +86,35 @@ export function buildReplyAllRecipients( export function formatCcList(ccList: string[]): string | undefined { return ccList.length > 0 ? ccList.join(", ") : undefined; } + +/** + * Merges manual CC/BCC recipients with existing recipients, + * ensuring deduplication and sanitization. + */ +export function mergeAndDedupeRecipients( + existing: string[], + manual: string | undefined, +): string[] { + const result = [...existing]; + const seen = new Set(existing.map((e) => extractEmailAddress(e).toLowerCase())); + + if (manual) { + const manualEntries = manual + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + for (const entry of manualEntries) { + const email = extractEmailAddress(entry); + if (email) { + const key = email.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + result.push(entry); + } + } + } + } + + return result; +} diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 1699ecb049..95b5234457 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -104,7 +104,13 @@ export interface EmailProvider { removeThreadLabels(threadId: string, labelIds: string[]): Promise; draftEmail( email: ParsedMessage, - args: { to?: string; subject?: string; content: string }, + args: { + to?: string; + subject?: string; + content: string; + cc?: string; + bcc?: string; + }, userEmail: string, executedRule?: { id: string; threadId: string; emailAccountId: string }, ): Promise<{ draftId: string }>; diff --git a/apps/web/utils/gmail/mail.ts b/apps/web/utils/gmail/mail.ts index a4e33ceeff..b7c980af54 100644 --- a/apps/web/utils/gmail/mail.ts +++ b/apps/web/utils/gmail/mail.ts @@ -15,7 +15,11 @@ 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"; +import { + buildReplyAllRecipients, + formatCcList, + mergeAndDedupeRecipients, +} from "@/utils/email/reply-all"; import { formatReplySubject } from "@/utils/email/subject"; import { ensureEmailSendingEnabled } from "@/utils/mail"; @@ -252,6 +256,8 @@ export async function draftEmail( to?: string; subject?: string; content: string; + cc?: string; + bcc?: string; attachments?: Attachment[]; }, userEmail: string, @@ -267,9 +273,16 @@ export async function draftEmail( userEmail, ); + // Merge CC from reply-all with CC from args + const ccList = mergeAndDedupeRecipients(recipients.cc, args.cc); + + // Sanitize BCC + const bccList = mergeAndDedupeRecipients([], args.bcc); + const raw = await createRawMailMessage({ to: recipients.to, - cc: formatCcList(recipients.cc), + cc: formatCcList(ccList), + bcc: formatCcList(bccList), 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 8172021b35..fbd1858143 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -6,7 +6,10 @@ import type { ParsedMessage } from "@/utils/types"; import type { EmailForAction } from "@/utils/ai/types"; import { createOutlookReplyContent } from "@/utils/outlook/reply"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; -import { buildReplyAllRecipients } from "@/utils/email/reply-all"; +import { + buildReplyAllRecipients, + mergeAndDedupeRecipients, +} from "@/utils/email/reply-all"; import { formatReplySubject } from "@/utils/email/subject"; import { withOutlookRetry } from "@/utils/outlook/retry"; import { extractEmailAddress, extractNameFromEmail } from "@/utils/email"; @@ -189,6 +192,8 @@ export async function draftEmail( to?: string; subject?: string; content: string; + cc?: string; + bcc?: string; attachments?: Attachment[]; }, userEmail: string, @@ -213,8 +218,20 @@ export async function draftEmail( }, }; + // Build CC list from reply-all and args + const ccAddresses = mergeAndDedupeRecipients(recipients.cc, args.cc); + // Convert CC addresses to Outlook format - const ccRecipients = recipients.cc.map((addr) => ({ + const ccRecipients = ccAddresses.map((addr) => ({ + emailAddress: { + address: extractEmailAddress(addr), + name: extractNameFromEmail(addr), + }, + })); + + // Handle BCC if provided + const bccAddresses = mergeAndDedupeRecipients([], args.bcc); + const bccRecipients = bccAddresses.map((addr) => ({ emailAddress: { address: extractEmailAddress(addr), name: extractNameFromEmail(addr), @@ -264,6 +281,7 @@ export async function draftEmail( }, toRecipients: [toRecipient], ...(ccRecipients.length > 0 ? { ccRecipients } : {}), + ...(bccRecipients.length > 0 ? { bccRecipients } : {}), }), logger, );