diff --git a/tools/kofi-worker/email.js b/tools/kofi-worker/email.js
new file mode 100644
index 00000000..be93b63f
--- /dev/null
+++ b/tools/kofi-worker/email.js
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('
')
+ 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 = `
+
+
+
+
+
+
+${SUBJECT}
+
+
+
+Your Folio supporter code is inside.
+
+
+
+
+
+
+
+  |
+ Folio |
+
+ |
+ |
+ Thank you${who ? `, ${escapeHtml(who)}` : ''}.
+ Here is your Folio supporter code:
+
+ |
+ |
+
+ On your Android phone, this adds the code in one tap.
+ |
+ |
+ 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 ? `${escapeHtml(beta)} ` : ''}
+ |
+ |
+ Folio stays free and open source. Thank you for keeping it going.
+ McCal
+ |
+
+ |
+ |
+ You are getting this because you supported Folio. Reply to this email to reach me.
+ foliolauncher.com · github.com/McCal-Codes/folio
+ |
+
+ |
+
+
+
+`
+ return { subject: SUBJECT, text, html }
+}
diff --git a/tools/kofi-worker/email.test.mjs b/tools/kofi-worker/email.test.mjs
new file mode 100644
index 00000000..f8a1ba10
--- /dev/null
+++ b/tools/kofi-worker/email.test.mjs
@@ -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.'))
+})
+
+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: `
& "Co" 'x'`, code: CODE, pool: 'm1' })
+ assert.ok(!html.includes('
{
+ const { html } = supporterEmail({ name: 'Alex', code: CODE, pool: 'beta' })
+ assert.ok(!/