Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
144 changes: 138 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,103 @@ 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,
})),
// 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 +260,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
109 changes: 109 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,110 @@ 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,
}));
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 };
},
);
40 changes: 40 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,43 @@ 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(),
});
Comment thread
rsnodgrass marked this conversation as resolved.
Outdated

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