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
246 changes: 246 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/settings/ExpirationSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"use client";

import { useCallback } from "react";
import useSWR from "swr";
import { type SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormSection, FormSectionLeft } from "@/components/Form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/Input";
import { Toggle } from "@/components/Toggle";
import { toastError, toastSuccess } from "@/components/Toast";
import { useAccount } from "@/providers/EmailAccountProvider";
import { postRequest } from "@/utils/api";
import type { ExpirationSettingsResponse } from "@/app/api/user/expiration-settings/route";
import { LoadingContent } from "@/components/LoadingContent";

const CATEGORIES = [
{
id: "NOTIFICATION",
name: "Notifications",
description: "Package tracking, alerts, system notifications",
field: "notificationDays" as const,
defaultDays: 7,
},
{
id: "NEWSLETTER",
name: "Newsletters",
description: "Subscribed email newsletters",
field: "newsletterDays" as const,
defaultDays: 30,
},
{
id: "MARKETING",
name: "Marketing",
description: "Promotional emails and offers",
field: "marketingDays" as const,
defaultDays: 14,
},
{
id: "SOCIAL",
name: "Social",
description: "Social media notifications",
field: "socialDays" as const,
defaultDays: 7,
},
{
id: "CALENDAR",
name: "Calendar",
description: "Days after event to expire",
field: "calendarDays" as const,
defaultDays: 1,
},
];

const formSchema = z.object({
enabled: z.boolean(),
notificationDays: z.number().min(1).max(365),
newsletterDays: z.number().min(1).max(365),
marketingDays: z.number().min(1).max(365),
socialDays: z.number().min(1).max(365),
calendarDays: z.number().min(1).max(30),
applyLabel: z.boolean(),
enabledCategories: z.array(z.string()),
});

type FormValues = z.infer<typeof formSchema>;

export function ExpirationSection() {
const { emailAccountId } = useAccount();
const { data, isLoading, mutate } = useSWR<ExpirationSettingsResponse>(
"/api/user/expiration-settings",
);

const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
values: {
enabled: data?.settings?.enabled ?? false,
notificationDays: data?.settings?.notificationDays ?? 7,
newsletterDays: data?.settings?.newsletterDays ?? 30,
marketingDays: data?.settings?.marketingDays ?? 14,
socialDays: data?.settings?.socialDays ?? 7,
calendarDays: data?.settings?.calendarDays ?? 1,
applyLabel: data?.settings?.applyLabel ?? true,
enabledCategories: data?.settings?.enabledCategories ?? [
"NOTIFICATION",
"NEWSLETTER",
"MARKETING",
"SOCIAL",
"CALENDAR",
],
},
});

const enabled = watch("enabled");
const enabledCategories = watch("enabledCategories");

const toggleCategory = useCallback(
(categoryId: string) => {
const current = enabledCategories || [];
if (current.includes(categoryId)) {
setValue(
"enabledCategories",
current.filter((c) => c !== categoryId),
);
} else {
setValue("enabledCategories", [...current, categoryId]);
}
},
[enabledCategories, setValue],
);

const onSubmit: SubmitHandler<FormValues> = useCallback(
async (formData) => {
const res = await postRequest<
ExpirationSettingsResponse,
Partial<FormValues>
>("/api/user/expiration-settings", formData);

if ("error" in res) {
toastError({
description: "There was an error saving settings.",
});
} else {
toastSuccess({ description: "Expiration settings saved!" });
mutate();
}
},
[mutate],
);

return (
<FormSection>
<FormSectionLeft
title="Email Expiration & Auto-Cleanup"
description="Automatically archive old emails that are no longer timely. AI analyzes each email to set smart expiration dates based on content."
/>

<div className="sm:col-span-2">
<LoadingContent loading={isLoading}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Master toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="font-medium">Enable Auto-Expiration</p>
<p className="text-sm text-muted-foreground">
Automatically archive emails when they expire
</p>
</div>
<Toggle
name="enabled"
enabled={enabled}
onChange={(value) => setValue("enabled", value)}
/>
</div>

{enabled && (
<>
{/* Apply label toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="font-medium">Apply "Expired" Label</p>
<p className="text-sm text-muted-foreground">
Add an "Inbox Zero/Expired" label when archiving
</p>
</div>
<Toggle
name="applyLabel"
enabled={watch("applyLabel")}
onChange={(value) => setValue("applyLabel", value)}
/>
</div>

{/* Category settings */}
<div className="space-y-4">
<p className="text-sm font-medium">
Default Expiration by Category
</p>
<p className="text-sm text-muted-foreground">
AI will analyze email content for specific dates (e.g.,
"package arrives Tuesday"). When no date is found, these
defaults apply.
</p>

<div className="space-y-3">
{CATEGORIES.map((category) => {
const isEnabled = enabledCategories?.includes(
category.id,
);
return (
<div
key={category.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Toggle
name={`category-${category.id}`}
enabled={isEnabled}
onChange={() => toggleCategory(category.id)}
/>
<div>
<p className="font-medium">{category.name}</p>
<p className="text-sm text-muted-foreground">
{category.description}
</p>
</div>
</div>

<div className="flex items-center gap-2">
<Input
type="number"
className="w-20"
min={1}
max={category.id === "CALENDAR" ? 30 : 365}
disabled={!isEnabled}
{...register(category.field, {
valueAsNumber: true,
})}
/>
<span className="text-sm text-muted-foreground">
days
</span>
</div>
</div>
);
})}
</div>
</div>
</>
)}

<Button type="submit" loading={isSubmitting}>
Save Settings
</Button>
</form>
</LoadingContent>
</div>
</FormSection>
);
}
2 changes: 2 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { ApiKeysSection } from "@/app/(app)/[emailAccountId]/settings/ApiKeysSection";
import { BillingSection } from "@/app/(app)/[emailAccountId]/settings/BillingSection";
import { DeleteSection } from "@/app/(app)/[emailAccountId]/settings/DeleteSection";
import { ExpirationSection } from "@/app/(app)/[emailAccountId]/settings/ExpirationSection";
import { ModelSection } from "@/app/(app)/[emailAccountId]/settings/ModelSection";
import { MultiAccountSection } from "@/app/(app)/[emailAccountId]/settings/MultiAccountSection";
import { ResetAnalyticsSection } from "@/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection";
Expand Down Expand Up @@ -58,6 +59,7 @@ export default function SettingsPage() {
</SectionDescription>
</FormSection>

<ExpirationSection />
<ResetAnalyticsSection />

{/* this is only used in Gmail when sending a new message. disabling for now. */}
Expand Down
55 changes: 55 additions & 0 deletions apps/web/app/api/user/expiration-settings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import prisma from "@/utils/prisma";
import { withEmailAccount } from "@/utils/middleware";

const updateSettingsSchema = z.object({
enabled: z.boolean().optional(),
notificationDays: z.number().min(1).max(365).optional(),
newsletterDays: z.number().min(1).max(365).optional(),
marketingDays: z.number().min(1).max(365).optional(),
socialDays: z.number().min(1).max(365).optional(),
calendarDays: z.number().min(1).max(30).optional(),
applyLabel: z.boolean().optional(),
enabledCategories: z.array(z.string()).optional(),
});

export type ExpirationSettingsResponse = {
settings: {
enabled: boolean;
notificationDays: number;
newsletterDays: number;
marketingDays: number;
socialDays: number;
calendarDays: number;
applyLabel: boolean;
enabledCategories: string[];
} | null;
};

export const GET = withEmailAccount(async (request) => {
const emailAccountId = request.auth.emailAccountId;

const settings = await prisma.emailExpirationSettings.findUnique({
where: { emailAccountId },
});

return NextResponse.json({ settings });
});

export const POST = withEmailAccount(async (request) => {
const emailAccountId = request.auth.emailAccountId;
const body = await request.json();
const data = updateSettingsSchema.parse(body);

const settings = await prisma.emailExpirationSettings.upsert({
where: { emailAccountId },
create: {
emailAccountId,
...data,
},
update: data,
});

return NextResponse.json({ settings });
});
24 changes: 22 additions & 2 deletions apps/web/app/api/watch/all/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { withError } from "@/utils/middleware";
import { captureException } from "@/utils/error";
import type { Logger } from "@/utils/logger";
import { ensureEmailAccountsWatched } from "@/utils/email/watch-manager";
import { cleanupExpiredEmails } from "@/utils/expiration/process-expired";

export const dynamic = "force-dynamic";
export const maxDuration = 800;
Expand All @@ -28,8 +29,27 @@ export const POST = withError("watch/all", async (request) => {

async function watchAllEmails(logger: Logger) {
try {
const results = await ensureEmailAccountsWatched({ userIds: null, logger });
return NextResponse.json({ success: true, results });
// Existing: Ensure email accounts are watched
const watchResults = await ensureEmailAccountsWatched({
userIds: null,
logger,
});

// Run expiration cleanup (every 6 hours is fine for this)
// Wrapped in try-catch to not break watch functionality on cleanup errors
let expirationCleanup = { totalArchived: 0, totalErrors: 0 };
try {
expirationCleanup = await cleanupExpiredEmails(logger);
} catch (error) {
logger.error("Expiration cleanup failed", { error });
// Don't throw - let the cron succeed even if cleanup fails
}

return NextResponse.json({
success: true,
results: watchResults,
expirationCleanup,
});
} catch (error) {
logger.error("Failed to watch all emails", { error });
throw error;
Expand Down
Loading
Loading