Add email threading, snooze, bulk actions, scheduled send, stars, and attachments; v1.1.21

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 16:29:24 -04:00
parent 57fe34ba47
commit ee7c783de8
6 changed files with 1008 additions and 336 deletions
+57 -7
View File
@@ -3,13 +3,13 @@ import { state } from '../state.js'
import { queueContactSubmissionsWrite } from '../data.js'
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
function decodeHtmlBody(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null
const s = raw.trim()
if (s.startsWith('<')) return s // already plain HTML
if (s.startsWith('<')) return s
try {
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
return decoded.trimStart().startsWith('<') ? decoded : s
@@ -18,6 +18,50 @@ function decodeHtmlBody(raw) {
}
}
function normalizeSubject(subject) {
return (subject ?? '').toLowerCase().replace(/^(re|fwd?):\s*/i, '').trim()
}
function resolveThreadId(fromEmail, normalizedSubject, inReplyTo) {
// 1. Exact In-Reply-To match
if (inReplyTo) {
const parent = state.contactSubmissions.find(s => s.messageId === inReplyTo)
if (parent?.threadId) return parent.threadId
}
// 2. Same sender + matching subject within 30 days
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
const match = state.contactSubmissions.find(s =>
s.email === fromEmail &&
normalizedSubject &&
normalizeSubject(s.message.match(/^Subject:\s*(.+)/m)?.[1] ?? '') === normalizedSubject &&
new Date(s.submittedAt).getTime() > cutoff,
)
if (match?.threadId) return match.threadId
// 3. New thread
return randomUUID()
}
function parseAttachments(raw) {
if (!Array.isArray(raw)) return []
let totalBytes = 0
const out = []
for (const a of raw.slice(0, 10)) {
if (!a || typeof a.filename !== 'string') continue
const dataStr = typeof a.content === 'string' ? a.content : ''
const size = typeof a.size === 'number' ? a.size : Math.floor(dataStr.length * 0.75)
if (totalBytes + size > MAX_ATTACHMENT_BYTES) continue
totalBytes += size
out.push({
id: randomUUID(),
filename: a.filename.slice(0, 255),
contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream',
size,
data: dataStr,
})
}
return out
}
export function register(app) {
app.post('/api/inbound-email', (req, res) => {
const secret = process.env.INBOUND_EMAIL_SECRET
@@ -32,20 +76,19 @@ export function register(app) {
res.status(401).json({ message: 'Unauthorized.' }); return
}
const { from, to, subject, body, htmlBody, date, messageId, source } = req.body ?? {}
const { from, to, subject, body, htmlBody, date, messageId, inReplyTo, attachments: rawAttachments, source } = req.body ?? {}
if (!from || typeof from !== 'string') {
res.status(400).json({ message: 'Missing from address.' }); return
}
// Extract display name and email address from "Name <email>" format
const fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim())
const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim()
const fromName = fromMatch ? fromMatch[1].trim() : from.trim()
const normalizedMessageId = typeof messageId === 'string' && MESSAGE_ID_RE.test(messageId.trim()) ? messageId.trim() : ''
const normalizedInReplyTo = typeof inReplyTo === 'string' && MESSAGE_ID_RE.test(inReplyTo.trim()) ? inReplyTo.trim() : ''
// Deduplicate by messageId if provided
if (normalizedMessageId) {
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
if (exists) {
@@ -53,19 +96,26 @@ export function register(app) {
}
}
const subjectStr = typeof subject === 'string' ? subject.trim() : ''
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
const submission = {
id: randomUUID(),
threadId,
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
name: fromName || fromEmail,
email: fromEmail,
message: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'),
message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'),
htmlBody: decodeHtmlBody(htmlBody),
messageType: 'general',
subscribe: false,
archived: false,
starred: false,
snoozedUntil: null,
source: 'inbound-email',
inboundTo: typeof to === 'string' ? to : '',
messageId: normalizedMessageId,
attachments: parseAttachments(rawAttachments),
emailStatus: {
welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
adminNotification: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
@@ -77,7 +127,7 @@ export function register(app) {
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
console.log(`[inbound-email] received from ${fromEmail} — subject: ${subject ?? '(none)'}`)
console.log(`[inbound-email] received from ${fromEmail} — subject: ${subjectStr || '(none)'} — thread: ${threadId}`)
res.json({ ok: true })
})
}