Skip to content
Merged
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
138 changes: 138 additions & 0 deletions tools/kofi-worker/email.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* The email a supporter gets with their code, as plain text and as HTML. The words are McCal's, from the first version
* of this email; the HTML only sets them out. It is table-based with inline styles because mail apps ignore most of
* CSS, it asks for a dark look only where the app supports one, and it carries no script and one image, the icon, from
* foliolauncher.com (an app that blocks images shows the wordmark beside it instead).
*
* The code is long, 24 groups of up to five characters, so it is set out four groups to a line. Folio's Redeem a Code
* ignores hyphens, spaces and line breaks and upper-cases what it reads, so pasting the block works as it does on one line.
*/

const TEAL = '#2e5e66'
const TEAL_DEEP = '#173d43'
const MINT = '#7fd8c4'

/** Ko-fi gives the name the supporter typed, so it is escaped before it goes into HTML. */
export function escapeHtml(text) {
return String(text ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}

/** The code four groups to a line: `AAAAA-BBBBB-CCCCC-DDDDD-` and so on, each line ending on a hyphen but the last. */
export function codeLines(code) {
const groups = String(code).split('-')
const lines = []
for (let i = 0; i < groups.length; i += 4) lines.push(groups.slice(i, i + 4).join('-'))
return lines.map((line, index) => (index < lines.length - 1 ? `${line}-` : line))
}

/** Folio's own link for a code (RedeemActivity, since 0.6.5): on an Android phone with Folio it adds the code in one tap. */
export function redeemLink(code) {
return `folio://redeem?c=${encodeURIComponent(String(code))}`
}

const SUBJECT = 'Your Folio supporter code'
const betaLine = (pool) => pool === 'beta' || pool === 'all'
? 'It turns on Beta Features, which you can switch off any time: early features come with more bugs.' : ''

export function supporterEmail({ name = '', code, pool = '' }) {
const who = String(name).trim()
const beta = betaLine(pool)
const text = [
`Thank you${who ? `, ${who}` : ''}.`,
'',
'Here is your Folio supporter code:',
'',
` ${code}`,
'',
'On your Android phone, this link adds it in one tap:',
` ${redeemLink(code)}`,
'',
'In Folio: Settings › Supporter › Redeem a Code, and paste it in. It is checked on your phone, so it works',
'offline and tells nobody that you supported.',
...(beta ? ['', beta] : []),
'',
'Folio stays free and open source. Thank you for keeping it going.',
'McCal',
].join('\n')

const link = escapeHtml(redeemLink(code))
const lines = codeLines(code).map((line) => escapeHtml(line)).join('<br>')
const font = `-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',Roboto,Helvetica,Arial,sans-serif`
const mono = `ui-monospace,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace`
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>${SUBJECT}</title>
<style>
@media (prefers-color-scheme: dark) {
.page { background:#000000 !important }
.card { background:#1c1c1e !important }
.ink { color:#f5f5f7 !important }
.dim { color:#aeaeb2 !important }
.codebox { background:#0f2a2f !important; border-color:#2e5e66 !important }
.codetext { color:#7fd8c4 !important }
.path { background:#2c2c2e !important; color:#f5f5f7 !important }
.btn { background:#7fd8c4 !important }
.btntext { color:#06222a !important }
.rule { border-color:#2c2c2e !important }
a { color:#7fd8c4 !important }
}
@media (max-width:480px) { .pad { padding-left:22px !important; padding-right:22px !important } .codetext { font-size:15px !important } }
</style>
</head>
<body class="page" style="margin:0;padding:0;background:#f2f2f7;-webkit-text-size-adjust:100%">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">Your Folio supporter code is inside.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="page" bgcolor="#f2f2f7" style="background:#f2f2f7">
<tr><td align="center" style="padding:28px 12px">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px">
<tr><td class="card" bgcolor="#ffffff" style="background:#ffffff;border-radius:20px;overflow:hidden">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td bgcolor="${TEAL_DEEP}" style="background:${TEAL_DEEP};background-image:linear-gradient(135deg,${TEAL} 0%,${TEAL_DEEP} 100%);padding:26px 32px" class="pad">
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle" style="padding-right:14px"><img src="https://foliolauncher.com/icon.png" width="44" height="44" alt="" style="display:block;border-radius:10px;border:0"></td>
<td valign="middle" style="font:700 22px/1 ${font};color:#ffffff;letter-spacing:.2px">Folio</td>
</tr></table>
</td></tr>
<tr><td class="pad" style="padding:32px 32px 8px">
<p class="ink" style="margin:0 0 14px;font:700 26px/1.2 ${font};color:#1c1c1e">Thank you${who ? `, ${escapeHtml(who)}` : ''}.</p>
<p class="ink" style="margin:0 0 16px;font:400 16px/1.5 ${font};color:#1c1c1e">Here is your Folio supporter code:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="codebox" bgcolor="#eaf6f3" style="background:#eaf6f3;border:1px solid #bfe3da;border-radius:14px;padding:18px 20px">
<span class="codetext" style="font:600 16px/1.7 ${mono};color:${TEAL_DEEP};letter-spacing:.6px;word-break:break-all">${lines}</span>
</td>
</tr></table>
</td></tr>
<tr><td class="pad" style="padding:20px 32px 0">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr>
<td class="btn" align="center" bgcolor="${TEAL}" style="background:${TEAL};border-radius:12px">
<a href="${link}" class="btntext" style="display:block;padding:15px 22px;font:600 16px/1.2 ${font};color:#ffffff;text-decoration:none">Open in Folio</a>
</td>
</tr></table>
<p class="dim" style="margin:10px 0 0;font:400 13px/1.5 ${font};color:#6c6c70;text-align:center">On your Android phone, this adds the code in one tap.</p>
</td></tr>
<tr><td class="pad" style="padding:22px 32px 4px">
<p class="ink" style="margin:0 0 14px;font:400 16px/1.55 ${font};color:#1c1c1e">In Folio: <span class="path" style="background:#f2f2f7;color:#1c1c1e;border-radius:6px;padding:2px 7px;font-weight:600;white-space:nowrap">Settings › Supporter › Redeem a Code</span>, and paste it in. It is checked on your phone, so it works offline and tells nobody that you supported.</p>
${beta ? `<p class="dim" style="margin:0 0 14px;font:400 15px/1.5 ${font};color:#6c6c70">${escapeHtml(beta)}</p>` : ''}
</td></tr>
<tr><td class="pad" style="padding:8px 32px 30px">
<p class="ink" style="margin:0 0 4px;font:400 16px/1.55 ${font};color:#1c1c1e">Folio stays free and open source. Thank you for keeping it going.</p>
<p class="ink" style="margin:14px 0 0;font:600 16px/1.4 ${font};color:#1c1c1e">McCal</p>
</td></tr>
</table>
</td></tr>
<tr><td align="center" class="pad" style="padding:18px 22px 0">
<p class="dim" style="margin:0;font:400 12.5px/1.6 ${font};color:#636366">You are getting this because you supported Folio. Reply to this email to reach me.<br>
<a href="https://foliolauncher.com" style="color:${TEAL};text-decoration:none">foliolauncher.com</a> &nbsp;·&nbsp; <a href="https://github.com/McCal-Codes/folio" style="color:${TEAL};text-decoration:none">github.com/McCal-Codes/folio</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
`
return { subject: SUBJECT, text, html }
}
66 changes: 66 additions & 0 deletions tools/kofi-worker/email.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* The supporter email: McCal's words kept as written, the HTML safe to build from a name anyone can type on Ko-fi, and
* the "Open in Folio" link in the exact shape RedeemActivity (app/.../RedeemActivity.kt, since 0.6.5) accepts.
* Run with `node --test email.test.mjs`; no network.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { codeLines, escapeHtml, redeemLink, supporterEmail } from './email.js'

// 24 groups of up to five characters, the shape admin.js's CODE accepts. Made up; it is not a real code.
const CODE = Array.from({ length: 24 }, (_, i) => (i === 23 ? 'JW' : `K9H${String(i).padStart(2, '0')}`.slice(0, 5))).join('-')

test('the code is set out four groups to a line and joins back to the same code', () => {
const lines = codeLines(CODE)
assert.equal(lines.length, 6)
assert.ok(lines.slice(0, -1).every((line) => line.endsWith('-')), 'every line but the last ends on its hyphen')
assert.equal(lines.join(''), CODE)
})

test('the plain text keeps the words as they were, without the old dash before the name', () => {
const { text, subject } = supporterEmail({ name: 'Alex', code: CODE, pool: 'm1' })
assert.equal(subject, 'Your Folio supporter code')
assert.ok(text.startsWith('Thank you, Alex.\n\nHere is your Folio supporter code:\n\n ' + CODE))
assert.ok(text.includes('In Folio: Settings › Supporter › Redeem a Code, and paste it in. It is checked on your phone, so it works'))
assert.ok(text.includes('Folio stays free and open source. Thank you for keeping it going.\nMcCal'))
assert.ok(!text.includes('—'), 'no em dash')
})

test('without a name it just says thank you', () => {
const { text, html } = supporterEmail({ name: ' ', code: CODE, pool: 'm1' })
assert.ok(text.startsWith('Thank you.\n'))
assert.ok(html.includes('>Thank you.</p>'))
})

test('the beta line is only for the pools that turn on Beta Features', () => {
for (const pool of ['beta', 'all']) assert.ok(supporterEmail({ code: CODE, pool }).text.includes('It turns on Beta Features'))
for (const pool of ['m1', 'thanks', '']) assert.ok(!supporterEmail({ code: CODE, pool }).text.includes('Beta Features'))
})

test('a name is escaped, so nothing a supporter types becomes markup', () => {
const { html } = supporterEmail({ name: `<img src=x onerror=alert(1)> & "Co" 'x'`, code: CODE, pool: 'm1' })
assert.ok(!html.includes('<img src=x'))
assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt; &amp; &quot;Co&quot; &#39;x&#39;'))
assert.equal(escapeHtml(undefined), '')
})

test('the HTML has no script, no javascript: link, and reaches out to nowhere but Folio and its repository', () => {
const { html } = supporterEmail({ name: 'Alex', code: CODE, pool: 'beta' })
assert.ok(!/<script/i.test(html))
assert.ok(!/javascript:/i.test(html))
const hosts = new Set([...html.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)].map((m) => m[1].toLowerCase()))
assert.deepEqual([...hosts].sort(), ['foliolauncher.com', 'github.com'])
for (const line of codeLines(CODE)) assert.ok(html.includes(line))
})

test('the Open in Folio link is the one RedeemActivity reads', () => {
const link = redeemLink(CODE)
assert.equal(link, `folio://redeem?c=${CODE}`)
assert.ok(supporterEmail({ code: CODE, pool: 'm1' }).html.includes(`href="${link}"`))
assert.ok(supporterEmail({ code: CODE, pool: 'm1' }).text.includes(link))
// The app's redeemCode(): the prefix, a c= parameter, and 20 to 200 letters, digits and hyphens.
const query = link.slice('folio://redeem'.length).split('?')[1]
const code = decodeURIComponent(query.split('&').find((p) => p.startsWith('c=')).slice(2))
assert.ok(code.length >= 20 && code.length <= 200 && /^[A-Za-z0-9-]+$/.test(code))
assert.equal(code, CODE)
})
24 changes: 6 additions & 18 deletions tools/kofi-worker/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/
import { admin } from './admin.js'
import { betaAsset, betaReleases, sameSecret } from './beta.js'
import { supporterEmail } from './email.js'

// Ko-fi's "Send test" buttons post a made-up payment with this transaction id. It proves the webhook reaches the
// worker, so it's written down for the health check, but it never takes a real code out of a pool.
Expand Down Expand Up @@ -187,30 +188,17 @@ async function claimCode(env, pool, data) {
*/
async function email(env, data, code, pool) {
if (!env.RESEND_KEY || !data.email) return false
// The words and the look live in email.js: plain text for apps that show no HTML, and the HTML that most show.
const { subject, text, html } = supporterEmail({ name: data.from_name, code, pool })
const body = {
from: env.MAIL_FROM,
to: data.email,
// Where a reply lands. The From address is on the domain Resend verified, which nobody reads mail at; this is a
// mailbox McCal actually opens, so a supporter who answers isn't talking into the void.
...(env.REPLY_TO ? { reply_to: env.REPLY_TO } : {}),
subject: 'Your Folio supporter code',
text: [
`Thank you${data.from_name ? `, ${data.from_name}` : ''}.`,
'',
'Here is your Folio supporter code:',
'',
` ${code}`,
'',
'In Folio: Settings › Supporter › Redeem a Code, and paste it in. It is checked on your phone, so it works',
'offline and tells nobody that you supported.',
'',
pool === 'beta' || pool === 'all'
? 'It turns on Beta Features, which you can switch off any time: early features come with more bugs.'
: '',
'',
'Folio stays free and open source. Thank you for keeping it going.',
'— McCal',
].filter((line) => line !== undefined).join('\n'),
subject,
text,
html,
}
const sent = await fetch('https://api.resend.com/emails', {
method: 'POST',
Expand Down
22 changes: 22 additions & 0 deletions tools/kofi-worker/worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,25 @@ test('without a mail key the code is still claimed, to send by hand', async () =
assert.equal(DB.handled.get('m1').emailed, 0)
assert.equal(DB.handled.get('m1').code, 'CODE-1')
})

test('the email goes to Resend as both HTML and plain text, with the link and the reply address', async () => {
const DB = database([{ code: 'CODE-1', pool: 'beta' }])
const sent = []
const real = globalThis.fetch
globalThis.fetch = async (url, init) => { sent.push({ url, init }); return new Response('{}', { status: 200 }) }
try {
const env = { DB, KOFI_TOKEN: TOKEN, POOLS, RESEND_KEY: 'key', MAIL_FROM: 'Folio <a@send.example>', REPLY_TO: 'me@example.com' }
await worker.fetch(payment({ type: 'Subscription', tier_name: 'Bronze', from_name: 'Alex' }), env)
} finally {
globalThis.fetch = real
}
assert.equal(sent.length, 1)
assert.equal(sent[0].url, 'https://api.resend.com/emails')
const body = JSON.parse(sent[0].init.body)
assert.equal(body.to, 'jo@example.com')
assert.equal(body.reply_to, 'me@example.com')
assert.equal(body.subject, 'Your Folio supporter code')
assert.ok(body.text.includes('CODE-1') && body.html.includes('CODE-1'))
assert.ok(body.html.includes('href="folio://redeem?c=CODE-1"'))
assert.equal(DB.handled.get('m1').emailed, 1)
})
Loading