Files
TeamFlow--Trello-Like-/server/email/imap.js
admin 460f83aef8 🌊 TeamFlow — Modern Trello alternative with email integration
- Full-stack: React 18 + Express + SQLite
- Drag-and-drop kanban boards with @hello-pangea/dnd
- Google App Password email integration (SMTP + IMAP)
- Inbound email: create cards by sending emails
- Reply-to-card: email replies become comments
- Admin/user management with role-based access
- Setup wizard: email config → admin creation
- Checklists, time tracking, priorities, labels, due dates
- Real-time notifications with activity feed
- Beautiful HTML email templates
2026-04-03 15:11:27 +00:00

156 lines
5.1 KiB
JavaScript

import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import db from '../db.js';
import { generateToken } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';
let imapClient = null;
let pollInterval = null;
function getConfig() {
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
}
function getBoardByPrefix(prefix) {
const boards = db.prepare('SELECT id, title FROM boards WHERE is_archived = 0').all();
return boards.find(b => b.title.toLowerCase().replace(/\s+/g, '-').startsWith(prefix.toLowerCase()));
}
function getCardByToken(token) {
const record = db.prepare(`
SELECT et.*, c.title as card_title, l.board_id, b.title as board_title, u.name as user_name
FROM email_tokens et
JOIN cards c ON c.id = et.card_id
JOIN lists l ON l.id = c.list_id
JOIN boards b ON b.id = l.board_id
LEFT JOIN users u ON u.id = et.user_id
WHERE et.token = ? AND et.expires_at > datetime('now')
`).get(token);
return record;
}
async function processMessage(msg) {
const config = getConfig();
if (!config || !config.inbound_enabled) return;
const parsed = await simpleParser(msg.source);
const fromAddr = parsed.from?.value?.[0]?.address || '';
const subject = parsed.subject || '(no subject)';
const bodyText = parsed.text || '';
const bodyHtml = parsed.html || '';
const messageId = parsed.messageId || '';
const references = parsed.references || '';
const inReplyTo = parsed.inReplyTo || '';
const sender = db.prepare('SELECT id, name, email FROM users WHERE email = ? AND is_active = 1').get(fromAddr);
if (!sender) return;
// Check if this is a reply to an existing card
if (inReplyTo || references) {
const refStr = (inReplyTo + ' ' + references).toLowerCase();
const tokens = db.prepare('SELECT * FROM email_tokens').all();
for (const t of tokens) {
if (refStr.includes(t.token.toLowerCase())) {
const card = getCardByToken(t.token);
if (card) {
db.prepare(`INSERT INTO card_comments (card_id, user_id, content, is_email_reply) VALUES (?, ?, ?, 1)`)
.run(card.card_id, sender.id, `📧 ${bodyText.trim()}`);
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'email_reply', ?)`)
.run(card.card_id, sender.id, `Replied via email`);
return;
}
}
}
}
// Check if subject matches board prefix pattern (e.g., [tf-board-name] Card title)
const prefixMatch = subject.match(/^\[(.+?)\]\s*(.+)/);
if (prefixMatch) {
const board = getBoardByPrefix(prefixMatch[1]);
if (board) {
const firstList = db.prepare('SELECT id FROM lists WHERE board_id = ? AND is_archived = 0 ORDER BY position LIMIT 1').get(board.id);
if (firstList) {
const result = db.prepare(`INSERT INTO cards (list_id, title, description, position, created_by) VALUES (?, ?, ?, ?, ?)`)
.run(firstList.id, prefixMatch[2].trim(), bodyText.substring(0, 2000), Date.now(), sender.id);
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'created_via_email', ?)`)
.run(result.lastInsertRowid, sender.id, `Created via email from ${fromAddr}`);
db.prepare(`INSERT INTO email_log (from_email, subject, card_id, direction, status) VALUES (?, ?, ?, 'received', 'processed')`)
.run(fromAddr, subject, result.lastInsertRowid);
return;
}
}
}
}
async function pollInbox() {
const config = getConfig();
if (!config || !config.inbound_enabled) {
return;
}
try {
if (!imapClient) {
imapClient = new ImapFlow({
host: config.imap_host,
port: config.imap_port,
secure: true,
auth: {
user: config.email,
pass: config.app_password,
},
logger: false,
});
await imapClient.connect();
}
const lock = await imapClient.getMailboxLock(config.inbound_folder);
try {
const lastPoll = db.prepare("SELECT value FROM app_state WHERE key = 'last_imap_poll'").get();
let since = new Date(Date.now() - 24 * 60 * 60 * 1000);
if (lastPoll) {
const d = new Date(lastPoll.value);
if (!isNaN(d)) since = d;
}
for await (const msg of imapClient.fetch({ since }, { source: true })) {
try {
await processMessage(msg);
} catch (err) {
console.error('Error processing email:', err.message);
}
}
db.prepare("INSERT OR REPLACE INTO app_state (key, value) VALUES ('last_imap_poll', ?)")
.run(new Date().toISOString());
} finally {
lock.release();
}
} catch (err) {
console.error('IMAP poll error:', err.message);
imapClient = null;
}
}
export function startImapPolling() {
if (pollInterval) clearInterval(pollInterval);
const config = getConfig();
if (config?.inbound_enabled) {
pollInterval = setInterval(pollInbox, 60000);
pollInbox();
}
}
export function stopImapPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
if (imapClient) {
imapClient.logout().catch(() => {});
imapClient = null;
}
}
export { pollInbox };