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: 1 addition & 1 deletion apps/web/__tests__/mocks/email-provider.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function createMockEmailProvider(
.mockResolvedValue(false),
isReplyInThread: vi.fn().mockReturnValue(false),
isSentMessage: vi.fn().mockReturnValue(false),
getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder-123"),
getOrCreateFolderIdByName: vi.fn().mockResolvedValue("folder-123"),
getSignatures: vi.fn().mockResolvedValue([]),

// Watch/webhooks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules";
import { Process } from "@/app/(app)/[emailAccountId]/assistant/Process";
import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPrompt";
import { SettingsTab } from "@/app/(app)/[emailAccountId]/assistant/settings/SettingsTab";
import { TabsToolbar } from "@/components/TabsToolbar";
import { TypographyP } from "@/components/Typography";
import { RuleTab } from "@/app/(app)/[emailAccountId]/assistant/RuleTab";
Expand All @@ -24,6 +25,7 @@ export function AssistantTabs() {
<TabsTrigger value="rules">Rules</TabsTrigger>
<TabsTrigger value="test">Test</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
</div>
<CloseArtifactButton />
Expand All @@ -50,6 +52,9 @@ export function AssistantTabs() {
<TabsContent value="history" className="content-container pb-4">
<History />
</TabsContent>
<TabsContent value="settings" className="content-container pb-4">
<SettingsTab />
</TabsContent>
{/* Set via search params. Not a visible tab. */}
<TabsContent value="rule" className="content-container pb-4">
<RuleTab />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use client";

import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { DownloadIcon, UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingCard } from "@/components/SettingCard";
import { toastError } from "@/components/Toast";
import { useRules } from "@/hooks/useRules";
import { useAccount } from "@/providers/EmailAccountProvider";
import { importRulesAction } from "@/utils/actions/rule";

export function RuleImportExportSetting() {
const { data, mutate } = useRules();
const { emailAccountId } = useAccount();
const fileInputRef = useRef<HTMLInputElement>(null);

const exportRules = useCallback(() => {
if (!data) return;

const exportData = data.map((rule) => ({
name: rule.name,
instructions: rule.instructions,
enabled: rule.enabled,
automate: rule.automate,
runOnThreads: rule.runOnThreads,
systemType: rule.systemType,
conditionalOperator: rule.conditionalOperator,
from: rule.from,
to: rule.to,
subject: rule.subject,
body: rule.body,
categoryFilterType: rule.categoryFilterType,
actions: rule.actions.map((action) => ({
type: action.type,
label: action.label,
to: action.to,
cc: action.cc,
bcc: action.bcc,
subject: action.subject,
content: action.content,
folderName: action.folderName,
url: action.url,
delayInMinutes: action.delayInMinutes,
})),
// note: group associations are not exported as they require matching group IDs
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `inbox-zero-rules-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);

toast.success("Rules exported successfully");
}, [data]);

const importRules = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

try {
const text = await file.text();
const rules = JSON.parse(text);

const rulesArray = Array.isArray(rules) ? rules : rules.rules;

if (!Array.isArray(rulesArray) || rulesArray.length === 0) {
toastError({ description: "Invalid rules file format" });
return;
}

const result = await importRulesAction(emailAccountId, {
rules: rulesArray,
});

if (result?.serverError) {
toastError({
title: "Import failed",
description: result.serverError,
});
} else if (result?.data) {
const { createdCount, updatedCount, skippedCount } = result.data;
toast.success(
`Imported ${createdCount} new, updated ${updatedCount} existing${skippedCount > 0 ? `, skipped ${skippedCount}` : ""}`,
);
mutate();
}
} catch (error) {
toastError({
title: "Import failed",
description:
error instanceof Error ? error.message : "Failed to parse file",
});
}

if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[emailAccountId, mutate],
);

return (
<SettingCard
title="Import / Export Rules"
description="Backup your rules to a JSON file or restore from a previous export."
right={
<div className="flex gap-2">
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={importRules}
className="hidden"
aria-label="Import rules from JSON file"
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="mr-2 size-4" />
Import
</Button>
<Button
size="sm"
variant="outline"
onClick={exportRules}
disabled={!data?.length}
>
<DownloadIcon className="mr-2 size-4" />
Export
</Button>
</div>
}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LearnedPatternsSetting } from "@/app/(app)/[emailAccountId]/assistant/s
import { PersonalSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/PersonalSignatureSetting";
import { MultiRuleSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/MultiRuleSetting";
import { WritingStyleSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/WritingStyleSetting";
import { RuleImportExportSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting";
import { env } from "@/env";

export function SettingsTab() {
Expand All @@ -21,6 +22,7 @@ export function SettingsTab() {
{env.NEXT_PUBLIC_DIGEST_ENABLED && <DigestSetting />}
<ReferralSignatureSetting />
<LearnedPatternsSetting />
<RuleImportExportSetting />
</div>
);
}
2 changes: 2 additions & 0 deletions apps/web/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ 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(),
NEXT_PUBLIC_CLEANER_ENABLED: z.coerce.boolean().optional(),
Comment thread
rsnodgrass marked this conversation as resolved.
NEXT_PUBLIC_IS_RESEND_CONFIGURED: z.coerce.boolean().optional(),
},
// For Next.js >= 13.4.4, you only need to destructure client variables:
Expand Down Expand Up @@ -252,6 +253,7 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED,
NEXT_PUBLIC_INTEGRATIONS_ENABLED:
process.env.NEXT_PUBLIC_INTEGRATIONS_ENABLED,
NEXT_PUBLIC_CLEANER_ENABLED: process.env.NEXT_PUBLIC_CLEANER_ENABLED,
NEXT_PUBLIC_IS_RESEND_CONFIGURED:
process.env.NEXT_PUBLIC_IS_RESEND_CONFIGURED,
},
Expand Down
3 changes: 2 additions & 1 deletion apps/web/hooks/useFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
import { env } from "@/env";

export function useCleanerEnabled() {
return useFeatureFlagEnabled("inbox-cleaner");
const posthogEnabled = useFeatureFlagEnabled("inbox-cleaner");
return env.NEXT_PUBLIC_CLEANER_ENABLED || posthogEnabled;
}

export function useMeetingBriefsEnabled() {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/utils/__mocks__/email-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const createMockEmailProvider = (
getThreadsFromSenderWithSubject: vi.fn().mockResolvedValue([]),
processHistory: vi.fn().mockResolvedValue(undefined),
moveThreadToFolder: vi.fn().mockResolvedValue(undefined),
getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder1"),
getOrCreateFolderIdByName: vi.fn().mockResolvedValue("folder1"),
sendEmailWithHtml: vi.fn().mockResolvedValue(undefined),
getDrafts: vi.fn().mockResolvedValue([]),
...overrides,
Expand Down
119 changes: 116 additions & 3 deletions apps/web/utils/actions/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
toggleRuleBody,
toggleAllRulesBody,
copyRulesFromAccountBody,
importRulesBody,
type ImportedRule,
} from "@/utils/actions/rule.validation";
import prisma from "@/utils/prisma";
import { isDuplicateError, isNotFoundError } from "@/utils/prisma-helpers";
Expand Down Expand Up @@ -673,7 +675,7 @@ async function toggleRule({

for (const actionType of actionTypes) {
if (actionType.includeFolder) {
const folderId = await emailProvider.getOrCreateOutlookFolderIdByName(
const folderId = await emailProvider.getOrCreateFolderIdByName(
ruleConfig.name,
);
actions.push({
Expand Down Expand Up @@ -839,7 +841,7 @@ async function resolveActionLabels<
const folderName = action.folderName?.value;
if (folderName && !action.folderId?.value) {
const resolvedFolderId =
await emailProvider.getOrCreateOutlookFolderIdByName(folderName);
await emailProvider.getOrCreateFolderIdByName(folderName);
return {
...action,
folderId: {
Expand Down Expand Up @@ -930,7 +932,7 @@ async function getActionsFromCategoryAction({
}
case ActionType.MOVE_FOLDER: {
const folderId =
await emailProvider.getOrCreateOutlookFolderIdByName(ruleName);
await emailProvider.getOrCreateFolderIdByName(ruleName);

logger.info("Resolved folder ID during onboarding", {
folderName: ruleName,
Expand Down Expand Up @@ -961,3 +963,114 @@ async function getActionsFromCategoryAction({

return actions;
}

export const importRulesAction = actionClient
.metadata({ name: "importRules" })
.inputSchema(importRulesBody)
.action(
async ({ ctx: { emailAccountId, logger }, parsedInput: { rules } }) => {
logger.info("Importing rules", { count: rules.length });

// Fetch existing rules to check for duplicates by name or systemType
const existingRules = await prisma.rule.findMany({
where: { emailAccountId },
select: { id: true, name: true, systemType: true },
});

const rulesByName = new Map(
existingRules.map((r) => [r.name.toLowerCase(), r.id]),
);
const rulesBySystemType = new Map(
existingRules
.filter((r) => r.systemType)
.map((r) => [r.systemType!, r.id]),
);

let createdCount = 0;
let updatedCount = 0;
let skippedCount = 0;

for (const rule of rules) {
try {
// Match by systemType first, then by name
const existingRuleId = rule.systemType
? rulesBySystemType.get(rule.systemType)
: rulesByName.get(rule.name.toLowerCase());

// Map actions - keep label names but clear IDs
const mappedActions = rule.actions.map((action) => ({
type: action.type,
label: action.label,
labelId: null,
subject: action.subject,
content: action.content,
to: action.to,
cc: action.cc,
bcc: action.bcc,
folderName: action.folderName,
folderId: null,
url: action.url,
delayInMinutes: action.delayInMinutes,
}));
Comment thread
rsnodgrass marked this conversation as resolved.
Comment thread
rsnodgrass marked this conversation as resolved.

if (existingRuleId) {
// Update existing rule
await prisma.rule.update({
where: { id: existingRuleId },
data: {
instructions: rule.instructions,
enabled: rule.enabled ?? true,
automate: rule.automate ?? true,
runOnThreads: rule.runOnThreads ?? false,
conditionalOperator: rule.conditionalOperator,
categoryFilterType: rule.categoryFilterType,
from: rule.from,
to: rule.to,
subject: rule.subject,
body: rule.body,
groupId: null,
actions: {
deleteMany: {},
createMany: { data: mappedActions },
},
},
});
updatedCount++;
} else {
// Create new rule
await prisma.rule.create({
data: {
emailAccountId,
name: rule.name,
systemType: rule.systemType,
instructions: rule.instructions,
enabled: rule.enabled ?? true,
automate: rule.automate ?? true,
runOnThreads: rule.runOnThreads ?? false,
conditionalOperator: rule.conditionalOperator,
categoryFilterType: rule.categoryFilterType,
from: rule.from,
to: rule.to,
subject: rule.subject,
body: rule.body,
groupId: null,
actions: { createMany: { data: mappedActions } },
},
});
createdCount++;
}
} catch (error) {
logger.error("Failed to import rule", { ruleName: rule.name, error });
skippedCount++;
}
}

logger.info("Import complete", {
createdCount,
updatedCount,
skippedCount,
});

return { createdCount, updatedCount, skippedCount };
},
);
Loading
Loading