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
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,16 @@ function ActionCard({
const isDraftEmailWithoutManualContent =
actionType === ActionType.DRAFT_EMAIL && !contentSetManually;

const isNotifySender = actionType === ActionType.NOTIFY_SENDER;

const rightContent = (
<>
{isDraftEmailWithoutManualContent ? (
{isNotifySender ? (
<div className="px-1 h-full flex items-center text-sm text-muted-foreground">
Sends an automated notification from Inbox Zero informing the sender
their email was filtered as cold outreach.
</div>
) : isDraftEmailWithoutManualContent ? (
<div className="px-1 h-full flex items-center text-sm text-muted-foreground">
Our AI generates a draft reply from your email history and knowledge
base.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const actionNames: Record<ActionType, string> = {
[ActionType.SEND_EMAIL]: "Send email",
[ActionType.CALL_WEBHOOK]: "Call webhook",
[ActionType.DIGEST]: "Add to digest",
[ActionType.NOTIFY_SENDER]: "Notify sender",
};

const actionTooltips: Partial<Record<ActionType, string>> = {
Expand Down
15 changes: 13 additions & 2 deletions apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
WebhookIcon,
FileTextIcon,
FolderInputIcon,
BellIcon,
} from "lucide-react";
import { ActionType } from "@/generated/prisma/enums";

Expand All @@ -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",
Comment thread
elie222 marked this conversation as resolved.
} as const;

export const ACTION_TYPE_TEXT_COLORS = {
Expand All @@ -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 = {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export function ColdEmailContent({ searchParam }: { searchParam?: string }) {
const { emailAccountId } = useAccount();

return (
<Tabs defaultValue="test" searchParam={searchParam}>
<Tabs defaultValue="cold-emails" searchParam={searchParam}>
<TabsList>
<TabsTrigger value="test">Test</TabsTrigger>
<TabsTrigger value="cold-emails">Cold Emails</TabsTrigger>
<TabsTrigger value="rejected">Marked Not Cold</TabsTrigger>
<TabsTrigger value="test">Test</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>

Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/(landing)/components/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/components/PlanBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/email-list/EmailMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/web/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ActionType" ADD VALUE 'NOTIFY_SENDER';
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/web/utils/action-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ActionType } from "@/generated/prisma/enums";
import { getEmailTerminology } from "@/utils/terminology";
import {
ArchiveIcon,
BellIcon,
FolderInputIcon,
ForwardIcon,
ReplyIcon,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/web/utils/action-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ export const actionInputs: Record<
},
],
},
[ActionType.NOTIFY_SENDER]: {
fields: [],
},
};

export function getActionFields(fields: Action | ExecutedAction | undefined) {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/web/utils/actions/rule.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
43 changes: 43 additions & 0 deletions apps/web/utils/ai/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -321,6 +326,44 @@ const move_folder: ActionFunction<{ folderId?: string | null }> = async ({
await client.moveThreadToFolder(email.threadId, userEmail, args.folderId);
};

const notify_sender: ActionFunction<Record<string, unknown>> = 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,
Expand Down
55 changes: 55 additions & 0 deletions apps/web/utils/cold-email/send-notification.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
elie222 marked this conversation as resolved.
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", {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendColdEmailNotification treats a non-throwing { data, error } response as success. Consider checking result.error before logging/returning success and propagate it if present.

+    if (result.error) {
+      logger.error("Resend returned an error when sending cold email notification", { senderEmail, error: result.error });
+      return { success: false, error: getErrorMessage(result.error) };
+    }

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getErrorMessage is imported but not used, and the manual instanceof Error check may drop detail. Consider using getErrorMessage(error) here (or remove the import if you don’t need it).

Suggested change
error: error instanceof Error ? error.message : "Unknown error",
error: getErrorMessage(error),

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

};
}
}
4 changes: 4 additions & 0 deletions apps/web/utils/email/subject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading