feat: Gitea Webhook, IMAP, Settings & Deployment docs
- Webhook route and Gitea integration - IMAP service and Nextcloud/Porkbun setup docs - Settings UI improvements and API updates - SSH/Webhook fix prompt for emailsorter.webklar.com - Bootstrap, config and AI sorter updates
This commit is contained in:
@@ -417,7 +417,8 @@ Subject: ${subject}
|
||||
Preview: ${snippet?.substring(0, 500) || 'No preview'}
|
||||
|
||||
RESPONSE FORMAT (JSON ONLY):
|
||||
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"}
|
||||
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation", "assignedTo": "name_label_id_or_name_or_null"}
|
||||
If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subject/body mentions them), set assignedTo to that worker's id or name. Otherwise set assignedTo to null or omit it.
|
||||
|
||||
Respond ONLY with the JSON object.`
|
||||
|
||||
@@ -438,6 +439,15 @@ Respond ONLY with the JSON object.`
|
||||
result.category = 'review'
|
||||
}
|
||||
|
||||
// Validate assignedTo against name labels (id or name)
|
||||
if (result.assignedTo && preferences.nameLabels?.length) {
|
||||
const match = preferences.nameLabels.find(
|
||||
l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
|
||||
)
|
||||
if (!match) result.assignedTo = null
|
||||
else result.assignedTo = match.id || match.name
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error('AI categorization failed', { error: error.message })
|
||||
@@ -484,7 +494,8 @@ EMAILS:
|
||||
${emailList}
|
||||
|
||||
RESPONSE FORMAT (JSON ARRAY ONLY):
|
||||
[{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...]
|
||||
[{"index": 0, "category": "key", "assignedTo": "id_or_name_or_null"}, ...]
|
||||
If an email is clearly FOR a specific worker, set assignedTo to that worker's id or name. Otherwise omit or null.
|
||||
|
||||
Respond ONLY with the JSON array.`
|
||||
|
||||
@@ -515,9 +526,16 @@ Respond ONLY with the JSON array.`
|
||||
return emails.map((email, i) => {
|
||||
const result = parsed.find(r => r.index === i)
|
||||
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
|
||||
let assignedTo = result?.assignedTo || null
|
||||
if (assignedTo && preferences.nameLabels?.length) {
|
||||
const match = preferences.nameLabels.find(
|
||||
l => l.enabled && (l.id === assignedTo || l.name === assignedTo)
|
||||
)
|
||||
assignedTo = match ? (match.id || match.name) : null
|
||||
}
|
||||
return {
|
||||
email,
|
||||
classification: { category, confidence: 0.8, reason: 'Batch' },
|
||||
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -578,6 +596,14 @@ Respond ONLY with the JSON array.`
|
||||
}
|
||||
}
|
||||
|
||||
// Name labels (workers) – assign email to a person when clearly for them
|
||||
if (preferences.nameLabels?.length) {
|
||||
const activeNameLabels = preferences.nameLabels.filter(l => l.enabled)
|
||||
if (activeNameLabels.length > 0) {
|
||||
parts.push(`NAME LABELS (workers) – assign email to ONE person when the email is clearly FOR that person (e.g. "für Max", "an Anna", "Max bitte prüfen", subject/body mentions them):\n${activeNameLabels.map(l => `- id: "${l.id}", name: "${l.name}"${l.keywords?.length ? `, keywords: ${JSON.stringify(l.keywords)}` : ''}`).join('\n')}\nIf the email is for a specific worker, set "assignedTo" to that label's id or name. Otherwise omit assignedTo.`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +373,7 @@ export const userPreferences = {
|
||||
enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'],
|
||||
categoryActions: {},
|
||||
companyLabels: [],
|
||||
nameLabels: [],
|
||||
autoDetectCompanies: true,
|
||||
version: 1,
|
||||
categoryAdvanced: {},
|
||||
@@ -410,6 +411,7 @@ export const userPreferences = {
|
||||
enabledCategories: preferences.enabledCategories || defaults.enabledCategories,
|
||||
categoryActions: preferences.categoryActions || defaults.categoryActions,
|
||||
companyLabels: preferences.companyLabels || defaults.companyLabels,
|
||||
nameLabels: preferences.nameLabels || defaults.nameLabels,
|
||||
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
|
||||
}
|
||||
},
|
||||
|
||||
181
server/services/imap.mjs
Normal file
181
server/services/imap.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* IMAP Service
|
||||
* Generic IMAP (e.g. Porkbun, Nextcloud mail backend) – connect, list, fetch, move to folder
|
||||
*/
|
||||
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
const INBOX = 'INBOX'
|
||||
const FOLDER_PREFIX = 'EmailSorter'
|
||||
|
||||
/** Map category key to IMAP folder name */
|
||||
export function getFolderNameForCategory(category) {
|
||||
const map = {
|
||||
vip: 'VIP',
|
||||
customers: 'Clients',
|
||||
invoices: 'Invoices',
|
||||
newsletters: 'Newsletters',
|
||||
promotions: 'Promotions',
|
||||
social: 'Social',
|
||||
security: 'Security',
|
||||
calendar: 'Calendar',
|
||||
review: 'Review',
|
||||
archive: 'Archive',
|
||||
}
|
||||
return map[category] || 'Review'
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP Service – same conceptual interface as GmailService/OutlookService
|
||||
*/
|
||||
export class ImapService {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.host - e.g. imap.porkbun.com
|
||||
* @param {number} opts.port - e.g. 993
|
||||
* @param {boolean} opts.secure - true for SSL/TLS
|
||||
* @param {string} opts.user - email address
|
||||
* @param {string} opts.password - app password
|
||||
*/
|
||||
constructor(opts) {
|
||||
const { host, port = 993, secure = true, user, password } = opts
|
||||
this.client = new ImapFlow({
|
||||
host: host || 'imap.porkbun.com',
|
||||
port: port || 993,
|
||||
secure: secure !== false,
|
||||
auth: { user, pass: password },
|
||||
logger: false,
|
||||
})
|
||||
this.lock = null
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect()
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.lock) await this.lock.release().catch(() => {})
|
||||
await this.client.logout()
|
||||
} catch {
|
||||
this.client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages from INBOX (returns ids = UIDs for use with getEmail/batchGetEmails)
|
||||
* @param {number} maxResults
|
||||
* @param {string|null} _pageToken - reserved for future pagination
|
||||
*/
|
||||
async listEmails(maxResults = 50, _pageToken = null) {
|
||||
const lock = await this.client.getMailboxLock(INBOX)
|
||||
this.lock = lock
|
||||
try {
|
||||
const uids = await this.client.search({ all: true }, { uid: true })
|
||||
const slice = uids.slice(0, maxResults)
|
||||
const nextPageToken = uids.length > maxResults ? String(slice[slice.length - 1]) : null
|
||||
return {
|
||||
messages: slice.map((uid) => ({ id: String(uid) })),
|
||||
nextPageToken,
|
||||
}
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize ImapFlow message to same shape as Gmail/Outlook (id, headers.from, headers.subject, snippet) */
|
||||
_normalize(msg) {
|
||||
if (!msg || !msg.envelope) return null
|
||||
const from = msg.envelope.from && msg.envelope.from[0] ? (msg.envelope.from[0].address || msg.envelope.from[0].name || '') : ''
|
||||
const subject = msg.envelope.subject || ''
|
||||
return {
|
||||
id: String(msg.uid),
|
||||
headers: { from, subject },
|
||||
snippet: subject.slice(0, 200),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one message by id (UID string)
|
||||
*/
|
||||
async getEmail(messageId) {
|
||||
const lock = await this.client.getMailboxLock(INBOX)
|
||||
this.lock = lock
|
||||
try {
|
||||
const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true })
|
||||
return this._normalize(list && list[0])
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get multiple messages by id (UID strings) – single lock, one fetch
|
||||
*/
|
||||
async batchGetEmails(messageIds) {
|
||||
if (!messageIds.length) return []
|
||||
const lock = await this.client.getMailboxLock(INBOX)
|
||||
this.lock = lock
|
||||
try {
|
||||
const uids = messageIds.map((id) => (typeof id === 'string' ? Number(id) : id)).filter((n) => !Number.isNaN(n))
|
||||
if (!uids.length) return []
|
||||
const list = await this.client.fetchAll(uids, { envelope: true }, { uid: true })
|
||||
return (list || []).map((m) => this._normalize(m)).filter(Boolean)
|
||||
} catch (e) {
|
||||
log.warn('IMAP batchGetEmails failed', { error: e.message })
|
||||
return []
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure folder exists (create if not). Use subfolder under EmailSorter to avoid clutter.
|
||||
*/
|
||||
async ensureFolder(folderName) {
|
||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||
try {
|
||||
await this.client.mailboxCreate(path)
|
||||
log.info(`IMAP folder created: ${path}`)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Move message (by UID) from INBOX to folder name (under EmailSorter/)
|
||||
*/
|
||||
async moveToFolder(messageId, folderName) {
|
||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||
await this.ensureFolder(folderName)
|
||||
const lock = await this.client.getMailboxLock(INBOX)
|
||||
this.lock = lock
|
||||
try {
|
||||
await this.client.messageMove(String(messageId), path, { uid: true })
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as read (\\Seen)
|
||||
*/
|
||||
async markAsRead(messageId) {
|
||||
const lock = await this.client.getMailboxLock(INBOX)
|
||||
this.lock = lock
|
||||
try {
|
||||
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user