From 1d536827e3fccc35f9bbc01e35ae60a704cf8525 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 1 Jun 2026 12:56:26 -0400 Subject: [PATCH] Improve admin UX and autosave handling --- data/admin-content-draft.json | 171 +++++--- public/sitemap.xml | 42 +- server.js | 787 +++++++++++++++++++++++++++------- src/AdminPage.tsx | 770 +++++++++++++++++++++++++-------- src/App.css | 150 ++++++- 5 files changed, 1504 insertions(+), 416 deletions(-) diff --git a/data/admin-content-draft.json b/data/admin-content-draft.json index 0798342..2ad08af 100644 --- a/data/admin-content-draft.json +++ b/data/admin-content-draft.json @@ -54,7 +54,15 @@ "slug": "colossians", "title": "Colossians: Rooted in Christ", "description": "Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.", + "homepageEyebrow": "", + "showOnHomepage": false, + "showNewTag": false, + "newTagLabel": "NEW", "status": "active", + "difficulty": "beginner", + "estimatedHours": 8, + "completionBadge": "", + "numberOfChapters": 1, "sections": [ { "id": "1-1-2", @@ -65,10 +73,6 @@ "passageText": "v. 1 Paul, an apostle of Christ Jesus by the will of God, and Timothy our brother,\n\nv. 2 To the saints in Colossae, the faithful brothers in Christ: Grace and peace to you from God our Father.", "summary": "Paul opens with apostolic authority and warm fellowship, greeting the saints at Colossae with the twin gifts that frame every believer's life: grace and peace.", "commentary": "Paul opens with his credentials: an apostle of Christ Jesus. But the manner of the claim matters - he immediately attributes this not to his own merit but to the will of God. This is the same humility that runs beneath the letter's entire argument: everything comes from God, not from human achievement. Timothy is included not as a co-author but as a fellow laborer, identified simply as 'our brother' - the relational warmth is immediate. The recipients are addressed as 'saints' - the Greek hagios means holy ones, set apart by God - and as 'faithful brothers in Christ.' These are not terms of flattery but of theological definition: these are people who belong to God. Paul's greeting, 'grace and peace,' is more than convention. Grace (charis) is God's unmerited favor; peace (eirene) is the wholeness and well-being that flows from it. Every believer's life is framed by these two realities.", - "studyQuestions": [ - "Why does Paul begin with grace and peace?", - "What tone does this create for the rest of the study?" - ], "greekNotes": [ "Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.", "Will (thelema): desire or purpose; Paul was not self-appointed but called by God’s initiative.", @@ -77,6 +81,10 @@ "Grace (charis): God's unmerited favor that initiates and sustains the believer's life.", "Peace (eirene): wholeness and well-being that flows from reconciliation with God." ], + "studyQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], "releasedAt": "2026-11-09T00:00:00Z" }, { @@ -88,11 +96,11 @@ "passageText": "", "summary": "Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.", "commentary": "The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.", + "greekNotes": [], "studyQuestions": [ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "greekNotes": [], "releasedAt": "2026-11-23T00:00:00Z" }, { @@ -104,11 +112,11 @@ "passageText": "", "summary": "Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.", "commentary": "This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know God’s will and walk in a way that reflects it.", + "greekNotes": [], "studyQuestions": [ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], - "greekNotes": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -120,11 +128,11 @@ "passageText": "", "summary": "Christ is supreme over creation, the church, and reconciliation.", "commentary": "This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.", + "greekNotes": [], "studyQuestions": [ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], - "greekNotes": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -136,11 +144,11 @@ "passageText": "", "summary": "Paul explains how Christ’s work changes alienation into steadfast faith.", "commentary": "The gospel moves from doctrine to real life. Paul shows what reconciliation looks like for people who have been brought near to God.", + "greekNotes": [], "studyQuestions": [ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], - "greekNotes": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -152,11 +160,11 @@ "passageText": "", "summary": "Paul describes his labor to proclaim Christ and present believers mature in Him.", "commentary": "Paul’s ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.", + "greekNotes": [], "studyQuestions": [ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], - "greekNotes": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -168,11 +176,11 @@ "passageText": "", "summary": "Paul reveals his struggle for the believers and his desire for encouragement and unity.", "commentary": "This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.", + "greekNotes": [], "studyQuestions": [ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], - "greekNotes": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -184,11 +192,11 @@ "passageText": "", "summary": "Paul calls believers to continue in Christ and resist deceptive teaching.", "commentary": "The Christian life begins in Christ and must continue in Christ. Paul warns that subtle deception can slowly pull believers off course.", + "greekNotes": [], "studyQuestions": [ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], - "greekNotes": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -200,11 +208,11 @@ "passageText": "", "summary": "The fullness of deity dwells in Christ, and believers are made complete in Him.", "commentary": "This section corrects the idea that anything must be added to Christ. He is fully sufficient for the believer’s life and growth.", + "greekNotes": [], "studyQuestions": [ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], - "greekNotes": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -216,11 +224,11 @@ "passageText": "", "summary": "Paul explains the believer’s union with Christ in His death, resurrection, and victory.", "commentary": "The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.", + "greekNotes": [], "studyQuestions": [ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], - "greekNotes": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -232,11 +240,11 @@ "passageText": "", "summary": "Paul warns against letting others judge believers over shadows when Christ is the substance.", "commentary": "Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.", + "greekNotes": [], "studyQuestions": [ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], - "greekNotes": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -248,11 +256,11 @@ "passageText": "", "summary": "Paul warns against prideful spirituality that disconnects believers from Christ the head.", "commentary": "Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.", + "greekNotes": [], "studyQuestions": [ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], - "greekNotes": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -264,11 +272,11 @@ "passageText": "", "summary": "Human rules cannot produce true transformation.", "commentary": "External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.", + "greekNotes": [], "studyQuestions": [ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], - "greekNotes": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -280,11 +288,11 @@ "passageText": "", "summary": "Paul calls believers to set their hearts and minds on Christ.", "commentary": "Identity in Christ leads to a new direction for the mind and heart. The believer’s life is hidden with Christ, so priorities shift upward.", + "greekNotes": [], "studyQuestions": [ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], - "greekNotes": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -296,11 +304,11 @@ "passageText": "", "summary": "Paul calls believers to put away the practices of the old life.", "commentary": "Christian growth includes real moral change. Paul’s language is strong because the old life cannot be carried into the new one.", + "greekNotes": [], "studyQuestions": [ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], - "greekNotes": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -312,11 +320,11 @@ "passageText": "", "summary": "Believers are renewed in Christ and formed into one new people.", "commentary": "Renewal in Christ changes both personal identity and community life. The new self is not merely private; it is shared by the body of Christ.", + "greekNotes": [], "studyQuestions": [ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], - "greekNotes": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -328,11 +336,11 @@ "passageText": "", "summary": "Paul lists the virtues believers should wear as Christ’s character becomes visible in them.", "commentary": "The image of clothing makes virtue practical and visible. Paul wants Christ’s character to define the way believers live together.", + "greekNotes": [], "studyQuestions": [ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], - "greekNotes": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -344,11 +352,11 @@ "passageText": "", "summary": "Paul calls the church to let Christ’s peace rule and His word dwell richly among them.", "commentary": "Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.", + "greekNotes": [], "studyQuestions": [ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], - "greekNotes": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -360,11 +368,11 @@ "passageText": "", "summary": "Paul applies the new life in Christ to family relationships.", "commentary": "The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.", + "greekNotes": [], "studyQuestions": [ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], - "greekNotes": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -376,11 +384,11 @@ "passageText": "", "summary": "Paul instructs believers to serve faithfully and wholeheartedly.", "commentary": "Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.", + "greekNotes": [], "studyQuestions": [ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], - "greekNotes": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -392,11 +400,11 @@ "passageText": "", "summary": "Paul reminds masters to act justly because they too answer to the Lord.", "commentary": "The gospel confronts authority as well as submission. All authority is accountable to Christ.", + "greekNotes": [], "studyQuestions": [ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], - "greekNotes": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -408,11 +416,11 @@ "passageText": "", "summary": "Paul closes with a call to steadfast prayer and wise witness toward outsiders.", "commentary": "Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.", + "greekNotes": [], "studyQuestions": [ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], - "greekNotes": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -424,11 +432,11 @@ "passageText": "", "summary": "Paul sends trusted messengers to encourage the church and bring news from the ministry field.", "commentary": "These greetings reveal the relational side of ministry. The letter is carried by real people who serve the church with loyalty and care.", + "greekNotes": [], "studyQuestions": [ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], - "greekNotes": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -440,11 +448,11 @@ "passageText": "", "summary": "Paul names the coworkers who are with him and commends their ministry support.", "commentary": "The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.", + "greekNotes": [], "studyQuestions": [ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], - "greekNotes": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -456,32 +464,30 @@ "passageText": "", "summary": "Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.", "commentary": "The letter ends with community, responsibility, and grace. Paul’s final words keep the church focused on the mission it has received.", + "greekNotes": [], "studyQuestions": [ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], - "greekNotes": [], "releasedAt": "2027-10-11T00:00:00Z" } - ], - "homepageEyebrow": "", - "showOnHomepage": false, - "showNewTag": false, - "newTagLabel": "NEW", - "numberOfChapters": 1 + ] }, { "id": "study-titus", "slug": "titus", "title": "Titus: Sound Doctrine", "description": "A full online class version of Titus with lesson-by-lesson notes and discussion.", - "status": "planned", - "sections": [], "homepageEyebrow": "", "showOnHomepage": false, "showNewTag": false, "newTagLabel": "NEW", - "numberOfChapters": 1 + "status": "planned", + "difficulty": "beginner", + "estimatedHours": 8, + "completionBadge": "", + "numberOfChapters": 1, + "sections": [] } ], "colossiansStudySections": [ @@ -505,7 +511,8 @@ "studyQuestions": [ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" - ] + ], + "releasedAt": "2026-11-09T00:00:00Z" }, { "id": "1-3-8", @@ -520,7 +527,8 @@ "studyQuestions": [ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" - ] + ], + "releasedAt": "2026-11-23T00:00:00Z" }, { "id": "1-9-14", @@ -535,7 +543,8 @@ "studyQuestions": [ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" - ] + ], + "releasedAt": "2026-12-07T00:00:00Z" }, { "id": "1-15-20", @@ -550,7 +559,8 @@ "studyQuestions": [ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" - ] + ], + "releasedAt": "2026-12-21T00:00:00Z" }, { "id": "1-21-23", @@ -565,7 +575,8 @@ "studyQuestions": [ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" - ] + ], + "releasedAt": "2027-01-04T00:00:00Z" }, { "id": "1-24-29", @@ -580,7 +591,8 @@ "studyQuestions": [ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" - ] + ], + "releasedAt": "2027-01-18T00:00:00Z" }, { "id": "2-1-5", @@ -595,7 +607,8 @@ "studyQuestions": [ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" - ] + ], + "releasedAt": "2027-02-01T00:00:00Z" }, { "id": "2-6-8", @@ -610,7 +623,8 @@ "studyQuestions": [ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" - ] + ], + "releasedAt": "2027-02-15T00:00:00Z" }, { "id": "2-9-10", @@ -625,7 +639,8 @@ "studyQuestions": [ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" - ] + ], + "releasedAt": "2027-03-01T00:00:00Z" }, { "id": "2-11-15", @@ -640,7 +655,8 @@ "studyQuestions": [ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" - ] + ], + "releasedAt": "2027-03-15T00:00:00Z" }, { "id": "2-16-17", @@ -655,7 +671,8 @@ "studyQuestions": [ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" - ] + ], + "releasedAt": "2027-03-29T00:00:00Z" }, { "id": "2-18-19", @@ -670,7 +687,8 @@ "studyQuestions": [ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" - ] + ], + "releasedAt": "2027-04-12T00:00:00Z" }, { "id": "2-20-23", @@ -685,7 +703,8 @@ "studyQuestions": [ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" - ] + ], + "releasedAt": "2027-04-26T00:00:00Z" }, { "id": "3-1-4", @@ -700,7 +719,8 @@ "studyQuestions": [ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" - ] + ], + "releasedAt": "2027-05-10T00:00:00Z" }, { "id": "3-5-9", @@ -715,7 +735,8 @@ "studyQuestions": [ "Which old patterns does Paul name?", "Why is decisive change necessary?" - ] + ], + "releasedAt": "2027-05-24T00:00:00Z" }, { "id": "3-10-11", @@ -730,7 +751,8 @@ "studyQuestions": [ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" - ] + ], + "releasedAt": "2027-06-07T00:00:00Z" }, { "id": "3-12-14", @@ -745,7 +767,8 @@ "studyQuestions": [ "Which virtues are most needed in your context?", "How does love bind these virtues together?" - ] + ], + "releasedAt": "2027-06-21T00:00:00Z" }, { "id": "3-15-17", @@ -760,7 +783,8 @@ "studyQuestions": [ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" - ] + ], + "releasedAt": "2027-07-05T00:00:00Z" }, { "id": "3-18-21", @@ -775,7 +799,8 @@ "studyQuestions": [ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" - ] + ], + "releasedAt": "2027-07-19T00:00:00Z" }, { "id": "3-22-25", @@ -790,7 +815,8 @@ "studyQuestions": [ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" - ] + ], + "releasedAt": "2027-08-02T00:00:00Z" }, { "id": "4-1", @@ -805,7 +831,8 @@ "studyQuestions": [ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" - ] + ], + "releasedAt": "2027-08-16T00:00:00Z" }, { "id": "4-2-6", @@ -820,7 +847,8 @@ "studyQuestions": [ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" - ] + ], + "releasedAt": "2027-08-30T00:00:00Z" }, { "id": "4-7-9", @@ -835,7 +863,8 @@ "studyQuestions": [ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" - ] + ], + "releasedAt": "2027-09-13T00:00:00Z" }, { "id": "4-10-14", @@ -850,7 +879,8 @@ "studyQuestions": [ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" - ] + ], + "releasedAt": "2027-09-27T00:00:00Z" }, { "id": "4-15-18", @@ -865,11 +895,24 @@ "studyQuestions": [ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" - ] + ], + "releasedAt": "2027-10-11T00:00:00Z" } ], "shareHeading": "Help one more person hear the Word this week.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", + "prismVideoUrl": "/images/PRISM.mp4", + "prismEyebrow": "Featured Video", + "prismHeading": "PRISM", + "prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?", + "prismPatternIntro": "In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.", + "prismStep1": "P — Pray before you read", + "prismStep2": "R — Read slowly and observe", + "prismStep3": "I — Interpret with context", + "prismStep4": "S — Study and apply specifically", + "prismStep5": "M — Memorize one verse at a time", + "prismOutro": "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day.", + "prismClosing": "The goal isn't to get through the text - it's to let the text get through to you.", "whereToNextEyebrow": "Where to Next", "whereToNextHeading": "Choose the page you need.", "whereToNextCards": [ @@ -1003,5 +1046,5 @@ ] } }, - "updatedAt": "2026-05-12T16:46:21.985Z" -} + "updatedAt": "2026-06-01T16:33:33.885Z" +} \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml index 03bb24b..8f69566 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,37 +2,67 @@ https://versebyversewithnate.us/ - 2026-04-27 + 2026-06-01 weekly 1.0 https://versebyversewithnate.us/start-here - 2026-04-27 + 2026-06-01 monthly 0.8 + + https://versebyversewithnate.us/episodes + 2026-06-01 + weekly + 0.9 + + + https://versebyversewithnate.us/study + 2026-06-01 + weekly + 0.85 + + + https://versebyversewithnate.us/resources + 2026-06-01 + weekly + 0.8 + https://versebyversewithnate.us/questions - 2026-04-27 + 2026-06-01 weekly 0.7 + + https://versebyversewithnate.us/about + 2026-06-01 + monthly + 0.6 + + + https://versebyversewithnate.us/contact + 2026-06-01 + monthly + 0.6 + https://versebyversewithnate.us/subscribe - 2026-04-27 + 2026-06-01 monthly 0.6 https://versebyversewithnate.us/privacy - 2026-04-27 + 2026-06-01 yearly 0.3 https://versebyversewithnate.us/terms - 2026-04-27 + 2026-06-01 yearly 0.3 diff --git a/server.js b/server.js index 2c7aad3..09518b2 100644 --- a/server.js +++ b/server.js @@ -610,6 +610,7 @@ const EMPTY_VISITOR_STATS = { } const MAX_CONTACT_SUBMISSIONS = 5000 +const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000) const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 const titusDownloadTokens = new Map() @@ -625,6 +626,8 @@ let visitorStats = { ...EMPTY_VISITOR_STATS } let visitorStatsWritePromise = Promise.resolve() let contactSubmissions = [] let contactSubmissionsWritePromise = Promise.resolve() +const contactSubmitCooldownByEmail = new Map() +const resendEmailSubmissionIndex = new Map() let questions = [] let questionsWritePromise = Promise.resolve() let studyUsers = [] @@ -780,9 +783,7 @@ function loadContactSubmissionsFromDisk() { return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') .then(raw => { const parsed = JSON.parse(raw) - contactSubmissions = Array.isArray(parsed?.submissions) - ? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS) - : [] + contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions) }) .catch(() => { contactSubmissions = [] @@ -841,8 +842,186 @@ function normalizeMessageType(value) { return 'general' } +function createEmailDeliveryState(status = 'pending') { + return { + status, + lastEventAt: null, + lastEventType: null, + resendEmailId: null, + error: null, + } +} + +function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createEmailDeliveryState(fallbackStatus) + } + + return { + status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus, + lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null, + lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null, + resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null, + error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null, + } +} + +function normalizeContactEmailStatus(value, subscribe) { + const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + return { + welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'), + adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'), + adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'), + } +} + +function upsertContactEmailStatus(submissionId, stream, patch) { + if (!submissionId || typeof submissionId !== 'string') return + if (!stream || typeof stream !== 'string') return + const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString() + let updated = false + + contactSubmissions = contactSubmissions.map(submission => { + if (submission.id !== submissionId) return submission + const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true) + const current = normalizeEmailDeliveryState(next[stream], 'pending') + next[stream] = { + ...current, + ...patch, + lastEventAt: at, + } + updated = true + return { + ...submission, + emailStatus: next, + } + }) + + if (updated) { + queueContactSubmissionsWrite() + } +} + +function extractResendMessageId(result) { + if (!result || typeof result !== 'object') return '' + if (typeof result.id === 'string' && result.id.trim()) return result.id.trim() + if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) { + return result.data.id.trim() + } + return '' +} + +function registerResendMessageForSubmission(submissionId, stream, sendResult) { + const resendMessageId = extractResendMessageId(sendResult) + if (!resendMessageId || !submissionId || !stream) return + resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream }) + upsertContactEmailStatus(submissionId, stream, { + resendEmailId: resendMessageId, + }) +} + +function noteContactEmailCooldown(emailAddress) { + const normalized = String(emailAddress || '').trim().toLowerCase() + if (!normalized) return { ok: true, retryAfterMs: 0 } + + const now = Date.now() + const lastAt = contactSubmitCooldownByEmail.get(normalized) + if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) { + return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) } + } + + contactSubmitCooldownByEmail.set(normalized, now) + + // Prevent unbounded growth while keeping this in-memory cache simple. + if (contactSubmitCooldownByEmail.size > 8000) { + const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3 + for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) { + if (timestamp < cutoff) { + contactSubmitCooldownByEmail.delete(email) + } + } + } + + return { ok: true, retryAfterMs: 0 } +} + +function extractTagValue(tags, name) { + if (!Array.isArray(tags)) return '' + const target = String(name || '').trim().toLowerCase() + if (!target) return '' + for (const tag of tags) { + if (!tag || typeof tag !== 'object') continue + const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : '' + const value = typeof tag.value === 'string' ? tag.value.trim() : '' + if (key === target && value) return value + } + return '' +} + +function mapResendEventToStatus(eventType) { + const normalized = String(eventType || '').trim().toLowerCase() + if (!normalized) return 'updated' + if (normalized.includes('delivered')) return 'delivered' + if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed' + if (normalized.includes('bounce')) return 'bounced' + if (normalized.includes('complain')) return 'complained' + if (normalized.includes('click')) return 'clicked' + if (normalized.includes('open')) return 'opened' + if (normalized.includes('send')) return 'sent' + return 'updated' +} + +function getAddressDomain(addressValue) { + const raw = String(addressValue || '').trim() + if (!raw) return '' + const candidate = raw.includes('<') && raw.includes('>') + ? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim() + : raw + const at = candidate.lastIndexOf('@') + if (at <= 0 || at === candidate.length - 1) return '' + return candidate.slice(at + 1).toLowerCase() +} + +function logResendEmailAlignmentWarnings() { + const warnings = [] + const fromAddress = getResendFromAddress() + const replyToAddress = getResendReplyToAddress() + const fromDomain = getAddressDomain(fromAddress) + const replyDomain = getAddressDomain(replyToAddress) + const hasApiKey = Boolean(process.env.RESEND_API_KEY) + + if (!hasApiKey) { + warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.') + } + if (!fromDomain) { + warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.') + } + if (fromDomain.endsWith('resend.dev')) { + warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.') + } + if (fromDomain && replyDomain && fromDomain !== replyDomain) { + warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.') + } + if (!process.env.RESEND_WEBHOOK_TOKEN) { + warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.') + } + if (hasApiKey) { + warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.') + } + + if (warnings.length > 0) { + console.warn('[email-health] Resend alignment checks:') + for (const warning of warnings) { + console.warn(`[email-health] - ${warning}`) + } + } +} + const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' -const ADMIN_REPLY_FROM = 'Verse by Verse with Nate ' +const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate ' +const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us' +const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us' +const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM function shouldSendWelcomeEmail({ subscribe }) { return subscribe === true @@ -884,7 +1063,201 @@ function buildAdminReplyTemplate({ recipientName, message }) { ` } +function buildContactWelcomeEmailTemplate({ + greetingName, + welcomeIntro, + welcomeCurrentSeries, + welcomeStartHereTitle, + welcomeStartHereSummary, + welcomeExpect1, + welcomeExpect2, + welcomeExpect3, + welcomeScripture, + welcomeScriptureRef, + welcomeSignoff, + welcomeHeading, + welcomeSpotifyUrl, + welcomeAppleUrl, + welcomeAmazonUrl, + welcomeWebsiteUrl, + welcomeEpisodeUrl, + welcomeImageUrl, +}) { + const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') + + return { + text: + `Welcome to Verse by Verse with Nate!\n\n` + + `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + + `${welcomeIntro}\n\n` + + `${welcomeCurrentSeries}\n\n` + + `Start here: ${welcomeEpisodeUrl}\n` + + `${welcomeStartHereTitle}\n` + + `${welcomeStartHereSummary}\n` + + `Spotify: ${welcomeSpotifyUrl}\n` + + `Apple Podcasts: ${welcomeAppleUrl}\n` + + `Amazon Music: ${welcomeAmazonUrl}\n` + + `Website: ${welcomeWebsiteUrl}\n\n` + + `What to expect:\n` + + `- ${welcomeExpect1}\n` + + `- ${welcomeExpect2}\n` + + `- ${welcomeExpect3}\n\n` + + `${welcomeScripture}\n${welcomeScriptureRef}\n\n` + + `${welcomeSignoff}`, + html: + `
` + + `` + + `
` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
` + + `Verse by Verse with Nate` + + `

Verse by Verse with Nate

` + + `

Verse by verse. Nugget by nugget.

` + + `
` + + `

Welcome

` + + `

${welcomeHeading}

` + + `
` + + `
` + + `

${escapeHtml(welcomeIntro)}

` + + `

${escapeHtml(welcomeCurrentSeries)}

` + + `

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

` + + `
` + + `

Start here

` + + `

${escapeHtml(welcomeStartHereTitle)}

` + + `

${escapeHtml(welcomeStartHereSummary)}

` + + `` + + `` + + `` + + `
Listen on SpotifyApple Podcasts
` + + `

Open Start Here page

` + + `
` + + `

What to expect

` + + `

${escapeHtml(welcomeExpect1)}

` + + `

${escapeHtml(welcomeExpect2)}

` + + `

${escapeHtml(welcomeExpect3)}

` + + `
` + + `
` + + `

“${escapeHtml(welcomeScripture)}”

` + + `

${escapeHtml(welcomeScriptureRef)}

` + + `
` + + `
` + + `

Find the podcast on

` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
Spotify·Apple Podcasts·Amazon Music·Website
` + + `

You’re receiving this because you subscribed to Verse by Verse with Nate.

` + + `

${welcomeSignoffHtml}

` + + `
` + + `
` + + `
`, + } +} + +function buildContactAdminNotificationTemplate({ + normalizedMessageType, + trimmedName, + trimmedEmail, + submittedAt, + trimmedMessage, +}) { + return { + subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`, + text: + `New contact form submission\n\n` + + `Message Type: ${normalizedMessageType}\n` + + `Name: ${trimmedName}\n` + + `Email: ${trimmedEmail}\n` + + `Submitted: ${submittedAt}\n\n` + + `Message:\n${trimmedMessage}`, + html: + `
` + + `
` + + `
` + + `
Verse by Verse with Nate
` + + `

New Contact Form Submission

` + + `
` + + `
` + + `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + + `
` + + `
Message
` + + `
${escapeHtml(trimmedMessage)}
` + + `
` + + `
` + + `
` + + `
`, + } +} + +function getResendFromAddress() { + return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM +} + +function getResendReplyToAddress() { + return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO +} + +function getResendInboxAddress() { + return process.env.RESEND_TO ?? DEFAULT_RESEND_TO +} + +async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) { + let lastError = null + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const result = await resend.emails.send(payload) + if (!result?.error) return result + lastError = result.error + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } + } catch (err) { + lastError = err + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } + } + } + + throw lastError ?? new Error(`[${context}] email send failed`) +} + function addContactSubmission({ name, email, message, messageType, subscribe }) { + const wantsWelcome = subscribe === true const submission = { id: randomUUID(), submittedAt: new Date().toISOString(), @@ -892,8 +1265,9 @@ function addContactSubmission({ name, email, message, messageType, subscribe }) email, message, messageType: normalizeMessageType(messageType), - subscribe: subscribe === true, + subscribe: wantsWelcome, archived: false, + emailStatus: normalizeContactEmailStatus(null, wantsWelcome), } contactSubmissions.unshift(submission) @@ -1369,6 +1743,7 @@ function sanitizeLoadedContactSubmissions(value) { messageType: normalizeMessageType(entry.messageType), subscribe: entry.subscribe === true, archived: entry.archived === true, + emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), })) } @@ -3325,8 +3700,8 @@ app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => { res.json({ - fromEmail: 'hello@versebyversewithnate.us', - fromIdentity: ADMIN_REPLY_FROM, + fromEmail: getResendReplyToAddress(), + fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM, resendApiConfigured: Boolean(process.env.RESEND_API_KEY), canSendReplies: Boolean(process.env.RESEND_API_KEY), note: process.env.RESEND_API_KEY @@ -3407,26 +3782,44 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re const recipientName = splitName(submission.name).firstName || submission.name || 'friend' const html = buildAdminReplyTemplate({ recipientName, message }) - const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us` + const replyToAddress = getResendReplyToAddress() + const fromAddress = getResendFromAddress() + 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 { error } = await resend.emails.send({ - from: ADMIN_REPLY_FROM, - to: [submission.email], - subject, - replyTo: 'hello@versebyversewithnate.us', - text, - html, + const sendResult = await sendResendEmailWithRetry({ + resend, + context: 'admin-contact-reply', + payload: { + from: fromAddress || ADMIN_REPLY_FROM, + to: [submission.email], + subject, + replyTo: replyToAddress, + tags: [ + { name: 'flow', value: 'admin-reply' }, + { name: 'message_type', value: submission.messageType ?? 'general' }, + { name: 'submission_id', value: submission.id }, + ], + headers: { + 'X-Contact-Submission-Id': submission.id, + }, + text, + html, + }, + }) + registerResendMessageForSubmission(submission.id, 'adminReply', sendResult) + upsertContactEmailStatus(submission.id, 'adminReply', { + status: 'sent', + lastEventType: 'email.sent', + error: null, }) - - if (error) throw error replyHistory.unshift({ id: randomUUID(), submissionId: submission.id, toEmail: submission.email, toName: submission.name, - fromEmail: 'hello@versebyversewithnate.us', + fromEmail: replyToAddress, subject, preview: message.slice(0, 500), sentAt: new Date().toISOString(), @@ -3436,6 +3829,13 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re res.json({ ok: true }) } catch (err) { + if (typeof req.params?.id === 'string' && req.params.id.trim()) { + upsertContactEmailStatus(req.params.id.trim(), 'adminReply', { + status: 'failed', + lastEventType: 'email.failed', + error: String(err?.message ?? err ?? 'unknown error').slice(0, 600), + }) + } console.error('[admin-reply] send error:', err) res.status(500).json({ message: 'Failed to send reply email.' }) } @@ -3831,6 +4231,12 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { const trimmedEmail = email.trim() const trimmedMessage = message.trim() const normalizedMessageType = normalizeMessageType(messageType) + const cooldown = noteContactEmailCooldown(trimmedEmail) + if (!cooldown.ok) { + const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000)) + res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }) + return + } const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', @@ -3839,7 +4245,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { subscribe, }) - addContactSubmission({ + const submission = addContactSubmission({ name: trimmedName, email: trimmedEmail, message: trimmedMessage, @@ -3870,6 +4276,18 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { queueQuestionsWrite() } const resend = new Resend(process.env.RESEND_API_KEY) + const adminInbox = getResendInboxAddress() + const replyToAddress = getResendReplyToAddress() + const fromAddress = getResendFromAddress() + const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase() + const adminTemplate = buildContactAdminNotificationTemplate({ + normalizedMessageType, + trimmedName, + trimmedEmail, + submittedAt, + trimmedMessage, + }) + let welcomeSent = false if (subscribe === true) { await syncContactToResend(trimmedName, trimmedEmail) @@ -3908,7 +4326,6 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.' const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB' const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate' - const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') const welcomeSpotifyUrl = buildAbsoluteUrl( welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify', @@ -3934,156 +4351,209 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg', ) - const { error: welcomeError } = await resend.emails.send({ - from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [trimmedEmail], - subject: welcomeSubject, - text: - `Welcome to Verse by Verse with Nate!\n\n` + - `${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + - `${welcomeIntro}\n\n` + - `${welcomeCurrentSeries}\n\n` + - `Start here: ${welcomeEpisodeUrl}\n` + - `${welcomeStartHereTitle}\n` + - `${welcomeStartHereSummary}\n` + - `Spotify: ${welcomeSpotifyUrl}\n` + - `Apple Podcasts: ${welcomeAppleUrl}\n` + - `Amazon Music: ${welcomeAmazonUrl}\n` + - `Website: ${welcomeWebsiteUrl}\n\n` + - `What to expect:\n` + - `- ${welcomeExpect1}\n` + - `- ${welcomeExpect2}\n` + - `- ${welcomeExpect3}\n\n` + - `${welcomeScripture}\n${welcomeScriptureRef}\n\n` + - `${welcomeSignoff}`, - html: - `
` + - `` + - `
` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
` + - `Verse by Verse with Nate` + - `

Verse by Verse with Nate

` + - `

Verse by verse. Nugget by nugget.

` + - `
` + - `

Welcome

` + - `

${welcomeHeading}

` + - `
` + - `
` + - `

${escapeHtml(welcomeIntro)}

` + - `

${escapeHtml(welcomeCurrentSeries)}

` + - `

If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.

` + - `
` + - `

Start here

` + - `

${escapeHtml(welcomeStartHereTitle)}

` + - `

${escapeHtml(welcomeStartHereSummary)}

` + - `` + - `` + - `` + - `
Listen on SpotifyApple Podcasts
` + - `

Open Start Here page

` + - `
` + - `

What to expect

` + - `

${escapeHtml(welcomeExpect1)}

` + - `

${escapeHtml(welcomeExpect2)}

` + - `

${escapeHtml(welcomeExpect3)}

` + - `
` + - `
` + - `

“${escapeHtml(welcomeScripture)}”

` + - `

${escapeHtml(welcomeScriptureRef)}

` + - `
` + - `
` + - `

Find the podcast on

` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
Spotify·Apple Podcasts·Amazon Music·Website
` + - `

You’re receiving this because you subscribed to Verse by Verse with Nate.

` + - `

${welcomeSignoffHtml}

` + - `
` + - `
` + - `
`, + const welcomeTemplate = buildContactWelcomeEmailTemplate({ + greetingName, + welcomeIntro, + welcomeCurrentSeries, + welcomeStartHereTitle, + welcomeStartHereSummary, + welcomeExpect1, + welcomeExpect2, + welcomeExpect3, + welcomeScripture, + welcomeScriptureRef, + welcomeSignoff, + welcomeHeading, + welcomeSpotifyUrl, + welcomeAppleUrl, + welcomeAmazonUrl, + welcomeWebsiteUrl, + welcomeEpisodeUrl, + welcomeImageUrl, }) - if (welcomeError) throw welcomeError + try { + const welcomeSendResult = await sendResendEmailWithRetry({ + resend, + context: 'contact-welcome', + payload: { + from: fromAddress, + to: [trimmedEmail], + replyTo: replyToAddress, + subject: welcomeSubject, + tags: [ + { name: 'flow', value: 'contact-welcome' }, + { name: 'message_type', value: safeMessageTypeTag }, + { name: 'submission_id', value: submission.id }, + ], + headers: { + 'List-Unsubscribe': ``, + 'X-Contact-Submission-Id': submission.id, + }, + text: welcomeTemplate.text, + html: welcomeTemplate.html, + }, + }) + registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult) + upsertContactEmailStatus(submission.id, 'welcome', { + status: 'sent', + lastEventType: 'email.sent', + error: null, + }) + welcomeSent = true + } catch (welcomeErr) { + upsertContactEmailStatus(submission.id, 'welcome', { + status: 'failed', + lastEventType: 'email.failed', + error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600), + }) + throw welcomeErr + } + } else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) { + upsertContactEmailStatus(submission.id, 'welcome', { + status: 'automation-enabled', + lastEventType: 'email.automation.enabled', + error: null, + }) } - if (shouldSendWelcome) { - res.json({ ok: true }) - return - } - - const { error } = await resend.emails.send({ - from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', - to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'], + try { + const adminSendResult = await sendResendEmailWithRetry({ + resend, + context: 'contact-admin-notification', + payload: { + from: fromAddress, + to: [adminInbox], replyTo: trimmedEmail, - subject: `Verse by Verse contact form: ${trimmedName}`, - text: - `New contact form submission\n\n` + - `Message Type: ${normalizedMessageType}\n` + - `Name: ${trimmedName}\n` + - `Email: ${trimmedEmail}\n` + - `Submitted: ${submittedAt}\n\n` + - `Message:\n${trimmedMessage}`, - html: - `
` + - `
` + - `
` + - `
Verse by Verse with Nate
` + - `

New Contact Form Submission

` + - `
` + - `
` + - `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + - `
` + - `
Message
` + - `
${escapeHtml(trimmedMessage)}
` + - `
` + - `
` + - `
` + - `
`, - }) - if (error) throw error + subject: adminTemplate.subject, + tags: [ + { name: 'flow', value: 'contact-admin' }, + { name: 'message_type', value: safeMessageTypeTag }, + { name: 'submission_id', value: submission.id }, + ], + headers: { + 'X-Contact-Submission-Id': submission.id, + }, + text: adminTemplate.text, + html: adminTemplate.html, + }, + }) + registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult) + upsertContactEmailStatus(submission.id, 'adminNotification', { + status: 'sent', + lastEventType: 'email.sent', + error: null, + }) + } catch (adminSendErr) { + upsertContactEmailStatus(submission.id, 'adminNotification', { + status: 'failed', + lastEventType: 'email.failed', + error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600), + }) + throw adminSendErr + } - res.json({ ok: true }) + res.json({ + ok: true, + welcomeSent, + welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME, + }) } catch (err) { console.error('[contact] send error:', err) res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' }) } }) +app.post('/api/resend/webhook', (req, res) => { + const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : '' + if (!expectedToken) { + res.status(503).json({ message: 'Webhook token is not configured.' }) + return + } + + const providedToken = (req.get('x-webhook-token') || '').trim() + || (req.get('x-resend-webhook-token') || '').trim() + || String(req.query?.token || '').trim() + || (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim() + + if (!providedToken || providedToken !== expectedToken) { + res.status(401).json({ message: 'Unauthorized webhook.' }) + return + } + + const body = req.body && typeof req.body === 'object' ? req.body : {} + const eventType = typeof body.type === 'string' ? body.type.trim() : '' + const data = body.data && typeof body.data === 'object' ? body.data : {} + const tags = Array.isArray(data.tags) ? data.tags : [] + + const resendMessageId = ( + typeof data.email_id === 'string' && data.email_id.trim() + ? data.email_id.trim() + : (typeof data.emailId === 'string' && data.emailId.trim() + ? data.emailId.trim() + : (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : '')) + ) + + const indexed = resendMessageId ? resendEmailSubmissionIndex.get(resendMessageId) : null + const taggedSubmissionId = extractTagValue(tags, 'submission_id') + const submissionId = indexed?.submissionId || taggedSubmissionId + + const flow = extractTagValue(tags, 'flow') + const stream = indexed?.stream + || (flow === 'contact-welcome' ? 'welcome' : '') + || (flow === 'contact-admin' ? 'adminNotification' : '') + || (flow === 'admin-reply' ? 'adminReply' : '') + + if (!submissionId || !stream) { + res.json({ ok: true, ignored: true }) + return + } + + upsertContactEmailStatus(submissionId, stream, { + status: mapResendEventToStatus(eventType), + lastEventType: eventType || 'webhook.event', + resendEmailId: resendMessageId || null, + error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null, + }) + + res.json({ ok: true }) +}) + +app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => { + const fromAddress = getResendFromAddress() + const replyToAddress = getResendReplyToAddress() + const fromDomain = getAddressDomain(fromAddress) + const replyDomain = getAddressDomain(replyToAddress) + const warnings = [] + + if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.') + if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.') + if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.') + if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.') + if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.') + warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.') + + const recent = contactSubmissions.slice(0, 300) + const failed = recent.filter(item => { + const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true) + return ['failed', 'bounced', 'complained'].includes(status.welcome.status) + || ['failed', 'bounced', 'complained'].includes(status.adminNotification.status) + || ['failed', 'bounced', 'complained'].includes(status.adminReply.status) + }).length + + res.json({ + resendApiConfigured: Boolean(process.env.RESEND_API_KEY), + webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN), + fromAddress, + replyToAddress, + fromDomain, + replyDomain, + warnings, + recentSubmissionFailures: failed, + trackedSubmissions: recent.length, + }) +}) + // Get all questions (for admin) app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { res.json({ questions: draftQuestions ?? questions }) @@ -4576,6 +5046,7 @@ Promise.all([ }, 60 * 60 * 1000) app.listen(PORT, () => { + logResendEmailAlignmentWarnings() console.log(`Portfolio app listening on http://localhost:${PORT}`) }) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index ce32971..c4f0891 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -7,6 +7,7 @@ import { Link } from 'react-router-dom' import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content' import { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' +import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { useAutosave } from './hooks/useAutosave' interface SortableLessonSectionProps { @@ -302,6 +303,104 @@ type AdminView = | 'emails' | 'subscribers' | 'contacts' | 'seo' | 'legal' | 'security' | 'brand' | 'global' +interface AdminSectionLink { + id: string + label: string +} + +const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminView; label: string }> }> = [ + { + group: 'Overview', + options: [{ value: 'dashboard', label: 'Dashboard' }], + }, + { + group: 'Site', + options: [ + { value: 'homepage', label: 'Homepage' }, + { value: 'start-here', label: 'Start Here' }, + { value: 'about', label: 'About' }, + { value: 'contact', label: 'Contact' }, + ], + }, + { + group: 'Content', + options: [ + { value: 'podcast', label: 'Podcast Hub' }, + { value: 'colossians-study', label: 'Studies' }, + { value: 'downloads', label: 'Downloads' }, + { value: 'custom-links', label: 'Custom Links' }, + { value: 'content-blocks', label: 'Content Blocks' }, + ], + }, + { + group: 'Manage', + options: [ + { value: 'questions', label: 'Questions' }, + { value: 'emails', label: 'Emails' }, + { value: 'contacts', label: 'Contacts' }, + { value: 'subscribers', label: 'Subscribers' }, + { value: 'analytics', label: 'Analytics' }, + { value: 'assets', label: 'Asset Manager' }, + ], + }, + { + group: 'Configure', + options: [ + { value: 'global', label: 'Footer & Global' }, + { value: 'seo', label: 'SEO & Redirects' }, + { value: 'legal', label: 'Legal Pages' }, + { value: 'security', label: 'Security' }, + { value: 'brand', label: 'Brand Kit' }, + ], + }, +] + +const ADMIN_SECTION_LINKS: Partial> = { + homepage: [ + { id: 'homepage-hero', label: 'Hero' }, + { id: 'homepage-share', label: 'Share Section' }, + { id: 'homepage-prism', label: 'PRISM Block' }, + { id: 'homepage-where-to-next-labels', label: 'Where to Next Labels' }, + { id: 'homepage-where-to-next-cards', label: 'Where to Next Cards' }, + ], + 'start-here': [ + { id: 'start-here-intro', label: 'Intro' }, + { id: 'start-here-step-1', label: 'Step 1' }, + { id: 'start-here-step-2', label: 'Step 2' }, + { id: 'start-here-step-3', label: 'Step 3' }, + ], + about: [ + { id: 'about-show-copy', label: 'Show Copy' }, + { id: 'about-nate-bio', label: 'Nate Bio' }, + { id: 'about-images', label: 'Images' }, + ], + contact: [ + { id: 'contact-profile', label: 'Profile' }, + { id: 'contact-intro', label: 'Intro Copy' }, + { id: 'contact-scripture', label: 'Scripture' }, + ], + downloads: [ + { id: 'downloads-study-guide', label: 'Study Guide' }, + { id: 'downloads-library', label: 'Download Library' }, + { id: 'downloads-previous-studies', label: 'Previous Studies' }, + ], + global: [ + { id: 'global-footer', label: 'Footer' }, + { id: 'global-header', label: 'Header' }, + { id: 'global-platforms', label: 'Platform URLs' }, + { id: 'global-banner', label: 'Analytics Banner' }, + { id: 'global-welcome-email', label: 'Welcome Email' }, + ], + seo: [ + { id: 'seo-metadata', label: 'Metadata' }, + { id: 'seo-redirects', label: 'Redirects' }, + ], + legal: [ + { id: 'legal-privacy', label: 'Privacy' }, + { id: 'legal-terms', label: 'Terms' }, + ], +} + type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' @@ -543,6 +642,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [questionPage, setQuestionPage] = useState(0) const [selectedQuestionIds, setSelectedQuestionIds] = useState>(new Set()) const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [navSearch, setNavSearch] = useState('') const [subscribers, setSubscribers] = useState([]) const [subscriberSearch, setSubscriberSearch] = useState('') const [contactSearch, setContactSearch] = useState('') @@ -564,14 +664,34 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [previewOpen, setPreviewOpen] = useState(false) const previewIframeRef = useRef(null) const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const saveStatusTimeoutRef = useRef(null) + const autosaveRestoreCheckedRef = useRef(false) const isDirty = JSON.stringify(form) !== lastSavedSnapshot const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length const unansweredCount = questions.filter(q => !q.answer?.trim()).length + const navSearchTerm = navSearch.trim().toLowerCase() + const filteredAdminViewOptions = ADMIN_VIEW_OPTIONS + .map(group => ({ + ...group, + options: group.options.filter(option => { + if (!navSearchTerm) return true + return `${group.group} ${option.label} ${option.value}`.toLowerCase().includes(navSearchTerm) + }), + })) + .filter(group => group.options.length > 0) // Silently saves draft every 30s when there are unsaved changes useAutosave(form, isDirty, handleSaveDraft) + function clearAutosaveDraft() { + try { + sessionStorage.removeItem('admin-autosave-draft') + } catch { + // Ignore storage failures; the server draft is still the source of truth. + } + } + // Broadcast live form state + active view into the preview iframe whenever they change useEffect(() => { if (!previewOpen) return @@ -589,8 +709,60 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const nextSnapshot = JSON.stringify(normalized) setForm(normalized) setLastSavedSnapshot(nextSnapshot) + autosaveRestoreCheckedRef.current = false }, [content]) + useEffect(() => { + if (autosaveRestoreCheckedRef.current) return + autosaveRestoreCheckedRef.current = true + + try { + const rawDraft = sessionStorage.getItem('admin-autosave-draft') + if (!rawDraft) return + + const parsedDraft = JSON.parse(rawDraft) as SiteContent + const restoredDraft = normalizeSiteContentForAdmin(parsedDraft) + if (JSON.stringify(restoredDraft) === lastSavedSnapshot) { + clearAutosaveDraft() + return + } + + if (confirm('Restore an unsaved draft from this browser session?')) { + setForm(restoredDraft) + } + } catch { + clearAutosaveDraft() + } + }, [lastSavedSnapshot]) + + useEffect(() => { + return () => { + if (saveStatusTimeoutRef.current !== null) { + window.clearTimeout(saveStatusTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!mobileNavOpen) return + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMobileNavOpen(false) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = previousOverflow + } + }, [mobileNavOpen]) + useEffect(() => { const intervalId = window.setInterval(() => { setDashboardNow(new Date()) @@ -856,8 +1028,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const data = await res.json() as { updatedAt?: string } setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() })) setLastSavedSnapshot(JSON.stringify(payload)) + clearAutosaveDraft() setStatus('saved') - setTimeout(() => setStatus('idle'), 3500) + if (saveStatusTimeoutRef.current !== null) { + window.clearTimeout(saveStatusTimeoutRef.current) + } + saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500) } catch (err) { setErrorMsg(err instanceof Error ? err.message : 'Unknown error') setStatus('error') @@ -901,9 +1077,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() })) + clearAutosaveDraft() await reloadStats() setStatus('saved') - setTimeout(() => setStatus('idle'), 3500) + if (saveStatusTimeoutRef.current !== null) { + window.clearTimeout(saveStatusTimeoutRef.current) + } + saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500) } catch (err) { setErrorMsg(err instanceof Error ? err.message : 'Unknown error') setStatus('error') @@ -1430,6 +1610,37 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) } + function getCustomLinkPlacementLabel(placement: CustomLink['placement']) { + if (placement === 'platforms') return 'Homepage listen/platform links' + if (placement === 'footer') return 'Footer navigation links' + return 'Downloads page (More Resources)' + } + + function getCustomLinkPreviewPath(placement: CustomLink['placement']) { + if (placement === 'resources') return '/resources' + return '/' + } + + function getContentBlockPageLabel(page: CustomBlock['page']) { + if (page === 'homepage') return 'Homepage' + if (page === 'start-here') return 'Start Here' + if (page === 'episodes') return 'Episodes' + if (page === 'downloads') return 'Downloads' + if (page === 'about') return 'About' + if (page === 'contact') return 'Contact' + return 'Q&A' + } + + function getContentBlockPreviewPath(page: CustomBlock['page']) { + if (page === 'homepage') return '/' + if (page === 'start-here') return '/start-here' + if (page === 'episodes') return '/episodes' + if (page === 'downloads') return '/resources' + if (page === 'about') return '/about' + if (page === 'contact') return '/contact' + return '/questions' + } + function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) { const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))] const currentValue = value?.trim() ?? '' @@ -1471,6 +1682,69 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setMobileNavOpen(false) } + function getAdminViewBadge(view: AdminView) { + if (view === 'questions' && unansweredCount > 0) { + return { count: unansweredCount, neutral: false } + } + if (view === 'emails' && unreadEmailCount > 0) { + return { count: unreadEmailCount, neutral: false } + } + if (view === 'contacts' && contactSubmissions.length > 0) { + return { count: contactSubmissions.length, neutral: true } + } + if (view === 'subscribers' && subscribers.length > 0) { + return { count: subscribers.length, neutral: true } + } + return null + } + + function renderAdminNavItem(view: AdminView, label: string) { + const badge = getAdminViewBadge(view) + + return ( + + ) + } + + function jumpToAdminSection(sectionId: string) { + document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + function renderSectionJumpNav(view: AdminView) { + const links = ADMIN_SECTION_LINKS[view] ?? [] + if (links.length < 2) return null + + return ( +
+ Jump to +
+ {links.map(link => ( + + ))} +
+
+ ) + } + function addLink() { setForm(f => ({ ...f, @@ -2222,6 +2496,24 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { Live: {formatDate(publishState.publishedAt)}
+ @@ -2259,58 +2551,27 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
-
- Overview - +
+ + setNavSearch(e.target.value)} + placeholder="Search admin..." + />
-
- Site - - - - -
- -
- Podcast - -
- -
- Content - - - - -
- -
- Manage - - - - - - -
- -
- Configure - - - - - -
+ {filteredAdminViewOptions.length === 0 ? ( +

No admin sections match this search.

+ ) : ( + filteredAdminViewOptions.map(group => ( +
+ {group.group} + {group.options.map(option => renderAdminNavItem(option.value, option.label))} +
+ )) + )} {mobileNavOpen &&
- {FIELDS.filter(f => f.section === 'hero' || f.section === 'share' || f.section === 'prism').map(({ key, label, multiline }) => ( + + {renderSectionJumpNav('homepage')} + +
+

Hero

+

Primary landing page headline, tag line, and hero call-to-action labels.

+
+ {FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
{multiline @@ -2647,7 +2915,33 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
))} -
+
+

Share Section

+

Controls the share block that points visitors toward the next step.

+
+ {FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => ( +
+ + {multiline + ?