Add manual Scan Inbox button for email monitoring

- Add scanInbox function that opens a separate IMAP connection and
  processes all unseen messages in the configured folder
- Wire to email-watch API as action "scan"
- Add Scan Inbox button to settings page next to Start/Stop Monitoring
- Useful for processing emails that arrived before monitoring started,
  since IMAP IDLE only notifies about new messages

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-10 13:04:45 -05:00
parent b2c29120cd
commit aacf9c776b
3 changed files with 112 additions and 2 deletions

View file

@ -4,6 +4,7 @@ import {
startEmailWatching, startEmailWatching,
stopEmailWatching, stopEmailWatching,
isEmailWatching, isEmailWatching,
scanInbox,
} from "@/lib/email-watcher"; } from "@/lib/email-watcher";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -11,13 +12,21 @@ export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const action = body.action as string | undefined; const action = body.action as string | undefined;
if (!action || !["start", "stop"].includes(action)) { if (!action || !["start", "stop", "scan"].includes(action)) {
return NextResponse.json( return NextResponse.json(
{ error: "Invalid action. Use 'start' or 'stop'" }, { error: "Invalid action. Use 'start', 'stop', or 'scan'" },
{ status: 400 } { status: 400 }
); );
} }
if (action === "scan") {
const result = await scanInbox();
if (result.error) {
return NextResponse.json({ error: result.error, ...result }, { status: 500 });
}
return NextResponse.json({ ok: true, ...result });
}
if (action === "start") { if (action === "start") {
const settings = await prisma.appSettings.findUnique({ const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, where: { id: "singleton" },

View file

@ -189,6 +189,7 @@ export default function SettingsPage() {
const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null);
const [cleaning, setCleaning] = React.useState(false); const [cleaning, setCleaning] = React.useState(false);
const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
const [scanning, setScanning] = React.useState(false);
const [mondayColumns, setMondayColumns] = React.useState<MondayColumn[]>([]); const [mondayColumns, setMondayColumns] = React.useState<MondayColumn[]>([]);
const [fetchingColumns, setFetchingColumns] = React.useState(false); const [fetchingColumns, setFetchingColumns] = React.useState(false);
const [subscribing, setSubscribing] = React.useState(false); const [subscribing, setSubscribing] = React.useState(false);
@ -327,6 +328,32 @@ export default function SettingsPage() {
} }
}; };
const scanInbox = async () => {
setScanning(true);
try {
const res = await fetch("/api/email-watch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "scan" }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || "Scan failed");
} else if (data.processed === 0 && data.skipped === 0) {
toast.info("No unread emails found in inbox");
} else {
const parts: string[] = [];
if (data.processed > 0) parts.push(`${data.processed} processed`);
if (data.skipped > 0) parts.push(`${data.skipped} skipped`);
toast.success(`Inbox scan: ${parts.join(", ")}`);
}
} catch {
toast.error("Failed to scan inbox");
} finally {
setScanning(false);
}
};
const testEmailConnection = async () => { const testEmailConnection = async () => {
setEmailTestStatus("testing"); setEmailTestStatus("testing");
try { try {
@ -925,6 +952,16 @@ export default function SettingsPage() {
> >
{settings.emailWatching ? "Stop Monitoring" : "Start Monitoring"} {settings.emailWatching ? "Stop Monitoring" : "Start Monitoring"}
</Button> </Button>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={scanInbox}
disabled={scanning || !settings.emailImapHost || !settings.emailImapUser || !settings.emailImapPass}
>
{scanning ? <Loader2 className="mr-2 size-3 animate-spin" /> : <RefreshCw className="mr-2 size-3" />}
Scan Inbox
</Button>
{settings.emailWatching && ( {settings.emailWatching && (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"> <Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">
Active Active

View file

@ -241,6 +241,70 @@ export function getEmailWatchStatus() {
}; };
} }
export async function scanInbox(): Promise<{ processed: number; skipped: number; error?: string }> {
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
if (!settings?.emailImapHost || !settings?.emailImapUser || !settings?.emailImapPass) {
return { processed: 0, skipped: 0, error: "IMAP not configured" };
}
const config: EmailConfig = {
host: settings.emailImapHost,
port: settings.emailImapPort,
user: settings.emailImapUser,
pass: settings.emailImapPass,
tls: settings.emailImapTls,
folder: settings.emailFolder,
processedAction: settings.emailProcessed,
processedFolder: settings.emailProcessedFolder,
};
const scanClient = new ImapFlow({
host: config.host,
port: config.port,
secure: config.tls,
auth: { user: config.user, pass: config.pass },
logger: false,
emitLogs: false,
});
let processed = 0;
let skipped = 0;
try {
await scanClient.connect();
log(`[scan] Connected for manual inbox scan`);
const lock = await scanClient.getMailboxLock(config.folder);
try {
const searchResult = await scanClient.search({ seen: false }, { uid: true });
const unseen = Array.isArray(searchResult) ? searchResult : [];
log(`[scan] Found ${unseen.length} unseen message(s)`);
for (const uid of unseen) {
try {
await handleMessage(scanClient, uid, config);
processed++;
} catch (err) {
logError(`[scan] Error handling message UID ${uid}:`, err);
skipped++;
}
}
} finally {
lock.release();
}
await scanClient.logout();
} catch (err) {
const message = err instanceof Error ? err.message : "Scan failed";
logError("[scan] Error:", message);
try { await scanClient.logout(); } catch {}
return { processed, skipped, error: message };
}
log(`[scan] Complete: ${processed} processed, ${skipped} skipped`);
return { processed, skipped };
}
export async function testEmailConnection(config: Omit<EmailConfig, "processedAction" | "processedFolder">): Promise<{ ok: boolean; folders?: string[]; error?: string }> { export async function testEmailConnection(config: Omit<EmailConfig, "processedAction" | "processedFolder">): Promise<{ ok: boolean; folders?: string[]; error?: string }> {
let testClient: ImapFlow | null = null; let testClient: ImapFlow | null = null;
try { try {