Fix inbound-email data loss, MIME truncation, and header injection
- server/data.js: preserve source/htmlBody/inboundTo/messageId across server restarts (sanitizeLoadedContactSubmissions was silently dropping them on reload from disk) - cloudflare/email-worker.js: rewrite MIME parsing to split on the actual boundary marker instead of any literal "--", unfold multi-line headers, and correctly recombine multi-byte UTF-8 in quoted-printable decoding - server/routes/inbound-email.js: validate Message-ID against RFC 5322 grammar before storing/using it, and compare the webhook secret with timingSafeEqual to match the rest of the codebase's auth checks - server/routes/contact.js: re-validate messageId at the point it's injected into outgoing In-Reply-To/References headers; move the allowed reply-from addresses into a shared config constant - src/AdminPage.tsx: 30s inbox poll now syncs field updates (e.g. archived) on already-loaded submissions instead of only appending new ones; consolidate the duplicated from-address list - .claude/launch.json: add a vite dev server preview config used to verify these changes Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,13 @@
|
|||||||
"runtimeArgs": ["--env-file=.env", "server.js"],
|
"runtimeArgs": ["--env-file=.env", "server.js"],
|
||||||
"port": 4173,
|
"port": 4173,
|
||||||
"autoPort": false
|
"autoPort": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "siteforge-vite",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173,
|
||||||
|
"autoPort": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-36
@@ -60,23 +60,7 @@ async function streamToText(stream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseEmail(raw, message) {
|
function parseEmail(raw, message) {
|
||||||
const lines = raw.split(/\r?\n/)
|
const { headers, body } = splitHeadersAndBody(raw)
|
||||||
|
|
||||||
// Parse headers (everything before the first blank line)
|
|
||||||
const headers = {}
|
|
||||||
let bodyStart = 0
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (lines[i].trim() === '') {
|
|
||||||
bodyStart = i + 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const colon = lines[i].indexOf(':')
|
|
||||||
if (colon > 0) {
|
|
||||||
const key = lines[i].slice(0, colon).trim().toLowerCase()
|
|
||||||
const val = lines[i].slice(colon + 1).trim()
|
|
||||||
if (!headers[key]) headers[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
|
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
|
||||||
const from = message.from ?? headers['from'] ?? ''
|
const from = message.from ?? headers['from'] ?? ''
|
||||||
@@ -84,9 +68,7 @@ function parseEmail(raw, message) {
|
|||||||
const date = headers['date'] ?? new Date().toISOString()
|
const date = headers['date'] ?? new Date().toISOString()
|
||||||
const messageId = headers['message-id'] ?? ''
|
const messageId = headers['message-id'] ?? ''
|
||||||
|
|
||||||
const bodyLines = lines.slice(bodyStart)
|
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
|
||||||
const plainText = extractPart(raw, bodyLines, 'text/plain')
|
|
||||||
const htmlBody = extractPart(raw, bodyLines, 'text/html')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from,
|
from,
|
||||||
@@ -100,28 +82,111 @@ function parseEmail(raw, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPart(raw, bodyLines, contentType) {
|
// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
|
||||||
const escaped = contentType.replace('/', '\\/')
|
function splitHeadersAndBody(raw) {
|
||||||
const re = new RegExp(`Content-Type: ${escaped}[^\\r\\n]*\\r?\\n(?:[^\\r\\n]+\\r?\\n)*\\r?\\n([\\s\\S]*?)(?=--|$)`, 'i')
|
const match = /\r?\n\r?\n/.exec(raw)
|
||||||
const match = re.exec(raw)
|
const headerBlock = match ? raw.slice(0, match.index) : raw
|
||||||
if (match) return decodeEmailBody(match[1]).trim()
|
const body = match ? raw.slice(match.index + match[0].length) : ''
|
||||||
|
// RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
|
||||||
if (contentType === 'text/plain') {
|
const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
|
||||||
const joined = bodyLines.join('\n')
|
const headers = {}
|
||||||
if (/<[a-z][\s\S]*>/i.test(joined)) {
|
for (const line of unfolded.split(/\r?\n/)) {
|
||||||
return joined.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim()
|
const colon = line.indexOf(':')
|
||||||
|
if (colon <= 0) continue
|
||||||
|
const key = line.slice(0, colon).trim().toLowerCase()
|
||||||
|
const val = line.slice(colon + 1).trim()
|
||||||
|
if (!headers[key]) headers[key] = val
|
||||||
}
|
}
|
||||||
return joined.trim()
|
return { headers, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
|
||||||
|
function extractBodies(topContentType, topBody) {
|
||||||
|
let plainText = null
|
||||||
|
let htmlBody = null
|
||||||
|
|
||||||
|
function visit(contentType, body, transferEncoding) {
|
||||||
|
const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (type.startsWith('multipart/')) {
|
||||||
|
const boundary = getBoundary(contentType)
|
||||||
|
if (!boundary) return
|
||||||
|
for (const part of splitOnBoundary(body, boundary)) {
|
||||||
|
const { headers, body: partBody } = splitHeadersAndBody(part)
|
||||||
|
visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
const decoded = decodeBody(body, transferEncoding)
|
||||||
|
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
|
||||||
|
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(topContentType || 'text/plain', topBody, '')
|
||||||
|
|
||||||
|
if (plainText === null && htmlBody !== null) {
|
||||||
|
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim()
|
||||||
|
}
|
||||||
|
if (plainText === null) plainText = ''
|
||||||
|
|
||||||
|
return { plainText, htmlBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoundary(contentType) {
|
||||||
|
const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
|
||||||
|
if (!match) return null
|
||||||
|
return match[1] ?? match[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
|
||||||
|
function splitOnBoundary(body, boundary) {
|
||||||
|
const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
|
||||||
|
const parts = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let started = false
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(body)) !== null) {
|
||||||
|
if (started) parts.push(body.slice(lastIndex, match.index))
|
||||||
|
started = true
|
||||||
|
lastIndex = match.index + match[0].length
|
||||||
|
if (match[1]) break // final boundary: "--boundary--"
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBody(text, transferEncoding) {
|
||||||
|
const enc = (transferEncoding || '').trim().toLowerCase()
|
||||||
|
if (enc === 'base64') {
|
||||||
|
try {
|
||||||
|
const binary = atob(text.replace(/\s+/g, ''))
|
||||||
|
return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enc === 'quoted-printable') {
|
||||||
|
return decodeEmailBody(text)
|
||||||
|
}
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeEmailBody(text) {
|
function decodeEmailBody(text) {
|
||||||
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks)
|
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
|
||||||
return text
|
// Decode into raw bytes first, then run through TextDecoder so multi-byte
|
||||||
.replace(/=\r?\n/g, '')
|
// UTF-8 sequences split across multiple =XX escapes recombine correctly.
|
||||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
const unfolded = text.replace(/=\r?\n/g, '')
|
||||||
|
const bytes = []
|
||||||
|
for (let i = 0; i < unfolded.length; i++) {
|
||||||
|
if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
|
||||||
|
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
bytes.push(unfolded.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeHeaderValue(value) {
|
function decodeHeaderValue(value) {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyverse
|
|||||||
export const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
|
export const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
|
||||||
export const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
|
export const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
|
||||||
export const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
|
export const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
|
||||||
|
export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewithnate.us>'
|
||||||
|
// Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx.
|
||||||
|
export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM]
|
||||||
|
|
||||||
export const DEFAULT_SEO = {
|
export const DEFAULT_SEO = {
|
||||||
title: 'Verse by Verse with Nate',
|
title: 'Verse by Verse with Nate',
|
||||||
|
|||||||
@@ -1048,6 +1048,10 @@ function sanitizeLoadedContactSubmissions(value) {
|
|||||||
subscribe: entry.subscribe === true,
|
subscribe: entry.subscribe === true,
|
||||||
archived: entry.archived === true,
|
archived: entry.archived === true,
|
||||||
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',
|
||||||
|
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 : '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
USE_RESEND_AUTOMATION_WELCOME,
|
USE_RESEND_AUTOMATION_WELCOME,
|
||||||
DEFAULT_SEO,
|
DEFAULT_SEO,
|
||||||
ADMIN_REPLY_FROM,
|
ADMIN_REPLY_FROM,
|
||||||
|
ADMIN_REPLY_FROM_OPTIONS,
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,9 @@ import {
|
|||||||
syncContactToResend,
|
syncContactToResend,
|
||||||
} from '../email.js'
|
} from '../email.js'
|
||||||
|
|
||||||
|
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
||||||
|
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
||||||
|
|
||||||
function upsertContactEmailStatus(submissionId, stream, patch) {
|
function upsertContactEmailStatus(submissionId, stream, patch) {
|
||||||
if (!submissionId || typeof submissionId !== 'string') return
|
if (!submissionId || typeof submissionId !== 'string') return
|
||||||
if (!stream || typeof stream !== 'string') return
|
if (!stream || typeof stream !== 'string') return
|
||||||
@@ -479,11 +483,7 @@ export function register(app) {
|
|||||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
const html = buildAdminReplyTemplate({ recipientName, message })
|
||||||
const replyToAddress = getResendReplyToAddress()
|
const replyToAddress = getResendReplyToAddress()
|
||||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||||
const ALLOWED_FROM = [
|
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||||
'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
|
||||||
'Verse by Verse with Nate <nate@versebyversewithnate.us>',
|
|
||||||
]
|
|
||||||
const fromAddress = ALLOWED_FROM.includes(requestedFrom) ? requestedFrom : defaultFrom
|
|
||||||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
|
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ export function register(app) {
|
|||||||
],
|
],
|
||||||
headers: {
|
headers: {
|
||||||
'X-Contact-Submission-Id': submission.id,
|
'X-Contact-Submission-Id': submission.id,
|
||||||
...(submission.messageId ? {
|
...(typeof submission.messageId === 'string' && MESSAGE_ID_RE.test(submission.messageId) ? {
|
||||||
'In-Reply-To': submission.messageId,
|
'In-Reply-To': submission.messageId,
|
||||||
'References': submission.messageId,
|
'References': submission.messageId,
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||||
import { state } from '../state.js'
|
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]+>$/
|
||||||
|
|
||||||
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
|
||||||
@@ -11,7 +14,9 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provided = req.get('x-webhook-secret') ?? ''
|
const provided = req.get('x-webhook-secret') ?? ''
|
||||||
if (!provided || provided !== 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
|
res.status(401).json({ message: 'Unauthorized.' }); return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +31,11 @@ export function register(app) {
|
|||||||
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() : ''
|
||||||
|
|
||||||
// Deduplicate by messageId if provided
|
// Deduplicate by messageId if provided
|
||||||
if (messageId && typeof messageId === 'string' && messageId.trim()) {
|
if (normalizedMessageId) {
|
||||||
const exists = state.contactSubmissions.some(s => s.messageId === messageId.trim())
|
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
res.json({ ok: true, duplicate: true }); return
|
res.json({ ok: true, duplicate: true }); return
|
||||||
}
|
}
|
||||||
@@ -46,7 +53,7 @@ export function register(app) {
|
|||||||
archived: false,
|
archived: false,
|
||||||
source: 'inbound-email',
|
source: 'inbound-email',
|
||||||
inboundTo: typeof to === 'string' ? to : '',
|
inboundTo: typeof to === 'string' ? to : '',
|
||||||
messageId: typeof messageId === 'string' ? messageId.trim() : '',
|
messageId: normalizedMessageId,
|
||||||
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 },
|
||||||
|
|||||||
+15
-6
@@ -10,6 +10,12 @@ import { AnalyticsPanel } from './components/AnalyticsPanel'
|
|||||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||||
import { useAutosave } from './hooks/useAutosave'
|
import { useAutosave } from './hooks/useAutosave'
|
||||||
|
|
||||||
|
// Addresses an admin may send a reply from — must stay in sync with ADMIN_REPLY_FROM_OPTIONS in server/config.js.
|
||||||
|
const REPLY_FROM_OPTIONS = [
|
||||||
|
{ value: 'Verse by Verse with Nate <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
|
||||||
|
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', label: 'nate@versebyversewithnate.us' },
|
||||||
|
]
|
||||||
|
|
||||||
interface SortableLessonSectionProps {
|
interface SortableLessonSectionProps {
|
||||||
section: ColossiansStudySection
|
section: ColossiansStudySection
|
||||||
study: StudyProgram
|
study: StudyProgram
|
||||||
@@ -1428,7 +1434,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
setContactStatus('ready')
|
setContactStatus('ready')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for new messages every 30 seconds — only prepend genuinely new ones
|
// Poll for new messages every 30 seconds — prepend new ones and refresh fields (e.g. archived) on existing ones
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1437,9 +1443,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const data = await r.json() as { submissions?: ContactSubmission[] }
|
const data = await r.json() as { submissions?: ContactSubmission[] }
|
||||||
const fresh = Array.isArray(data.submissions) ? data.submissions : []
|
const fresh = Array.isArray(data.submissions) ? data.submissions : []
|
||||||
setContactSubmissions(prev => {
|
setContactSubmissions(prev => {
|
||||||
|
const freshById = new Map(fresh.map(s => [s.id, s]))
|
||||||
const existingIds = new Set(prev.map(s => s.id))
|
const existingIds = new Set(prev.map(s => s.id))
|
||||||
|
const merged = prev.map(s => freshById.get(s.id) ?? s)
|
||||||
const newOnes = fresh.filter(s => !existingIds.has(s.id))
|
const newOnes = fresh.filter(s => !existingIds.has(s.id))
|
||||||
return newOnes.length > 0 ? [...newOnes, ...prev] : prev
|
return newOnes.length > 0 ? [...newOnes, ...merged] : merged
|
||||||
})
|
})
|
||||||
} catch { /* silent — don't disrupt the UI */ }
|
} catch { /* silent — don't disrupt the UI */ }
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
@@ -2755,8 +2763,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
// Default reply-from to whichever address the email was sent to
|
// Default reply-from to whichever address the email was sent to
|
||||||
const inboundTo = submission.inboundTo ?? ''
|
const inboundTo = submission.inboundTo ?? ''
|
||||||
const defaultFrom = inboundTo.includes('nate@')
|
const defaultFrom = inboundTo.includes('nate@')
|
||||||
? 'Verse by Verse with Nate <nate@versebyversewithnate.us>'
|
? REPLY_FROM_OPTIONS[1].value
|
||||||
: 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
: REPLY_FROM_OPTIONS[0].value
|
||||||
setContactReplyDraft({
|
setContactReplyDraft({
|
||||||
submissionId: submission.id,
|
submissionId: submission.id,
|
||||||
recipientName: submission.name,
|
recipientName: submission.name,
|
||||||
@@ -5150,8 +5158,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
value={contactReplyDraft.fromAddress}
|
value={contactReplyDraft.fromAddress}
|
||||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)}
|
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)}
|
||||||
>
|
>
|
||||||
<option value="Verse by Verse with Nate <hello@versebyversewithnate.us>">hello@versebyversewithnate.us</option>
|
{REPLY_FROM_OPTIONS.map(option => (
|
||||||
<option value="Verse by Verse with Nate <nate@versebyversewithnate.us>">nate@versebyversewithnate.us</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
|
|||||||
Reference in New Issue
Block a user