- 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
156 lines
5.1 KiB
JavaScript
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 };
|