Compare commits

...

3 commits

Author SHA1 Message Date
corsacca
ca2addcfac
add embeddable map page for iframes (#24)
- serves at /embed/map with tab, lang, map and utm_source query params
- fetches the mapbox token at load and points the bundle at the prayer api
- strips the bundle's source tag from prayer links and applies utm_source
2026-09-15 15:28:54 +01:00
corsacca
cea9583792 guard people group cards against missing region, rop1 and country fields 2026-09-15 14:57:16 +01:00
corsacca
4dfee08d13 replace mailgun with sendgrid for transactional email
- send through the sendgrid v3 json api via direct fetch with a
  manually cleared abort timeout; mailhog stays for development
- drop nodemailer-mailgun-transport and its types
- swap MAILGUN_* runtime config and env example for SENDGRID_API_KEY
  and optional SENDGRID_HOST
2026-09-15 11:16:44 +01:00
8 changed files with 327 additions and 76 deletions

View file

@ -5,10 +5,12 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000
JWT_SECRET=your-super-secret-jwt-key-change-this
MAILGUN_API_KEY=your-mailgun-api-key
MAILGUN_DOMAIN=mg.yourdomain.com
# Optional: Use 'api.eu.mailgun.net' for EU region
MAILGUN_HOST=
# SendGrid (transactional email: invites, password resets, verification).
# Sends through the v3 JSON API. The From address in SMTP_FROM must be on a
# domain authenticated in the SendGrid account.
SENDGRID_API_KEY=your-sendgrid-api-key
# Optional API host override; defaults to api.sendgrid.com (use api.eu.sendgrid.com for EU)
SENDGRID_HOST=
SMTP_FROM=noreply@yourdomain.com
SMTP_FROM_NAME=My App

View file

@ -600,7 +600,7 @@ const showMore = computed(() => hasMore())
<div v-if="useSelectCard" class="stack stack--sm | card | highlighted-uupg__card">
<div class="repel align-start">
<img :src="uupg.image_url" :alt="uupg.name">
<p class="color-brand-lighter uppercase text-end overflow-wrap-anywhere" v-html="uupg.wagf_region_label ? uupg.wagf_region_label : uupg.wagf_region.label" />
<p class="color-brand-lighter uppercase text-end overflow-wrap-anywhere" v-html="uupg.wagf_region_label ? uupg.wagf_region_label : uupg.wagf_region?.label" />
</div>
<div>
<p class="line-height-tight" v-html="uupg.name" />
@ -633,9 +633,10 @@ const showMore = computed(() => hasMore())
<h3 class="uupg__name line-height-tight" v-html="uupg.name" />
<p class="uupg__country">
<a
v-if="uupg.country_code"
class="uupg__country-link light-link"
:href="localePath(`/regions/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1.label" />)
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1?.label" />)
</p>
<template v-if="uupg.matches">
<p v-for="(match, mi) in uupg.matches" :key="mi" class="font-size-sm color-brand-lighter">

View file

@ -38,7 +38,6 @@
"mapbox-gl": "^3.22.0",
"markdown-it": "^14.1.1",
"nodemailer": "^7.0.6",
"nodemailer-mailgun-transport": "^2.1.5",
"nuxt": "^4.4.2",
"postgres": "^3.4.7",
"tailwindcss": "^4.2.2",
@ -54,7 +53,6 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.1",
"@types/nodemailer-mailgun-transport": "^1.4.6",
"@types/turndown": "^5.0.6",
"eslint": "^10.2.0",
"sass": "^1.99.0",
@ -892,8 +890,6 @@
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
"@types/nodemailer-mailgun-transport": ["@types/nodemailer-mailgun-transport@1.4.6", "", { "dependencies": { "@types/nodemailer": "*" } }, "sha512-6qhtDo+1ZLtrmmpQN7O9e3NLK5ggnTS2Oca+22SvmhwChNKxDZErecTlF6qTOLnNW/CCcHmDaSmG2MXUeP1w9g=="],
"@types/pbf": ["@types/pbf@3.0.5", "", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@ -1004,7 +1000,7 @@
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.34", "", { "dependencies": { "@vue/compiler-dom": "3.5.34", "@vue/shared": "3.5.34" } }, "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ=="],
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="],
"@vue/devtools-core": ["@vue/devtools-core@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2", "@vue/devtools-shared": "^8.1.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg=="],
@ -1100,8 +1096,6 @@
"bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="],
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="],
@ -1114,8 +1108,6 @@
"birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@ -1196,8 +1188,6 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"consolidate": ["consolidate@0.15.1", "", { "dependencies": { "bluebird": "^3.1.1" } }, "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@ -1762,8 +1752,6 @@
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"mailgun.js": ["mailgun.js@8.2.2", "", { "dependencies": { "axios": "^1.3.3", "base-64": "^1.0.0", "url-join": "^4.0.1" } }, "sha512-po/KtofzrTuKhHLenbmliDsVVOFANwcfDFUGnggwnyZJmZz7JgBlV6nzK9o2Fk+OK2SiBmJTK25RbkAj57Hd+Q=="],
"mapbox-gl": ["mapbox-gl@3.24.0", "", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
@ -1848,8 +1836,6 @@
"nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
"nodemailer-mailgun-transport": ["nodemailer-mailgun-transport@2.1.5", "", { "dependencies": { "consolidate": "^0.15.1", "form-data": "^4.0.0", "mailgun.js": "^8.0.1" } }, "sha512-hF7POkaxFgMvYEd5aHLaQJI2511ld+aQlQi7JH6bGjhjlZ33cIbTB9PimlIrLu5XC3z76Kde6e65OIwL9lOdTA=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@ -2354,8 +2340,6 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@1.4.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg=="],
@ -2696,7 +2680,7 @@
"vite-plugin-inspect/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="],
"vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@ -2766,6 +2750,8 @@
"@intlify/vue-i18n-extensions/vue-i18n/@intlify/core-base": ["@intlify/core-base@10.0.8", "", { "dependencies": { "@intlify/message-compiler": "10.0.8", "@intlify/shared": "10.0.8" } }, "sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ=="],
"@intlify/vue-i18n-extensions/vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],

View file

@ -245,9 +245,8 @@ export default defineNuxtConfig({
oauthDcrDefaultScopes: ['pages.view', 'pages.write', 'pages.publish'],
databaseUrl: process.env.DATABASE_URL || '',
jwtSecret: process.env.JWT_SECRET || '',
mailgunApiKey: process.env.MAILGUN_API_KEY || '',
mailgunDomain: process.env.MAILGUN_DOMAIN || '',
mailgunHost: process.env.MAILGUN_HOST || '',
sendgridApiKey: process.env.SENDGRID_API_KEY || '',
sendgridHost: process.env.SENDGRID_HOST || 'api.sendgrid.com',
smtpFrom: process.env.SMTP_FROM || '',
smtpFromName: process.env.SMTP_FROM_NAME || '',
s3Endpoint: process.env.S3_ENDPOINT || '',

View file

@ -45,7 +45,6 @@
"mapbox-gl": "^3.22.0",
"markdown-it": "^14.1.1",
"nodemailer": "^7.0.6",
"nodemailer-mailgun-transport": "^2.1.5",
"nuxt": "^4.4.2",
"postgres": "^3.4.7",
"tailwindcss": "^4.2.2",
@ -61,7 +60,6 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.1",
"@types/nodemailer-mailgun-transport": "^1.4.6",
"@types/turndown": "^5.0.6",
"eslint": "^10.2.0",
"sass": "^1.99.0",

170
public/embed/map/index.html Normal file
View file

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>Doxa map</title>
<link rel="icon" href="/favicon.ico">
<!--
Embeddable map page. Renders one Doxa map with no site chrome so any
website can drop it in an iframe:
<iframe src="https://doxa.life/embed/map?tab=prayer"
width="100%" height="600" style="border:none"
allowfullscreen loading="lazy"></iframe>
Query parameters (all optional):
tab prayer | engagement | adoption. A comma list renders a tab bar
with the first entry open; "all" is every tab. Default: prayer.
lang UI language: ar de en es fr hi it pt ro ru zh. Default: en.
map "research" loads the multi-layer research map instead of the
simple map (tab is then ignored).
utm_source
Appended as utm_source to the people-group sign-up links the
popup opens on the prayer site, so sign-ups can be attributed
to the embedding website.
The Mapbox token is fetched from /api/maps/token at load so it never
lands in this file. People-group data comes from the prayer API, whose
origin is set on window.MAP_APP_API_URL before the bundle loads.
base href="/" so bundle asset fetches resolve against the site root,
not /embed/map/.
-->
<base href="/">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: #f3f3f1;
overflow: hidden;
}
doxa-map, doxa-research-map {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
doxa-map:fullscreen, doxa-map:-webkit-full-screen,
doxa-research-map:fullscreen, doxa-research-map:-webkit-full-screen {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
}
.embed-error {
padding: 20px;
max-width: 600px;
margin: 40px auto;
background: #fff3f3;
border: 1px solid #e05a5a;
color: #7a1f1f;
font: 13px/1.5 ui-monospace, monospace;
border-radius: 4px;
}
.embed-error code { background: #fde3e3; padding: 1px 5px; border-radius: 2px; }
</style>
</head>
<body>
<script>
(async function init() {
const params = new URLSearchParams(window.location.search);
// ── Language ─────────────────────────────────────────────────────────
// The bundle's i18n reads <html lang> when the element is created, so
// it must be set before the custom element is appended.
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh'];
const requestedLang = (params.get('lang') || 'en').split('-')[0].toLowerCase();
const lang = LOCALES.includes(requestedLang) ? requestedLang : 'en';
document.documentElement.lang = lang;
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
// ── Tabs ─────────────────────────────────────────────────────────────
const TAB_IDS = ['prayer', 'engagement', 'adoption'];
const rawTab = (params.get('tab') || 'prayer').toLowerCase();
const wanted = rawTab === 'all' ? TAB_IDS : rawTab.split(',').map(s => s.trim());
const tabIds = [...new Set(wanted.filter(id => TAB_IDS.includes(id)))];
if (tabIds.length === 0) tabIds.push('prayer');
const tabs = tabIds.map(id => ({ id, colorStrategy: id, legend: id, popup: id }));
const isResearch = params.get('map') === 'research';
// ── Prayer API origin ────────────────────────────────────────────────
// Read by the bundle's getApiBaseUrl(); without it the map fetches
// relative /api/people-groups/* paths, which this site does not serve.
window.MAP_APP_API_URL = 'https://pray.doxa.life';
// ── Sign-up link attribution ─────────────────────────────────────────
// The popup opens its Pray links with window.open, so links bound for
// the prayer site are rewritten before the bundle loads: the bundle's
// own source=doxalife tag is dropped and, when this page was given a
// utm_source, that is set instead.
const utmSource = (params.get('utm_source') || '').trim().slice(0, 100);
const prayHost = new URL(window.MAP_APP_API_URL).hostname;
const nativeOpen = window.open.bind(window);
window.open = function (url, ...rest) {
try {
const target = new URL(url, window.location.href);
if (target.hostname === prayHost) {
target.searchParams.delete('source');
if (utmSource) target.searchParams.set('utm_source', utmSource);
url = target.toString();
}
} catch (_) {
// Not a URL: pass through unchanged.
}
return nativeOpen(url, ...rest);
};
function showError(message) {
const box = document.createElement('div');
box.className = 'embed-error';
box.innerHTML = message;
document.body.appendChild(box);
}
// ── Mapbox token ─────────────────────────────────────────────────────
let token;
try {
const res = await fetch('/api/maps/token');
if (!res.ok) throw new Error('Token endpoint returned ' + res.status);
token = (await res.json()).token;
if (!token) throw new Error('Token endpoint returned an empty token');
// The map bundles only accept public keys.
if (!token.startsWith('pk.')) throw new Error('Expected a pk. token, got ' + token.slice(0, 3) + '…');
} catch (err) {
console.error('[embed-map] Failed to fetch Mapbox token:', err);
showError(
'<strong>Could not load the Mapbox token from <code>/api/maps/token</code>.</strong><br><br>' +
'Check that <code>NUXT_PUBLIC_MAPBOX_TOKEN</code> is set to a public (pk.) token ' +
'in the host environment. Error: ' + err.message
);
return;
}
// ── Render the custom element ────────────────────────────────────────
const config = isResearch
? { profile: 'research-map', tk: token, dataSource: 'pray-tools', instanceId: 'embed-research' }
: { profile: 'doxa-simple-map', tk: token, dataSource: 'pray-tools', instanceId: 'embed-map', tabs };
const el = document.createElement(isResearch ? 'doxa-research-map' : 'doxa-map');
el.setAttribute('profile-config', JSON.stringify(config));
document.body.appendChild(el);
// ── Load the bundle (registers the custom element) ───────────────────
const s = document.createElement('script');
s.src = isResearch ? '/js/doxa-research-map.js' : '/js/doxa-simple-map.js';
s.onerror = () => showError('<strong>Could not load the map bundle</strong> <code>' + s.src + '</code>.');
document.head.appendChild(s);
})();
</script>
</body>
</html>

View file

@ -1,28 +1,23 @@
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import mailgunTransport from 'nodemailer-mailgun-transport'
import { sendViaSendgrid } from './sendgrid'
import { renderEmailTemplate, type EmailTemplateData } from './email-templates'
const isDevelopment = (process.env.NODE_ENV || 'development') === 'development'
// MailHog transporter, development only. Production sends go through SendGrid.
let transporter: Transporter | null = null
function getEmailConfig() {
try {
const config = useRuntimeConfig()
return {
mailgunApiKey: config.mailgunApiKey || process.env.MAILGUN_API_KEY,
mailgunDomain: config.mailgunDomain || process.env.MAILGUN_DOMAIN,
mailgunHost: config.mailgunHost || process.env.MAILGUN_HOST,
smtpFrom: config.smtpFrom || process.env.SMTP_FROM,
smtpFromName: config.smtpFromName || process.env.SMTP_FROM_NAME,
appName: config.appName || process.env.APP_NAME
}
} catch {
return {
mailgunApiKey: process.env.MAILGUN_API_KEY,
mailgunDomain: process.env.MAILGUN_DOMAIN,
mailgunHost: process.env.MAILGUN_HOST,
smtpFrom: process.env.SMTP_FROM,
smtpFromName: process.env.SMTP_FROM_NAME,
appName: process.env.APP_NAME
@ -30,39 +25,16 @@ function getEmailConfig() {
}
}
function getTransporter(): Transporter {
function getDevTransporter(): Transporter {
if (transporter) return transporter
const config = getEmailConfig()
if (isDevelopment) {
console.log('[Email] Using MailHog (development mode)')
transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false,
tls: { rejectUnauthorized: false }
})
return transporter
}
if (!config.mailgunApiKey || !config.mailgunDomain) {
throw new Error('Mailgun configuration incomplete. Set MAILGUN_API_KEY and MAILGUN_DOMAIN.')
}
console.log('[Email] Using Mailgun HTTP API')
const mailgunOptions: any = {
auth: {
api_key: config.mailgunApiKey,
domain: config.mailgunDomain
}
}
if (config.mailgunHost) {
mailgunOptions.host = config.mailgunHost
}
transporter = nodemailer.createTransport(mailgunTransport(mailgunOptions))
console.log('[Email] Using MailHog (development mode)')
transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false,
tls: { rejectUnauthorized: false }
})
return transporter
}
@ -95,16 +67,31 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
fromEmail = fromName ? `${fromName} <${fromAddress}>` : fromAddress
}
const mailOptions = {
from: fromEmail,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
subject: options.subject,
html: options.html,
text: options.text || options.html.replace(/<[^>]*>/g, '')
}
const text = options.text || options.html.replace(/<[^>]*>/g, '')
const info = await getTransporter().sendMail(mailOptions)
console.log('[Email] Sent successfully:', info.messageId)
let messageId: string | undefined
if (isDevelopment) {
const info = await getDevTransporter().sendMail({
from: fromEmail,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
subject: options.subject,
html: options.html,
text
})
messageId = info.messageId
} else {
// Recipient arrays go through as-is: SendGrid takes one address object per
// recipient, and a comma-joined string would be read as a single address.
const info = await sendViaSendgrid({
from: fromEmail,
to: options.to,
subject: options.subject,
html: options.html,
text
})
messageId = info.messageId
}
console.log('[Email] Sent successfully:', messageId)
return true
} catch (error) {
if (!process.env.VITEST) {

108
server/utils/sendgrid.ts Normal file
View file

@ -0,0 +1,108 @@
/**
* Twilio SendGrid transport for transactional email. Sends via a direct fetch
* to the v3 mail/send JSON API (no SDK); the response's X-Message-Id header is
* the id SendGrid shows in its Activity Feed and echoes in event webhooks.
*/
export function getSendgridConfig() {
let apiKey = ''
let host = ''
try {
const config = useRuntimeConfig()
apiKey = config.sendgridApiKey
host = config.sendgridHost
} catch {
// Outside the Nitro context (scripts) fall through to process.env
}
return {
apiKey: apiKey || process.env.SENDGRID_API_KEY || '',
host: host || process.env.SENDGRID_HOST || 'api.sendgrid.com'
}
}
export function isSendgridConfigured(): boolean {
return Boolean(getSendgridConfig().apiKey)
}
export interface SendgridSendOptions {
from: string // full address, e.g. '"Doxa" <noreply@mail.doxa.life>'
to: string | string[]
subject: string
html: string
text?: string
replyTo?: string
headers?: Record<string, string>
// Per-request cap; default 30s.
timeoutMs?: number
}
/** Bare lowercase address from a `"Name" <a@b>` or plain `a@b` value. */
function extractEmailAddress(value: string): string | null {
const angle = value.match(/<([^>]+)>/)
const bare = (angle ? angle[1]! : value).trim().toLowerCase()
const match = bare.match(/[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+/)
return match ? match[0] : null
}
/** Display name before the angle brackets of a `"Name" <a@b>` value, if any. */
function extractDisplayName(value: string): string | null {
const angle = value.match(/^\s*"?([^"<]*?)"?\s*</)
const name = angle?.[1]?.trim()
return name || null
}
/** Split a `"Name" <a@b>` style address into SendGrid's {email, name} shape. */
function toAddressObject(value: string): { email: string, name?: string } {
const email = extractEmailAddress(value) || value
const name = extractDisplayName(value)
return name ? { email, name } : { email }
}
/**
* Send one message via the SendGrid v3 API. Throws on a non-2xx response;
* returns the X-Message-Id SendGrid assigned.
*/
export async function sendViaSendgrid(options: SendgridSendOptions): Promise<{ messageId?: string }> {
const { apiKey, host } = getSendgridConfig()
if (!apiKey) {
throw new Error('SendGrid configuration incomplete. Set SENDGRID_API_KEY.')
}
const recipients = (Array.isArray(options.to) ? options.to : [options.to]).map(toAddressObject)
const payload: Record<string, unknown> = {
personalizations: [{ to: recipients }],
from: toAddressObject(options.from),
subject: options.subject,
content: [
{ type: 'text/plain', value: options.text || options.html.replace(/<[^>]*>/g, '') },
{ type: 'text/html', value: options.html }
]
}
if (options.replyTo) payload.reply_to = toAddressObject(options.replyTo)
if (options.headers && Object.keys(options.headers).length) payload.headers = options.headers
// Bound the request with a manually-cleared AbortController rather than
// AbortSignal.timeout(): under Bun the latter's per-call timer is never
// reclaimed, so each send leaks memory.
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 30000)
try {
const res = await fetch(`https://${host}/v3/mail/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller.signal
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`SendGrid responded ${res.status}: ${body}`)
}
return { messageId: res.headers.get('x-message-id') || undefined }
} finally {
clearTimeout(timeout)
}
}