diff --git a/package.json b/package.json index 08ec5b8..a374bd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.20", + "version": "1.1.21", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index ec4ade7..5e75ccb 100644 --- a/server/data.js +++ b/server/data.js @@ -1109,11 +1109,23 @@ function sanitizeLoadedContactSubmissions(value) { messageType: normalizeMessageType(entry.messageType), subscribe: entry.subscribe === true, archived: entry.archived === true, + starred: entry.starred === true, + snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null, + threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(), emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form', htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null, inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '', messageId: typeof entry.messageId === 'string' ? entry.messageId : '', + attachments: Array.isArray(entry.attachments) + ? entry.attachments.filter(a => a && typeof a.filename === 'string').slice(0, 10).map(a => ({ + id: typeof a.id === 'string' ? a.id : randomUUID(), + filename: String(a.filename).slice(0, 255), + contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream', + size: typeof a.size === 'number' ? a.size : 0, + data: typeof a.data === 'string' ? a.data : '', + })) + : [], })) } diff --git a/server/routes/contact.js b/server/routes/contact.js index 94de20a..e7ddb81 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -439,8 +439,14 @@ export function register(app) { const patch = {} if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived + if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200) if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000) + if ('snoozedUntil' in (req.body ?? {})) { + const v = req.body.snoozedUntil + patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined) + if (patch.snoozedUntil === undefined) delete patch.snoozedUntil + } let found = false state.contactSubmissions = state.contactSubmissions.map(item => { @@ -473,6 +479,46 @@ export function register(app) { res.json({ ok: true }) }) + app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => { + const { ids, action } = req.body ?? {} + if (!Array.isArray(ids) || !['archive', 'unarchive', 'delete', 'star', 'unstar'].includes(action)) { + res.status(400).json({ message: 'Invalid bulk action.' }); return + } + const idSet = new Set(ids.filter(id => typeof id === 'string')) + if (idSet.size === 0) { res.json({ ok: true, affected: 0 }); return } + + let affected = 0 + if (action === 'delete') { + const before = state.contactSubmissions.length + state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id)) + affected = before - state.contactSubmissions.length + } else { + const patch = action === 'archive' ? { archived: true } + : action === 'unarchive' ? { archived: false } + : action === 'star' ? { starred: true } + : { starred: false } + state.contactSubmissions = state.contactSubmissions.map(s => { + if (!idSet.has(s.id)) return s + affected++ + return { ...s, ...patch } + }) + } + queueContactSubmissionsWrite() + res.json({ ok: true, affected }) + }) + + app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => { + const { id, attachmentId } = req.params + const submission = state.contactSubmissions.find(s => s.id === id) + if (!submission) { res.status(404).send('Not found.'); return } + const attachment = (submission.attachments ?? []).find(a => a.id === attachmentId) + if (!attachment) { res.status(404).send('Attachment not found.'); return } + const safe = attachment.filename.replace(/[^\w.\-]/g, '_') + res.setHeader('Content-Disposition', `attachment; filename="${safe}"`) + res.setHeader('Content-Type', attachment.contentType || 'application/octet-stream') + res.send(Buffer.from(attachment.data, 'base64')) + }) + app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => { res.json({ fromEmail: getResendReplyToAddress(), @@ -510,6 +556,7 @@ export function register(app) { const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '' const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : '' + const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return @@ -546,6 +593,7 @@ export function register(app) { to: [submission.email], subject, replyTo: replyToAddress, + ...(scheduledAt ? { scheduledAt } : {}), tags: [ { name: 'flow', value: 'admin-reply' }, { name: 'message_type', value: submission.messageType ?? 'general' }, @@ -574,11 +622,12 @@ export function register(app) { subject, preview: message.slice(0, 500), sentAt: new Date().toISOString(), + scheduledAt: scheduledAt ?? null, }) state.replyHistory = state.replyHistory.slice(0, 500) queueReplyHistoryWrite() - res.json({ ok: true }) + res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null }) } catch (err) { if (typeof req.params?.id === 'string' && req.params.id.trim()) { upsertContactEmailStatus(req.params.id.trim(), 'adminReply', { @@ -602,6 +651,7 @@ export function register(app) { const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '' const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : '' + const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null if (!to || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(to)) { res.status(400).json({ message: 'A valid recipient email address is required.' }); return @@ -630,6 +680,7 @@ export function register(app) { to: [to], subject, replyTo: replyToAddress, + ...(scheduledAt ? { scheduledAt } : {}), tags: [{ name: 'flow', value: 'admin-reply' }], text, html, @@ -645,11 +696,12 @@ export function register(app) { subject, preview: message.slice(0, 500), sentAt: new Date().toISOString(), + scheduledAt: scheduledAt ?? null, }) state.replyHistory = state.replyHistory.slice(0, 500) queueReplyHistoryWrite() - res.json({ ok: true }) + res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null }) } catch (err) { console.error('[admin-compose] send error:', err) res.status(500).json({ message: 'Failed to send email.' }) diff --git a/server/routes/inbound-email.js b/server/routes/inbound-email.js index 5bf9d6d..15eb157 100644 --- a/server/routes/inbound-email.js +++ b/server/routes/inbound-email.js @@ -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 " 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 }) }) } diff --git a/src/App.css b/src/App.css index 70122e9..6350dce 100644 --- a/src/App.css +++ b/src/App.css @@ -10945,6 +10945,312 @@ } .em-sig-edit-link:hover { color: #e0a020; } +/* ── Threading ────────────────────────────────────────────────────────────── */ +.em-thread-count { + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.15); + border-radius: 999px; + color: #c9a84c; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0; + margin-left: 0.35rem; + min-width: 1.25rem; + padding: 0 0.3rem; +} + +.em-thread-badge { + background: rgba(201,168,76,0.18); + border-radius: 999px; + color: #c9a84c; + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.55rem; + white-space: nowrap; +} + +.em-detail-subject-line { + color: #f0ead8; + font-size: 0.9rem; + font-weight: 500; + opacity: 0.8; +} + +.em-conversation { + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + padding: 1.25rem 1.5rem; + flex: 1; +} + +.em-conv-bubble { + border-left: 3px solid transparent; + margin-bottom: 1.25rem; + padding: 0.9rem 1rem; + border-radius: 0 8px 8px 0; +} + +.em-conv-bubble--inbound { + background: rgba(255,255,255,0.04); + border-left-color: rgba(255,255,255,0.15); +} + +.em-conv-bubble--sent { + background: rgba(201,168,76,0.07); + border-left-color: #c9a84c; +} + +.em-conv-bubble--first { + margin-top: 0; +} + +.em-conv-meta { + align-items: baseline; + display: flex; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 0.4rem; + flex-wrap: wrap; +} + +.em-conv-meta-right { + align-items: baseline; + display: flex; + gap: 0.5rem; +} + +.em-conv-author { + color: #f0ead8; + font-size: 0.82rem; + font-weight: 600; +} + +.em-conv-date { + color: #b8a884; + font-family: system-ui, sans-serif; + font-size: 0.72rem; + white-space: nowrap; +} + +.em-conv-subject { + color: #c9a84c; + font-size: 0.78rem; + font-weight: 500; + margin-bottom: 0.45rem; + opacity: 0.9; +} + +.em-conv-body { + color: #d4c8a8; + font-size: 0.85rem; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.em-conv-body-wrap { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* ── Attachments ─────────────────────────────────────────────────────────── */ +.em-attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.em-attachment-chip { + align-items: center; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + color: #d4c8a8; + display: inline-flex; + font-size: 0.78rem; + gap: 0.35rem; + padding: 0.3rem 0.65rem; + text-decoration: none; + transition: background 0.15s; +} + +.em-attachment-chip:hover { + background: rgba(201,168,76,0.12); + border-color: rgba(201,168,76,0.3); + color: #f0ead8; +} + +.em-attachment-icon { font-size: 0.85rem; } + +.em-attachment-name { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.em-attachment-size { + color: #8a7a5a; + font-size: 0.7rem; + font-family: system-ui, sans-serif; +} + +/* ── Bulk selection ──────────────────────────────────────────────────────── */ +.em-bulk-bar { + align-items: center; + background: #1a1a24; + border-bottom: 1px solid rgba(201,168,76,0.25); + display: flex; + gap: 0.5rem; + padding: 0.5rem 1.25rem; +} + +.em-bulk-count { + color: #c9a84c; + font-size: 0.82rem; + font-weight: 600; + margin-right: 0.25rem; + min-width: 6rem; +} + +.em-list-toolbar { + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + gap: 0.5rem; + padding: 0.45rem 0.75rem; +} + +.em-list-check { + accent-color: #c9a84c; + cursor: pointer; + flex-shrink: 0; + height: 1rem; + margin-right: 0.5rem; + width: 1rem; +} + +.em-list-item--selected { + background: rgba(201,168,76,0.1) !important; + border-left-color: #c9a84c !important; +} + +/* ── Star ─────────────────────────────────────────────────────────────────── */ +.em-star-icon { + color: #c9a84c; + display: inline-block; + margin-right: 0.2rem; +} + +.em-btn--star-active { + background: rgba(201,168,76,0.18); + border: 1px solid rgba(201,168,76,0.45); + border-radius: 6px; + color: #c9a84c; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + padding: 0.45rem 0.9rem; + transition: background 0.15s; +} + +.em-btn--star-active:hover { + background: rgba(201,168,76,0.28); +} + +/* ── Snooze ──────────────────────────────────────────────────────────────── */ +.em-snooze-wrap { + position: relative; +} + +.em-snooze-picker { + background: #1c1c28; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + bottom: calc(100% + 6px); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + left: 0; + min-width: 200px; + overflow: hidden; + position: absolute; + z-index: 100; +} + +.em-snooze-option { + background: none; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.06); + color: #d4c8a8; + cursor: pointer; + font-size: 0.82rem; + padding: 0.7rem 1rem; + text-align: left; + transition: background 0.12s; +} + +.em-snooze-option:hover { background: rgba(255,255,255,0.06); color: #f0ead8; } + +.em-snooze-option--cancel { color: #f87171; border-bottom: none; } +.em-snooze-option--cancel:hover { background: rgba(220,38,38,0.12); } + +.em-snooze-custom { + align-items: center; + border-top: 1px solid rgba(255,255,255,0.06); + display: flex; + gap: 0.5rem; + padding: 0.6rem; +} + +/* ── Schedule send ───────────────────────────────────────────────────────── */ +.em-schedule-wrap { + position: relative; +} + +.em-schedule-picker { + background: #1c1c28; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + bottom: calc(100% + 6px); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + left: 0; + min-width: 220px; + overflow: hidden; + position: absolute; + z-index: 100; +} + +.em-schedule-option { + background: none; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.06); + color: #d4c8a8; + cursor: pointer; + font-size: 0.82rem; + padding: 0.7rem 1rem; + text-align: left; + transition: background 0.12s; +} + +.em-schedule-option:hover { background: rgba(255,255,255,0.06); color: #f0ead8; } + +.em-schedule-custom { + align-items: center; + border-top: 1px solid rgba(255,255,255,0.06); + display: flex; + gap: 0.5rem; + padding: 0.6rem; +} + @media (max-width: 600px) { .ct-card { flex-direction: column; gap: 0.6rem; } .ct-card-actions { flex-direction: row; } diff --git a/src/EmailPage.tsx b/src/EmailPage.tsx index 68e5999..efec9a8 100644 --- a/src/EmailPage.tsx +++ b/src/EmailPage.tsx @@ -10,8 +10,16 @@ const REPLY_FROM_OPTIONS = [ // ── Types ──────────────────────────────────────────────────────────────────── +interface Attachment { + id: string + filename: string + contentType: string + size: number +} + interface ContactSubmission { id: string + threadId: string submittedAt: string name: string email: string @@ -19,19 +27,34 @@ interface ContactSubmission { messageType: 'question' | 'testimony' | 'topic' | 'general' subscribe: boolean archived?: boolean + starred?: boolean + snoozedUntil?: string | null source?: 'contact-form' | 'download' | 'inbound-email' inboundTo?: string htmlBody?: string | null messageId?: string + attachments?: Attachment[] +} + +interface Thread { + threadId: string + messages: ContactSubmission[] // all inbound messages in thread + sentMessages: ReplyHistoryItem[] // outbound replies in thread + latest: ContactSubmission + subject: string + email: string + name: string } interface ReplyDraft { submissionId: string | null + threadId: string | null recipientName: string recipientEmail: string subject: string message: string fromAddress: string + scheduledAt: string // '' = send now } interface ReplyTemplate { @@ -50,6 +73,7 @@ interface ReplyHistoryItem { subject: string preview: string sentAt: string + scheduledAt?: string | null } interface ReplyConfig { @@ -72,8 +96,11 @@ function extractSubject(message: string): string { } function extractBodyPreview(message: string): string { - const afterSubject = message.replace(/^Subject:\s*.+\n+/m, '').trim() - return afterSubject.slice(0, 200) + return message.replace(/^Subject:\s*.+\n+/m, '').trim().slice(0, 200) +} + +function normalizeSubject(s: string): string { + return s.replace(/^(re|fwd?):\s*/i, '').trim().toLowerCase() } function formatDate(value: string | null | undefined): string { @@ -82,23 +109,27 @@ function formatDate(value: string | null | undefined): string { return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString() } -function decodeHtmlBody(raw: string): string { - if (!raw || raw.trimStart().startsWith('<')) return raw - try { return atob(raw.replace(/\s+/g, '')) } catch { return raw } -} - function formatShortDate(value: string | null | undefined): string { if (!value) return '—' const d = new Date(value) if (Number.isNaN(d.getTime())) return '—' const now = new Date() - const sameDay = d.toDateString() === now.toDateString() - if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - const sameYear = d.getFullYear() === now.getFullYear() - if (sameYear) return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) } +function decodeHtmlBody(raw: string): string { + if (!raw || raw.trimStart().startsWith('<')) return raw + try { return atob(raw.replace(/\s+/g, '')) } catch { return raw } +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB` + return `${(n / (1024 * 1024)).toFixed(1)} MB` +} + function readIds(): Set { try { return new Set(JSON.parse(localStorage.getItem('em-read-ids') ?? '[]') as string[]) } catch { return new Set() } @@ -111,13 +142,75 @@ function persistReadId(id: string) { } catch {} } +function snoozeQuickDate(option: 'tomorrow' | 'next-week' | 'next-month'): string { + const d = new Date() + if (option === 'tomorrow') { d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0) } + else if (option === 'next-week') { d.setDate(d.getDate() + 7); d.setHours(9, 0, 0, 0) } + else { d.setMonth(d.getMonth() + 1); d.setDate(1); d.setHours(9, 0, 0, 0) } + return d.toISOString() +} + +function scheduleQuickDate(option: 'tomorrow-9' | 'next-monday-9'): string { + const d = new Date() + if (option === 'tomorrow-9') { + d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0) + } else { + const day = d.getDay() + const daysUntilMonday = day === 0 ? 1 : 8 - day + d.setDate(d.getDate() + daysUntilMonday); d.setHours(9, 0, 0, 0) + } + // Format for datetime-local input value + return d.toISOString().slice(0, 16) +} + +// ── Thread builder ──────────────────────────────────────────────────────────── + +function buildThreads(submissions: ContactSubmission[], history: ReplyHistoryItem[]): Map { + const map = new Map() + + for (const s of submissions) { + const tid = s.threadId ?? s.id + const existing = map.get(tid) + if (existing) { + existing.messages.push(s) + if (new Date(s.submittedAt) > new Date(existing.latest.submittedAt)) existing.latest = s + } else { + const subject = s.source === 'inbound-email' ? extractSubject(s.message) : '' + map.set(tid, { + threadId: tid, + messages: [s], + sentMessages: [], + latest: s, + subject, + email: s.email, + name: s.name, + }) + } + } + + // Attach sent replies to threads + for (const item of history) { + if (!item.submissionId) continue + for (const [, thread] of map) { + if (thread.messages.some(m => m.id === item.submissionId)) { + thread.sentMessages.push(item) + break + } + } + } + + return map +} + // ── EmailClient ────────────────────────────────────────────────────────────── +type Mailbox = 'inbox' | 'starred' | 'snoozed' | 'archived' + function EmailClient({ onLogout }: { onLogout: () => void }) { const [submissions, setSubmissions] = useState([]) const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading') - const [mailbox, setMailbox] = useState<'inbox' | 'archived'>('inbox') - const [selectedId, setSelectedId] = useState(null) + const [mailbox, setMailbox] = useState('inbox') + const [selectedThreadId, setSelectedThreadId] = useState(null) const [search, setSearch] = useState('') const [localReadIds, setLocalReadIds] = useState>(readIds) const [replyDraft, setReplyDraft] = useState(null) @@ -137,8 +230,19 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { const [actionMsg, setActionMsg] = useState('') const [showContactPicker, setShowContactPicker] = useState(false) const [contactPickerSearch, setContactPickerSearch] = useState('') + // Bulk selection + const [selectMode, setSelectMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + // Snooze popover + const [snoozeTargetId, setSnoozeTargetId] = useState(null) + const [snoozeCustom, setSnoozeCustom] = useState('') + // Schedule send + const [showSchedule, setShowSchedule] = useState(false) + const [scheduleCustom, setScheduleCustom] = useState('') + const composeRef = useRef(null) const listRef = useRef(null) + const now = new Date() // ── Load ── @@ -175,7 +279,6 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { useEffect(() => { loadAll() }, [loadAll]) - // 30s poll for new messages useEffect(() => { const id = setInterval(async () => { try { @@ -195,58 +298,105 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { return () => clearInterval(id) }, []) - // ── Derived ── + // ── Thread computation ── - const filtered = useMemo(() => { - const inMailbox = submissions.filter(s => mailbox === 'archived' ? s.archived === true : s.archived !== true) - const q = search.trim().toLowerCase() - if (!q) return inMailbox - return inMailbox.filter(s => { - const subj = extractSubject(s.message).toLowerCase() - return ( - s.name.toLowerCase().includes(q) || - s.email.toLowerCase().includes(q) || - subj.includes(q) || - s.message.toLowerCase().includes(q) - ) + const allThreads = useMemo(() => buildThreads(submissions, history), [submissions, history]) + + // Snooze check: messages whose snoozedUntil has passed get re-shown in inbox + const effectiveSubmissions = useMemo(() => { + const nowMs = now.getTime() + return submissions.map(s => { + if (s.snoozedUntil && new Date(s.snoozedUntil).getTime() <= nowMs) { + return { ...s, snoozedUntil: null } + } + return s }) - }, [submissions, mailbox, search]) + }, [submissions, now]) - const selected = filtered.find(s => s.id === selectedId) ?? null + const filteredThreadIds = useMemo(() => { + // Re-build threads from effective submissions for filtering + const threads = buildThreads(effectiveSubmissions, history) + const q = search.trim().toLowerCase() - const inboxCount = submissions.filter(s => s.archived !== true).length - const unreadCount = submissions.filter(s => s.archived !== true && !localReadIds.has(s.id)).length + return [...threads.values()].filter(thread => { + const latest = thread.latest + const msgs = thread.messages - // Auto-select first on mailbox switch or after actions + if (mailbox === 'inbox') { + if (msgs.some(m => m.archived === true)) return false + if (msgs.some(m => m.snoozedUntil && new Date(m.snoozedUntil) > now)) return false + } else if (mailbox === 'starred') { + if (!msgs.some(m => m.starred === true)) return false + if (msgs.every(m => m.archived === true)) return false + } else if (mailbox === 'snoozed') { + if (!msgs.some(m => m.snoozedUntil && new Date(m.snoozedUntil) > now)) return false + } else if (mailbox === 'archived') { + if (msgs.every(m => m.archived !== true)) return false + } + + if (!q) return true + const subj = thread.subject.toLowerCase() + return ( + latest.name.toLowerCase().includes(q) || + latest.email.toLowerCase().includes(q) || + subj.includes(q) || + msgs.some(m => m.message.toLowerCase().includes(q)) + ) + }).sort((a, b) => new Date(b.latest.submittedAt).getTime() - new Date(a.latest.submittedAt).getTime()) + }, [effectiveSubmissions, history, mailbox, search, now]) + + const selectedThread = filteredThreadIds.find(t => t.threadId === selectedThreadId) ?? null + + // Get the full thread for the selected (including all messages) + const selectedFullThread = selectedThread + ? allThreads.get(selectedThread.threadId) ?? selectedThread + : null + + const inboxCount = useMemo(() => { + const threads = buildThreads(effectiveSubmissions, history) + return [...threads.values()].filter(t => + t.messages.every(m => m.archived !== true) && + t.messages.every(m => !m.snoozedUntil || new Date(m.snoozedUntil) <= now) + ).length + }, [effectiveSubmissions, history, now]) + + const unreadCount = useMemo(() => { + const threads = buildThreads(effectiveSubmissions, history) + return [...threads.values()].filter(t => + t.messages.every(m => m.archived !== true) && + t.messages.every(m => !m.snoozedUntil || new Date(m.snoozedUntil) <= now) && + t.messages.some(m => !localReadIds.has(m.id)) + ).length + }, [effectiveSubmissions, history, localReadIds, now]) + + // Auto-select first on mailbox switch useEffect(() => { - if (filtered.length === 0) { setSelectedId(null); return } - if (!selectedId || !filtered.some(s => s.id === selectedId)) { - setSelectedId(filtered[0].id) + if (filteredThreadIds.length === 0) { setSelectedThreadId(null); return } + if (!selectedThreadId || !filteredThreadIds.some(t => t.threadId === selectedThreadId)) { + setSelectedThreadId(filteredThreadIds[0].threadId) } - }, [filtered, selectedId]) + }, [filteredThreadIds, selectedThreadId]) - // Mark as read when selected + // Mark as read when thread selected useEffect(() => { - if (!selectedId) return - if (localReadIds.has(selectedId)) return - persistReadId(selectedId) - setLocalReadIds(prev => { const n = new Set(prev); n.add(selectedId); return n }) - }, [selectedId, localReadIds]) + if (!selectedFullThread) return + const unread = selectedFullThread.messages.filter(m => !localReadIds.has(m.id)) + if (unread.length === 0) return + const newSet = new Set(localReadIds) + for (const m of unread) { persistReadId(m.id); newSet.add(m.id) } + setLocalReadIds(newSet) + }, [selectedFullThread]) - // Auto-scroll selected into view in list useEffect(() => { - if (!selectedId || !listRef.current) return - const el = listRef.current.querySelector(`[data-id="${selectedId}"]`) as HTMLElement | null + if (!selectedThreadId || !listRef.current) return + const el = listRef.current.querySelector(`[data-tid="${selectedThreadId}"]`) as HTMLElement | null el?.scrollIntoView({ block: 'nearest' }) - }, [selectedId]) + }, [selectedThreadId]) - // Focus compose textarea only when reply/compose first opens (not on every keystroke) const hadDraftRef = useRef(false) useEffect(() => { const hasDraft = Boolean(replyDraft) - if (hasDraft && !hadDraftRef.current) { - setTimeout(() => composeRef.current?.focus(), 50) - } + if (hasDraft && !hadDraftRef.current) setTimeout(() => composeRef.current?.focus(), 50) hadDraftRef.current = hasDraft }, [replyDraft]) @@ -256,36 +406,71 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { function onKey(e: KeyboardEvent) { const tag = (e.target as Element).tagName.toLowerCase() if (tag === 'input' || tag === 'textarea' || tag === 'select') return - const idx = filtered.findIndex(s => s.id === selectedId) + const idx = filteredThreadIds.findIndex(t => t.threadId === selectedThreadId) if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault() - if (idx < filtered.length - 1) setSelectedId(filtered[idx + 1].id) + if (idx < filteredThreadIds.length - 1) setSelectedThreadId(filteredThreadIds[idx + 1].threadId) } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault() - if (idx > 0) setSelectedId(filtered[idx - 1].id) - } else if (e.key === 'r' && selected && !replyDraft) { - openReply(selected) + if (idx > 0) setSelectedThreadId(filteredThreadIds[idx - 1].threadId) + } else if (e.key === 'r' && selectedThread && !replyDraft) { + openReply(selectedThread.latest) } else if (e.key === 'Escape' && replyDraft) { setReplyDraft(null) - } else if (e.key === 'e' && selected) { - handleArchive(selected.id, !(selected.archived === true)) + } else if (e.key === 'e' && selectedThread) { + const isArchived = selectedThread.messages.some(m => m.archived) + handleBulkAction([...selectedThread.messages.map(m => m.id)], isArchived ? 'unarchive' : 'archive') + } else if (e.key === 's' && selectedThread) { + const isStarred = selectedThread.messages.some(m => m.starred) + handleBulkAction([...selectedThread.messages.map(m => m.id)], isStarred ? 'unstar' : 'star') } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [filtered, selectedId, selected, replyDraft]) + }, [filteredThreadIds, selectedThreadId, selectedThread, replyDraft]) // ── Actions ── - async function handleArchive(id: string, archive: boolean) { + async function handlePatch(id: string, patch: Record) { const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ archived: archive }), + body: JSON.stringify(patch), }) - if (!res.ok) { flash('Failed to update.'); return } - setSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived: archive } : s)) - flash(archive ? 'Archived.' : 'Moved to inbox.') + if (!res.ok) { flash('Failed to update.'); return false } + setSubmissions(prev => prev.map(s => s.id === id ? { ...s, ...patch } as ContactSubmission : s)) + return true + } + + async function handleBulkAction(ids: string[], action: 'archive' | 'unarchive' | 'delete' | 'star' | 'unstar') { + if (action === 'delete' && !confirm(`Delete ${ids.length} message${ids.length !== 1 ? 's' : ''} permanently?`)) return + const res = await fetch('/api/admin-contact-submissions/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, action }), + }) + if (!res.ok) { flash('Failed to apply action.'); return } + setSubmissions(prev => { + const idSet = new Set(ids) + if (action === 'delete') return prev.filter(s => !idSet.has(s.id)) + const patch = action === 'archive' ? { archived: true } + : action === 'unarchive' ? { archived: false } + : action === 'star' ? { starred: true } + : { starred: false } + return prev.map(s => idSet.has(s.id) ? { ...s, ...patch } : s) + }) + setSelectedIds(new Set()) + if (action === 'delete') flash(`Deleted ${ids.length} message${ids.length !== 1 ? 's' : ''}.`) + else if (action === 'archive') flash(`Archived ${ids.length} message${ids.length !== 1 ? 's' : ''}.`) + else if (action === 'unarchive') flash(`Moved ${ids.length} to inbox.`) + else if (action === 'star') flash(`Starred ${ids.length} message${ids.length !== 1 ? 's' : ''}.`) + else flash(`Unstarred ${ids.length} message${ids.length !== 1 ? 's' : ''}.`) + } + + async function handleSnooze(id: string, snoozedUntil: string | null) { + await handlePatch(id, { snoozedUntil }) + setSnoozeTargetId(null) + flash(snoozedUntil ? `Snoozed until ${formatDate(snoozedUntil)}.` : 'Snooze cancelled.') } async function handleDelete(id: string) { @@ -308,27 +493,35 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { const defaultFrom = inboundTo.includes('nate@') ? REPLY_FROM_OPTIONS[1].value : REPLY_FROM_OPTIONS[0].value setReplyDraft({ submissionId: submission.id, + threadId: submission.threadId, recipientName: submission.name, recipientEmail: submission.email, - subject: subject ? `Re: ${subject}` : 'Re: Your message', + subject: subject ? `Re: ${normalizeSubject(subject) ? subject : subject}` : 'Re: Your message', message: '', fromAddress: defaultFrom, + scheduledAt: '', }) setReplyMsg('') + setShowSchedule(false) + setScheduleCustom('') flash(`Composing reply to ${firstName}…`) } function openCompose() { - setSelectedId(null) + setSelectedThreadId(null) setReplyDraft({ submissionId: null, + threadId: null, recipientName: '', recipientEmail: '', subject: '', message: '', fromAddress: REPLY_FROM_OPTIONS[0].value, + scheduledAt: '', }) setReplyMsg('') + setShowSchedule(false) + setScheduleCustom('') } function applyTemplate(templateId: string) { @@ -338,14 +531,15 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { setReplyDraft({ ...replyDraft, message: tpl.message }) } - async function handleSend() { + async function handleSend(overrideScheduledAt?: string) { if (!replyDraft) return setReplySending(true) setReplyMsg('') + const scheduledAt = overrideScheduledAt ?? replyDraft.scheduledAt ?? '' + try { if (replyDraft.submissionId) { - // Reply to existing submission const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(replyDraft.submissionId)}/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -353,14 +547,20 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { subject: replyDraft.subject, message: replyDraft.message, fromAddress: replyDraft.fromAddress, + scheduledAt: scheduledAt || null, }), }) if (!res.ok) { const d = await res.json().catch(() => ({})) as { message?: string } throw new Error(d.message ?? 'Failed to send.') } + const result = await res.json() as { scheduled?: boolean; scheduledAt?: string | null } + if (result.scheduled && result.scheduledAt) { + setReplyMsg(`Scheduled for ${formatDate(result.scheduledAt)}.`) + } else { + setReplyMsg(`Sent to ${replyDraft.recipientEmail}.`) + } } else { - // Compose new (no existing submission) const res = await fetch('/api/admin-email/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -370,19 +570,23 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { subject: replyDraft.subject, message: replyDraft.message, fromAddress: replyDraft.fromAddress, + scheduledAt: scheduledAt || null, }), }) if (!res.ok) { const d = await res.json().catch(() => ({})) as { message?: string } throw new Error(d.message ?? 'Failed to send.') } + const result = await res.json() as { scheduled?: boolean; scheduledAt?: string | null } + if (result.scheduled && result.scheduledAt) { + setReplyMsg(`Scheduled for ${formatDate(result.scheduledAt)}.`) + } else { + setReplyMsg(`Sent to ${replyDraft.recipientEmail}.`) + } } - const sentFrom = replyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? replyDraft.fromAddress - setReplyMsg(`Sent to ${replyDraft.recipientEmail} from ${sentFrom}.`) setReplyDraft(null) - - // Refresh history + setShowSchedule(false) fetch('/api/admin-contact-reply-history') .then(r => r.ok ? r.json() : null) .then(d => { if (d) setHistory((d as { items?: ReplyHistoryItem[] }).items ?? []) }) @@ -409,8 +613,43 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { } } + // ── Bulk selection helpers ── + + function toggleSelectAll() { + if (selectedIds.size === filteredThreadIds.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(filteredThreadIds.map(t => t.threadId))) + } + } + + function toggleSelectThread(tid: string) { + setSelectedIds(prev => { + const n = new Set(prev) + if (n.has(tid)) n.delete(tid) + else n.add(tid) + return n + }) + } + + function getSelectedMessageIds(): string[] { + return filteredThreadIds + .filter(t => selectedIds.has(t.threadId)) + .flatMap(t => t.messages.map(m => m.id)) + } + + // ── Build sorted thread messages for detail view ── + + function getConversationItems(thread: Thread): Array<{ type: 'inbound'; msg: ContactSubmission } | { type: 'sent'; item: ReplyHistoryItem }> { + const inbound = thread.messages.map(m => ({ type: 'inbound' as const, msg: m, date: new Date(m.submittedAt).getTime() })) + const sent = thread.sentMessages.map(i => ({ type: 'sent' as const, item: i, date: new Date(i.sentAt).getTime() })) + return [...inbound, ...sent].sort((a, b) => a.date - b.date) + } + // ── Render ── + const allSelectedMessageIds = getSelectedMessageIds() + return (
{/* Header */} @@ -420,9 +659,7 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { {unreadCount > 0 && {unreadCount} new}
- + Contacts Calendar ← Admin @@ -430,73 +667,100 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
+ {/* Bulk action bar */} + {selectMode && selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + + + +
+ )} + {/* Body */}
{/* Sidebar */}
@@ -542,26 +801,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { .slice(0, 40) return (
- setContactPickerSearch(e.target.value)} - /> + setContactPickerSearch(e.target.value)} />
{opts.length === 0 &&
No contacts found
} {opts.map(s => ( - @@ -573,32 +818,17 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
- setReplyDraft({ ...replyDraft, recipientName: e.target.value })} - /> + setReplyDraft({ ...replyDraft, recipientName: e.target.value })} />
- setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}> {REPLY_FROM_OPTIONS.map(o => )}
- setReplyDraft({ ...replyDraft, subject: e.target.value })} - /> + setReplyDraft({ ...replyDraft, subject: e.target.value })} />
{templates.length > 0 && (
@@ -609,125 +839,208 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
)} -