feat(email): incoming mail (IMAP) intake - backend + standalone flag
Adds a second mail config (incoming/IMAP) alongside the outgoing SMTP one, a 1-minute poller, and a received-emails log. Standalone `incomingMail` feature flag (default off). - deps: imapflow + mailparser (receive-side; picpeak only had nodemailer). - migration 128: email_configs gains imap_* columns (same shape as smtp_*); seed incomingMail flag; new received_emails audit table. - emailIntakeService: polls the mailbox every 60s when the flag is on AND a mailbox is configured (no-op otherwise); parses each unseen message (mailparser flattens forwarded/nested attachments), drops PDF/JPEG/PNG into the incoming-invoices inbox (inbound_documents, source='email'), logs each message in received_emails (dedupe by message-id; duplicate attachments caught by the existing SHA-256 guard), marks it \Seen. - adminEmail: GET/POST /incoming-config (mirrors SMTP config, masks imap_pass, SSRF host guard) + GET /received (paginated log). - server.js starts the poller at boot. Verified: node -c, require-graph, migration-128 harness (imap columns, flag, received_emails). Frontend (IMAP block under SMTP + Received tab + flag card) follows.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Migration 128: incoming mail (IMAP) support.
|
||||
*
|
||||
* - email_configs gains imap_* columns (a second config block alongside the
|
||||
* outgoing smtp_* one; single row, same field shape).
|
||||
* - `incomingMail` feature flag (default OFF, standalone).
|
||||
* - received_emails: an audit log of messages the IMAP poller processed
|
||||
* (dedupe key = message_id), mirroring the outgoing email_queue / "Sent
|
||||
* emails" surface with a "Received emails" one.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (await knex.schema.hasTable('email_configs')) {
|
||||
await addColumn(knex, 'email_configs', 'imap_host', (t) => t.string('imap_host', 255));
|
||||
await addColumn(knex, 'email_configs', 'imap_port', (t) => t.integer('imap_port'));
|
||||
await addColumn(knex, 'email_configs', 'imap_secure', (t) => t.boolean('imap_secure').notNullable().defaultTo(true));
|
||||
await addColumn(knex, 'email_configs', 'imap_user', (t) => t.string('imap_user', 255));
|
||||
await addColumn(knex, 'email_configs', 'imap_pass', (t) => t.string('imap_pass', 512));
|
||||
await addColumn(knex, 'email_configs', 'imap_folder', (t) => t.string('imap_folder', 128).defaultTo('INBOX'));
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
const existing = await knex('feature_flags').where({ key: 'incomingMail' }).first();
|
||||
if (!existing) await knex('feature_flags').insert({ key: 'incomingMail', value: false });
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable('received_emails'))) {
|
||||
await knex.schema.createTable('received_emails', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('message_id', 512);
|
||||
table.string('from_address', 512);
|
||||
table.text('subject');
|
||||
table.timestamp('received_at');
|
||||
table.integer('attachment_count').notNullable().defaultTo(0);
|
||||
// ingested | no_attachment | duplicate | error
|
||||
table.string('status', 24).notNullable().defaultTo('ingested');
|
||||
table.integer('inbound_document_id').unsigned();
|
||||
table.text('error');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.index(['message_id']);
|
||||
table.index(['status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
await knex.schema.dropTableIfExists('received_emails');
|
||||
if (await knex.schema.hasTable('feature_flags')) {
|
||||
await knex('feature_flags').where({ key: 'incomingMail' }).del();
|
||||
}
|
||||
if (await knex.schema.hasTable('email_configs')) {
|
||||
for (const col of ['imap_host', 'imap_port', 'imap_secure', 'imap_user', 'imap_pass', 'imap_folder']) {
|
||||
if (await knex.schema.hasColumn('email_configs', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('email_configs', (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user