@readystack/email-footer-law-lint 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,16 @@
1
+ Email Footer Law Lint (CAN-SPAM · CASL · DDG) — Licence
2
+
3
+ Free scope
4
+ Check the email template open in your editor against all 14 footer-law rules, each finding carrying the statute and the fix is free for any use, personal or commercial, for as long as you keep the
5
+ extension installed, and the online check at the product page is free without registration.
6
+
7
+ Paid scope
8
+ Sweep every template in the workspace and write one dated, citable audit report file you can hand to a client or to counsel is the paid part. It asks for a licence key issued at purchase (one seat, one key;
9
+ 7-day full refund, no questions). The key is checked against the payment provider and
10
+ cached locally for 30 days so it keeps working offline.
11
+
12
+ Redistribution
13
+ You may not resell, re-host or bundle this extension or its rule set. The rule set and the
14
+ engine are provided as-is; check results are advisory and do not constitute legal advice.
15
+
16
+ (c) ReadyStack — https://getreadystack.com
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Email Footer Law Lint
2
+
3
+ ![Email Footer Law Lint](https://getreadystack.com/img/promo/sku40591_result_card.jpg)
4
+
5
+ Fourteen checks on an HTML email footer: CAN-SPAM postal address and 10-business-day opt-out, CASL's 60-day window, and German 5 DDG, the statute that replaced 5 TMG on 2024-05-14.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npx @readystack/email-footer-law-lint file
11
+ ```
12
+
13
+ Node 18+. The same 14 rules as the VS Code extension, from a terminal or CI.
14
+
15
+ ## Free
16
+
17
+ - Check the email template open in your editor against all 14 footer-law rules, every finding carrying its line, its statute and its fix
18
+ - `--rules` lists every rule
19
+
20
+ ## With a licence ($29 once)
21
+
22
+ - Sweep every template in the workspace and write one dated, citable audit report file you can hand to a client or to counsel
23
+
24
+ ```
25
+ @readystack/email-footer-law-lint --dir ./templates --report html --out report.html
26
+ ```
27
+
28
+ Outside counsel reads one email footer against CAN-SPAM, CASL and 5 DDG at about $300 an hour, and reads it once.
29
+
30
+ 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
31
+
32
+ [Get a licence](https://buy.polar.sh/polar_cl_UpcPQkomm2d1N52PaYHHfPjdB89okIRPLaTa70CzAj0)
33
+
34
+
35
+ <!-- email footer law lint -->
package/cli.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
3
+ 'use strict';
4
+ const fs = require('fs'), path = require('path');
5
+ const RULES = require('./rules.json');
6
+ const S = require('./strings.json');
7
+ const lic = require('./license.js');
8
+ const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
9
+ const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
10
+ const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
11
+ function scan(text, file) {
12
+ const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
13
+ return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
14
+ }
15
+ function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
16
+ function walk(dir, exts, out) {
17
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
18
+ for (const e of ents) {
19
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
20
+ const p = path.join(dir, e.name);
21
+ if (e.isDirectory()) walk(p, exts, out);
22
+ else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
27
+ function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
28
+ function render(rows, fmt) {
29
+ if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
30
+ if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
31
+ if (fmt === 'html') {
32
+ const n = rows.reduce((a, r) => a + r.hits.length, 0);
33
+ let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
34
+ o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
35
+ for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
36
+ return o + '</table>';
37
+ }
38
+ let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
39
+ return o;
40
+ }
41
+ function help() {
42
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
43
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
44
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
45
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
46
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
47
+ ' ' + S.bin + ' --rules list the ' + RULE_N + ' rules', '',
48
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
49
+ }
50
+ (async function main() {
51
+ try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
52
+ const a = process.argv.slice(2);
53
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
54
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
55
+ if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
56
+ if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
57
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
58
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
59
+ const paid = !!(dir || fmt || ci);
60
+ if (paid) {
61
+ const r = await lic.ensure();
62
+ if (!r.ok) { process.stderr.write(S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ', once): ' + lic.BUY_URL + '\n'); process.exit(2); }
63
+ }
64
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
65
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
66
+ const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
67
+ const text = render(rows, fmt || 'text');
68
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
69
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
70
+ if (ci && errors) process.exit(1);
71
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
package/engine.js ADDED
@@ -0,0 +1,164 @@
1
+ // Email Footer Law Lint — the whole brain. Same file runs in Node (VS Code) and in the browser.
2
+ 'use strict';
3
+ var RULES = (typeof module !== 'undefined' && module.exports) ? require('./rules.json') : window.EFL_RULES;
4
+
5
+ var UNSUB = /(unsubscrib|un-?subscribe|opt[\s-]?out|opting[\s-]?out|abmelde|abbestell|austragen|désabonn)/i;
6
+ var DE_MARK = /(GmbH|UG \(haftungsbeschr|Abmelden|Datenschutz|Impressum|Anbieter|Geschäftsführer|Handelsregister|Umsatzsteuer|USt-IdNr|Newsletter abbestellen)/g;
7
+ var IMPRESSUM = /(impressum|anbieterkennzeichnung|legal-?notice)/i;
8
+ var REGISTER = /(handelsregister|\bHRB\b|\bHRA\b|USt-?IdNr|VAT ID|Umsatzsteuer-Identifikationsnummer)/i;
9
+ var ENTITY = /\b(Inc\.?|LLC|L\.L\.C\.|Ltd\.?|Limited|Corp\.?|Corporation|GmbH|AG|KGaA|KG|UG|e\.K\.|B\.V\.|N\.V\.|S\.A\.|S\.A\.S|SARL|S\.r\.l\.|Oy|AB|A\/S|ApS|Pty|PLC|plc)\b/;
10
+ var CONSENT = /(because you (signed up|subscribed|opted|asked|created)|you (signed up|subscribed|opted in)|opted[- ]in|consent|einwillig|you are receiving this)/i;
11
+
12
+ function blankOut(s, re) { return s.replace(re, function (m) { return m.replace(/[^\n]/g, ' '); }); }
13
+ function lineAt(text, i) { var n = 1, k; for (k = 0; k < i && k < text.length; k++) { if (text.charCodeAt(k) === 10) n++; } return n; }
14
+
15
+ // tags out, character positions kept so every finding can name a real line
16
+ function stripTags(s) {
17
+ var out = '', map = [], inTag = false, k, c;
18
+ for (k = 0; k < s.length; k++) {
19
+ c = s.charAt(k);
20
+ if (c === '<') { inTag = true; continue; }
21
+ if (c === '>') { if (inTag) { inTag = false; out += ' '; map.push(k); continue; } }
22
+ if (!inTag) { out += c; map.push(k); }
23
+ }
24
+ return { text: out, map: map };
25
+ }
26
+
27
+ function anchors(masked, raw) {
28
+ var list = [], re = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi, m, h;
29
+ while ((m = re.exec(masked))) {
30
+ h = /href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(m[1] || '');
31
+ list.push({
32
+ href: h ? (h[1] || h[2] || h[3] || '') : '',
33
+ text: (m[2] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(),
34
+ line: lineAt(raw, m.index),
35
+ i: m.index
36
+ });
37
+ }
38
+ return list;
39
+ }
40
+
41
+ function check(text, opts) {
42
+ text = String(text == null ? '' : text);
43
+ opts = opts || {};
44
+ var today = /^\d{4}-\d{2}-\d{2}$/.test(String(opts.today || '')) ? String(opts.today) : new Date().toISOString().slice(0, 10);
45
+ var thisYear = parseInt(today.slice(0, 4), 10);
46
+
47
+ var masked = blankOut(blankOut(text, /<!--[\s\S]*?-->/g), /<style\b[\s\S]*?<\/style\s*>/gi);
48
+ var P = stripTags(masked);
49
+ var plain = P.text;
50
+ var flat = plain.replace(/\s+/g, ' ');
51
+ var findings = [];
52
+ var seen = {};
53
+
54
+ function add(id, line, detail) {
55
+ if (seen[id]) { return; }
56
+ seen[id] = 1;
57
+ var r = null, k;
58
+ for (k = 0; k < RULES.length; k++) { if (RULES[k].id === id) { r = RULES[k]; break; } }
59
+ if (!r) { return; }
60
+ findings.push({
61
+ check: id,
62
+ sev: r.sev,
63
+ line: Math.max(1, line || 1),
64
+ msg: (detail ? detail + ' ' : '') + r.msg + ' ' + r.fix + ' [' + r.cite + ']'
65
+ });
66
+ }
67
+ function lineOfPlain(pi) { return lineAt(text, P.map[Math.min(pi, P.map.length - 1)] || 0); }
68
+ function bodyLine() {
69
+ var m = /<\/body\s*>/i.exec(text);
70
+ return m ? lineAt(text, m.index) : Math.max(1, text.split(/\r?\n/).length);
71
+ }
72
+
73
+ var A = anchors(masked, text);
74
+ var unsubs = A.filter(function (a) { return UNSUB.test(a.text) || UNSUB.test(a.href); });
75
+
76
+ // 1 · is there an opt-out at all
77
+ if (!unsubs.length) { add('unsub_missing', bodyLine()); }
78
+
79
+ unsubs.forEach(function (a) {
80
+ var h = a.href.trim();
81
+ // 2 · does it go anywhere
82
+ if (!h || h === '#' || /^javascript:/i.test(h) || /^about:blank$/i.test(h)) { add('unsub_dead_href', a.line, 'href="' + h + '".'); }
83
+ // 3 · does it demand an account
84
+ if (/(\/|[?&=])(log-?in|sign-?in|sign-?on|account|my-?account|dashboard)\b/i.test(h)) { add('unsub_behind_login', a.line, h.slice(0, 70) + ' .'); }
85
+ });
86
+
87
+ // 4..6 · what the footer promises, read near the opt-out words
88
+ var ure = new RegExp(UNSUB.source, 'gi'), um;
89
+ while ((um = ure.exec(flat))) {
90
+ var win = flat.slice(Math.max(0, um.index - 90), um.index + 220);
91
+ var pi = flat.indexOf(win.slice(0, 24));
92
+ var ln = lineOfPlain(pi < 0 ? 0 : pi);
93
+ var gate = /(account number|customer number|member(ship)? number|order number|credit card|processing fee|a fee of|\$\d+(\.\d+)?\s*(fee|charge)|postal code and)/i.exec(win);
94
+ if (gate) { add('unsub_fee_or_data', ln, '"' + gate[0] + '".'); }
95
+ var slow = /(please allow|may take|allow up to|takes? up to|within|processing time of|removal in)[^.]{0,40}?(\d{1,3})\s*(business days|working days|days|weeks|months)/i.exec(win);
96
+ if (slow) {
97
+ var n = parseInt(slow[2], 10), unit = slow[3].toLowerCase(), over = false;
98
+ if (/business|working/.test(unit)) { over = n > 10; }
99
+ else if (unit === 'days') { over = n > 14; }
100
+ else if (unit === 'weeks') { over = n > 2; }
101
+ else { over = n >= 1; }
102
+ if (over) { add('unsub_deadline_too_long', ln, '"' + slow[0].trim() + '".'); }
103
+ }
104
+ var win2 = /(valid|active|work|works|available|good|expires?|live)[^.]{0,40}?(\d{1,3})\s*(days|months)/i.exec(win);
105
+ if (win2) {
106
+ var d = parseInt(win2[2], 10) * (/month/i.test(win2[3]) ? 30 : 1);
107
+ if (d < 60) { add('unsub_window_under_60d', ln, '"' + win2[0].trim() + '" = ' + d + ' days.'); }
108
+ }
109
+ }
110
+
111
+ // 7 · conspicuous, or buried
112
+ var sre = /(font-size\s*:\s*(\d+(?:\.\d+)?)\s*px|display\s*:\s*none|visibility\s*:\s*hidden)/gi, sm;
113
+ while ((sm = sre.exec(text))) {
114
+ var near = text.slice(Math.max(0, sm.index - 260), sm.index + 420);
115
+ if (!UNSUB.test(near)) { continue; }
116
+ if (sm[2] === undefined) { add('unsub_too_small', lineAt(text, sm.index), 'The opt-out block carries ' + sm[1].replace(/\s+/g, '') + '.'); }
117
+ else if (parseFloat(sm[2]) < 10) { add('unsub_too_small', lineAt(text, sm.index), 'The opt-out is set at ' + sm[2] + 'px.'); }
118
+ }
119
+
120
+ // 8..9 · the postal address
121
+ var STREET = /(\b\d{1,6}\s+[A-Z][\w.'-]*(?:\s+[\w.'-]+){0,4}\s+(St|Street|Ave|Avenue|Rd|Road|Blvd|Boulevard|Dr|Drive|Ln|Lane|Way|Pkwy|Parkway|Ct|Court|Plaza|Sq|Square|Terrace|Circle)\b|\bP\.?\s?O\.?\s*Box\s*\d+|[A-ZÄÖÜ][\wäöüß.-]*(?:straße|strasse|str\.|weg|allee|platz|gasse|ring|damm|ufer)\s+\d+|\b\d{1,4}\s+(rue|avenue|boulevard)\s+[A-Z])/i;
122
+ var PLACEHOLDER = /(\[[^\]]*(address|street|company|city)[^\]]*\]|123 Main (St|Street)|1234 Street|Your Company (Name|Address)|123 Anywhere|Musterstraße|Musterstrasse|Lorem ipsum|ADDRESS_LINE|YOUR[_ ]ADDRESS|Street Address Here)/i;
123
+ var ph = PLACEHOLDER.exec(flat);
124
+ if (ph) { add('postal_address_placeholder', lineOfPlain(flat.indexOf(ph[0])), '"' + ph[0] + '".'); }
125
+ if (!STREET.test(flat)) { add('postal_address_missing', bodyLine()); }
126
+
127
+ // 10 · the German provider line
128
+ var tmg = /(§\s*5\s*(Abs\.?\s*\d\s*)?TMG|\bTMG\b|Telemediengesetz)/.exec(text);
129
+ if (tmg) { add('tmg_5_outdated', lineAt(text, tmg.index), '"' + tmg[0] + '" as of ' + today + '.'); }
130
+
131
+ // 11 · German-facing, but the imprint is not reachable
132
+ var marks = flat.match(DE_MARK) || [];
133
+ var distinct = {}, dn = 0;
134
+ marks.forEach(function (x) { var key = x.toLowerCase(); if (!distinct[key]) { distinct[key] = 1; dn++; } });
135
+ var hasImpressumLink = A.some(function (a) { return IMPRESSUM.test(a.text) || IMPRESSUM.test(a.href); });
136
+ if (dn >= 2 && !hasImpressumLink && !REGISTER.test(flat)) { add('de_imprint_incomplete', bodyLine(), dn + ' German provider markers, no Impressum link, no register or VAT number.'); }
137
+
138
+ // 12 · who is actually sending this
139
+ if (!ENTITY.test(flat) && !/(©|&copy;|Copyright)\s*\d{0,4}\s*[A-Z]/.test(flat)) { add('sender_identity_missing', bodyLine()); }
140
+
141
+ // 13 · the open-tracking pixel
142
+ var ire = /<img\b[^>]*>/gi, im2, pixel = null;
143
+ while ((im2 = ire.exec(masked))) {
144
+ var tag = im2[0];
145
+ var w = /\bwidth\s*=\s*["']?(\d+)/i.exec(tag), hh = /\bheight\s*=\s*["']?(\d+)/i.exec(tag);
146
+ var styled = /width\s*:\s*1px/i.test(tag) && /height\s*:\s*1px/i.test(tag);
147
+ if ((w && hh && +w[1] <= 1 && +hh[1] <= 1) || styled) { pixel = im2; break; }
148
+ }
149
+ if (pixel && !CONSENT.test(flat)) { add('tracking_pixel_unconsented', lineAt(text, pixel.index)); }
150
+
151
+ // 14 · the year in the footer, read against today
152
+ var yre = /(©|&copy;|Copyright)[^0-9]{0,20}(\d{4})(\s*[-–]\s*(\d{4}))?/gi, ym, newest = 0, yline = 1;
153
+ while ((ym = yre.exec(text))) {
154
+ var y = parseInt(ym[4] || ym[2], 10);
155
+ if (y > newest) { newest = y; yline = lineAt(text, ym.index); }
156
+ }
157
+ if (newest && newest < thisYear) { add('copyright_year_stale', yline, 'Footer says ' + newest + '; today is ' + today + '.'); }
158
+
159
+ findings.sort(function (a, b) { return a.line - b.line; });
160
+ return { findings: findings };
161
+ }
162
+
163
+ var EFLENGINE = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
164
+ if (typeof module !== 'undefined' && module.exports) { module.exports = EFLENGINE; } else { window.EFLENGINE = EFLENGINE; }
package/license.js ADDED
@@ -0,0 +1,64 @@
1
+ // license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
2
+ 'use strict';
3
+ const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
4
+ const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
5
+ const BENEFIT_ID = '14f46867-b9b1-488f-9e0f-9315a28c4b58'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
6
+ const BUY_URL = 'https://buy.polar.sh/polar_cl_UpcPQkomm2d1N52PaYHHfPjdB89okIRPLaTa70CzAj0';
7
+ const SLUG = 'email-footer-law-lint';
8
+ const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
9
+ const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
10
+ function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
11
+ function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
12
+ function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
13
+ function validate(key) {
14
+ return new Promise(function (resolve) {
15
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
16
+ const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(BENEFIT_ID) ? { key: key, organization_id: ORG_ID, benefit_id: BENEFIT_ID } : { key: key, organization_id: ORG_ID });
17
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
18
+ headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
19
+ let buf = ''; res.on('data', function (d) { buf += d; });
20
+ res.on('end', function () {
21
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
22
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
23
+ catch (e) { resolve({ ok: false, offline: false }); }
24
+ });
25
+ });
26
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
27
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
28
+ req.write(body); req.end();
29
+ });
30
+ }
31
+ async function ensure(explicitKey) {
32
+ const st = load();
33
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
34
+ if (!key) return { ok: false, why: 'no_key' };
35
+ const age = Date.now() - (st.okAt || 0);
36
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
37
+ const r = await validate(String(key).trim());
38
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
39
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
40
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
41
+ }
42
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
43
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
44
+ function pullFeed() {
45
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
46
+ if (!key) return Promise.resolve(cached);
47
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
48
+ return new Promise(function (resolve) {
49
+ let req;
50
+ try {
51
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
52
+ let buf = ''; res.on('data', function (d) { buf += d; });
53
+ res.on('end', function () {
54
+ if (res.statusCode !== 200) return resolve(cached);
55
+ try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
56
+ catch (e) { resolve(cached); }
57
+ });
58
+ });
59
+ } catch (e) { return resolve(cached); }
60
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
61
+ req.on('error', function () { resolve(cached); });
62
+ });
63
+ }
64
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@readystack/email-footer-law-lint",
3
+ "version": "1.0.0",
4
+ "description": "Fourteen checks on an HTML email footer: CAN-SPAM postal address and 10-business-day opt-out, CASL's 60-day window, and German 5 DDG, the statute that replaced 5 TMG on 2024-05-14.",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "email",
11
+ "can-spam",
12
+ "casl",
13
+ "ddg",
14
+ "impressum",
15
+ "unsubscribe",
16
+ "html email",
17
+ "compliance",
18
+ "deliverability"
19
+ ],
20
+ "homepage": "https://getreadystack.com",
21
+ "funding": "https://buy.polar.sh/polar_cl_UpcPQkomm2d1N52PaYHHfPjdB89okIRPLaTa70CzAj0",
22
+ "bin": {
23
+ "email-footer-law-lint": "cli.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "cli.js",
30
+ "license.js",
31
+ "rules.json",
32
+ "strings.json",
33
+ "README.md",
34
+ "LICENSE.txt",
35
+ "engine.js"
36
+ ]
37
+ }
package/rules.json ADDED
@@ -0,0 +1,100 @@
1
+ [
2
+ {
3
+ "id": "unsub_missing",
4
+ "sev": "error",
5
+ "msg": "No unsubscribe link anywhere in this template.",
6
+ "fix": "Add a working one-click opt-out link in the footer.",
7
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(3); CASL s.11(1)"
8
+ },
9
+ {
10
+ "id": "unsub_dead_href",
11
+ "sev": "error",
12
+ "msg": "The opt-out link points nowhere.",
13
+ "fix": "Point it at a real opt-out endpoint or an ESP merge tag, not # or an empty href.",
14
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(3)(A)"
15
+ },
16
+ {
17
+ "id": "unsub_behind_login",
18
+ "sev": "error",
19
+ "msg": "The opt-out link lands on a sign-in page.",
20
+ "fix": "Opt-out must work from one page with no account; drop the login redirect.",
21
+ "cite": "CAN-SPAM 16 CFR 316.5"
22
+ },
23
+ {
24
+ "id": "unsub_fee_or_data",
25
+ "sev": "error",
26
+ "msg": "Opting out is gated behind a fee or extra personal data.",
27
+ "fix": "Ask for nothing but the click; the address is already in the link.",
28
+ "cite": "CAN-SPAM 16 CFR 316.5"
29
+ },
30
+ {
31
+ "id": "unsub_deadline_too_long",
32
+ "sev": "error",
33
+ "msg": "The footer promises a removal window longer than the law allows.",
34
+ "fix": "Honour opt-outs within 10 business days and say so.",
35
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(4)(A)(ii)"
36
+ },
37
+ {
38
+ "id": "unsub_window_under_60d",
39
+ "sev": "error",
40
+ "msg": "The opt-out link is declared valid for less than 60 days.",
41
+ "fix": "Keep the unsubscribe mechanism live for at least 60 days after sending.",
42
+ "cite": "CASL S.C. 2010 c.23 s.11(1)(b)"
43
+ },
44
+ {
45
+ "id": "unsub_too_small",
46
+ "sev": "warn",
47
+ "msg": "The opt-out is styled too small or hidden to be conspicuous.",
48
+ "fix": "Render the opt-out at 10px or larger and keep it visible.",
49
+ "cite": "CAN-SPAM 16 CFR 316.5; CASL s.11(1)"
50
+ },
51
+ {
52
+ "id": "postal_address_missing",
53
+ "sev": "error",
54
+ "msg": "No physical postal address in the template.",
55
+ "fix": "Add the sender's street address or registered PO Box to the footer.",
56
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(5)(A)(iii)"
57
+ },
58
+ {
59
+ "id": "postal_address_placeholder",
60
+ "sev": "error",
61
+ "msg": "The postal address is still sample or placeholder text.",
62
+ "fix": "Replace it with the real registered address before this template ships.",
63
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(5)(A)(iii)"
64
+ },
65
+ {
66
+ "id": "tmg_5_outdated",
67
+ "sev": "error",
68
+ "msg": "The provider line still cites the TMG, which was repealed on 2024-05-14.",
69
+ "fix": "Cite 5 DDG (Digitale-Dienste-Gesetz) instead of 5 TMG.",
70
+ "cite": "DDG 5; TMG repealed by DDG, in force 2024-05-14"
71
+ },
72
+ {
73
+ "id": "de_imprint_incomplete",
74
+ "sev": "error",
75
+ "msg": "German-facing footer with no Impressum link and no register or VAT identifier.",
76
+ "fix": "Link the Impressum, or name the register court, HRB number and USt-IdNr.",
77
+ "cite": "DDG 5 Abs. 1 Nr. 4 and Nr. 6"
78
+ },
79
+ {
80
+ "id": "sender_identity_missing",
81
+ "sev": "error",
82
+ "msg": "The footer never names the legal entity sending the mail.",
83
+ "fix": "Name the sending company as registered, not just the brand.",
84
+ "cite": "CAN-SPAM 15 U.S.C. 7704(a)(5); CASL s.6(2)(a); DDG 5 Abs. 1 Nr. 1"
85
+ },
86
+ {
87
+ "id": "tracking_pixel_unconsented",
88
+ "sev": "warn",
89
+ "msg": "A 1x1 open-tracking pixel ships with no line telling the reader why they got this mail.",
90
+ "fix": "State the consent basis in the footer, or drop the pixel for EU recipients.",
91
+ "cite": "ePrivacy Directive Art. 5(3); EDPB Guidelines 2/2023, adopted 2024-10-14"
92
+ },
93
+ {
94
+ "id": "copyright_year_stale",
95
+ "sev": "warn",
96
+ "msg": "The copyright year in the footer is older than today.",
97
+ "fix": "Roll the year, or render it from the send date.",
98
+ "cite": "House style; a stale year is the cheapest tell that a footer is unmaintained"
99
+ }
100
+ ]
package/strings.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Email Footer Law Lint",
3
+ "subtitle": "Fourteen checks on an HTML email footer: CAN-SPAM postal address and 10-business-day opt-out, CASL's 60-day window, and German 5 DDG, the statute that replaced 5 TMG on 2024-05-14.",
4
+ "bin": "email-footer-law-lint",
5
+ "price": 29,
6
+ "free": "Check the email template open in your editor against all 14 footer-law rules, every finding carrying its line, its statute and its fix",
7
+ "paid": "Sweep every template in the workspace and write one dated, citable audit report file you can hand to a client or to counsel",
8
+ "need_key": "This option needs a licence (Email Footer Law Lint).",
9
+ "exts": []
10
+ }