Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
146 changes: 140 additions & 6 deletions apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
SparklesIcon,
InfoIcon,
CopyIcon,
DownloadIcon,
UploadIcon,
} from "lucide-react";
import { useMemo } from "react";
import { useMemo, useCallback, useRef } from "react";
import { LoadingContent } from "@/components/LoadingContent";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader } from "@/components/ui/card";
Expand All @@ -32,7 +34,11 @@ import {
TableRow,
} from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { deleteRuleAction, toggleRuleAction } from "@/utils/actions/rule";
import {
deleteRuleAction,
toggleRuleAction,
importRulesAction,
} from "@/utils/actions/rule";
import { Badge } from "@/components/Badge";
import { getActionColor } from "@/components/PlanBadge";
import { toastError } from "@/components/Toast";
Expand Down Expand Up @@ -85,6 +91,7 @@ export function Rules({
duplicateRule?: RulesResponse[number];
}>();
const onCreateRule = () => ruleDialog.onOpen();
const fileInputRef = useRef<HTMLInputElement>(null);

const { emailAccountId, provider } = useAccount();
const { executeAsync: toggleRule } = useAction(
Expand Down Expand Up @@ -141,6 +148,105 @@ export function Rules({

const hasRules = !!rules?.length;

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

// Filter out placeholder rules and prepare export data
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,
// Conditions
from: rule.from,
to: rule.to,
subject: rule.subject,
body: rule.body,
categoryFilterType: rule.categoryFilterType,
// Actions
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,
})),
// Group info
group: rule.group?.name || null,
}));

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

// Handle both array format and object with rules property
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",
});
}

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

return (
<div className="space-y-6">
<Card>
Expand All @@ -156,16 +262,44 @@ export function Rules({
</TableHead>
<TableHead className="px-2 sm:px-4">Action</TableHead>
<TableHead className="w-fit whitespace-nowrap px-1">
{showAddRuleButton && (
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={importRules}
className="hidden"
/>
<div className="my-2">
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="mr-2 hidden size-4 md:block" />
Import
</Button>
</div>
<div className="my-2">
<Button
size="sm"
variant="outline"
onClick={exportRules}
disabled={!data?.length}
>
<DownloadIcon className="mr-2 hidden size-4 md:block" />
Export
</Button>
</div>
{showAddRuleButton && (
<div className="my-2">
<Button size="sm" onClick={onCreateRule}>
<PlusIcon className="mr-2 hidden size-4 md:block" />
Add Rule
</Button>
</div>
</div>
)}
)}
</div>
</TableHead>
</TableRow>
</TableHeader>
Expand Down
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
111 changes: 111 additions & 0 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 @@ -961,3 +963,112 @@ 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,
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,
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 };
},
);
54 changes: 54 additions & 0 deletions apps/web/utils/actions/rule.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,57 @@ export const copyRulesFromAccountBody = z.object({
ruleIds: z.array(z.string()).min(1, "Select at least one rule to copy"),
});
export type CopyRulesFromAccountBody = z.infer<typeof copyRulesFromAccountBody>;

// Schema for importing rules from JSON export
const importedAction = z.object({
type: zodActionType,
label: z.string().nullish(),
to: z.string().nullish(),
cc: z.string().nullish(),
bcc: z.string().nullish(),
subject: z.string().nullish(),
content: z.string().nullish(),
folderName: z.string().nullish(),
url: z.string().nullish(),
delayInMinutes: delayInMinutesSchema,
});
Comment thread
rsnodgrass marked this conversation as resolved.
Outdated
Comment thread
rsnodgrass marked this conversation as resolved.
Outdated

const importedRule = z
.object({
name: z.string().min(1),
instructions: z.string().nullish(),
enabled: z.boolean().optional().default(true),
automate: z.boolean().optional().default(true),
runOnThreads: z.boolean().optional().default(false),
systemType: zodSystemRule.nullish(),
conditionalOperator: z
.enum([LogicalOperator.AND, LogicalOperator.OR])
.optional()
.default(LogicalOperator.AND),
from: z.string().nullish(),
to: z.string().nullish(),
subject: z.string().nullish(),
body: z.string().nullish(),
categoryFilterType: z.string().nullish(),
actions: z.array(importedAction).min(1),
group: z.string().nullish(),
})
.refine(
(data) =>
data.systemType ||
data.from ||
data.to ||
data.subject ||
data.body ||
data.instructions,
Comment thread
rsnodgrass marked this conversation as resolved.
Outdated
{
message:
"At least one condition (from, to, subject, body, or instructions) must be provided",
},
);

export const importRulesBody = z.object({
rules: z.array(importedRule).min(1, "No rules to import"),
});
export type ImportRulesBody = z.infer<typeof importRulesBody>;
export type ImportedRule = z.infer<typeof importedRule>;
Loading