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:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.20",
|
"version": "1.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1109,11 +1109,23 @@ function sanitizeLoadedContactSubmissions(value) {
|
|||||||
messageType: normalizeMessageType(entry.messageType),
|
messageType: normalizeMessageType(entry.messageType),
|
||||||
subscribe: entry.subscribe === true,
|
subscribe: entry.subscribe === true,
|
||||||
archived: entry.archived === 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),
|
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
|
||||||
source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form',
|
source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form',
|
||||||
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
|
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
|
||||||
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
|
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
|
||||||
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
|
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 : '',
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -439,8 +439,14 @@ export function register(app) {
|
|||||||
|
|
||||||
const patch = {}
|
const patch = {}
|
||||||
if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
|
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?.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 (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
|
let found = false
|
||||||
state.contactSubmissions = state.contactSubmissions.map(item => {
|
state.contactSubmissions = state.contactSubmissions.map(item => {
|
||||||
@@ -473,6 +479,46 @@ export function register(app) {
|
|||||||
res.json({ ok: true })
|
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) => {
|
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
fromEmail: getResendReplyToAddress(),
|
fromEmail: getResendReplyToAddress(),
|
||||||
@@ -510,6 +556,7 @@ export function register(app) {
|
|||||||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||||||
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.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') {
|
if (!id || typeof id !== 'string') {
|
||||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
res.status(400).json({ message: 'Invalid submission id.' }); return
|
||||||
@@ -546,6 +593,7 @@ export function register(app) {
|
|||||||
to: [submission.email],
|
to: [submission.email],
|
||||||
subject,
|
subject,
|
||||||
replyTo: replyToAddress,
|
replyTo: replyToAddress,
|
||||||
|
...(scheduledAt ? { scheduledAt } : {}),
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'flow', value: 'admin-reply' },
|
{ name: 'flow', value: 'admin-reply' },
|
||||||
{ name: 'message_type', value: submission.messageType ?? 'general' },
|
{ name: 'message_type', value: submission.messageType ?? 'general' },
|
||||||
@@ -574,11 +622,12 @@ export function register(app) {
|
|||||||
subject,
|
subject,
|
||||||
preview: message.slice(0, 500),
|
preview: message.slice(0, 500),
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
|
scheduledAt: scheduledAt ?? null,
|
||||||
})
|
})
|
||||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||||
queueReplyHistoryWrite()
|
queueReplyHistoryWrite()
|
||||||
|
|
||||||
res.json({ ok: true })
|
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
|
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
|
||||||
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
|
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 subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||||||
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.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)) {
|
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
|
res.status(400).json({ message: 'A valid recipient email address is required.' }); return
|
||||||
@@ -630,6 +680,7 @@ export function register(app) {
|
|||||||
to: [to],
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
replyTo: replyToAddress,
|
replyTo: replyToAddress,
|
||||||
|
...(scheduledAt ? { scheduledAt } : {}),
|
||||||
tags: [{ name: 'flow', value: 'admin-reply' }],
|
tags: [{ name: 'flow', value: 'admin-reply' }],
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
@@ -645,11 +696,12 @@ export function register(app) {
|
|||||||
subject,
|
subject,
|
||||||
preview: message.slice(0, 500),
|
preview: message.slice(0, 500),
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
|
scheduledAt: scheduledAt ?? null,
|
||||||
})
|
})
|
||||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||||
queueReplyHistoryWrite()
|
queueReplyHistoryWrite()
|
||||||
|
|
||||||
res.json({ ok: true })
|
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[admin-compose] send error:', err)
|
console.error('[admin-compose] send error:', err)
|
||||||
res.status(500).json({ message: 'Failed to send email.' })
|
res.status(500).json({ message: 'Failed to send email.' })
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { state } from '../state.js'
|
|||||||
import { queueContactSubmissionsWrite } from '../data.js'
|
import { queueContactSubmissionsWrite } from '../data.js'
|
||||||
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||||||
|
|
||||||
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
|
||||||
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
||||||
|
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
|
||||||
|
|
||||||
function decodeHtmlBody(raw) {
|
function decodeHtmlBody(raw) {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return null
|
if (typeof raw !== 'string' || !raw.trim()) return null
|
||||||
const s = raw.trim()
|
const s = raw.trim()
|
||||||
if (s.startsWith('<')) return s // already plain HTML
|
if (s.startsWith('<')) return s
|
||||||
try {
|
try {
|
||||||
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
|
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
|
||||||
return decoded.trimStart().startsWith('<') ? decoded : s
|
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) {
|
export function register(app) {
|
||||||
app.post('/api/inbound-email', (req, res) => {
|
app.post('/api/inbound-email', (req, res) => {
|
||||||
const secret = process.env.INBOUND_EMAIL_SECRET
|
const secret = process.env.INBOUND_EMAIL_SECRET
|
||||||
@@ -32,20 +76,19 @@ export function register(app) {
|
|||||||
res.status(401).json({ message: 'Unauthorized.' }); return
|
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') {
|
if (!from || typeof from !== 'string') {
|
||||||
res.status(400).json({ message: 'Missing from address.' }); return
|
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 fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim())
|
||||||
const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim()
|
const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim()
|
||||||
const fromName = fromMatch ? fromMatch[1].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 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) {
|
if (normalizedMessageId) {
|
||||||
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
|
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
|
||||||
if (exists) {
|
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 = {
|
const submission = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
threadId,
|
||||||
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
|
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
|
||||||
name: fromName || fromEmail,
|
name: fromName || fromEmail,
|
||||||
email: 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),
|
htmlBody: decodeHtmlBody(htmlBody),
|
||||||
messageType: 'general',
|
messageType: 'general',
|
||||||
subscribe: false,
|
subscribe: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
|
starred: false,
|
||||||
|
snoozedUntil: null,
|
||||||
source: 'inbound-email',
|
source: 'inbound-email',
|
||||||
inboundTo: typeof to === 'string' ? to : '',
|
inboundTo: typeof to === 'string' ? to : '',
|
||||||
messageId: normalizedMessageId,
|
messageId: normalizedMessageId,
|
||||||
|
attachments: parseAttachments(rawAttachments),
|
||||||
emailStatus: {
|
emailStatus: {
|
||||||
welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
|
welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
|
||||||
adminNotification: { 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)
|
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||||
queueContactSubmissionsWrite()
|
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 })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+306
@@ -10945,6 +10945,312 @@
|
|||||||
}
|
}
|
||||||
.em-sig-edit-link:hover { color: #e0a020; }
|
.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) {
|
@media (max-width: 600px) {
|
||||||
.ct-card { flex-direction: column; gap: 0.6rem; }
|
.ct-card { flex-direction: column; gap: 0.6rem; }
|
||||||
.ct-card-actions { flex-direction: row; }
|
.ct-card-actions { flex-direction: row; }
|
||||||
|
|||||||
+578
-326
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user