Improve admin UX and autosave handling
This commit is contained in:
+106
-63
@@ -54,7 +54,15 @@
|
|||||||
"slug": "colossians",
|
"slug": "colossians",
|
||||||
"title": "Colossians: Rooted in Christ",
|
"title": "Colossians: Rooted in Christ",
|
||||||
"description": "Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.",
|
"description": "Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.",
|
||||||
|
"homepageEyebrow": "",
|
||||||
|
"showOnHomepage": false,
|
||||||
|
"showNewTag": false,
|
||||||
|
"newTagLabel": "NEW",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
"difficulty": "beginner",
|
||||||
|
"estimatedHours": 8,
|
||||||
|
"completionBadge": "",
|
||||||
|
"numberOfChapters": 1,
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"id": "1-1-2",
|
"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.",
|
"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.",
|
"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.",
|
"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": [
|
"greekNotes": [
|
||||||
"Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.",
|
"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.",
|
"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.",
|
"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."
|
"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"
|
"releasedAt": "2026-11-09T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,11 +96,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.",
|
"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.",
|
"commentary": "The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What signs of gospel growth does Paul mention?",
|
"What signs of gospel growth does Paul mention?",
|
||||||
"How do faith, hope, and love work together here?"
|
"How do faith, hope, and love work together here?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2026-11-23T00:00:00Z"
|
"releasedAt": "2026-11-23T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,11 +112,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"What does Paul ask God to produce?",
|
"What does Paul ask God to produce?",
|
||||||
"How does this prayer shape the goals of the study?"
|
"How does this prayer shape the goals of the study?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2026-12-07T00:00:00Z"
|
"releasedAt": "2026-12-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,11 +128,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Christ is supreme over creation, the church, and reconciliation.",
|
"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.",
|
"commentary": "This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What stands out most about Christ’s supremacy?",
|
"What stands out most about Christ’s supremacy?",
|
||||||
"Why is this passage central to the study?"
|
"Why is this passage central to the study?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2026-12-21T00:00:00Z"
|
"releasedAt": "2026-12-21T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,11 +144,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul explains how Christ’s work changes alienation into steadfast faith.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"How does Paul describe the believer’s former condition?",
|
"How does Paul describe the believer’s former condition?",
|
||||||
"What does continuing in the faith look like?"
|
"What does continuing in the faith look like?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-01-04T00:00:00Z"
|
"releasedAt": "2027-01-04T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,11 +160,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul describes his labor to proclaim Christ and present believers mature in Him.",
|
"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.",
|
"commentary": "Paul’s ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What is the goal of Paul’s ministry?",
|
"What is the goal of Paul’s ministry?",
|
||||||
"Why is maturity such an important theme here?"
|
"Why is maturity such an important theme here?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-01-18T00:00:00Z"
|
"releasedAt": "2027-01-18T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,11 +176,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul reveals his struggle for the believers and his desire for encouragement and unity.",
|
"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.",
|
"commentary": "This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does Paul want the believers to experience?",
|
"What does Paul want the believers to experience?",
|
||||||
"Why does he emphasize encouragement and unity?"
|
"Why does he emphasize encouragement and unity?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-02-01T00:00:00Z"
|
"releasedAt": "2027-02-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -184,11 +192,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul calls believers to continue in Christ and resist deceptive teaching.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"What does it mean to continue in Christ?",
|
"What does it mean to continue in Christ?",
|
||||||
"What kinds of deception should believers watch for?"
|
"What kinds of deception should believers watch for?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-02-15T00:00:00Z"
|
"releasedAt": "2027-02-15T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -200,11 +208,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "The fullness of deity dwells in Christ, and believers are made complete in Him.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"Why is fullness in Christ such an important correction?",
|
"Why is fullness in Christ such an important correction?",
|
||||||
"How does this guard against spiritual insecurity?"
|
"How does this guard against spiritual insecurity?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-03-01T00:00:00Z"
|
"releasedAt": "2027-03-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -216,11 +224,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul explains the believer’s union with Christ in His death, resurrection, and victory.",
|
"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.",
|
"commentary": "The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What changes because of Christ’s work here?",
|
"What changes because of Christ’s work here?",
|
||||||
"How does victory over the powers encourage believers today?"
|
"How does victory over the powers encourage believers today?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-03-15T00:00:00Z"
|
"releasedAt": "2027-03-15T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -232,11 +240,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul warns against letting others judge believers over shadows when Christ is the substance.",
|
"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.",
|
"commentary": "Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What do shadows and substance mean here?",
|
"What do shadows and substance mean here?",
|
||||||
"Where do believers still feel pressure from external judgment?"
|
"Where do believers still feel pressure from external judgment?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-03-29T00:00:00Z"
|
"releasedAt": "2027-03-29T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -248,11 +256,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul warns against prideful spirituality that disconnects believers from Christ the head.",
|
"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.",
|
"commentary": "Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How does Paul describe false spirituality?",
|
"How does Paul describe false spirituality?",
|
||||||
"Why is connection to Christ essential?"
|
"Why is connection to Christ essential?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-04-12T00:00:00Z"
|
"releasedAt": "2027-04-12T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -264,11 +272,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Human rules cannot produce true transformation.",
|
"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.",
|
"commentary": "External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why do human regulations fail to transform the heart?",
|
"Why do human regulations fail to transform the heart?",
|
||||||
"What does real spiritual change require?"
|
"What does real spiritual change require?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-04-26T00:00:00Z"
|
"releasedAt": "2027-04-26T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -280,11 +288,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul calls believers to set their hearts and minds on Christ.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"What does it mean to seek the things above?",
|
"What does it mean to seek the things above?",
|
||||||
"How does hidden life in Christ shape daily priorities?"
|
"How does hidden life in Christ shape daily priorities?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-05-10T00:00:00Z"
|
"releasedAt": "2027-05-10T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -296,11 +304,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul calls believers to put away the practices of the old life.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"Which old patterns does Paul name?",
|
"Which old patterns does Paul name?",
|
||||||
"Why is decisive change necessary?"
|
"Why is decisive change necessary?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-05-24T00:00:00Z"
|
"releasedAt": "2027-05-24T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -312,11 +320,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Believers are renewed in Christ and formed into one new people.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"What does renewal in Christ produce?",
|
"What does renewal in Christ produce?",
|
||||||
"How does this reshape how believers see one another?"
|
"How does this reshape how believers see one another?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-06-07T00:00:00Z"
|
"releasedAt": "2027-06-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -328,11 +336,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul lists the virtues believers should wear as Christ’s character becomes visible in them.",
|
"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.",
|
"commentary": "The image of clothing makes virtue practical and visible. Paul wants Christ’s character to define the way believers live together.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Which virtues are most needed in your context?",
|
"Which virtues are most needed in your context?",
|
||||||
"How does love bind these virtues together?"
|
"How does love bind these virtues together?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-06-21T00:00:00Z"
|
"releasedAt": "2027-06-21T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -344,11 +352,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul calls the church to let Christ’s peace rule and His word dwell richly among them.",
|
"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.",
|
"commentary": "Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does it look like for Christ’s peace to rule?",
|
"What does it look like for Christ’s peace to rule?",
|
||||||
"How can the word of Christ dwell richly in a church family?"
|
"How can the word of Christ dwell richly in a church family?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-07-05T00:00:00Z"
|
"releasedAt": "2027-07-05T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -360,11 +368,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul applies the new life in Christ to family relationships.",
|
"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.",
|
"commentary": "The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How do these instructions reflect Christ’s lordship?",
|
"How do these instructions reflect Christ’s lordship?",
|
||||||
"Which relationship is most challenging to apply today?"
|
"Which relationship is most challenging to apply today?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-07-19T00:00:00Z"
|
"releasedAt": "2027-07-19T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -376,11 +384,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul instructs believers to serve faithfully and wholeheartedly.",
|
"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.",
|
"commentary": "Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How does serving the Lord reshape everyday work?",
|
"How does serving the Lord reshape everyday work?",
|
||||||
"What does Paul say about fairness and accountability?"
|
"What does Paul say about fairness and accountability?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-08-02T00:00:00Z"
|
"releasedAt": "2027-08-02T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -392,11 +400,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul reminds masters to act justly because they too answer to the Lord.",
|
"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.",
|
"commentary": "The gospel confronts authority as well as submission. All authority is accountable to Christ.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why does Paul remind masters of their heavenly Master?",
|
"Why does Paul remind masters of their heavenly Master?",
|
||||||
"What does fairness look like in this context?"
|
"What does fairness look like in this context?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-08-16T00:00:00Z"
|
"releasedAt": "2027-08-16T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -408,11 +416,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul closes with a call to steadfast prayer and wise witness toward outsiders.",
|
"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.",
|
"commentary": "Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How are prayer and witness connected here?",
|
"How are prayer and witness connected here?",
|
||||||
"What does gracious speech look like in real life?"
|
"What does gracious speech look like in real life?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-08-30T00:00:00Z"
|
"releasedAt": "2027-08-30T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -424,11 +432,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul sends trusted messengers to encourage the church and bring news from the ministry field.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"Why do these messengers matter to the letter?",
|
"Why do these messengers matter to the letter?",
|
||||||
"What does this tell us about trusted coworkers?"
|
"What does this tell us about trusted coworkers?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-09-13T00:00:00Z"
|
"releasedAt": "2027-09-13T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -440,11 +448,11 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul names the coworkers who are with him and commends their ministry support.",
|
"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.",
|
"commentary": "The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.",
|
||||||
|
"greekNotes": [],
|
||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What stands out about Paul’s coworkers?",
|
"What stands out about Paul’s coworkers?",
|
||||||
"How does shared ministry strengthen the church?"
|
"How does shared ministry strengthen the church?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-09-27T00:00:00Z"
|
"releasedAt": "2027-09-27T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -456,32 +464,30 @@
|
|||||||
"passageText": "",
|
"passageText": "",
|
||||||
"summary": "Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.",
|
"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.",
|
"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": [
|
"studyQuestions": [
|
||||||
"What final responsibility does Paul place on the church?",
|
"What final responsibility does Paul place on the church?",
|
||||||
"How does the closing reinforce the letter’s themes?"
|
"How does the closing reinforce the letter’s themes?"
|
||||||
],
|
],
|
||||||
"greekNotes": [],
|
|
||||||
"releasedAt": "2027-10-11T00:00:00Z"
|
"releasedAt": "2027-10-11T00:00:00Z"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"homepageEyebrow": "",
|
|
||||||
"showOnHomepage": false,
|
|
||||||
"showNewTag": false,
|
|
||||||
"newTagLabel": "NEW",
|
|
||||||
"numberOfChapters": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "study-titus",
|
"id": "study-titus",
|
||||||
"slug": "titus",
|
"slug": "titus",
|
||||||
"title": "Titus: Sound Doctrine",
|
"title": "Titus: Sound Doctrine",
|
||||||
"description": "A full online class version of Titus with lesson-by-lesson notes and discussion.",
|
"description": "A full online class version of Titus with lesson-by-lesson notes and discussion.",
|
||||||
"status": "planned",
|
|
||||||
"sections": [],
|
|
||||||
"homepageEyebrow": "",
|
"homepageEyebrow": "",
|
||||||
"showOnHomepage": false,
|
"showOnHomepage": false,
|
||||||
"showNewTag": false,
|
"showNewTag": false,
|
||||||
"newTagLabel": "NEW",
|
"newTagLabel": "NEW",
|
||||||
"numberOfChapters": 1
|
"status": "planned",
|
||||||
|
"difficulty": "beginner",
|
||||||
|
"estimatedHours": 8,
|
||||||
|
"completionBadge": "",
|
||||||
|
"numberOfChapters": 1,
|
||||||
|
"sections": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"colossiansStudySections": [
|
"colossiansStudySections": [
|
||||||
@@ -505,7 +511,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why does Paul begin with grace and peace?",
|
"Why does Paul begin with grace and peace?",
|
||||||
"What tone does this create for the rest of the study?"
|
"What tone does this create for the rest of the study?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2026-11-09T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1-3-8",
|
"id": "1-3-8",
|
||||||
@@ -520,7 +527,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What signs of gospel growth does Paul mention?",
|
"What signs of gospel growth does Paul mention?",
|
||||||
"How do faith, hope, and love work together here?"
|
"How do faith, hope, and love work together here?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2026-11-23T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1-9-14",
|
"id": "1-9-14",
|
||||||
@@ -535,7 +543,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does Paul ask God to produce?",
|
"What does Paul ask God to produce?",
|
||||||
"How does this prayer shape the goals of the study?"
|
"How does this prayer shape the goals of the study?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2026-12-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1-15-20",
|
"id": "1-15-20",
|
||||||
@@ -550,7 +559,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What stands out most about Christ’s supremacy?",
|
"What stands out most about Christ’s supremacy?",
|
||||||
"Why is this passage central to the study?"
|
"Why is this passage central to the study?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2026-12-21T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1-21-23",
|
"id": "1-21-23",
|
||||||
@@ -565,7 +575,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How does Paul describe the believer’s former condition?",
|
"How does Paul describe the believer’s former condition?",
|
||||||
"What does continuing in the faith look like?"
|
"What does continuing in the faith look like?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-01-04T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1-24-29",
|
"id": "1-24-29",
|
||||||
@@ -580,7 +591,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What is the goal of Paul’s ministry?",
|
"What is the goal of Paul’s ministry?",
|
||||||
"Why is maturity such an important theme here?"
|
"Why is maturity such an important theme here?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-01-18T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-1-5",
|
"id": "2-1-5",
|
||||||
@@ -595,7 +607,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does Paul want the believers to experience?",
|
"What does Paul want the believers to experience?",
|
||||||
"Why does he emphasize encouragement and unity?"
|
"Why does he emphasize encouragement and unity?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-02-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-6-8",
|
"id": "2-6-8",
|
||||||
@@ -610,7 +623,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does it mean to continue in Christ?",
|
"What does it mean to continue in Christ?",
|
||||||
"What kinds of deception should believers watch for?"
|
"What kinds of deception should believers watch for?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-02-15T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-9-10",
|
"id": "2-9-10",
|
||||||
@@ -625,7 +639,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why is fullness in Christ such an important correction?",
|
"Why is fullness in Christ such an important correction?",
|
||||||
"How does this guard against spiritual insecurity?"
|
"How does this guard against spiritual insecurity?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-03-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-11-15",
|
"id": "2-11-15",
|
||||||
@@ -640,7 +655,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What changes because of Christ’s work here?",
|
"What changes because of Christ’s work here?",
|
||||||
"How does victory over the powers encourage believers today?"
|
"How does victory over the powers encourage believers today?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-03-15T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-16-17",
|
"id": "2-16-17",
|
||||||
@@ -655,7 +671,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What do shadows and substance mean here?",
|
"What do shadows and substance mean here?",
|
||||||
"Where do believers still feel pressure from external judgment?"
|
"Where do believers still feel pressure from external judgment?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-03-29T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-18-19",
|
"id": "2-18-19",
|
||||||
@@ -670,7 +687,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How does Paul describe false spirituality?",
|
"How does Paul describe false spirituality?",
|
||||||
"Why is connection to Christ essential?"
|
"Why is connection to Christ essential?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-04-12T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2-20-23",
|
"id": "2-20-23",
|
||||||
@@ -685,7 +703,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why do human regulations fail to transform the heart?",
|
"Why do human regulations fail to transform the heart?",
|
||||||
"What does real spiritual change require?"
|
"What does real spiritual change require?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-04-26T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-1-4",
|
"id": "3-1-4",
|
||||||
@@ -700,7 +719,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does it mean to seek the things above?",
|
"What does it mean to seek the things above?",
|
||||||
"How does hidden life in Christ shape daily priorities?"
|
"How does hidden life in Christ shape daily priorities?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-05-10T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-5-9",
|
"id": "3-5-9",
|
||||||
@@ -715,7 +735,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Which old patterns does Paul name?",
|
"Which old patterns does Paul name?",
|
||||||
"Why is decisive change necessary?"
|
"Why is decisive change necessary?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-05-24T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-10-11",
|
"id": "3-10-11",
|
||||||
@@ -730,7 +751,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does renewal in Christ produce?",
|
"What does renewal in Christ produce?",
|
||||||
"How does this reshape how believers see one another?"
|
"How does this reshape how believers see one another?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-06-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-12-14",
|
"id": "3-12-14",
|
||||||
@@ -745,7 +767,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Which virtues are most needed in your context?",
|
"Which virtues are most needed in your context?",
|
||||||
"How does love bind these virtues together?"
|
"How does love bind these virtues together?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-06-21T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-15-17",
|
"id": "3-15-17",
|
||||||
@@ -760,7 +783,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What does it look like for Christ’s peace to rule?",
|
"What does it look like for Christ’s peace to rule?",
|
||||||
"How can the word of Christ dwell richly in a church family?"
|
"How can the word of Christ dwell richly in a church family?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-07-05T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-18-21",
|
"id": "3-18-21",
|
||||||
@@ -775,7 +799,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How do these instructions reflect Christ’s lordship?",
|
"How do these instructions reflect Christ’s lordship?",
|
||||||
"Which relationship is most challenging to apply today?"
|
"Which relationship is most challenging to apply today?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-07-19T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3-22-25",
|
"id": "3-22-25",
|
||||||
@@ -790,7 +815,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How does serving the Lord reshape everyday work?",
|
"How does serving the Lord reshape everyday work?",
|
||||||
"What does Paul say about fairness and accountability?"
|
"What does Paul say about fairness and accountability?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-08-02T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4-1",
|
"id": "4-1",
|
||||||
@@ -805,7 +831,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why does Paul remind masters of their heavenly Master?",
|
"Why does Paul remind masters of their heavenly Master?",
|
||||||
"What does fairness look like in this context?"
|
"What does fairness look like in this context?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-08-16T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4-2-6",
|
"id": "4-2-6",
|
||||||
@@ -820,7 +847,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"How are prayer and witness connected here?",
|
"How are prayer and witness connected here?",
|
||||||
"What does gracious speech look like in real life?"
|
"What does gracious speech look like in real life?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-08-30T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4-7-9",
|
"id": "4-7-9",
|
||||||
@@ -835,7 +863,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"Why do these messengers matter to the letter?",
|
"Why do these messengers matter to the letter?",
|
||||||
"What does this tell us about trusted coworkers?"
|
"What does this tell us about trusted coworkers?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-09-13T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4-10-14",
|
"id": "4-10-14",
|
||||||
@@ -850,7 +879,8 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What stands out about Paul’s coworkers?",
|
"What stands out about Paul’s coworkers?",
|
||||||
"How does shared ministry strengthen the church?"
|
"How does shared ministry strengthen the church?"
|
||||||
]
|
],
|
||||||
|
"releasedAt": "2027-09-27T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "4-15-18",
|
"id": "4-15-18",
|
||||||
@@ -865,11 +895,24 @@
|
|||||||
"studyQuestions": [
|
"studyQuestions": [
|
||||||
"What final responsibility does Paul place on the church?",
|
"What final responsibility does Paul place on the church?",
|
||||||
"How does the closing reinforce the letter’s themes?"
|
"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.",
|
"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.",
|
"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",
|
"whereToNextEyebrow": "Where to Next",
|
||||||
"whereToNextHeading": "Choose the page you need.",
|
"whereToNextHeading": "Choose the page you need.",
|
||||||
"whereToNextCards": [
|
"whereToNextCards": [
|
||||||
@@ -1003,5 +1046,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-05-12T16:46:21.985Z"
|
"updatedAt": "2026-06-01T16:33:33.885Z"
|
||||||
}
|
}
|
||||||
+36
-6
@@ -2,37 +2,67 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/</loc>
|
<loc>https://versebyversewithnate.us/</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/start-here</loc>
|
<loc>https://versebyversewithnate.us/start-here</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://versebyversewithnate.us/episodes</loc>
|
||||||
|
<lastmod>2026-06-01</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://versebyversewithnate.us/study</loc>
|
||||||
|
<lastmod>2026-06-01</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.85</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://versebyversewithnate.us/resources</loc>
|
||||||
|
<lastmod>2026-06-01</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/questions</loc>
|
<loc>https://versebyversewithnate.us/questions</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://versebyversewithnate.us/about</loc>
|
||||||
|
<lastmod>2026-06-01</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://versebyversewithnate.us/contact</loc>
|
||||||
|
<lastmod>2026-06-01</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/subscribe</loc>
|
<loc>https://versebyversewithnate.us/subscribe</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/privacy</loc>
|
<loc>https://versebyversewithnate.us/privacy</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.3</priority>
|
<priority>0.3</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://versebyversewithnate.us/terms</loc>
|
<loc>https://versebyversewithnate.us/terms</loc>
|
||||||
<lastmod>2026-04-27</lastmod>
|
<lastmod>2026-06-01</lastmod>
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.3</priority>
|
<priority>0.3</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ const EMPTY_VISITOR_STATS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CONTACT_SUBMISSIONS = 5000
|
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 DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
|
||||||
const titusDownloadTokens = new Map()
|
const titusDownloadTokens = new Map()
|
||||||
|
|
||||||
@@ -625,6 +626,8 @@ let visitorStats = { ...EMPTY_VISITOR_STATS }
|
|||||||
let visitorStatsWritePromise = Promise.resolve()
|
let visitorStatsWritePromise = Promise.resolve()
|
||||||
let contactSubmissions = []
|
let contactSubmissions = []
|
||||||
let contactSubmissionsWritePromise = Promise.resolve()
|
let contactSubmissionsWritePromise = Promise.resolve()
|
||||||
|
const contactSubmitCooldownByEmail = new Map()
|
||||||
|
const resendEmailSubmissionIndex = new Map()
|
||||||
let questions = []
|
let questions = []
|
||||||
let questionsWritePromise = Promise.resolve()
|
let questionsWritePromise = Promise.resolve()
|
||||||
let studyUsers = []
|
let studyUsers = []
|
||||||
@@ -780,9 +783,7 @@ function loadContactSubmissionsFromDisk() {
|
|||||||
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
|
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
contactSubmissions = Array.isArray(parsed?.submissions)
|
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions)
|
||||||
? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
|
||||||
: []
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
contactSubmissions = []
|
contactSubmissions = []
|
||||||
@@ -841,8 +842,186 @@ function normalizeMessageType(value) {
|
|||||||
return 'general'
|
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 USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
|
||||||
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
||||||
|
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 }) {
|
function shouldSendWelcomeEmail({ subscribe }) {
|
||||||
return subscribe === true
|
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, '<br/>')
|
||||||
|
|
||||||
|
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:
|
||||||
|
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
|
||||||
|
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
|
||||||
|
`<tr><td align="center" style="padding:40px 20px;">` +
|
||||||
|
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
|
||||||
|
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
|
||||||
|
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
|
||||||
|
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
|
||||||
|
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="padding:40px 40px 0;">` +
|
||||||
|
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
|
||||||
|
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
|
||||||
|
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px 32px;">` +
|
||||||
|
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
|
||||||
|
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
|
||||||
|
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px 32px;">` +
|
||||||
|
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
|
||||||
|
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
|
||||||
|
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
|
||||||
|
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
|
||||||
|
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
|
||||||
|
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
|
||||||
|
`</tr></table>` +
|
||||||
|
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px 32px;">` +
|
||||||
|
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
|
||||||
|
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
|
||||||
|
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
|
||||||
|
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
||||||
|
`<tr><td style="padding:0 40px 40px;">` +
|
||||||
|
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
|
||||||
|
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">“${escapeHtml(welcomeScripture)}”</p>` +
|
||||||
|
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
|
||||||
|
`</td></tr></table>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
|
||||||
|
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
|
||||||
|
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
|
||||||
|
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
|
||||||
|
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||||
|
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
|
||||||
|
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||||
|
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
|
||||||
|
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
||||||
|
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
|
||||||
|
`</tr></table>` +
|
||||||
|
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You’re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
|
||||||
|
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
|
||||||
|
`</td></tr>` +
|
||||||
|
`</table>` +
|
||||||
|
`</td></tr></table>` +
|
||||||
|
`</div>`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
|
||||||
|
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
|
||||||
|
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
|
||||||
|
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
|
||||||
|
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div style="padding:24px;">` +
|
||||||
|
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
|
||||||
|
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
||||||
|
`<tr>` +
|
||||||
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
|
||||||
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
|
||||||
|
`</tr>` +
|
||||||
|
`<tr>` +
|
||||||
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
|
||||||
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
||||||
|
`</tr>` +
|
||||||
|
`<tr>` +
|
||||||
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
|
||||||
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
|
||||||
|
`</tr>` +
|
||||||
|
`<tr>` +
|
||||||
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
|
||||||
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
|
||||||
|
`</tr>` +
|
||||||
|
`</table>` +
|
||||||
|
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
|
||||||
|
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
|
||||||
|
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function addContactSubmission({ name, email, message, messageType, subscribe }) {
|
||||||
|
const wantsWelcome = subscribe === true
|
||||||
const submission = {
|
const submission = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
@@ -892,8 +1265,9 @@ function addContactSubmission({ name, email, message, messageType, subscribe })
|
|||||||
email,
|
email,
|
||||||
message,
|
message,
|
||||||
messageType: normalizeMessageType(messageType),
|
messageType: normalizeMessageType(messageType),
|
||||||
subscribe: subscribe === true,
|
subscribe: wantsWelcome,
|
||||||
archived: false,
|
archived: false,
|
||||||
|
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||||
}
|
}
|
||||||
|
|
||||||
contactSubmissions.unshift(submission)
|
contactSubmissions.unshift(submission)
|
||||||
@@ -1369,6 +1743,7 @@ 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,
|
||||||
|
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) => {
|
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
fromEmail: 'hello@versebyversewithnate.us',
|
fromEmail: getResendReplyToAddress(),
|
||||||
fromIdentity: ADMIN_REPLY_FROM,
|
fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
|
||||||
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||||
canSendReplies: Boolean(process.env.RESEND_API_KEY),
|
canSendReplies: Boolean(process.env.RESEND_API_KEY),
|
||||||
note: 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 recipientName = splitName(submission.name).firstName || submission.name || 'friend'
|
||||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
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 resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
const { error } = await resend.emails.send({
|
const sendResult = await sendResendEmailWithRetry({
|
||||||
from: ADMIN_REPLY_FROM,
|
resend,
|
||||||
|
context: 'admin-contact-reply',
|
||||||
|
payload: {
|
||||||
|
from: fromAddress || ADMIN_REPLY_FROM,
|
||||||
to: [submission.email],
|
to: [submission.email],
|
||||||
subject,
|
subject,
|
||||||
replyTo: 'hello@versebyversewithnate.us',
|
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,
|
text,
|
||||||
html,
|
html,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
|
||||||
|
upsertContactEmailStatus(submission.id, 'adminReply', {
|
||||||
|
status: 'sent',
|
||||||
|
lastEventType: 'email.sent',
|
||||||
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
replyHistory.unshift({
|
replyHistory.unshift({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
submissionId: submission.id,
|
submissionId: submission.id,
|
||||||
toEmail: submission.email,
|
toEmail: submission.email,
|
||||||
toName: submission.name,
|
toName: submission.name,
|
||||||
fromEmail: 'hello@versebyversewithnate.us',
|
fromEmail: replyToAddress,
|
||||||
subject,
|
subject,
|
||||||
preview: message.slice(0, 500),
|
preview: message.slice(0, 500),
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
@@ -3436,6 +3829,13 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
|
|||||||
|
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} 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)
|
console.error('[admin-reply] send error:', err)
|
||||||
res.status(500).json({ message: 'Failed to send reply email.' })
|
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 trimmedEmail = email.trim()
|
||||||
const trimmedMessage = message.trim()
|
const trimmedMessage = message.trim()
|
||||||
const normalizedMessageType = normalizeMessageType(messageType)
|
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', {
|
const submittedAt = new Date().toLocaleString('en-US', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
@@ -3839,7 +4245,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
subscribe,
|
subscribe,
|
||||||
})
|
})
|
||||||
|
|
||||||
addContactSubmission({
|
const submission = addContactSubmission({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
email: trimmedEmail,
|
email: trimmedEmail,
|
||||||
message: trimmedMessage,
|
message: trimmedMessage,
|
||||||
@@ -3870,6 +4276,18 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
queueQuestionsWrite()
|
queueQuestionsWrite()
|
||||||
}
|
}
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
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) {
|
if (subscribe === true) {
|
||||||
await syncContactToResend(trimmedName, trimmedEmail)
|
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 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 welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
|
||||||
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
|
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
|
||||||
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
|
|
||||||
const welcomeSpotifyUrl = buildAbsoluteUrl(
|
const welcomeSpotifyUrl = buildAbsoluteUrl(
|
||||||
welcomeBaseUrl,
|
welcomeBaseUrl,
|
||||||
process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
|
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',
|
process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
|
||||||
)
|
)
|
||||||
|
|
||||||
const { error: welcomeError } = await resend.emails.send({
|
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
||||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
greetingName,
|
||||||
|
welcomeIntro,
|
||||||
|
welcomeCurrentSeries,
|
||||||
|
welcomeStartHereTitle,
|
||||||
|
welcomeStartHereSummary,
|
||||||
|
welcomeExpect1,
|
||||||
|
welcomeExpect2,
|
||||||
|
welcomeExpect3,
|
||||||
|
welcomeScripture,
|
||||||
|
welcomeScriptureRef,
|
||||||
|
welcomeSignoff,
|
||||||
|
welcomeHeading,
|
||||||
|
welcomeSpotifyUrl,
|
||||||
|
welcomeAppleUrl,
|
||||||
|
welcomeAmazonUrl,
|
||||||
|
welcomeWebsiteUrl,
|
||||||
|
welcomeEpisodeUrl,
|
||||||
|
welcomeImageUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const welcomeSendResult = await sendResendEmailWithRetry({
|
||||||
|
resend,
|
||||||
|
context: 'contact-welcome',
|
||||||
|
payload: {
|
||||||
|
from: fromAddress,
|
||||||
to: [trimmedEmail],
|
to: [trimmedEmail],
|
||||||
|
replyTo: replyToAddress,
|
||||||
subject: welcomeSubject,
|
subject: welcomeSubject,
|
||||||
text:
|
tags: [
|
||||||
`Welcome to Verse by Verse with Nate!\n\n` +
|
{ name: 'flow', value: 'contact-welcome' },
|
||||||
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
|
{ name: 'message_type', value: safeMessageTypeTag },
|
||||||
`${welcomeIntro}\n\n` +
|
{ name: 'submission_id', value: submission.id },
|
||||||
`${welcomeCurrentSeries}\n\n` +
|
],
|
||||||
`Start here: ${welcomeEpisodeUrl}\n` +
|
headers: {
|
||||||
`${welcomeStartHereTitle}\n` +
|
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
|
||||||
`${welcomeStartHereSummary}\n` +
|
'X-Contact-Submission-Id': submission.id,
|
||||||
`Spotify: ${welcomeSpotifyUrl}\n` +
|
},
|
||||||
`Apple Podcasts: ${welcomeAppleUrl}\n` +
|
text: welcomeTemplate.text,
|
||||||
`Amazon Music: ${welcomeAmazonUrl}\n` +
|
html: welcomeTemplate.html,
|
||||||
`Website: ${welcomeWebsiteUrl}\n\n` +
|
},
|
||||||
`What to expect:\n` +
|
})
|
||||||
`- ${welcomeExpect1}\n` +
|
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
|
||||||
`- ${welcomeExpect2}\n` +
|
upsertContactEmailStatus(submission.id, 'welcome', {
|
||||||
`- ${welcomeExpect3}\n\n` +
|
status: 'sent',
|
||||||
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
|
lastEventType: 'email.sent',
|
||||||
`${welcomeSignoff}`,
|
error: null,
|
||||||
html:
|
})
|
||||||
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
|
welcomeSent = true
|
||||||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
|
} catch (welcomeErr) {
|
||||||
`<tr><td align="center" style="padding:40px 20px;">` +
|
upsertContactEmailStatus(submission.id, 'welcome', {
|
||||||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
|
status: 'failed',
|
||||||
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
|
lastEventType: 'email.failed',
|
||||||
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
|
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
|
||||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
|
})
|
||||||
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
|
throw welcomeErr
|
||||||
`</td></tr>` +
|
}
|
||||||
`<tr><td style="padding:40px 40px 0;">` +
|
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
|
||||||
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
|
upsertContactEmailStatus(submission.id, 'welcome', {
|
||||||
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
|
status: 'automation-enabled',
|
||||||
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
|
lastEventType: 'email.automation.enabled',
|
||||||
`</td></tr>` +
|
error: null,
|
||||||
`<tr><td style="padding:0 40px 32px;">` +
|
|
||||||
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
|
|
||||||
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
|
|
||||||
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you’re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
|
|
||||||
`</td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px 32px;">` +
|
|
||||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
|
|
||||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
|
|
||||||
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
|
|
||||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
|
|
||||||
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
|
|
||||||
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
|
|
||||||
`</tr></table>` +
|
|
||||||
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
|
|
||||||
`</td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px 32px;">` +
|
|
||||||
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
|
|
||||||
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
|
|
||||||
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
|
|
||||||
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
|
|
||||||
`</td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
|
|
||||||
`<tr><td style="padding:0 40px 40px;">` +
|
|
||||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
|
|
||||||
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">“${escapeHtml(welcomeScripture)}”</p>` +
|
|
||||||
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
|
|
||||||
`</td></tr></table>` +
|
|
||||||
`</td></tr>` +
|
|
||||||
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
|
|
||||||
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
|
|
||||||
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
|
|
||||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
|
|
||||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
|
||||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
|
|
||||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
|
||||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
|
|
||||||
`<td style="color:#2a2518;font-size:12px;">·</td>` +
|
|
||||||
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
|
|
||||||
`</tr></table>` +
|
|
||||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You’re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
|
|
||||||
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
|
|
||||||
`</td></tr>` +
|
|
||||||
`</table>` +
|
|
||||||
`</td></tr></table>` +
|
|
||||||
`</div>`,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (welcomeError) throw welcomeError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSendWelcome) {
|
try {
|
||||||
res.json({ ok: true })
|
const adminSendResult = await sendResendEmailWithRetry({
|
||||||
return
|
resend,
|
||||||
}
|
context: 'contact-admin-notification',
|
||||||
|
payload: {
|
||||||
const { error } = await resend.emails.send({
|
from: fromAddress,
|
||||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
to: [adminInbox],
|
||||||
to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
|
|
||||||
replyTo: trimmedEmail,
|
replyTo: trimmedEmail,
|
||||||
subject: `Verse by Verse contact form: ${trimmedName}`,
|
subject: adminTemplate.subject,
|
||||||
text:
|
tags: [
|
||||||
`New contact form submission\n\n` +
|
{ name: 'flow', value: 'contact-admin' },
|
||||||
`Message Type: ${normalizedMessageType}\n` +
|
{ name: 'message_type', value: safeMessageTypeTag },
|
||||||
`Name: ${trimmedName}\n` +
|
{ name: 'submission_id', value: submission.id },
|
||||||
`Email: ${trimmedEmail}\n` +
|
],
|
||||||
`Submitted: ${submittedAt}\n\n` +
|
headers: {
|
||||||
`Message:\n${trimmedMessage}`,
|
'X-Contact-Submission-Id': submission.id,
|
||||||
html:
|
},
|
||||||
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
|
text: adminTemplate.text,
|
||||||
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
|
html: adminTemplate.html,
|
||||||
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
|
},
|
||||||
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
|
|
||||||
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div style="padding:24px;">` +
|
|
||||||
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
|
|
||||||
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
|
||||||
`<tr>` +
|
|
||||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
|
|
||||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
|
|
||||||
`</tr>` +
|
|
||||||
`<tr>` +
|
|
||||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
|
|
||||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
|
||||||
`</tr>` +
|
|
||||||
`<tr>` +
|
|
||||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
|
|
||||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
|
|
||||||
`</tr>` +
|
|
||||||
`<tr>` +
|
|
||||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
|
|
||||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
|
|
||||||
`</tr>` +
|
|
||||||
`</table>` +
|
|
||||||
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
|
|
||||||
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
|
|
||||||
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
|
|
||||||
`</div>` +
|
|
||||||
`</div>` +
|
|
||||||
`</div>` +
|
|
||||||
`</div>`,
|
|
||||||
})
|
})
|
||||||
if (error) throw error
|
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) {
|
} catch (err) {
|
||||||
console.error('[contact] send error:', err)
|
console.error('[contact] send error:', err)
|
||||||
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
|
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)
|
// Get all questions (for admin)
|
||||||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ questions: draftQuestions ?? questions })
|
res.json({ questions: draftQuestions ?? questions })
|
||||||
@@ -4576,6 +5046,7 @@ Promise.all([
|
|||||||
}, 60 * 60 * 1000)
|
}, 60 * 60 * 1000)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
logResendEmailAlignmentWarnings()
|
||||||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+588
-168
File diff suppressed because it is too large
Load Diff
+137
-13
@@ -4994,6 +4994,37 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-view-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid rgba(201,168,76,0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(201,168,76,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-view-picker-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240,234,216,0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-view-picker-select {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brand-warm-white);
|
||||||
|
font-family: var(--brand-font-body);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
min-width: 11rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-topbar-meta {
|
.admin-topbar-meta {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: rgba(240,234,216,0.45);
|
color: rgba(240,234,216,0.45);
|
||||||
@@ -5065,18 +5096,64 @@
|
|||||||
color: var(--brand-warm-white);
|
color: var(--brand-warm-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-nav-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0 1.1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-search label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240,234,216,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-search input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(201,168,76,0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(201,168,76,0.05);
|
||||||
|
color: var(--brand-warm-white);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-search input::placeholder {
|
||||||
|
color: rgba(240,234,216,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-empty {
|
||||||
|
padding: 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-nav-group {
|
.admin-nav-group {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.15rem;
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
border-top: 1px solid rgba(201, 168, 76, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group:first-of-type {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-label {
|
.admin-nav-label {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
font-size: 0.68rem;
|
align-items: center;
|
||||||
letter-spacing: 0.16em;
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(201,168,76,0.45);
|
color: #ffe3a3;
|
||||||
padding: 0 1.1rem;
|
background: rgba(201, 168, 76, 0.22);
|
||||||
margin-bottom: 0.25rem;
|
border: 1px solid rgba(201, 168, 76, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 0.2rem 0.62rem;
|
||||||
|
margin-left: 1.1rem;
|
||||||
|
margin-bottom: 0.42rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-item {
|
.admin-nav-item {
|
||||||
@@ -5088,9 +5165,10 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
color: rgba(240,234,216,0.6);
|
color: rgba(240,234,216,0.84);
|
||||||
padding: 0.4rem 1.1rem;
|
padding: 0.4rem 1.1rem;
|
||||||
font-size: 0.88rem;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 150ms, background 150ms;
|
transition: color 150ms, background 150ms;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -5118,13 +5196,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-item:hover {
|
.admin-nav-item:hover {
|
||||||
color: var(--brand-warm-white);
|
color: #fff6de;
|
||||||
background: rgba(201,168,76,0.06);
|
background: rgba(201,168,76,0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-item--active {
|
.admin-nav-item--active {
|
||||||
color: var(--brand-gold) !important;
|
color: #ffe4a8 !important;
|
||||||
background: rgba(201,168,76,0.1);
|
background: rgba(201,168,76,0.18);
|
||||||
border-left-color: var(--brand-gold);
|
border-left-color: var(--brand-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5169,6 +5247,11 @@
|
|||||||
border-bottom: 1px solid rgba(201,168,76,0.08);
|
border-bottom: 1px solid rgba(201,168,76,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-panel-subhead[id],
|
||||||
|
.admin-section-header[id] {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel-subhead h3 {
|
.admin-panel-subhead h3 {
|
||||||
font-family: var(--brand-font-heading);
|
font-family: var(--brand-font-heading);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@@ -5202,6 +5285,47 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-section-jump-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: -0.4rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-jump-nav-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240,234,216,0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-jump-nav-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-jump-nav-btn {
|
||||||
|
border: 1px solid rgba(201,168,76,0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(201,168,76,0.07);
|
||||||
|
color: var(--brand-warm-white);
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms, border-color 150ms, color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-jump-nav-btn:hover {
|
||||||
|
background: rgba(201,168,76,0.14);
|
||||||
|
border-color: rgba(201,168,76,0.4);
|
||||||
|
color: #fff6de;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-archive-helper {
|
.admin-archive-helper {
|
||||||
background: rgba(201,168,76,0.05);
|
background: rgba(201,168,76,0.05);
|
||||||
border: 1px solid rgba(201,168,76,0.15);
|
border: 1px solid rgba(201,168,76,0.15);
|
||||||
|
|||||||
Reference in New Issue
Block a user