Publish Container / build (push) Successful in 16m39s
- Asset Manager: search/filter/sort, list+grid views, rename, move between R2 folders, tag chips, bulk actions, used-in counts; fix R2 delete lookup - Inbox: readable wrapped rows with subject/preview, real read state, type filters, bulk actions, mojibake repair - Broadcast: pick a Resend template to fill subject and body Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||
import { state } from '../state.js'
|
||
import { queueContactSubmissionsWrite } from '../data.js'
|
||
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||
|
||
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
||
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
|
||
|
||
const CP1252_EXTRA = {
|
||
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
|
||
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
|
||
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
|
||
}
|
||
|
||
// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that.
|
||
function repairMojibake(text) {
|
||
if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
|
||
const bytes = []
|
||
for (const ch of text) {
|
||
const code = ch.codePointAt(0)
|
||
if (code <= 0xff) bytes.push(code)
|
||
else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch])
|
||
else return text
|
||
}
|
||
const fixed = Buffer.from(bytes).toString('utf8')
|
||
return fixed.includes('\ufffd') ? text : fixed
|
||
}
|
||
|
||
function decodeHtmlBody(raw) {
|
||
if (typeof raw !== 'string' || !raw.trim()) return null
|
||
const s = raw.trim()
|
||
if (s.startsWith('<')) return s
|
||
try {
|
||
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
|
||
return decoded.trimStart().startsWith('<') ? decoded : s
|
||
} catch {
|
||
return s
|
||
}
|
||
}
|
||
|
||
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
|
||
if (!secret) {
|
||
res.status(503).json({ message: 'Inbound email not configured.' }); return
|
||
}
|
||
|
||
const provided = req.get('x-webhook-secret') ?? ''
|
||
const a = Buffer.from(provided, 'utf8')
|
||
const b = Buffer.from(secret, 'utf8')
|
||
if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) {
|
||
res.status(401).json({ message: 'Unauthorized.' }); return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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() : ''
|
||
|
||
if (normalizedMessageId) {
|
||
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
|
||
if (exists) {
|
||
res.json({ ok: true, duplicate: true }); return
|
||
}
|
||
}
|
||
|
||
const subjectStr = typeof subject === 'string' ? repairMojibake(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: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? 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 },
|
||
adminReply: { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
|
||
},
|
||
}
|
||
|
||
state.contactSubmissions.unshift(submission)
|
||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||
queueContactSubmissionsWrite()
|
||
|
||
console.log(`[inbound-email] received from ${fromEmail} — subject: ${subjectStr || '(none)'} — thread: ${threadId}`)
|
||
res.json({ ok: true })
|
||
})
|
||
}
|