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.
+ + + + + +` + 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(!/