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:
parent
b2c29120cd
commit
aacf9c776b
3 changed files with 112 additions and 2 deletions
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue