diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx index 99b3a5117b..c37543d56d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx @@ -533,9 +533,16 @@ function ActionCard({ const isDraftEmailWithoutManualContent = actionType === ActionType.DRAFT_EMAIL && !contentSetManually; + const isNotifySender = actionType === ActionType.NOTIFY_SENDER; + const rightContent = ( <> - {isDraftEmailWithoutManualContent ? ( + {isNotifySender ? ( +
+ Sends an automated notification from Inbox Zero informing the sender + their email was filtered as cold outreach. +
+ ) : isDraftEmailWithoutManualContent ? (
Our AI generates a draft reply from your email history and knowledge base. diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx index ab11571eff..08f92d2f68 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx @@ -203,6 +203,12 @@ export function ActionSummaryCard({ summaryContent = `Folder: ${action.folderName?.value || "unset"}`; break; + case ActionType.NOTIFY_SENDER: + summaryContent = "Notify sender"; + tooltipText = + "Sends an automated notification from Inbox Zero (not from your email) informing the sender their email was filtered as cold outreach."; + break; + default: summaryContent = actionTypeLabel; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx index d701e7327a..f7b62e4e0f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AvailableActionsPanel.tsx @@ -21,6 +21,7 @@ const actionNames: Record = { [ActionType.SEND_EMAIL]: "Send email", [ActionType.CALL_WEBHOOK]: "Call webhook", [ActionType.DIGEST]: "Add to digest", + [ActionType.NOTIFY_SENDER]: "Notify sender", }; const actionTooltips: Partial> = { diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 09d1bdcc29..f033179449 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { type SubmitHandler, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { usePostHog } from "posthog-js/react"; +import { env } from "@/env"; import { PencilIcon, TrashIcon, @@ -52,7 +53,6 @@ import { getRuleConfig } from "@/utils/rule/consts"; import { RuleSectionCard } from "@/app/(app)/[emailAccountId]/assistant/RuleSectionCard"; import { ConditionSteps } from "@/app/(app)/[emailAccountId]/assistant/ConditionSteps"; import { ActionSteps } from "@/app/(app)/[emailAccountId]/assistant/ActionSteps"; -import { env } from "@/env"; export function Rule({ ruleId, @@ -339,10 +339,21 @@ export function RuleForm({ value: ActionType.CALL_WEBHOOK, icon: getActionIcon(ActionType.CALL_WEBHOOK), }, + // NOTIFY_SENDER is only available for cold email rules + ...(rule.systemType === SystemType.COLD_EMAIL && + env.NEXT_PUBLIC_IS_RESEND_CONFIGURED + ? [ + { + label: "Notify sender", + value: ActionType.NOTIFY_SENDER, + icon: getActionIcon(ActionType.NOTIFY_SENDER), + }, + ] + : []), ]; return options; - }, [provider, terminology.label.action]); + }, [provider, terminology.label.action, rule.systemType]); const [isNameEditMode, setIsNameEditMode] = useState(alwaysEditMode); const [isDeleting, setIsDeleting] = useState(false); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts index f8876876e9..7bce481e21 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts +++ b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts @@ -10,6 +10,7 @@ import { WebhookIcon, FileTextIcon, FolderInputIcon, + BellIcon, } from "lucide-react"; import { ActionType } from "@/generated/prisma/enums"; @@ -25,6 +26,7 @@ const ACTION_TYPE_COLORS = { [ActionType.CALL_WEBHOOK]: "bg-gray-500", [ActionType.DIGEST]: "bg-teal-500", [ActionType.MOVE_FOLDER]: "bg-emerald-500", + [ActionType.NOTIFY_SENDER]: "bg-amber-500", } as const; export const ACTION_TYPE_TEXT_COLORS = { @@ -39,6 +41,7 @@ export const ACTION_TYPE_TEXT_COLORS = { [ActionType.CALL_WEBHOOK]: "text-gray-500", [ActionType.DIGEST]: "text-teal-500", [ActionType.MOVE_FOLDER]: "text-emerald-500", + [ActionType.NOTIFY_SENDER]: "text-amber-500", } as const; export const ACTION_TYPE_ICONS = { @@ -53,6 +56,7 @@ export const ACTION_TYPE_ICONS = { [ActionType.CALL_WEBHOOK]: WebhookIcon, [ActionType.DIGEST]: FileTextIcon, [ActionType.MOVE_FOLDER]: FolderInputIcon, + [ActionType.NOTIFY_SENDER]: BellIcon, } as const; // Helper function to get action type from string (for RulesPrompt.tsx) diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx index eadb9317e6..3ec02d5a6e 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailContent.tsx @@ -15,11 +15,11 @@ export function ColdEmailContent({ searchParam }: { searchParam?: string }) { const { emailAccountId } = useAccount(); return ( - + - Test Cold Emails Marked Not Cold + Test Settings diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index 12cc2ded5a..0c94dd6ada 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -330,6 +330,11 @@ export default function Components() { label: "Digest", id: "digest", }, + { + type: ActionType.NOTIFY_SENDER, + label: "Notify sender", + id: "notify_sender", + }, ]} provider="gmail" labels={[{ id: "label", name: "Label" }]} diff --git a/apps/web/components/PlanBadge.tsx b/apps/web/components/PlanBadge.tsx index f633fd2c05..23fbbb8f59 100644 --- a/apps/web/components/PlanBadge.tsx +++ b/apps/web/components/PlanBadge.tsx @@ -179,6 +179,8 @@ function getActionLabel(type: ActionType, provider: string) { return "Mark as spam"; case ActionType.MARK_READ: return "Mark as read"; + case ActionType.NOTIFY_SENDER: + return "Notify Sender"; default: return capitalCase(type); } @@ -223,6 +225,8 @@ export function getActionColor(actionType: ActionType): Color { case ActionType.CALL_WEBHOOK: case ActionType.DIGEST: return "purple"; + case ActionType.NOTIFY_SENDER: + return "purple"; default: { const exhaustiveCheck: never = actionType; return exhaustiveCheck; diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx index 635e2b4817..915d05d6a6 100644 --- a/apps/web/components/email-list/EmailMessage.tsx +++ b/apps/web/components/email-list/EmailMessage.tsx @@ -25,6 +25,7 @@ import { EmailAttachments } from "@/components/email-list/EmailAttachments"; import { Loading } from "@/components/Loading"; import { MessageText } from "@/components/Typography"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { formatReplySubject } from "@/utils/email/subject"; export function EmailMessage({ message, @@ -328,7 +329,7 @@ const prepareReplyingToEmail = ( // If following an email from yourself, don't add "Re:" prefix subject: sentFromUser ? message.headers.subject - : `Re: ${message.headers.subject}`, + : formatReplySubject(message.headers.subject), headerMessageId: message.headers["message-id"]!, threadId: message.threadId!, // Keep original CC diff --git a/apps/web/env.ts b/apps/web/env.ts index 4655e252a6..2abb69e28f 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -190,6 +190,8 @@ export const env = createEnv({ NEXT_PUBLIC_DIGEST_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_MEETING_BRIEFS_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_INTEGRATIONS_ENABLED: z.coerce.boolean().optional(), + // Derived from presence of RESEND_API_KEY + NEXT_PUBLIC_IS_RESEND_CONFIGURED: z.boolean().optional(), }, // For Next.js >= 13.4.4, you only need to destructure client variables: experimental__runtimeEnv: { @@ -251,5 +253,6 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED, NEXT_PUBLIC_INTEGRATIONS_ENABLED: process.env.NEXT_PUBLIC_INTEGRATIONS_ENABLED, + NEXT_PUBLIC_IS_RESEND_CONFIGURED: !!process.env.RESEND_API_KEY, }, }); diff --git a/apps/web/prisma/migrations/20251219012216_add_notify_sender_action_type/migration.sql b/apps/web/prisma/migrations/20251219012216_add_notify_sender_action_type/migration.sql new file mode 100644 index 0000000000..cd5a0435c7 --- /dev/null +++ b/apps/web/prisma/migrations/20251219012216_add_notify_sender_action_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ActionType" ADD VALUE 'NOTIFY_SENDER'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a558478efa..f5047bcea3 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -1051,6 +1051,7 @@ enum ActionType { // TRACK_THREAD // @deprecated - No longer used. We rely on rule SystemType instead to run this. DIGEST MOVE_FOLDER + NOTIFY_SENDER // Sends notification from Inbox Zero (not user's email). Only for cold email rules. // SUMMARIZE // SNOOZE // ADD_TO_DO diff --git a/apps/web/utils/action-display.tsx b/apps/web/utils/action-display.tsx index 5d956538c7..fefa76f279 100644 --- a/apps/web/utils/action-display.tsx +++ b/apps/web/utils/action-display.tsx @@ -2,6 +2,7 @@ import { ActionType } from "@/generated/prisma/enums"; import { getEmailTerminology } from "@/utils/terminology"; import { ArchiveIcon, + BellIcon, FolderInputIcon, ForwardIcon, ReplyIcon, @@ -76,6 +77,8 @@ export function getActionDisplay( return "Digest"; case ActionType.CALL_WEBHOOK: return "Call Webhook"; + case ActionType.NOTIFY_SENDER: + return "Notify Sender"; default: { const exhaustiveCheck: never = action.type; return exhaustiveCheck; @@ -107,6 +110,8 @@ export function getActionIcon(actionType: ActionType) { return WebhookIcon; case ActionType.DIGEST: return NewspaperIcon; + case ActionType.NOTIFY_SENDER: + return BellIcon; default: { const exhaustiveCheck: never = actionType; return exhaustiveCheck; diff --git a/apps/web/utils/action-item.ts b/apps/web/utils/action-item.ts index 09205194d4..6b1bfaece9 100644 --- a/apps/web/utils/action-item.ts +++ b/apps/web/utils/action-item.ts @@ -149,6 +149,9 @@ export const actionInputs: Record< }, ], }, + [ActionType.NOTIFY_SENDER]: { + fields: [], + }, }; export function getActionFields(fields: Action | ExecutedAction | undefined) { @@ -275,6 +278,9 @@ export function sanitizeActionFields( url: action.url ?? null, }; } + case ActionType.NOTIFY_SENDER: { + return base; + } default: // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check const exhaustiveCheck: never = action.type; diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 0508c51b0e..083548ea13 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -26,6 +26,7 @@ const zodActionType = z.enum([ ActionType.MARK_READ, ActionType.DIGEST, ActionType.MOVE_FOLDER, + ActionType.NOTIFY_SENDER, ]); const zodConditionType = z.enum([ConditionType.AI, ConditionType.STATIC]); diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index bdb05c9cb2..edd766545a 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -10,6 +10,9 @@ import { filterNullProperties } from "@/utils"; import { labelMessageAndSync } from "@/utils/label.server"; import { hasVariables } from "@/utils/template"; import prisma from "@/utils/prisma"; +import { sendColdEmailNotification } from "@/utils/cold-email/send-notification"; +import { extractEmailAddress } from "@/utils/email"; +import { captureException } from "@/utils/error"; const MODULE = "ai-actions"; @@ -73,6 +76,8 @@ export const runActionFunction = async (options: { return digest(opts); case ActionType.MOVE_FOLDER: return move_folder(opts); + case ActionType.NOTIFY_SENDER: + return notify_sender(opts); default: throw new Error(`Unknown action: ${action}`); } @@ -321,6 +326,44 @@ const move_folder: ActionFunction<{ folderId?: string | null }> = async ({ await client.moveThreadToFolder(email.threadId, userEmail, args.folderId); }; +const notify_sender: ActionFunction> = async ({ + email, + userEmail, + logger, +}) => { + const senderEmail = extractEmailAddress(email.headers.from); + if (!senderEmail) { + logger.error("Could not extract sender email for notify_sender action"); + return; + } + + const result = await sendColdEmailNotification({ + senderEmail, + recipientEmail: userEmail, + originalSubject: email.headers.subject, + originalMessageId: email.headers["message-id"], + logger, + }); + + if (!result.success) { + // Best-effort: don't fail the whole rule run if notification can't be sent. + logger.error("Cold email notification failed", { + senderEmail, + error: result.error, + }); + + captureException( + new Error(result.error ?? "Cold email notification failed"), + { + extra: { actionType: ActionType.NOTIFY_SENDER, senderEmail }, + sampleRate: 0.01, + }, + userEmail, + ); + return; + } +}; + async function lazyUpdateActionLabelId({ labelName, labelId, diff --git a/apps/web/utils/cold-email/send-notification.ts b/apps/web/utils/cold-email/send-notification.ts new file mode 100644 index 0000000000..3f459a2a29 --- /dev/null +++ b/apps/web/utils/cold-email/send-notification.ts @@ -0,0 +1,55 @@ +import { sendColdEmailNotification as sendColdEmailNotificationViaResend } from "@inboxzero/resend"; +import { env } from "@/env"; +import { getErrorMessage } from "@/utils/error"; +import type { Logger } from "@/utils/logger"; +import { formatReplySubject } from "@/utils/email/subject"; + +export async function sendColdEmailNotification({ + senderEmail, + recipientEmail, + originalSubject, + originalMessageId, + logger, +}: { + senderEmail: string; // The cold emailer we're notifying + recipientEmail: string; // The user who received the cold email + originalSubject: string; + originalMessageId?: string; // Message-ID of the original email for threading + logger: Logger; +}): Promise<{ success: boolean; error?: string }> { + if (!env.RESEND_API_KEY) { + logger.warn("Resend not configured, skipping cold email notification"); + return { success: false, error: "Resend not configured" }; + } + + const subject = formatReplySubject(originalSubject); + + try { + const result = await sendColdEmailNotificationViaResend({ + from: env.RESEND_FROM_EMAIL, + to: senderEmail, + replyTo: recipientEmail, + subject, + inReplyTo: originalMessageId, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + }, + }); + + logger.info("Cold email notification sent", { + senderEmail, + messageId: result.data?.id, + }); + + return { success: true }; + } catch (error) { + logger.error("Error sending cold email notification", { + error, + senderEmail, + }); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} diff --git a/apps/web/utils/email/subject.ts b/apps/web/utils/email/subject.ts index 9706060040..8683cf21a7 100644 --- a/apps/web/utils/email/subject.ts +++ b/apps/web/utils/email/subject.ts @@ -5,6 +5,10 @@ */ export function formatReplySubject(subject: string): string { const trimmed = (subject ?? "").trim(); + // Avoid "Re: " with no subject + if (!trimmed) { + return "Re: (no subject)"; + } // Avoid duplicate "Re:" prefix (case-insensitive check) if (/^re:/i.test(trimmed)) { return trimmed; diff --git a/apps/web/utils/error.ts b/apps/web/utils/error.ts index aa8fbc2cb5..fadc551d75 100644 --- a/apps/web/utils/error.ts +++ b/apps/web/utils/error.ts @@ -32,7 +32,7 @@ export function isGmailError( export function captureException( error: unknown, - additionalInfo?: { extra?: Record }, + additionalInfo?: { extra?: Record; sampleRate?: number }, userEmail?: string, ) { if (isKnownApiError(error)) { @@ -41,6 +41,15 @@ export function captureException( return; } + const sampleRate = additionalInfo?.sampleRate; + if ( + Number.isFinite(sampleRate) && + process.env.NODE_ENV === "production" && + Math.random() >= (sampleRate as number) + ) { + return; + } + if (userEmail) setUser({ email: userEmail }); sentryCaptureException(error, additionalInfo); } @@ -208,3 +217,33 @@ export function checkCommonErrors( return null; } + +export function getErrorMessage(error: unknown): string | undefined { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + + const outer = asRecord(error); + if (!outer) return undefined; + + const directMessage = getStringProp(outer, "message"); + if (directMessage) return directMessage; + + const nested = asRecord(outer.error); + if (!nested) return undefined; + + return getStringProp(nested, "message"); +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function getStringProp( + obj: Record, + key: string, +): string | undefined { + const value = obj[key]; + return typeof value === "string" ? value : undefined; +} diff --git a/packages/resend/emails/cold-email-notification.tsx b/packages/resend/emails/cold-email-notification.tsx new file mode 100644 index 0000000000..1b1c84615a --- /dev/null +++ b/packages/resend/emails/cold-email-notification.tsx @@ -0,0 +1,89 @@ +import { + Body, + Container, + Head, + Hr, + Html, + Img, + Link, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import type { FC } from "react"; + +export type ColdEmailNotificationProps = { + baseUrl: string; +}; + +type ColdEmailNotificationComponent = FC & { + PreviewProps: ColdEmailNotificationProps; +}; + +const ColdEmailNotification: ColdEmailNotificationComponent = ({ + baseUrl = "https://www.getinboxzero.com", +}: ColdEmailNotificationProps) => { + return ( + + + + + +
+ + Inbox Zero + + + + Inbox Zero + + +
+ +
+ + The recipient uses{" "} + + Inbox Zero + {" "} + to automatically detect and filter cold emails from first-time + senders. + + + Your email was identified as unsolicited outreach and has been + filtered. + + + If this was sent in error or you need to reach them, please try + an alternative contact method. + +
+ +
+
+ + This is an automated message from{" "} + + Inbox Zero + + . + +
+
+ +
+ + ); +}; + +export default ColdEmailNotification; + +ColdEmailNotification.PreviewProps = { + baseUrl: "https://www.getinboxzero.com", +}; diff --git a/packages/resend/src/send.tsx b/packages/resend/src/send.tsx index 4c6ada7b43..1ed5dd369e 100644 --- a/packages/resend/src/send.tsx +++ b/packages/resend/src/send.tsx @@ -14,6 +14,12 @@ import MeetingBriefingEmail, { type MeetingBriefingEmailProps, generateMeetingBriefingSubject, } from "../emails/meeting-briefing"; +import ColdEmailNotification, { + type ColdEmailNotificationProps, +} from "../emails/cold-email-notification"; + +const RESEND_NOT_CONFIGURED_MESSAGE = + "Resend is not configured. You need to add a RESEND_API_KEY in your .env file for emails to work."; const sendEmail = async ({ from, @@ -34,9 +40,7 @@ const sendEmail = async ({ unsubscribeToken: string; }) => { if (!resend) { - console.log( - "Resend is not configured. You need to add a RESEND_API_KEY in your .env file for emails to work.", - ); + console.log(RESEND_NOT_CONFIGURED_MESSAGE); return Promise.resolve(); } @@ -198,3 +202,60 @@ export const sendMeetingBriefingEmail = async ({ ], }); }; + +/** + * Send a notification to a cold emailer informing them their email was filtered. + * This is different from other emails - it goes to an external sender, not our user, + * so it doesn't have an unsubscribe token. + */ +export const sendColdEmailNotification = async ({ + from, + to, + replyTo, + subject, + inReplyTo, + emailProps, +}: { + from: string; + to: string; // The cold emailer we're notifying + replyTo: string; // The user who received the cold email + subject: string; + inReplyTo?: string; // Message-ID of original email for threading + emailProps: ColdEmailNotificationProps; +}) => { + if (!resend) { + console.log(RESEND_NOT_CONFIGURED_MESSAGE); + return { data: null, error: null }; + } + + const react = ; + const text = await render(react, { plainText: true }); + + const result = await resend.emails.send({ + from, + to, + replyTo, + subject, + react, + text, + // Threading headers - In-Reply-To and References make the reply appear in the same thread + headers: inReplyTo + ? { "In-Reply-To": inReplyTo, References: inReplyTo } + : undefined, + tags: [ + { + name: "category", + value: "cold-email-notification", + }, + ], + }); + + if (result.error) { + console.error("Error sending cold email notification", result.error); + throw new Error( + `Error sending cold email notification: ${result.error.message}`, + ); + } + + return result; +}; diff --git a/version.txt b/version.txt index df206df50e..098c5314fa 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.24.8 +v2.25.0