🌊 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
This commit is contained in:
155
server/email/imap.js
Normal file
155
server/email/imap.js
Normal file
@@ -0,0 +1,155 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user