-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: Add Outlook support to Deep Clean feature #875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
827bcae
9f91d96
adf8f4d
0628a06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,8 @@ import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts"; | |||||
| import { HistoryIcon, SettingsIcon } from "lucide-react"; | ||||||
| import { useAccount } from "@/providers/EmailAccountProvider"; | ||||||
| import { prefixPath } from "@/utils/path"; | ||||||
| import { isGoogleProvider } from "@/utils/email/provider-types"; | ||||||
| import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; | ||||||
|
|
||||||
| export function ConfirmationStep({ | ||||||
| showFooter, | ||||||
|
|
@@ -36,7 +38,9 @@ export function ConfirmationStep({ | |||||
| reuseSettings: boolean; | ||||||
| }) { | ||||||
| const router = useRouter(); | ||||||
| const { emailAccountId } = useAccount(); | ||||||
| const { emailAccountId, emailAccount } = useAccount(); | ||||||
| const { onPrevious } = useStep(); | ||||||
| const isGmail = isGoogleProvider(emailAccount?.provider); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| const handleStartCleaning = async () => { | ||||||
| const result = await cleanInboxAction(emailAccountId, { | ||||||
|
|
@@ -89,13 +93,15 @@ export function ConfirmationStep({ | |||||
| <li> | ||||||
| {action === CleanAction.ARCHIVE ? ( | ||||||
| <> | ||||||
| Archived emails will be labeled{" "} | ||||||
| <Badge color="green">Archived</Badge> in Gmail. | ||||||
| Archived emails will be {isGmail ? "labeled" : "moved to the"}{" "} | ||||||
| <Badge color="green">Archive{isGmail ? "d" : ""}</Badge>{" "} | ||||||
| {isGmail ? "in Gmail" : "folder in Outlook"}. | ||||||
| </> | ||||||
| ) : ( | ||||||
| <> | ||||||
| Emails marked as read will be labeled{" "} | ||||||
| <Badge color="green">Read</Badge> in Gmail. | ||||||
| <Badge color="green">Read</Badge>{" "} | ||||||
| {isGmail ? "in Gmail" : "in Outlook"}. | ||||||
| </> | ||||||
| )} | ||||||
| </li> | ||||||
|
|
@@ -115,7 +121,10 @@ export function ConfirmationStep({ | |||||
| )} | ||||||
| </ul> | ||||||
|
|
||||||
| <div className="mt-6"> | ||||||
| <div className="mt-6 flex justify-center gap-2"> | ||||||
| <Button size="lg" variant="outline" onClick={onPrevious}> | ||||||
| Back | ||||||
| </Button> | ||||||
|
Comment on lines
+124
to
+127
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent duplicate submissions; gate Gmail‑only settings link; add button type
Apply: @@
-export function ConfirmationStep({
+export function ConfirmationStep({
@@
- const router = useRouter();
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
@@
- const handleStartCleaning = async () => {
- const result = await cleanInboxAction(emailAccountId, {
+ const handleStartCleaning = async () => {
+ setIsLoading(true);
+ const result = await cleanInboxAction(emailAccountId, {
daysOld: timeRange ?? 7,
instructions: instructions || "",
action: action || CleanAction.ARCHIVE,
maxEmails: PREVIEW_RUN_COUNT,
skips,
});
if (result?.serverError) {
toastError({ description: result.serverError });
- return;
+ setIsLoading(false);
+ return;
}
router.push(
prefixPath(
emailAccountId,
`/clean/run?jobId=${result?.data?.jobId}&isPreviewBatch=true`,
),
);
+ setIsLoading(false);
};
@@
- <div className="mt-6 flex justify-center gap-2">
- <Button size="lg" variant="outline" onClick={onPrevious}>
+ <div className="mt-6 flex justify-center gap-2">
+ <Button type="button" size="lg" variant="outline" onClick={onPrevious}>
Back
</Button>
- <Button size="lg" onClick={handleStartCleaning}>
+ <Button
+ size="lg"
+ onClick={handleStartCleaning}
+ disabled={isLoading}
+ aria-busy={isLoading}
+ >
Start Cleaning
</Button>
</div>
@@
- <FooterLink
- icon={SettingsIcon}
- text="Edit settings"
- href={prefixPath(emailAccountId, "/clean/onboarding")}
- />
+ {isGmail && (
+ <FooterLink
+ icon={SettingsIcon}
+ text="Edit settings"
+ href={prefixPath(emailAccountId, "/clean/onboarding")}
+ />
+ )}Add missing import: -import Link from "next/link";
+import Link from "next/link";
+import { useState } from "react";Optionally, consider provider‑specific copy: Gmail uses “All Mail” rather than an “Archive” folder; you may want to keep the badge label constant (“Archive”) for both or switch Gmail text to “All Mail”. Based on learnings. Also applies to: 133-147 🤖 Prompt for AI Agents |
||||||
| <Button size="lg" onClick={handleStartCleaning}> | ||||||
| Start Cleaning | ||||||
| </Button> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,201 @@ | ||||||||||||||
| "use client"; | ||||||||||||||
|
|
||||||||||||||
| import { useCallback, useEffect, useState, useMemo } from "react"; | ||||||||||||||
| import { useRouter, useSearchParams } from "next/navigation"; | ||||||||||||||
| import { CleanAction } from "@prisma/client"; | ||||||||||||||
| import { LoadingContent } from "@/components/LoadingContent"; | ||||||||||||||
| import { EmailFirehose } from "@/app/(app)/[emailAccountId]/clean/EmailFirehose"; | ||||||||||||||
| import { cleanInboxAction } from "@/utils/actions/clean"; | ||||||||||||||
| import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts"; | ||||||||||||||
| import { useAccount } from "@/providers/EmailAccountProvider"; | ||||||||||||||
| import { toastError } from "@/components/Toast"; | ||||||||||||||
| import { Button } from "@/components/ui/button"; | ||||||||||||||
| import { | ||||||||||||||
| CardGreen, | ||||||||||||||
| CardContent, | ||||||||||||||
| CardDescription, | ||||||||||||||
| CardHeader, | ||||||||||||||
| CardTitle, | ||||||||||||||
| } from "@/components/ui/card"; | ||||||||||||||
| import { useEmailStream } from "@/app/(app)/[emailAccountId]/clean/useEmailStream"; | ||||||||||||||
|
|
||||||||||||||
| export function PreviewStep() { | ||||||||||||||
| const router = useRouter(); | ||||||||||||||
| const { emailAccountId } = useAccount(); | ||||||||||||||
| const searchParams = useSearchParams(); | ||||||||||||||
| const [isLoading, setIsLoading] = useState(true); | ||||||||||||||
| const [jobId, setJobId] = useState<string | null>(null); | ||||||||||||||
| const [error, setError] = useState<string | undefined>(undefined); | ||||||||||||||
| const [isLoadingPreview, setIsLoadingPreview] = useState(false); | ||||||||||||||
| const [isLoadingFull, setIsLoadingFull] = useState(false); | ||||||||||||||
|
|
||||||||||||||
| const action = | ||||||||||||||
| (searchParams.get("action") as CleanAction) ?? CleanAction.ARCHIVE; | ||||||||||||||
| const timeRange = searchParams.get("timeRange") | ||||||||||||||
| ? Number.parseInt(searchParams.get("timeRange")!) | ||||||||||||||
| : 7; | ||||||||||||||
| const instructions = searchParams.get("instructions") ?? undefined; | ||||||||||||||
| const skipReply = searchParams.get("skipReply") === "true"; | ||||||||||||||
| const skipStarred = searchParams.get("skipStarred") === "true"; | ||||||||||||||
| const skipCalendar = searchParams.get("skipCalendar") === "true"; | ||||||||||||||
| const skipReceipt = searchParams.get("skipReceipt") === "true"; | ||||||||||||||
| const skipAttachment = searchParams.get("skipAttachment") === "true"; | ||||||||||||||
|
|
||||||||||||||
| const runPreview = useCallback(async () => { | ||||||||||||||
| setIsLoading(true); | ||||||||||||||
| setError(null); | ||||||||||||||
|
Comment on lines
+44
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix type inconsistency. Line 46 calls Apply this diff: const runPreview = useCallback(async () => {
setIsLoading(true);
- setError(null);
+ setError(undefined);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| const result = await cleanInboxAction(emailAccountId, { | ||||||||||||||
| daysOld: timeRange, | ||||||||||||||
| instructions: instructions || "", | ||||||||||||||
| action, | ||||||||||||||
| maxEmails: PREVIEW_RUN_COUNT, | ||||||||||||||
| skips: { | ||||||||||||||
| reply: skipReply, | ||||||||||||||
| starred: skipStarred, | ||||||||||||||
| calendar: skipCalendar, | ||||||||||||||
| receipt: skipReceipt, | ||||||||||||||
| attachment: skipAttachment, | ||||||||||||||
| conversation: false, | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| if (result?.serverError) { | ||||||||||||||
| setError(result.serverError); | ||||||||||||||
| toastError({ description: result.serverError }); | ||||||||||||||
| } else if (result?.data?.jobId) { | ||||||||||||||
| setJobId(result.data.jobId); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| setIsLoading(false); | ||||||||||||||
| }, [ | ||||||||||||||
| emailAccountId, | ||||||||||||||
| action, | ||||||||||||||
| timeRange, | ||||||||||||||
| instructions, | ||||||||||||||
| skipReply, | ||||||||||||||
| skipStarred, | ||||||||||||||
| skipCalendar, | ||||||||||||||
| skipReceipt, | ||||||||||||||
| skipAttachment, | ||||||||||||||
| ]); | ||||||||||||||
|
|
||||||||||||||
| const handleProcessPreviewOnly = async () => { | ||||||||||||||
| setIsLoadingPreview(true); | ||||||||||||||
| const result = await cleanInboxAction(emailAccountId, { | ||||||||||||||
| daysOld: timeRange, | ||||||||||||||
| instructions: instructions || "", | ||||||||||||||
| action, | ||||||||||||||
| maxEmails: PREVIEW_RUN_COUNT, | ||||||||||||||
| skips: { | ||||||||||||||
| reply: skipReply, | ||||||||||||||
| starred: skipStarred, | ||||||||||||||
| calendar: skipCalendar, | ||||||||||||||
| receipt: skipReceipt, | ||||||||||||||
| attachment: skipAttachment, | ||||||||||||||
| conversation: false, | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| setIsLoadingPreview(false); | ||||||||||||||
|
|
||||||||||||||
| if (result?.serverError) { | ||||||||||||||
| toastError({ description: result.serverError }); | ||||||||||||||
| } else if (result?.data?.jobId) { | ||||||||||||||
| setJobId(result.data.jobId); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| const handleRunOnFullInbox = async () => { | ||||||||||||||
| setIsLoadingFull(true); | ||||||||||||||
| const result = await cleanInboxAction(emailAccountId, { | ||||||||||||||
| daysOld: timeRange, | ||||||||||||||
| instructions: instructions || "", | ||||||||||||||
| action, | ||||||||||||||
| skips: { | ||||||||||||||
| reply: skipReply, | ||||||||||||||
| starred: skipStarred, | ||||||||||||||
| calendar: skipCalendar, | ||||||||||||||
| receipt: skipReceipt, | ||||||||||||||
| attachment: skipAttachment, | ||||||||||||||
| conversation: false, | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| setIsLoadingFull(false); | ||||||||||||||
|
|
||||||||||||||
| if (result?.serverError) { | ||||||||||||||
| toastError({ description: result.serverError }); | ||||||||||||||
| } else if (result?.data?.jobId) { | ||||||||||||||
| setJobId(result.data.jobId); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
Comment on lines
+44
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Extract shared payload construction logic. The payload construction is duplicated across Apply this diff to extract a helper function: + const buildCleanPayload = useCallback((maxEmails?: number) => ({
+ daysOld: timeRange,
+ instructions: instructions || "",
+ action,
+ ...(maxEmails && { maxEmails }),
+ skips: {
+ reply: skipReply,
+ starred: skipStarred,
+ calendar: skipCalendar,
+ receipt: skipReceipt,
+ attachment: skipAttachment,
+ conversation: false,
+ },
+ }), [
+ timeRange,
+ instructions,
+ action,
+ skipReply,
+ skipStarred,
+ skipCalendar,
+ skipReceipt,
+ skipAttachment,
+ ]);
+
const runPreview = useCallback(async () => {
setIsLoading(true);
setError(undefined);
- const result = await cleanInboxAction(emailAccountId, {
- daysOld: timeRange,
- instructions: instructions || "",
- action,
- maxEmails: PREVIEW_RUN_COUNT,
- skips: {
- reply: skipReply,
- starred: skipStarred,
- calendar: skipCalendar,
- receipt: skipReceipt,
- attachment: skipAttachment,
- conversation: false,
- },
- });
+ const result = await cleanInboxAction(
+ emailAccountId,
+ buildCleanPayload(PREVIEW_RUN_COUNT)
+ );
if (result?.serverError) {
setError(result.serverError);
@@ -68,49 +53,16 @@
}
setIsLoading(false);
- }, [
- emailAccountId,
- action,
- timeRange,
- instructions,
- skipReply,
- skipStarred,
- skipCalendar,
- skipReceipt,
- skipAttachment,
- ]);
+ }, [emailAccountId, buildCleanPayload]);
const handleProcessPreviewOnly = async () => {
setIsLoadingPreview(true);
- const result = await cleanInboxAction(emailAccountId, {
- daysOld: timeRange,
- instructions: instructions || "",
- action,
- maxEmails: PREVIEW_RUN_COUNT,
- skips: {
- reply: skipReply,
- starred: skipStarred,
- calendar: skipCalendar,
- receipt: skipReceipt,
- attachment: skipAttachment,
- conversation: false,
- },
- });
+ const result = await cleanInboxAction(
+ emailAccountId,
+ buildCleanPayload(PREVIEW_RUN_COUNT)
+ );
setIsLoadingPreview(false);
@@ -123,22 +75,11 @@
const handleRunOnFullInbox = async () => {
setIsLoadingFull(true);
- const result = await cleanInboxAction(emailAccountId, {
- daysOld: timeRange,
- instructions: instructions || "",
- action,
- skips: {
- reply: skipReply,
- starred: skipStarred,
- calendar: skipCalendar,
- receipt: skipReceipt,
- attachment: skipAttachment,
- conversation: false,
- },
- });
+ const result = await cleanInboxAction(
+ emailAccountId,
+ buildCleanPayload()
+ );
setIsLoadingFull(false);🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| useEffect(() => { | ||||||||||||||
| runPreview(); | ||||||||||||||
| }, [runPreview]); | ||||||||||||||
|
|
||||||||||||||
| // Use the email stream hook to get real-time email data | ||||||||||||||
| const { emails } = useEmailStream(emailAccountId, false, []); | ||||||||||||||
|
|
||||||||||||||
| // Calculate stats from the emails | ||||||||||||||
| const stats = useMemo(() => { | ||||||||||||||
| const total = emails.length; | ||||||||||||||
| const done = emails.filter( | ||||||||||||||
| (email) => email.archive || email.label || email.status === "completed", | ||||||||||||||
| ).length; | ||||||||||||||
| return { total, done }; | ||||||||||||||
| }, [emails]); | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <LoadingContent loading={isLoading} error={error}> | ||||||||||||||
| {jobId && ( | ||||||||||||||
| <> | ||||||||||||||
| <div className="mb-4"> | ||||||||||||||
| <Button | ||||||||||||||
| variant="outline" | ||||||||||||||
| onClick={() => | ||||||||||||||
| router.push(`/${emailAccountId}/clean/onboarding?step=4`) | ||||||||||||||
| } | ||||||||||||||
| > | ||||||||||||||
| ← Back | ||||||||||||||
| </Button> | ||||||||||||||
| </div> | ||||||||||||||
| <CardGreen className="mb-4"> | ||||||||||||||
| <CardHeader> | ||||||||||||||
| <CardTitle>Preview run</CardTitle> | ||||||||||||||
| <CardDescription> | ||||||||||||||
| We're cleaning up {PREVIEW_RUN_COUNT} emails so you can see how | ||||||||||||||
| it works. | ||||||||||||||
| </CardDescription> | ||||||||||||||
| <CardDescription> | ||||||||||||||
| To undo any, hover over the " | ||||||||||||||
| {action === CleanAction.ARCHIVE ? "Archive" : "Mark as read"}" | ||||||||||||||
| badge and click undo. | ||||||||||||||
| </CardDescription> | ||||||||||||||
| </CardHeader> | ||||||||||||||
| <CardContent className="flex flex-col gap-3"> | ||||||||||||||
| <div className="flex items-center gap-3"> | ||||||||||||||
| {/* Temporarily hidden as requested */} | ||||||||||||||
| {/* <Button | ||||||||||||||
| onClick={handleProcessPreviewOnly} | ||||||||||||||
| loading={isLoadingPreview} | ||||||||||||||
| variant="secondary" | ||||||||||||||
| > | ||||||||||||||
| Process Only These {PREVIEW_RUN_COUNT} Emails | ||||||||||||||
| </Button> */} | ||||||||||||||
| <Button onClick={handleRunOnFullInbox} loading={isLoadingFull}> | ||||||||||||||
| Run on Full Inbox | ||||||||||||||
| </Button> | ||||||||||||||
| </div> | ||||||||||||||
| <CardDescription className="text-sm"> | ||||||||||||||
| Click to process your entire mailbox | ||||||||||||||
| </CardDescription> | ||||||||||||||
| </CardContent> | ||||||||||||||
| </CardGreen> | ||||||||||||||
| <EmailFirehose threads={emails} stats={stats} action={action} /> | ||||||||||||||
| </> | ||||||||||||||
| )} | ||||||||||||||
| </LoadingContent> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,9 +6,10 @@ import { TypographyH3 } from "@/components/Typography"; | |||||||||||||||||||||
| import { timeRangeOptions } from "@/app/(app)/[emailAccountId]/clean/types"; | ||||||||||||||||||||||
| import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; | ||||||||||||||||||||||
| import { ButtonListSurvey } from "@/components/ButtonListSurvey"; | ||||||||||||||||||||||
| import { Button } from "@/components/ui/button"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function TimeRangeStep() { | ||||||||||||||||||||||
| const { onNext } = useStep(); | ||||||||||||||||||||||
| const { onNext, onPrevious } = useStep(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const [_, setTimeRange] = useQueryState("timeRange", parseAsInteger); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -30,6 +31,12 @@ export function TimeRangeStep() { | |||||||||||||||||||||
| options={timeRangeOptions} | ||||||||||||||||||||||
| onClick={handleTimeRangeSelect} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <div className="mt-6"> | ||||||||||||||||||||||
| <Button variant="outline" onClick={onPrevious}> | ||||||||||||||||||||||
| Back | ||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
Comment on lines
+35
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add explicit button type for accessibility and predictability Include Apply: - <div className="mt-6">
- <Button variant="outline" onClick={onPrevious}>
+ <div className="mt-6">
+ <Button type="button" variant="outline" onClick={onPrevious}>
Back
</Button>
</div>As per coding guidelines. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,9 +14,14 @@ export function useStep() { | |
| setStep(step + 1); | ||
| }, [step, setStep]); | ||
|
|
||
| const onPrevious = useCallback(() => { | ||
| setStep(step - 1); | ||
| }, [step, setStep]); | ||
|
Comment on lines
+17
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clamp backward navigation and prevent underflow Going back from the first step can produce invalid step values. Clamp to the intro step. Apply: - const onPrevious = useCallback(() => {
- setStep(step - 1);
- }, [step, setStep]);
+ const onPrevious = useCallback(() => {
+ setStep(Math.max(step - 1, CleanStep.INTRO));
+ }, [step, setStep]);Optional: if - const onNext = useCallback(() => {
- setStep(step + 1);
- }, [step, setStep]);
+ const onNext = useCallback(() => {
+ setStep((s) => s + 1);
+ }, [setStep]);
- const onPrevious = useCallback(() => {
- setStep(Math.max(step - 1, CleanStep.INTRO));
- }, [step, setStep]);
+ const onPrevious = useCallback(() => {
+ setStep((s) => Math.max(s - 1, CleanStep.INTRO));
+ }, [setStep]);🤖 Prompt for AI Agents |
||
|
|
||
| return { | ||
| step, | ||
| setStep, | ||
| onNext, | ||
| onPrevious, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add explicit button type
Match other steps and prevent accidental submits inside forms by setting
type="button".Apply:
As per coding guidelines.
📝 Committable suggestion
🤖 Prompt for AI Agents