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
1 change: 1 addition & 0 deletions apps/web/__tests__/ai-choose-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ function getEmail({
content = "content",
}: { from?: string; subject?: string; content?: string } = {}) {
return {
id: "id",
from,
subject,
content,
Expand Down
29 changes: 12 additions & 17 deletions apps/web/app/api/google/webhook/process-history-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,24 +164,19 @@
});

it("should skip if message is outbound", async () => {
vi.mocked(getThreadMessages).mockResolvedValueOnce([
{
id: "123",
threadId: "thread-123",
labelIds: [GmailLabel.SENT],
internalDate: "1704067200000", // 2024-01-01T00:00:00Z
snippet: "Hello World",
historyId: "12345",
inline: [],
headers: {
from: "user@example.com",
to: "recipient@example.com",
subject: "Test Email",
date: "2024-01-01T00:00:00Z",
},
textPlain: "Hello World",
vi.mocked(getMessage).mockResolvedValueOnce({
id: "123",
threadId: "thread-123",
labelIds: [GmailLabel.SENT],
payload: {
headers: [
{ name: "From", value: "user@example.com" },
{ name: "To", value: "recipient@example.com" },
{ name: "Subject", value: "Test Email" },
{ name: "Date", value: "2024-01-01T00:00:00Z" },
],
},
]);
});

await processHistoryItem(createHistoryItem(), createOptions());

Expand All @@ -208,7 +203,7 @@

await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlocker).toHaveBeenCalledWith({

Check failure on line 206 in apps/web/app/api/google/webhook/process-history-item.test.ts

View workflow job for this annotation

GitHub Actions / test

app/api/google/webhook/process-history-item.test.ts > processHistoryItem > should run cold email blocker when enabled

AssertionError: expected "spy" to be called with arguments: [ Array(1) ] Received: 1st spy call: Array [ Object { - "email": ObjectContaining { - "content": Any<String>, - "date": Any<Date>, + "email": Object { + "content": "", + "date": 2025-03-20T20:08:51.535Z, "from": "sender@example.com", - "messageId": "123", + "id": "123", "subject": "Test Email", "threadId": "thread-123", }, "gmail": Object {}, "user": Object { "about": null, "aiApiKey": null, "aiModel": "gpt-4", "aiProvider": "openai", "autoCategorizeSenders": false, "coldEmailBlocker": "ARCHIVE_AND_LABEL", "coldEmailPrompt": null, "email": "user@example.com", "id": "user-123", }, }, ] Number of calls: 1 ❯ app/api/google/webhook/process-history-item.test.ts:206:33
email: expect.objectContaining({
from: "sender@example.com",
subject: "Test Email",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/google/webhook/process-history-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export async function processHistoryItem(
from: message.headers.from,
subject: message.headers.subject,
content,
messageId,
id: messageId,
threadId,
date: internalDateToDate(message.internalDate),
},
Expand Down
2 changes: 2 additions & 0 deletions apps/web/utils/actions/ai-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ export const generateRulesPromptAction = withActionInstrumentation(
const snippetsResult = await aiFindSnippets({
user,
sentEmails: lastSentMessages.map((message) => ({
id: message.id,
from: message.headers.from,
replyTo: message.headers["reply-to"],
cc: message.headers.cc,
Expand Down Expand Up @@ -721,6 +722,7 @@ export const reportAiMistakeAction = withActionInstrumentation(
actualRule,
expectedRule,
email: {
id: "",
...email,
content,
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/utils/actions/cold-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async function checkColdEmail(
content,
date: body.date ? new Date(body.date) : undefined,
threadId: body.threadId || undefined,
messageId: body.messageId,
id: body.messageId || "",
},
user,
gmail,
Expand Down
92 changes: 60 additions & 32 deletions apps/web/utils/ai/choose-rule/ai-choose-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,73 @@ import type { User } from "@prisma/client";
import { stringifyEmail } from "@/utils/stringify-email";
import type { EmailForLLM } from "@/utils/types";
import { createScopedLogger } from "@/utils/logger";
import { Braintrust } from "@/utils/braintrust";

const logger = createScopedLogger("ai-choose-rule");

const braintrust = new Braintrust("choose-rule-1");

type GetAiResponseOptions = {
email: {
from: string;
subject: string;
content: string;
cc?: string;
replyTo?: string;
};
email: EmailForLLM;
user: Pick<User, "email" | "about"> & UserAIFields;
rules: { instructions: string }[];
};

async function getAiResponse(options: GetAiResponseOptions) {
const { email, user, rules } = options;

const rulesWithUnknownRule = [
...rules,
{
instructions:
"None of the other rules match or not enough information to make a decision.",
},
];
const specialRuleNumber = rules.length + 1;

const emailSection = stringifyEmail(email, 500);

const system = `You are an AI assistant that helps people manage their emails.
IMPORTANT: You must strictly follow the exclusions mentioned in each rule.
- If a rule says to exclude certain types of emails, DO NOT select that rule for those excluded emails.
- When multiple rules match, choose the more specific one that best matches the email's content.
- Rules about requiring replies should be prioritized when the email clearly needs a response.
- If you're unsure, select the last rule (not enough information).
- It's better to select "not enough information" than to make an incorrect choice.

REMINDER: Pay careful attention to any exclusions mentioned in the rules. If an email matches an exclusion, that rule MUST NOT be selected.
<instructions>
IMPORTANT: Follow these instructions carefully when selecting a rule:

These are the rules you can select from:
${rulesWithUnknownRule
.map((rule, i) => `${i + 1}. ${rule.instructions}`)
.join("\n")}
<priority>
1. Match the email to a SPECIFIC user-defined rule that addresses the email's exact content or purpose.
2. If the email doesn't match any specific rule but the user has a catch-all rule (like "emails that don't match other criteria"), use that catch-all rule.
3. Only use rule #${specialRuleNumber} (system fallback) if no user-defined rule can reasonably apply.
</priority>

${user.about ? `Additional information about the user:\n\n${user.about}` : ""}
<guidelines>
- If a rule says to exclude certain types of emails, DO NOT select that rule for those excluded emails.
- When multiple rules match, choose the more specific one that best matches the email's content.
- Rules about requiring replies should be prioritized when the email clearly needs a response.
- Rule #${specialRuleNumber} should ONLY be selected when there is absolutely no user-defined rule that could apply.
</guidelines>
</instructions>

<user_rules>
${rules.map((rule, i) => `${i + 1}. ${rule.instructions}`).join("\n")}
</user_rules>

<system_fallback>
${specialRuleNumber}. None of the other rules match or not enough information to make a decision.
</system_fallback>

${
user.about
? `<user_info>
<about>${user.about}</about>
<email>${user.email}</email>
</user_info>`
: `<user_info>
<email>${user.email}</email>
</user_info>`
}

<outputFormat>
Respond with a JSON object with the following fields:
"reason" - the reason you chose that rule. Keep it concise.
"rule" - the number of the rule you want to apply
</outputFormat>
</outputFormat>`;

Select a rule to apply to the email:`;
const prompt = `Select a rule to apply to this email that was sent to me:

const prompt = `<email>
${stringifyEmail(email, 500)}
<email>
${emailSection}
</email>`;

logger.trace("Input", { system, prompt });
Expand Down Expand Up @@ -91,8 +105,22 @@ ${stringifyEmail(email, 500)}
});

logger.trace("Response", aiResponse.object);
// logger.trace("Usage", aiResponse.usage);
// logger.trace("Provider Metadata", aiResponse.experimental_providerMetadata);

braintrust.insertToDataset({
id: email.id,
input: {
email: emailSection,
rules: rules.map((rule, i) => ({
ruleNumber: i + 1,
instructions: rule.instructions,
})),
hasAbout: !!user.about,
userAbout: user.about,
userEmail: user.email,
specialRuleNumber,
},
expected: aiResponse.object.rule,
});

return aiResponse.object;
}
Expand Down
11 changes: 3 additions & 8 deletions apps/web/utils/ai/reply/generate-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ import { chatCompletion } from "@/utils/llms";
import type { UserEmailWithAI } from "@/utils/llms/types";
import { stringifyEmail } from "@/utils/stringify-email";
import { createScopedLogger } from "@/utils/logger";
import type { EmailForLLM } from "@/utils/types";

const logger = createScopedLogger("generate-nudge");

export async function aiGenerateNudge({
messages,
user,
}: {
messages: {
from: string;
to: string;
subject: string;
content: string;
date: Date;
}[];
messages: EmailForLLM[];
user: UserEmailWithAI;
onFinish?: (completion: string) => Promise<void>;
}) {
Expand All @@ -32,7 +27,7 @@ ${messages
.map(
(msg) => `<email>
${stringifyEmail(msg, 3000)}
<date>${msg.date.toISOString()}</date>
<date>${msg.date?.toISOString() ?? "unknown"}</date>
</email>`,
)
.join("\n")}
Expand Down
12 changes: 3 additions & 9 deletions apps/web/utils/ai/reply/generate-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,15 @@ import { chatCompletion } from "@/utils/llms";
import type { UserEmailWithAI } from "@/utils/llms/types";
import { stringifyEmail } from "@/utils/stringify-email";
import { createScopedLogger } from "@/utils/logger";

import type { EmailForLLM } from "@/utils/types";
const logger = createScopedLogger("generate-reply");

export async function aiGenerateReply({
messages,
user,
instructions,
}: {
messages: {
from: string;
to: string;
subject: string;
content: string;
date: Date;
}[];
messages: (EmailForLLM & { to: string })[];
user: UserEmailWithAI;
instructions: string | null;
}) {
Expand Down Expand Up @@ -47,7 +41,7 @@ ${messages
.map(
(msg) => `<email>
${stringifyEmail(msg, 3000)}
<date>${msg.date.toISOString()}</date>
<date>${msg.date?.toISOString() ?? "unknown"}</date>
</email>`,
)
.join("\n")}
Expand Down
33 changes: 10 additions & 23 deletions apps/web/utils/cold-email/is-cold-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt";
import { stringifyEmail } from "@/utils/stringify-email";
import { createScopedLogger } from "@/utils/logger";
import { hasPreviousEmailsFromSenderOrDomain } from "@/utils/gmail/message";
import type { EmailForLLM } from "@/utils/types";

const logger = createScopedLogger("ai-cold-email");

Expand All @@ -20,14 +21,7 @@ export async function isColdEmail({
user,
gmail,
}: {
email: {
from: string;
subject: string;
content: string;
date?: Date;
threadId?: string;
messageId: string | null;
};
email: EmailForLLM & { threadId?: string };
user: Pick<User, "id" | "email" | "coldEmailPrompt"> & UserAIFields;
gmail: gmail_v1.Gmail;
}): Promise<{
Expand All @@ -39,7 +33,7 @@ export async function isColdEmail({
userId: user.id,
email: user.email,
threadId: email.threadId,
messageId: email.messageId,
messageId: email.id,
};

logger.info("Checking is cold email", loggerOptions);
Expand All @@ -59,11 +53,11 @@ export async function isColdEmail({
}

const hasPreviousEmail =
email.date && email.messageId
email.date && email.id
? await hasPreviousEmailsFromSenderOrDomain(gmail, {
from: email.from,
date: email.date,
messageId: email.messageId,
messageId: email.id,
})
: false;

Expand Down Expand Up @@ -105,7 +99,7 @@ async function isKnownColdEmailSender({
}

async function aiIsColdEmail(
email: { from: string; subject: string; content: string },
email: EmailForLLM,
user: Pick<User, "email" | "coldEmailPrompt"> & UserAIFields,
) {
const system = `You are an assistant that decides if an email is a cold email or not.
Expand Down Expand Up @@ -153,14 +147,7 @@ ${stringifyEmail(email, 500)}
}

export async function runColdEmailBlocker(options: {
email: {
from: string;
subject: string;
content: string;
messageId: string;
threadId: string;
date: Date;
};
email: EmailForLLM & { threadId: string };
gmail: gmail_v1.Gmail;
user: Pick<User, "id" | "email" | "coldEmailPrompt" | "coldEmailBlocker"> &
UserAIFields;
Expand All @@ -173,7 +160,7 @@ export async function runColdEmailBlocker(options: {

export async function blockColdEmail(options: {
gmail: gmail_v1.Gmail;
email: { from: string; messageId: string; threadId: string };
email: { from: string; id: string; threadId: string };
user: Pick<User, "id" | "email" | "coldEmailBlocker">;
aiReason: string | null;
}) {
Expand All @@ -187,7 +174,7 @@ export async function blockColdEmail(options: {
fromEmail: email.from,
userId: user.id,
reason: aiReason,
messageId: email.messageId,
messageId: email.id,
threadId: email.threadId,
},
});
Expand Down Expand Up @@ -221,7 +208,7 @@ export async function blockColdEmail(options: {

await labelMessage({
gmail,
messageId: email.messageId,
messageId: email.id,
addLabelIds: addLabelIds.length ? addLabelIds : undefined,
removeLabelIds: removeLabelIds.length ? removeLabelIds : undefined,
});
Expand Down
1 change: 1 addition & 0 deletions apps/web/utils/get-email-from-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function getEmailForLLM(
contentOptions?: EmailToContentOptions,
): EmailForLLM {
return {
id: message.id,
from: message.headers.from,
replyTo: message.headers["reply-to"],
cc: message.headers.cc,
Expand Down
1 change: 1 addition & 0 deletions apps/web/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface ParsedMessageHeaders {
}

export type EmailForLLM = {
id: string;
from: string;
replyTo?: string;
cc?: string;
Expand Down
Loading