Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
@@ -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,
})),
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);

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() {
<PersonalSignatureSetting />
<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(),
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,
}));

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

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,
{
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>;
8 changes: 8 additions & 0 deletions docker/Dockerfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ ARG NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS="NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS_PLACEHO
ENV NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS=${NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS}
ARG NEXT_PUBLIC_EMAIL_SEND_ENABLED="NEXT_PUBLIC_EMAIL_SEND_ENABLED_PLACEHOLDER"
ENV NEXT_PUBLIC_EMAIL_SEND_ENABLED=${NEXT_PUBLIC_EMAIL_SEND_ENABLED}
ARG NEXT_PUBLIC_CLEANER_ENABLED="NEXT_PUBLIC_CLEANER_ENABLED_PLACEHOLDER"
ENV NEXT_PUBLIC_CLEANER_ENABLED=${NEXT_PUBLIC_CLEANER_ENABLED}
ARG NEXT_PUBLIC_MEETING_BRIEFS_ENABLED="NEXT_PUBLIC_MEETING_BRIEFS_ENABLED_PLACEHOLDER"
ENV NEXT_PUBLIC_MEETING_BRIEFS_ENABLED=${NEXT_PUBLIC_MEETING_BRIEFS_ENABLED}
ARG NEXT_PUBLIC_INTEGRATIONS_ENABLED="NEXT_PUBLIC_INTEGRATIONS_ENABLED_PLACEHOLDER"
ENV NEXT_PUBLIC_INTEGRATIONS_ENABLED=${NEXT_PUBLIC_INTEGRATIONS_ENABLED}
ARG NEXT_PUBLIC_DIGEST_ENABLED="NEXT_PUBLIC_DIGEST_ENABLED_PLACEHOLDER"
ENV NEXT_PUBLIC_DIGEST_ENABLED=${NEXT_PUBLIC_DIGEST_ENABLED}

# Provide safe dummy envs so Next build can complete at image build time
ENV DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public"
Expand Down
Loading
Loading