diff --git a/apps/web/__tests__/mocks/email-provider.mock.ts b/apps/web/__tests__/mocks/email-provider.mock.ts
index dd65a3b874..36355c200e 100644
--- a/apps/web/__tests__/mocks/email-provider.mock.ts
+++ b/apps/web/__tests__/mocks/email-provider.mock.ts
@@ -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
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx
index 1b3c081192..c8a32c150f 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx
@@ -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";
@@ -24,6 +25,7 @@ export function AssistantTabs() {
Rules
Test
History
+ Settings
@@ -50,6 +52,9 @@ export function AssistantTabs() {
+
+
+
{/* Set via search params. Not a visible tab. */}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx
new file mode 100644
index 0000000000..41f14bef4a
--- /dev/null
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx
@@ -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(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
+ }));
+
+ 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) => {
+ 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 (
+
+
+
+
+
+ }
+ />
+ );
+}
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx
index b84d12ac9a..878f14c090 100644
--- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx
+++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx
@@ -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() {
@@ -21,6 +22,7 @@ export function SettingsTab() {
{env.NEXT_PUBLIC_DIGEST_ENABLED && }
+
);
}
diff --git a/apps/web/env.ts b/apps/web/env.ts
index 5954715c4a..54591b6171 100644
--- a/apps/web/env.ts
+++ b/apps/web/env.ts
@@ -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:
@@ -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,
},
diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts
index 0434bbf34f..d74170cd2e 100644
--- a/apps/web/hooks/useFeatureFlags.ts
+++ b/apps/web/hooks/useFeatureFlags.ts
@@ -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() {
diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts
index cc974d81bd..6810419245 100644
--- a/apps/web/utils/__mocks__/email-provider.ts
+++ b/apps/web/utils/__mocks__/email-provider.ts
@@ -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,
diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts
index f2e1a6974b..a36f198814 100644
--- a/apps/web/utils/actions/rule.ts
+++ b/apps/web/utils/actions/rule.ts
@@ -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";
@@ -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({
@@ -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: {
@@ -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,
@@ -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,
+ }));
+
+ 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 };
+ },
+ );
diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts
index 083548ea13..73a4e66c70 100644
--- a/apps/web/utils/actions/rule.validation.ts
+++ b/apps/web/utils/actions/rule.validation.ts
@@ -279,3 +279,105 @@ export const copyRulesFromAccountBody = z.object({
ruleIds: z.array(z.string()).min(1, "Select at least one rule to copy"),
});
export type CopyRulesFromAccountBody = z.infer;
+
+// 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,
+ })
+ .superRefine((data, ctx) => {
+ if (data.type === ActionType.LABEL) {
+ const labelValue = data.label?.trim();
+
+ if (!labelValue) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Label action requires a label name",
+ path: ["label"],
+ });
+ return;
+ }
+
+ const validation = validateLabelNameBasic(labelValue);
+ if (!validation.valid) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: validation.error!,
+ path: ["label"],
+ });
+ }
+ }
+
+ if (data.type === ActionType.FORWARD && !data.to?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Forward action requires a recipient email address",
+ path: ["to"],
+ });
+ }
+
+ if (data.type === ActionType.CALL_WEBHOOK && !data.url?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Webhook action requires a URL",
+ path: ["url"],
+ });
+ }
+
+ if (data.type === ActionType.MOVE_FOLDER && !data.folderName?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Move folder action requires a folder name",
+ path: ["folderName"],
+ });
+ }
+ });
+
+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?.trim() ||
+ data.to?.trim() ||
+ data.subject?.trim() ||
+ data.body?.trim() ||
+ data.instructions?.trim(),
+ {
+ 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;
+export type ImportedRule = z.infer;
diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts
index a06555e410..cc584fccf3 100644
--- a/apps/web/utils/ai/actions.ts
+++ b/apps/web/utils/ai/actions.ts
@@ -318,14 +318,46 @@ const digest: ActionFunction<{ id?: string }> = async ({
await enqueueDigestItem({ email, emailAccountId, actionId, logger });
};
-const move_folder: ActionFunction<{ folderId?: string | null }> = async ({
- client,
- email,
- userEmail,
- args,
-}) => {
- if (!args.folderId) return;
- await client.moveThreadToFolder(email.threadId, userEmail, args.folderId);
+const move_folder: ActionFunction<{
+ folderId?: string | null;
+ folderName?: string | null;
+}> = async ({ client, email, userEmail, emailAccountId, args, logger }) => {
+ const originalFolderId = args.folderId;
+ let folderIdToUse = originalFolderId;
+
+ // resolve folder name to ID if needed (similar to label resolution)
+ if (!folderIdToUse && args.folderName) {
+ if (hasVariables(args.folderName)) {
+ logger.error("Template folder name not processed by AI", {
+ folderName: args.folderName,
+ });
+ return;
+ }
+
+ logger.info("Resolving folder name to ID", { folderName: args.folderName });
+ folderIdToUse = await client.getOrCreateFolderIdByName(args.folderName);
+
+ if (!folderIdToUse) {
+ logger.error("Failed to resolve folder", { folderName: args.folderName });
+ return;
+ }
+ }
+
+ if (!folderIdToUse) return;
+
+ await client.moveThreadToFolder(email.threadId, userEmail, folderIdToUse);
+
+ // lazy-update the folderId in the database for future runs
+ if (!originalFolderId && folderIdToUse && args.folderName) {
+ after(() =>
+ lazyUpdateActionFolderId({
+ folderName: args.folderName!,
+ folderId: folderIdToUse!,
+ emailAccountId,
+ logger,
+ }),
+ );
+ }
};
const notify_sender: ActionFunction> = async ({
@@ -401,3 +433,38 @@ async function lazyUpdateActionLabelId({
});
}
}
+
+async function lazyUpdateActionFolderId({
+ folderName,
+ folderId,
+ emailAccountId,
+ logger,
+}: {
+ folderName: string;
+ folderId: string;
+ emailAccountId: string;
+ logger: Logger;
+}) {
+ try {
+ const result = await prisma.action.updateMany({
+ where: {
+ folderName,
+ folderId: null,
+ rule: { emailAccountId },
+ },
+ data: { folderId },
+ });
+
+ if (result.count > 0) {
+ logger.info("Lazy-updated Action folderId", {
+ folderId,
+ updatedCount: result.count,
+ });
+ }
+ } catch (error) {
+ logger.warn("Failed to lazy-update Action folderId", {
+ folderId,
+ error,
+ });
+ }
+}
diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts
index 59b237e1d2..37ee6fbb32 100644
--- a/apps/web/utils/email/google.ts
+++ b/apps/web/utils/email/google.ts
@@ -1286,8 +1286,8 @@ export class GmailProvider implements EmailProvider {
this.logger.warn("Moving thread to folder is not supported for Gmail");
}
- async getOrCreateOutlookFolderIdByName(_folderName: string): Promise {
- this.logger.warn("Moving thread to folder is not supported for Gmail");
+ async getOrCreateFolderIdByName(_folderName: string): Promise {
+ this.logger.warn("Moving to folder is not supported for Gmail");
return "";
}
diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts
index 355e00f29c..28aa224c5c 100644
--- a/apps/web/utils/email/microsoft.ts
+++ b/apps/web/utils/email/microsoft.ts
@@ -1643,7 +1643,7 @@ export class OutlookProvider implements EmailProvider {
});
}
- async getOrCreateOutlookFolderIdByName(folderName: string): Promise {
+ async getOrCreateFolderIdByName(folderName: string): Promise {
return await getOrCreateOutlookFolderIdByName(
this.client,
folderName,
diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts
index 95b5234457..fcacbbc210 100644
--- a/apps/web/utils/email/types.ts
+++ b/apps/web/utils/email/types.ts
@@ -246,6 +246,6 @@ export interface EmailProvider {
ownerEmail: string,
folderName: string,
): Promise;
- getOrCreateOutlookFolderIdByName(folderName: string): Promise;
+ getOrCreateFolderIdByName(folderName: string): Promise;
getSignatures(): Promise;
}
diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts
index 39ad471ec7..acb9f25b0d 100644
--- a/apps/web/utils/rule/rule.ts
+++ b/apps/web/utils/rule/rule.ts
@@ -338,8 +338,7 @@ async function mapActionFields(
logger,
});
- folderId =
- await emailProvider.getOrCreateOutlookFolderIdByName(folderName);
+ folderId = await emailProvider.getOrCreateFolderIdByName(folderName);
}
return {
diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod
index 549d191556..09a13d6bc6 100644
--- a/docker/Dockerfile.prod
+++ b/docker/Dockerfile.prod
@@ -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"
diff --git a/docker/scripts/start.sh b/docker/scripts/start.sh
index beff121641..61c45ca9fa 100755
--- a/docker/scripts/start.sh
+++ b/docker/scripts/start.sh
@@ -20,6 +20,22 @@ if [ -n "$NEXT_PUBLIC_EMAIL_SEND_ENABLED" ]; then
/app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_EMAIL_SEND_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_EMAIL_SEND_ENABLED"
fi
+if [ -n "$NEXT_PUBLIC_CLEANER_ENABLED" ]; then
+ /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_CLEANER_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_CLEANER_ENABLED"
+fi
+
+if [ -n "$NEXT_PUBLIC_MEETING_BRIEFS_ENABLED" ]; then
+ /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_MEETING_BRIEFS_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_MEETING_BRIEFS_ENABLED"
+fi
+
+if [ -n "$NEXT_PUBLIC_INTEGRATIONS_ENABLED" ]; then
+ /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_INTEGRATIONS_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_INTEGRATIONS_ENABLED"
+fi
+
+if [ -n "$NEXT_PUBLIC_DIGEST_ENABLED" ]; then
+ /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_DIGEST_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_DIGEST_ENABLED"
+fi
+
if [ -n "$DATABASE_URL" ]; then
echo "🔄 Running database migrations..."
if timeout 320 prisma migrate deploy --schema=./apps/web/prisma/schema.prisma; then