🌊 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:
admin
2026-04-03 15:11:27 +00:00
Unverified
commit 460f83aef8
40 changed files with 8512 additions and 0 deletions

155
server/email/imap.js Normal file
View 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 };

110
server/email/transporter.js Normal file
View File

@@ -0,0 +1,110 @@
import nodemailer from 'nodemailer';
import db from '../db.js';
let transporter = null;
export function getEmailConfig() {
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
}
export async function initTransporter() {
const config = getEmailConfig();
if (!config) return null;
try {
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
secure: config.smtp_port === 465,
auth: {
user: config.email,
pass: config.app_password,
},
});
await transporter.verify();
return true;
} catch (err) {
console.error('Email transporter init failed:', err.message);
transporter = null;
return false;
}
}
export async function testConnection(host, port, email, password) {
const t = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user: email, pass: password },
});
await t.verify();
await t.close();
}
export async function sendMail({ to, subject, html, replyTo, inReplyTo, references }) {
if (!transporter) await initTransporter();
if (!transporter) throw new Error('Email not configured');
const config = getEmailConfig();
const msg = {
from: `"TeamFlow" <${config.email}>`,
to,
subject,
html,
headers: {},
};
if (replyTo) msg.replyTo = replyTo;
if (inReplyTo) msg.headers['In-Reply-To'] = inReplyTo;
if (references) msg.headers.References = references;
const info = await transporter.sendMail(msg);
db.prepare(`INSERT INTO email_log (from_email, to_email, subject, direction, status, message_id)
VALUES (?, ?, ?, 'sent', 'sent', ?)`)
.run(config.email, to, subject, info.messageId);
return info;
}
export function buildCardUrl(cardId, boardId) {
const origin = process.env.APP_URL || 'http://localhost:5173';
return `${origin}/board/${boardId}?card=${cardId}`;
}
export function buildCardNotificationHtml({ userName, boardTitle, cardTitle, action, cardUrl, comment }) {
const actionMessages = {
assigned: `assigned you to a card`,
commented: `commented on a card`,
moved: `moved a card`,
mentioned: `mentioned you in a comment`,
due_soon: `has a due date coming up`,
};
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;margin:0;padding:0}
.container{max-width:560px;margin:40px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px;color:#fff}
.header h1{margin:0;font-size:20px;font-weight:600}
.header p{margin:8px 0 0;opacity:.9;font-size:14px}
.body{padding:32px}
.card-preview{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0}
.card-preview h3{margin:0 0 8px;font-size:16px}
.card-preview p{margin:0;color:#64748b;font-size:14px}
.comment{background:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;font-size:14px;line-height:1.6;color:#334155}
.btn{display:inline-block;background:#6366f1;color:#fff!important;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;margin-top:16px}
.footer{padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center}
</style></head><body>
<div class="container">
<div class="header">
<h1>TeamFlow</h1>
<p>${userName} ${actionMessages[action] || action}</p>
</div>
<div class="body">
<p style="color:#64748b;margin:0 0 8px">Board: <strong>${boardTitle}</strong></p>
<div class="card-preview">
<h3>${cardTitle}</h3>
${comment ? `<div class="comment">${comment}</div>` : ''}
</div>
<a href="${cardUrl}" class="btn">View Card</a>
</div>
<div class="footer">
<p>Sent by TeamFlow · <a href="${cardUrl}" style="color:#6366f1">Open in browser</a></p>
</div>
</div>
</body></html>`;
}