Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cursor/commands/address-pr-comments.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions .cursor/commands/create-pr.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions .cursor/commands/next-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Continue to the next step.

7 changes: 7 additions & 0 deletions .cursor/commands/step-by-step.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .cursor/rules/testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/web/utils/ai/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion apps/web/utils/email/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/utils/email/microsoft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down
25 changes: 24 additions & 1 deletion apps/web/utils/email/reply-all.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 <john@example.com>, 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"]);
});
});
67 changes: 59 additions & 8 deletions apps/web/utils/email/reply-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,47 @@ export function buildReplyAllRecipients(

// Build CC list for reply-all behavior
const ccSet = new Set<string>();
const seenEmails = new Set<string>();

// 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);
}
}
}

// 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);
.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);
}
}
}

Expand All @@ -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;
}
8 changes: 7 additions & 1 deletion apps/web/utils/email/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ export interface EmailProvider {
removeThreadLabels(threadId: string, labelIds: string[]): Promise<void>;
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 }>;
Expand Down
17 changes: 15 additions & 2 deletions apps/web/utils/gmail/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -252,6 +256,8 @@ export async function draftEmail(
to?: string;
subject?: string;
content: string;
cc?: string;
bcc?: string;
attachments?: Attachment[];
},
userEmail: string,
Expand All @@ -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);

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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,
Expand Down
22 changes: 20 additions & 2 deletions apps/web/utils/outlook/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -189,6 +192,8 @@ export async function draftEmail(
to?: string;
subject?: string;
content: string;
cc?: string;
bcc?: string;
attachments?: Attachment[];
},
userEmail: string,
Expand All @@ -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),
Expand Down Expand Up @@ -264,6 +281,7 @@ export async function draftEmail(
},
toRecipients: [toRecipient],
...(ccRecipients.length > 0 ? { ccRecipients } : {}),
...(bccRecipients.length > 0 ? { bccRecipients } : {}),
}),
logger,
);
Expand Down
Loading