From 8592467bfb35b449963f0ca793ce2a24e528a684 Mon Sep 17 00:00:00 2001 From: localhost Date: Sat, 26 Sep 2026 17:59:17 +0200 Subject: [PATCH] Add manual inbox ingestion --- src/router/admin.ts | 18 +++++ src/templates/admin/dashboard.eta | 26 +++++++- src/templates/admin/inbox.eta | 52 +++++++++++++++ src/utils/archiveRequests.ts | 7 +- src/utils/inbox.ts | 105 +++++++++++++++++++++++++++++- 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/templates/admin/inbox.eta diff --git a/src/router/admin.ts b/src/router/admin.ts index 44189cd..cd4d658 100644 --- a/src/router/admin.ts +++ b/src/router/admin.ts @@ -12,6 +12,7 @@ import { import { startRestore, RestoreError } from '@/utils/restore' import { approveRequest, rejectRequest, dismissRequest } from '@/utils/archiveRequests' import { approveColdRequest, rejectColdRequest, dismissColdRequest } from '@/utils/coldRequests' +import { ingestInboxMail, listInboxMail } from '@/utils/inbox' const app = new Elysia({ prefix: '/admin' }) @@ -114,6 +115,23 @@ app.get('/', async ({ set }) => { })) }) +app.get('/inbox', async ({ set }) => { + set.headers['Content-Type'] = 'text/html; charset=utf-8' + return await m(eta.render('./admin/inbox', { + title: 'Inbox | PreserveTube Admin', + mails: await listInboxMail() + })) +}) + +app.post('/inbox/ingest', async ({ body, redirect }) => { + await ingestInboxMail(body.uid) + return redirect('/admin') +}, { + body: t.Object({ + uid: t.Numeric() + }) +}) + app.post('/restore', async ({ body, redirect, error }) => { const { videoId, requesterEmail } = body diff --git a/src/templates/admin/dashboard.eta b/src/templates/admin/dashboard.eta index 36a8996..0526d48 100644 --- a/src/templates/admin/dashboard.eta +++ b/src/templates/admin/dashboard.eta @@ -8,7 +8,10 @@ -

Archive Requests

+
+

Archive Requests

+
+

Requests found in the admin inbox. Approve to whitelist + archive and email the requester the links. Reject asks the requester for more context (optionally with your note) instead of refusing.

<% it.archiveRequests.forEach(function(r){ %> @@ -247,6 +250,27 @@ margin-top: 12px; } + .section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 1em; + } + + .section-head h3 { + margin: 0; + } + + .section-head button { + padding: 8px 16px; + border: 1px solid #1b1c1f; + background-color: #1b1c1f; + color: white; + cursor: pointer; + font-family: inherit; + } + .admin-link-button { background: none; border: none; diff --git a/src/templates/admin/inbox.eta b/src/templates/admin/inbox.eta new file mode 100644 index 0000000..984696c --- /dev/null +++ b/src/templates/admin/inbox.eta @@ -0,0 +1,52 @@ +<% layout('../layout') %> + +
+
+

Admin Inbox

+ Back to requests +
+ +

Select one of the latest 20 emails in INBOX. Manually ingesting bypasses AI classification, but still requires a valid YouTube video ID.

+ + <% if (it.mails.length) { %> +
+ + + + <% it.mails.forEach(function(mail, index){ %> + + + + + + <% }) %> + +
SenderSubject
required /><%= mail.fromName ? mail.fromName + ' ' : '' %><<%= mail.fromEmail %>><%= mail.subject %>
+
+
+ <% } %> + <% if (it.mails.length === 0) { %>

The inbox is empty or the inbox integration is disabled.

<% } %> +
+ + diff --git a/src/utils/archiveRequests.ts b/src/utils/archiveRequests.ts index d0f0ce5..6b7e0f0 100644 --- a/src/utils/archiveRequests.ts +++ b/src/utils/archiveRequests.ts @@ -41,6 +41,7 @@ async function createRequest(input: { bareIds?: string[] // one-time catch-up of old mail: re-classify ignored mail, only cold storage requests count backfill?: boolean + forceArchive?: boolean }): Promise<'created' | 'ignored' | 'duplicate'> { const [existingArchive, existingCold] = await Promise.all([ db.selectFrom('archive_requests').select('uuid').where('message_id', '=', input.messageId).executeTakeFirst(), @@ -50,9 +51,11 @@ async function createRequest(input: { // classified as "not a request" before: skip without paying for another llm call const ignoredKey = `inbox:ignored:${input.messageId}` - if (!input.backfill && await redis.get(ignoredKey)) return 'ignored' + if (!input.backfill && !input.forceArchive && await redis.get(ignoredKey)) return 'ignored' - const verdict = await classifyEmail(input.subject, input.body) + const verdict = input.forceArchive + ? { category: 'archive' as const, summary: 'Manually ingested from the admin inbox.' } + : await classifyEmail(input.subject, input.body) // the catch-up scan can't tell what an unclassified mail is: fail so it is retried, not skipped if (input.backfill && verdict.failed) throw new Error('AI classification failed') if (verdict.category === 'cold_storage') { diff --git a/src/utils/inbox.ts b/src/utils/inbox.ts index a93ef04..216222c 100644 --- a/src/utils/inbox.ts +++ b/src/utils/inbox.ts @@ -38,6 +38,13 @@ function referencedIds(mail: ParsedMail): string[] { // skip: leave it in the inbox for a human type MailResult = 'ingested' | 'seen' | 'skip' +interface InboxMail { + uid: number + fromEmail: string + fromName: string | null + subject: string +} + async function processMail(mail: ParsedMail, fallbackId: string, backfill: boolean): Promise { const from = mail.from?.value[0] if (!from?.address) return 'seen' @@ -183,6 +190,102 @@ async function pollInbox(backfill = false) { } } +async function listInboxMail(): Promise { + if (!process.env.IMAP_HOST || !process.env.IMAP_USER || !process.env.IMAP_PASS) return [] + + const port = Number(process.env.IMAP_PORT || 993) + const client = new ImapFlow({ + host: process.env.IMAP_HOST, + port, + secure: port === 993, + auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }, + logger: false + }) + client.on('error', (error: Error) => console.log(`[inbox] connection error: ${error.message}`)) + + try { + await client.connect() + const lock = await client.getMailboxLock('INBOX') + + try { + const uids = await client.search({ all: true }, { uid: true }) || [] + const mails: InboxMail[] = [] + + for (const uid of uids.slice(-20).reverse()) { + const msg = await client.fetchOne(String(uid), { source: true }, { uid: true }) + if (!msg || !msg.source) continue + + const mail = await simpleParser(msg.source) + mails.push({ + uid, + fromEmail: mail.from?.value[0]?.address || '(unknown sender)', + fromName: mail.from?.value[0]?.name || null, + subject: mail.subject || '(no subject)' + }) + } + + return mails + } finally { + lock.release() + } + } finally { + await client.logout().catch(() => {}) + } +} + +async function ingestInboxMail(uid: number): Promise<'created' | 'duplicate' | 'ignored' | 'missing'> { + if (!process.env.IMAP_HOST || !process.env.IMAP_USER || !process.env.IMAP_PASS) return 'missing' + + const port = Number(process.env.IMAP_PORT || 993) + const client = new ImapFlow({ + host: process.env.IMAP_HOST, + port, + secure: port === 993, + auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }, + logger: false + }) + client.on('error', (error: Error) => console.log(`[inbox] connection error: ${error.message}`)) + + try { + await client.connect() + await client.mailboxCreate(INGESTED_FOLDER).catch(() => {}) + const lock = await client.getMailboxLock('INBOX') + + try { + const msg = await client.fetchOne(String(uid), { source: true }, { uid: true }) + if (!msg || !msg.source) return 'missing' + + const mail = await simpleParser(msg.source) + const from = mail.from?.value[0] + if (!from?.address) return 'ignored' + + const body = bodyOf(mail) + const raw = `${mail.text || ''}\n${typeof mail.html === 'string' ? mail.html : ''}` + const videoIds = extractUrlIds(raw) + const result = await createRequest({ + messageId: mail.messageId || ``, + fromEmail: from.address, + fromName: from.name || null, + subject: mail.subject || '(no subject)', + body, + videoIds, + bareIds: extractBareIds(mail.text || body, new Set(videoIds)), + forceArchive: true + }) + + if (result !== 'ignored') { + await client.messageFlagsAdd(String(uid), ['\\Seen'], { uid: true }) + await client.messageMove(String(uid), INGESTED_FOLDER, { uid: true }) + } + return result + } finally { + lock.release() + } + } finally { + await client.logout().catch(() => {}) + } +} + async function startInboxPoller() { if (!process.env.IMAP_HOST || !process.env.IMAP_USER || !process.env.IMAP_PASS) { console.log('inbox poller disabled (IMAP_HOST / IMAP_USER / IMAP_PASS not set)') @@ -206,4 +309,4 @@ async function startInboxPoller() { console.log('inbox poller started (this server is primary)') } -export { startInboxPoller, pollInbox } +export { startInboxPoller, pollInbox, listInboxMail, ingestInboxMail }