Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions airtable-mailersend-emails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// User Inputs (set these in Airtable's script configuration)
let {
to_email,
first_name,
reply_to_email,
languages,
country,
onboarding_email,
airtable_id,
intent,
// Optional: override which render endpoint to call. Leave unmapped to use the
// production default below.
render_endpoint_url
} = input.config()
const apiKey = input.secret('MailerSend API key')
// New: shared secret for src/routes/api/onboarding-email/+server.ts (repo). Sent as
// "Authorization: Bearer <secret>". If this secret isn't configured yet, the render
// attempt below fails closed (401) and falls through to the template_id path.
const renderSecret = input.secret('Onboarding email render secret')

// A triggering record always has an ID, so this is empty only when the input variable is
// unmapped. Without the guard every email carries verificationKey=undefined and verification
// fails for the recipient with nothing failing here.
if (!airtable_id) {
throw new Error('airtable_id is missing - refusing to send a broken verification link')
}

let verification_link = `https://pauseai.info/verify?table=join&verificationKey=${airtable_id}`

// ---------------------------------------------------------------------------------
// New first attempt: ask the repo-owned render endpoint for {subject, html, text}
// and send that directly, instead of a MailerSend template_id. Render-only endpoint
// (never sends mail itself, never touches the MailerSend API key) - see
// src/lib/server/onboardingEmail/ and src/routes/api/onboarding-email/+server.ts.
// Any failure (network error, timeout, non-OK response, or a response missing any of
// subject/html/text) falls through to the existing template_id logic below,
// unchanged. This keeps the blast radius of the new endpoint at zero: worst case is
// today's behavior.
// ---------------------------------------------------------------------------------

const RENDER_ENDPOINT_URL = render_endpoint_url || 'https://pauseai.info/api/onboarding-email'
const RENDER_TIMEOUT_MS = 8000

function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
)
])
}

async function tryRenderEndpoint() {
let response
try {
response = await withTimeout(
fetch(RENDER_ENDPOINT_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${renderSecret}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ first_name, country, intent, languages, airtable_id })
}),
RENDER_TIMEOUT_MS
)
} catch (error) {
console.log(
`Render endpoint request failed (${error.message}) - falling back to template_id send`
)
return null
}

if (!response.ok) {
console.log(`Render endpoint returned ${response.status} - falling back to template_id send`)
return null
}

let data
try {
data = await response.json()
} catch (error) {
console.log(
`Render endpoint response wasn't valid JSON (${error.message}) - falling back to template_id send`
)
return null
}

if (!data || !data.subject || !data.html || !data.text) {
console.log(
'Render endpoint response missing subject/html/text - falling back to template_id send'
)
return null
}

return data
}

const rendered = await tryRenderEndpoint()

if (rendered) {
// Sender is a fixed rule, not chapter data: global default info@pauseai.info; UK
// standardizes on hello@pauseai.uk. Never pull an arbitrary "from" address out of
// Airtable - the sending domain must be verified in MailerSend.
const isUK = Boolean(country && country.trim().includes('United Kingdom'))
const from = isUK
? { email: 'hello@pauseai.uk', name: 'PauseAI UK' }
: { email: 'info@pauseai.info', name: 'PauseAI' }

// CC/reply-to logic is unchanged from the template_id path below.
let response = await fetch('https://api.mailersend.com/v1/email', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from,
reply_to: {
email: reply_to_email
},
to: [{ email: to_email }],
cc: [{ email: onboarding_email }],
subject: rendered.subject,
html: rendered.html,
text: rendered.text
})
})

if (!response.ok) throw new Error(`Failed: ${await response.text()}`)
console.log(
`✅ Email sent via render endpoint (rendered subject: "${rendered.subject}", sender: ${from.email})`
)
} else {
// -------------------------------------------------------------------------------
// Fallback path: the original template_id-based logic, unchanged.
// -------------------------------------------------------------------------------

// Define Template IDs
const DEFAULT_TEMPLATE = '3z0vkloo5v1l7qrx'
const SPANISH_TEMPLATE = 'o65qngkj1mjlwr12'
const UK_TEMPLATE = 'jy7zpl97rdrg5vx6'
const CANADA_EN_TEMPLATE = 'x2p0347j3r94zdrn'
const CANADA_FR_TEMPLATE = 'z86org8zmvkgew13'
// For people who did not sign up to volunteer (Intent = Act now / Keep informed).
const NOT_VOLUNTEERING_TEMPLATE = 'z86org8mdrelew13'
// UK-specific non-volunteer template (PauseAI UK, sender hello@pauseai.uk).
const UK_NON_VOLUNTEERING_TEMPLATE = 'zr6ke4nyyomgon12'

// `Languages` is a multipleSelects field, so its value is an ARRAY (e.g. ["English","Español"]),
// and it is only ever written by the step-3 volunteer form. That form runs after this automation's
// trigger (record creation), so `languages` is empty on every send and the language branches below
// never fire: Spanish reaches its template via `country` alone, and CANADA_FR is unreachable.
const langList = Array.isArray(languages) ? languages : languages ? [String(languages)] : []
const langLower = langList.map((l) => String(l).toLowerCase())

// Initial Logic: Default to English, switch if any selected language is Spanish
let template_id = DEFAULT_TEMPLATE

if (langLower.some((l) => l.startsWith('español') || l.startsWith('spanish'))) {
template_id = SPANISH_TEMPLATE
}

// List of Spanish-speaking countries
const spanishCountries = new Set([
'Argentina',
'Bolivia',
'Chile',
'Colombia',
'Costa Rica',
'Cuba',
'Dominican Republic',
'Ecuador',
'El Salvador',
'Equatorial Guinea',
'Guatemala',
'Honduras',
'Mexico',
'Nicaragua',
'Panama',
'Paraguay',
'Peru',
'Spain',
'Uruguay',
'Venezuela'
])

if (country) {
const cleanCountry = country.trim()

if (spanishCountries.has(cleanCountry)) {
template_id = SPANISH_TEMPLATE
}

if (cleanCountry.includes('United Kingdom')) {
template_id = UK_TEMPLATE
}

// Canada Logic: Default to English unless French is explicitly selected
if (cleanCountry.includes('Canada')) {
const isFrench = langLower.some((l) => l.includes('french') || l.includes('français'))
template_id = isFrench ? CANADA_FR_TEMPLATE : CANADA_EN_TEMPLATE
}
}

// Only `Volunteer` and `Lead` get the volunteer-framed geography templates. Everything else
// (`Act now`, `Keep informed`, an empty Intent, an unrecognised value) gets the non-volunteer
// template: it asserts nothing about why the reader signed up, so it is true for anyone, while
// the volunteer templates tell the reader they joined a volunteer network, which is false for the
// ~47% who did not. Intent therefore beats geography, and every uncertain case resolves to the
// email that cannot be wrong.
// Cost: if the `intent` mapping breaks, every record lands on the Global EN non-volunteer
// template and volunteers lose geography-specific content (the UK call invite, the ES template).
const KNOWN_INTENTS = ['Act now', 'Keep informed', 'Volunteer', 'Lead']
const cleanIntent = String(intent || '').trim()

// `intent` is empty when the input variable is unmapped, and also when the record itself has no
// Intent (the form always sets one; a hand-made Airtable record does not). The two are
// indistinguishable here. KNOWN_INTENTS are exact labels of an Airtable single-select option, so
// renaming one stops the match without any error. These logs are the only signal for either.
if (!cleanIntent) {
console.log(
`WARNING: Intent is empty - check the input variable mapping in the script config panel`
)
} else if (!KNOWN_INTENTS.includes(cleanIntent)) {
console.log(
`WARNING: unrecognised Intent "${cleanIntent}" - routing to the non-volunteer template`
)
}

if (cleanIntent !== 'Volunteer' && cleanIntent !== 'Lead') {
template_id = NOT_VOLUNTEERING_TEMPLATE
if (country && country.trim().includes('United Kingdom')) {
template_id = UK_NON_VOLUNTEERING_TEMPLATE
}
}

console.log(
`intent="${cleanIntent}" country="${country}" languages=${JSON.stringify(langList)} -> template ${template_id}`
)

// Send Email using Template
let response = await fetch('https://api.mailersend.com/v1/email', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
reply_to: {
email: reply_to_email
},
to: [{ email: to_email }],
cc: [{ email: onboarding_email }],
template_id: template_id,
personalization: [
{
email: to_email,
data: {
first_name: first_name,
verification_link: verification_link
}
}
]
})
})

// Error Handling
if (!response.ok) throw new Error(`Failed: ${await response.text()}`)
console.log(`✅ Email sent using template: ${template_id}`)
}
24 changes: 24 additions & 0 deletions email-templates/ROUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Email template routing

Script: `airtable-mailersend-emails.js` (repo root). Airtable automation → this script → MailerSend, picks a `template_id` based on `intent` / `country` / `languages`.

Each `.json` file here = raw payload from `https://app.mailersend.com/templates/<id>/` for one template constant in the script. Fetch that URL (logged in to MailerSend) and paste the response over the stub file. Edit UI: `https://app.mailersend.com/templates/<id>/edit`.

| Script constant | File | Routing condition |
| ------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `DEFAULT_TEMPLATE` | `default-3z0vkloo5v1l7qrx.json` | `Volunteer`/`Lead` intent, fallback when no other rule matches (non-Spanish, non-UK, non-Canada) |
| `SPANISH_TEMPLATE` | `spanish-o65qngkj1mjlwr12.json` | `Volunteer`/`Lead` intent + `country` in Spanish-speaking list (or `languages` includes Spanish — see gap below) |
| `UK_TEMPLATE` | `uk-jy7zpl97rdrg5vx6.json` | `Volunteer`/`Lead` intent + `country` includes "United Kingdom" |
| `CANADA_EN_TEMPLATE` | `canada-en-x2p0347j3r94zdrn.json` | `Volunteer`/`Lead` intent + `country` includes "Canada", not French |
| `CANADA_FR_TEMPLATE` | `canada-fr-z86org8zmvkgew13.json` | Intended: as above + `languages` includes French — **currently unreachable, see gap below** |
| `NOT_VOLUNTEERING_TEMPLATE` | `not-volunteering-z86org8mdrelew13.json` | `intent` NOT `Volunteer`/`Lead` (i.e. `Act now`, `Keep informed`, empty, unrecognised), any country except UK |
| `UK_NON_VOLUNTEERING_TEMPLATE` | `uk-non-volunteering-zr6ke4nyyomgon12.json` | `intent` NOT `Volunteer`/`Lead` + `country` includes "United Kingdom" |

## Known gaps / flags for whoever scopes the migration

- **`languages` is always empty at send time.** This automation fires on record creation; `Languages` is only set later, in step 3 of the volunteer form. So: `SPANISH_TEMPLATE` is only ever reached via `country`, never via `languages`, and `CANADA_FR_TEMPLATE` is dead code — every Canadian volunteer gets the English template regardless of language. Decide whether to fix the trigger timing or drop the FR template.
- **`reply_to_email` conflict.** The script always sets `reply_to` from the Airtable `reply_to_email` input at send time — it never uses the template's own `settings.reply_to_email`. So each template's stored reply-to may not reflect what recipients actually see. Worth confirming what Airtable actually passes in per record before assuming the template's value is real.
- **Sender address isn't in the script at all** — `from` is never set on the API call, so MailerSend uses whatever `settings.sender_email`/`sender_name` each template has configured. That's the only place UK's `hello@pauseai.uk` sender identity lives — don't lose it if templates get retired.
- **Personalization payload is fixed:** script only ever sends `first_name` and `verification_link`. Any template variable outside those two won't get filled — check `variables.personalization` in each JSON for surprises.
- **UK volunteer vs UK non-volunteer** (`uk-jy7zpl97rdrg5vx6` vs `uk-non-volunteering-zr6ke4nyyomgon12`) are likely near-duplicates once both are filled in — candidate for a shared base + variant slot instead of two fully separate bodies.
- **Images**: at least one template (UK non-volunteer) has a footer image hosted on MailerSend's own CDN (`bucket.mailersendapp.com`). Those need re-hosting in the repo if templates get retired.
85 changes: 85 additions & 0 deletions email-templates/canada-en-x2p0347j3r94zdrn.json

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions email-templates/canada-fr-z86org8zmvkgew13.json

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions email-templates/default-3z0vkloo5v1l7qrx.json

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions email-templates/not-volunteering-z86org8mdrelew13.json

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions email-templates/spanish-o65qngkj1mjlwr12.json

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions email-templates/uk-jy7zpl97rdrg5vx6.json

Large diffs are not rendered by default.

Loading
Loading