readystack-bulk-sender-lint 1.0.5__tar.gz

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.
Files changed (19) hide show
  1. readystack_bulk_sender_lint-1.0.5/LICENSE.txt +15 -0
  2. readystack_bulk_sender_lint-1.0.5/PKG-INFO +82 -0
  3. readystack_bulk_sender_lint-1.0.5/README.md +62 -0
  4. readystack_bulk_sender_lint-1.0.5/pyproject.toml +29 -0
  5. readystack_bulk_sender_lint-1.0.5/setup.cfg +4 -0
  6. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/__init__.py +1 -0
  7. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/__main__.py +12 -0
  8. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/cli.js +161 -0
  9. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/engine.js +121 -0
  10. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/license.js +83 -0
  11. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/package.json +44 -0
  12. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/rules.json +180 -0
  13. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint/js/strings.json +10 -0
  14. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/PKG-INFO +82 -0
  15. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/SOURCES.txt +17 -0
  16. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/dependency_links.txt +1 -0
  17. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/entry_points.txt +2 -0
  18. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/requires.txt +1 -0
  19. readystack_bulk_sender_lint-1.0.5/src/readystack_bulk_sender_lint.egg-info/top_level.txt +1 -0
@@ -0,0 +1,15 @@
1
+ Bulk Sender Lint - Gmail, Yahoo, Outlook — Licence
2
+
3
+ Free scope
4
+ Check the file you have open - every check, with the rule it comes from, the date it took effect and the line 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 file in the workspace and write a dated evidence report you keep - $29 once, one licence key per person or team seat; a deliverability consultant bills $150-$250 an hour is the paid part. It asks for a licence key issued at purchase (one seat, one key). The key is checked against the payment provider and
9
+ cached locally for 30 days so it keeps working offline.
10
+
11
+ Redistribution
12
+ You may not resell, re-host or bundle this extension or its rule set. The rule set and the
13
+ engine are provided as-is; check results are advisory and do not constitute legal advice.
14
+
15
+ (c) ReadyStack — https://getreadystack.com
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: readystack-bulk-sender-lint
3
+ Version: 1.0.5
4
+ Summary: 21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser (bundled Node.js runtime)
5
+ Author-email: ReadyStack <support@getreadystack.com>
6
+ License: Proprietary - see LICENSE.txt
7
+ Project-URL: Homepage, https://getreadystack.com/tools/bulk-sender-lint?ref=pypi
8
+ Project-URL: Documentation, https://getreadystack.com/tools/bulk-sender-lint?ref=pypi
9
+ Project-URL: Support, https://getreadystack.com/support
10
+ Keywords: dmarc,spf,dkim,email deliverability,list-unsubscribe,rfc8058,gmail bulk sender,yahoo,outlook,smtp
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: nodejs-wheel-binaries>=20
19
+ Dynamic: license-file
20
+
21
+ # Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
22
+
23
+ > Runs the same checker as the npm package `@readystack/bulk-sender-lint` on a Node.js runtime that pip installs for you (`nodejs-wheel-binaries`) - no system Node.js needed. Your files are checked locally.
24
+
25
+ 21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser
26
+
27
+ ## Install
28
+
29
+ ```
30
+ bulk-sender-lint file
31
+ ```
32
+
33
+ Node 18+. The same 21 rules as the VS Code extension, from a terminal or CI.
34
+
35
+ ## Free
36
+
37
+ - Check the file you have open - all 21 checks, every finding with the clause it comes from, the date it took effect and the line
38
+ - `--rules` lists every rule
39
+
40
+ ## With a licence ($29 once)
41
+
42
+ - Sweep every matching file in the workspace and write a dated evidence report you keep, for a deliverability review or a client handover
43
+
44
+ ```
45
+ @readystack/bulk-sender-lint --dir ./templates --report html --out report.html
46
+ ```
47
+
48
+ A deliverability consultant bills $150 to $250 an hour
49
+
50
+ ## Use from an AI agent (MCP)
51
+
52
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
53
+
54
+ ```json
55
+ { "mcpServers": { "bulk-sender-lint": { "command": "npx", "args": ["-y", "@readystack/bulk-sender-lint", "--mcp"] } } }
56
+ ```
57
+
58
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence). The agent gets every finding with the line number.
59
+
60
+ ## Use in CI
61
+
62
+ ```yaml
63
+ - name: Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
64
+ run: npx -y @readystack/bulk-sender-lint --dir . --ci
65
+ ```
66
+
67
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/bulk-sender-lint --dir /work --ci`)
68
+
69
+ The folder sweep, reports and CI mode need one licence — one payment, no subscription. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
70
+
71
+ [Get a licence](https://buy.polar.sh/polar_cl_wVUwTqi8qqkeHlhoZHE3VCSgjwBGHxsQL9rYt24DtQP)
72
+
73
+
74
+ <!-- bulk sender lint -->
75
+
76
+
77
+ ## Install (PyPI)
78
+
79
+ ```
80
+ pip install readystack-bulk-sender-lint
81
+ bulk-sender-lint --help
82
+ ```
@@ -0,0 +1,62 @@
1
+ # Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
2
+
3
+ > Runs the same checker as the npm package `@readystack/bulk-sender-lint` on a Node.js runtime that pip installs for you (`nodejs-wheel-binaries`) - no system Node.js needed. Your files are checked locally.
4
+
5
+ 21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser
6
+
7
+ ## Install
8
+
9
+ ```
10
+ bulk-sender-lint file
11
+ ```
12
+
13
+ Node 18+. The same 21 rules as the VS Code extension, from a terminal or CI.
14
+
15
+ ## Free
16
+
17
+ - Check the file you have open - all 21 checks, every finding with the clause it comes from, the date it took effect and the line
18
+ - `--rules` lists every rule
19
+
20
+ ## With a licence ($29 once)
21
+
22
+ - Sweep every matching file in the workspace and write a dated evidence report you keep, for a deliverability review or a client handover
23
+
24
+ ```
25
+ @readystack/bulk-sender-lint --dir ./templates --report html --out report.html
26
+ ```
27
+
28
+ A deliverability consultant bills $150 to $250 an hour
29
+
30
+ ## Use from an AI agent (MCP)
31
+
32
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
33
+
34
+ ```json
35
+ { "mcpServers": { "bulk-sender-lint": { "command": "npx", "args": ["-y", "@readystack/bulk-sender-lint", "--mcp"] } } }
36
+ ```
37
+
38
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence). The agent gets every finding with the line number.
39
+
40
+ ## Use in CI
41
+
42
+ ```yaml
43
+ - name: Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
44
+ run: npx -y @readystack/bulk-sender-lint --dir . --ci
45
+ ```
46
+
47
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/bulk-sender-lint --dir /work --ci`)
48
+
49
+ The folder sweep, reports and CI mode need one licence — one payment, no subscription. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
50
+
51
+ [Get a licence](https://buy.polar.sh/polar_cl_wVUwTqi8qqkeHlhoZHE3VCSgjwBGHxsQL9rYt24DtQP)
52
+
53
+
54
+ <!-- bulk sender lint -->
55
+
56
+
57
+ ## Install (PyPI)
58
+
59
+ ```
60
+ pip install readystack-bulk-sender-lint
61
+ bulk-sender-lint --help
62
+ ```
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "readystack-bulk-sender-lint"
7
+ version = "1.0.5"
8
+ description = "21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser (bundled Node.js runtime)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["nodejs-wheel-binaries>=20"]
12
+ license = {text = "Proprietary - see LICENSE.txt"}
13
+ keywords = ["dmarc", "spf", "dkim", "email deliverability", "list-unsubscribe", "rfc8058", "gmail bulk sender", "yahoo", "outlook", "smtp"]
14
+ authors = [{name = "ReadyStack", email = "support@getreadystack.com"}]
15
+ classifiers = ["Programming Language :: Python :: 3", "Environment :: Console", "Topic :: Software Development :: Quality Assurance", "Operating System :: OS Independent"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://getreadystack.com/tools/bulk-sender-lint?ref=pypi"
19
+ Documentation = "https://getreadystack.com/tools/bulk-sender-lint?ref=pypi"
20
+ Support = "https://getreadystack.com/support"
21
+
22
+ [project.scripts]
23
+ "bulk-sender-lint" = "readystack_bulk_sender_lint.__main__:main"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ "readystack_bulk_sender_lint" = ["js/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ import os, sys
2
+
3
+
4
+ def main():
5
+ # Runs the same checker as the npm package on the Node.js runtime installed with this package (nodejs-wheel-binaries).
6
+ from nodejs_wheel import node
7
+ here = os.path.dirname(os.path.abspath(__file__))
8
+ return node([os.path.join(here, "js", "cli.js")] + sys.argv[1:])
9
+
10
+
11
+ if __name__ == "__main__":
12
+ sys.exit(main())
@@ -0,0 +1,161 @@
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 (['node_modules', '.git', '.hg', '.svn', '.venv', 'venv', '.tox', '.cache', '.next', '.nuxt', '.terraform', '__pycache__'].includes(e.name)) continue; // s158 — .github · .gitlab-ci.yml · .env · .well-known 은 본다 (옛 줄은 점으로 시작하는 것을 전부 건너뛰어 워크플로 검사기가 .github/workflows 를 못 봤다)
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 trialState() {
42
+ // s158 — ⚑7일 무료 없음: 새 체험은 ⛔열지 않는다 · 이미 시작된 체험(trial.json)만 끝까지 지킨다 (읽기 전용)
43
+ if (process.env.READYSTACK_NO_TRIAL) return { active: false };
44
+ try {
45
+ const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
46
+ const t = JSON.parse(fs.readFileSync(p, 'utf8'));
47
+ return { active: !!(t && t.until) && new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t && t.until };
48
+ } catch (e) { return { active: false }; }
49
+ }
50
+ // ★s158 2026-09-23 — the free run SPEAKS about the rest of the folder (vsix: auto.js · crx: welcome tab).
51
+ // The answer for the named files is complete and free; the next step is shown with the user's OWN numbers:
52
+ // "this folder has N more matching files - M issues in K of them" + the one command that sweeps them.
53
+ // Never blocks, never starts a trial (s158: no free trial - the free answer on the named files is the "try it"), human seat only, 400 files / 1.5 s cap.
54
+ function folderHint(done) {
55
+ if (!process.stderr.isTTY || process.env.CI || process.env.READYSTACK_NO_HINT || !done.length) return;
56
+ const base = path.dirname(path.resolve(done[0]));
57
+ const seen = new Set(done.map((f) => path.resolve(f)));
58
+ const all = walk(base, S.exts || [], []).filter((f) => !seen.has(path.resolve(f)));
59
+ if (!all.length) return;
60
+ const t0 = Date.now(); let issues = 0, hitFiles = 0, scanned = 0;
61
+ for (const f of all.slice(0, 400)) { if (Date.now() - t0 > 1500) break; let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { continue; } scanned++; const h = scan(t, f); if (h.length) { issues += h.length; hitFiles++; } }
62
+ const rel = path.relative(process.cwd(), base) || '.';
63
+ const tail = trialState().active ? 'free during your trial' : '$' + S.price + ' once';
64
+ const more = all.length + ' more matching file' + (all.length > 1 ? 's' : '');
65
+ const found = issues ? ' - ' + (scanned < all.length ? 'at least ' : '') + issues + ' issue' + (issues > 1 ? 's' : '') + ' in ' + hitFiles + ' of them' : ' - no issues found in them';
66
+ process.stderr.write('\n' + (rel === '.' ? 'This folder' : rel) + ' has ' + more + found + '.\n' + (issues ? 'Sweep them all: ' + S.bin + ' --dir ' + (/\s/.test(rel) ? JSON.stringify(rel) : rel) + ' (' + tail + ')\n' : ''));
67
+ }
68
+ let _usePinged = false;
69
+ function pingUse() {
70
+ try {
71
+ if (_usePinged) return; _usePinged = true;
72
+ if (process.env.DO_NOT_TRACK === '1' || process.env.READYSTACK_NO_TELEMETRY || process.env.CI) return;
73
+ if (!(process.stdout.isTTY || process.stdin.isTTY)) return; // human seat only (s152)
74
+ const https = require('https');
75
+ const body = JSON.stringify({ t: 'use', slug: S.bin, src: 'cli', why: 'free' });
76
+ const req = https.request({ hostname: 'getreadystack.com', path: '/api/ev', method: 'POST', timeout: 3000,
77
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'user-agent': 'readystack-cli/' + S.bin } }, function (res) { res.resume(); });
78
+ req.on('timeout', function () { req.destroy(); }); req.on('error', function () {});
79
+ req.write(body); req.end();
80
+ } catch (e) {}
81
+ }
82
+ function mcpServe() {
83
+ process.env.READYSTACK_MCP = '1'; // s152 - 에이전트(Claude Code·Cursor)가 부른 세션은 사람 자리다 · 키 판 핑 src=mcp
84
+ // s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (s158: no free trial): check_dir.
85
+ const rl = require('readline').createInterface({ input: process.stdin });
86
+ const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
87
+ const tools = [
88
+ { name: 'check_text', description: S.name + ' - run all ' + RULE_N + ' checks on a text (free)', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'file contents' }, path: { type: 'string', description: 'optional file name for context' } }, required: ['text'] } },
89
+ { name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
90
+ { name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; $' + S.price + ' once)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
91
+ ];
92
+ const result = (id, rows) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: render(rows, 'text') }], structuredContent: { tool: S.name, rules: RULE_N, files: rows } } });
93
+ const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
94
+ rl.on('line', async (line) => {
95
+ let m; try { m = JSON.parse(line); } catch (e) { return; }
96
+ const id = m.id, method = m.method;
97
+ if (method === 'initialize') return send({ jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: '@readystack/' + S.bin, version: '1.0.0' } } });
98
+ if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
99
+ if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
100
+ if (method === 'tools/call') {
101
+ const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
102
+ try {
103
+ if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
104
+ if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
105
+ if (name === 'check_dir') {
106
+ const r = await lic.ensure();
107
+ if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once): ' + lic.BUY_URL);
108
+ const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
109
+ return result(id, 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, f) }; }));
110
+ }
111
+ return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
112
+ } catch (e) { return fail(id, String(e && e.message || e)); }
113
+ }
114
+ if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
115
+ });
116
+ }
117
+ function help() {
118
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
119
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
120
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
121
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
122
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
123
+ ' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
124
+ ' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
125
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
126
+ }
127
+ (async function main() {
128
+ 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 구독 피드 병합 (키 있는 손님만)
129
+ const a = process.argv.slice(2);
130
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
131
+ if (a.includes('--mcp')) { mcpServe(); return; }
132
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
133
+ 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; }
134
+ 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); }
135
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
136
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
137
+ const paid = !!(dir || fmt || ci);
138
+ if (paid) {
139
+ const r = await lic.ensure();
140
+ if (!r.ok) {
141
+ const t = trialState(); // s158 — ⚑7일 무료 없음 · 이미 시작된 체험만 지킨다
142
+ if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once. Get a licence: ' + lic.BUY_URL + '\n');
143
+ else {
144
+ // s158 — the key is asked WITH the customer's own count (endowment · open loop): how many issues this folder holds.
145
+ let own = '';
146
+ if (dir) { try { const all = walk(dir, exts, []); let n = 0, k = 0, sc = 0; const t0 = Date.now();
147
+ for (const f of all.slice(0, 2000)) { if (Date.now() - t0 > 4000) break; let tx = ''; try { tx = fs.readFileSync(f, 'utf8'); } catch (e) { continue; } sc++; const h = scan(tx, f); if (h.length) { n += h.length; k++; } }
148
+ if (n) own = dir + ': ' + (sc < all.length ? 'at least ' : '') + n + ' issue' + (n > 1 ? 's' : '') + ' in ' + k + ' of ' + all.length + ' files.\n'; } catch (e) {} }
149
+ process.stderr.write(own + 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);
150
+ }
151
+ }
152
+ }
153
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
154
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
155
+ 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) }; });
156
+ const text = render(rows, fmt || 'text');
157
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
158
+ if (!paid) { try { folderHint(files); } catch (e) {} pingUse(); } // s158 — a free run ends with the user's own folder count + one anonymous 'used' count (human seat only)
159
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
160
+ if (ci && errors) process.exit(1);
161
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
@@ -0,0 +1,121 @@
1
+ // Bulk Sender Lint - the brain. Same file runs in Node (VS Code extension) and in the browser (free web check).
2
+ 'use strict';
3
+ var RULES = (typeof module !== 'undefined' && module.exports) ? require('./rules.json') : window.BULK_RULES;
4
+
5
+ function lineOf(text, index) { return text.slice(0, index).split('\n').length; }
6
+ function rx(p, extra) { return new RegExp(p, 'i' + (extra || '')); }
7
+
8
+ function days(fromISO, toISO) {
9
+ var a = Date.parse(fromISO + 'T00:00:00Z'), b = Date.parse(toISO + 'T00:00:00Z');
10
+ if (isNaN(a) || isNaN(b)) return null;
11
+ return Math.round((b - a) / 86400000);
12
+ }
13
+ function dated(rule, today) {
14
+ if (!rule.since) return rule.msg + ' [' + rule.cite + ']';
15
+ var d = days(rule.since, today), when;
16
+ if (d === null) when = 'in force since ' + rule.since;
17
+ else if (d >= 0) when = 'in force since ' + rule.since + ', ' + d + ' days ago';
18
+ else when = 'takes effect ' + rule.since + ', in ' + (-d) + ' days';
19
+ return rule.msg + ' (' + when + ') [' + rule.cite + ']';
20
+ }
21
+
22
+ // --- the SPF / DKIM / DMARC records and the unsubscribe headers found in the text ---
23
+ function records(text, re) {
24
+ var out = [], m, g = new RegExp(re, 'gi');
25
+ while ((m = g.exec(text))) { out.push({ s: m[0], line: lineOf(text, m.index) }); if (m.index === g.lastIndex) g.lastIndex++; }
26
+ return out;
27
+ }
28
+ var SPF = "v=spf1[^\"'\\n]*", DMARC = "v=DMARC1[^\"'\\n]*", DKIM = "v=DKIM1[^\"'\\n]*";
29
+ var LOOKUP = /^[+\-~?]?(?:(?:include|exists|ptr|a|mx):\S+|redirect=\S+|a|mx|ptr)$/i;
30
+
31
+ var FN = {
32
+ spf_lookups: function (text) {
33
+ var hits = [];
34
+ records(text, SPF).forEach(function (r) {
35
+ var n = r.s.split(/\s+/).filter(function (t) { return LOOKUP.test(t); }).length;
36
+ if (n > 10) hits.push({ line: r.line, extra: n + ' lookups in the record, limit is 10' });
37
+ });
38
+ return hits;
39
+ },
40
+ spf_multiple: function (text) {
41
+ var r = records(text, SPF);
42
+ return r.length > 1 ? [{ line: r[1].line, extra: r.length + ' v=spf1 records in this file' }] : [];
43
+ },
44
+ dmarc_pct: function (text) {
45
+ var hits = [];
46
+ records(text, DMARC).forEach(function (r) {
47
+ var m = /pct=(\d+)/i.exec(r.s);
48
+ if (m && parseInt(m[1], 10) < 100) hits.push({ line: r.line, extra: 'pct=' + m[1] + ', so ' + (100 - parseInt(m[1], 10)) + '% of failing mail is let through' });
49
+ });
50
+ return hits;
51
+ },
52
+ dkim_key: function (text) {
53
+ var hits = [];
54
+ records(text, DKIM).forEach(function (r) {
55
+ var m = /p=([A-Za-z0-9+/=.]+)/.exec(r.s);
56
+ if (m && m[1].length < 300) hits.push({ line: r.line, extra: m[1].length + ' base64 characters, a 2048-bit key is about 392' });
57
+ });
58
+ return hits;
59
+ },
60
+ bimi: function (text) {
61
+ var b = records(text, "v=BIMI1[^\"'\\n]*");
62
+ if (!b.length) return [];
63
+ var d = records(text, DMARC);
64
+ var enforced = d.some(function (r) { return /p=(quarantine|reject)/i.test(r.s); });
65
+ return enforced ? [] : [{ line: b[0].line, extra: d.length ? 'DMARC is at p=none' : 'no DMARC record here' }];
66
+ },
67
+ unsub_post_value: function (text) {
68
+ var hits = [];
69
+ records(text, "List-Unsubscribe-Post[^\\n]*").forEach(function (r) {
70
+ if (!/List-Unsubscribe\s*=\s*One-Click/i.test(r.s)) hits.push({ line: r.line, extra: 'value found: ' + r.s.slice(0, 80) });
71
+ });
72
+ return hits;
73
+ },
74
+ unsub_mailto: function (text) {
75
+ var hits = [];
76
+ records(text, "List-Unsubscribe(?!-Post)[\"']?\\s*[:=,][^\\n]*").forEach(function (r) {
77
+ if (/mailto:/i.test(r.s) && !/https:\/\//i.test(r.s)) hits.push({ line: r.line, extra: 'header has mailto: and no https:// URI' });
78
+ });
79
+ return hits;
80
+ },
81
+ unsub_route: function (text) {
82
+ var getJs = /\.get\s*\(\s*['"][^'"]*unsubscribe/i.exec(text);
83
+ var postJs = /\.post\s*\(\s*['"][^'"]*unsubscribe/i.exec(text);
84
+ if (getJs && !postJs) return [{ line: lineOf(text, getJs.index), extra: 'GET route found, no matching POST route' }];
85
+ var py = /route\s*\(\s*['"][^'"]*unsubscribe[^'"]*['"][^)]*methods\s*=\s*\[[^\]]*\]/i.exec(text);
86
+ if (py && !/POST/i.test(py[0])) return [{ line: lineOf(text, py.index), extra: 'route declares methods without POST' }];
87
+ return [];
88
+ }
89
+ };
90
+
91
+ function check(text, opts) {
92
+ text = String(text == null ? '' : text);
93
+ opts = opts || {};
94
+ var today = opts.today || new Date().toISOString().slice(0, 10);
95
+ var findings = [];
96
+ function add(rule, line, extra) {
97
+ findings.push({ check: rule.id, sev: rule.sev, line: line || 1, msg: (extra ? extra + '. ' : '') + dated(rule, today) });
98
+ }
99
+ RULES.forEach(function (rule) {
100
+ if (rule.kind === 'forbid') {
101
+ var seen = {}, m, g = new RegExp(rule.bad, 'gi'), n = 0;
102
+ while ((m = g.exec(text)) && n < 10) {
103
+ var ln = lineOf(text, m.index);
104
+ if (!seen[ln]) { seen[ln] = 1; n++; add(rule, ln, null); }
105
+ if (m.index === g.lastIndex) g.lastIndex++;
106
+ }
107
+ } else if (rule.kind === 'require_when') {
108
+ var w = rx(rule.when).exec(text);
109
+ if (w && !rx(rule.need).test(text)) add(rule, lineOf(text, w.index), null);
110
+ } else if (rule.kind.indexOf('fn:') === 0) {
111
+ var fn = FN[rule.kind.slice(3)];
112
+ if (fn) fn(text).forEach(function (h) { add(rule, h.line, h.extra); });
113
+ }
114
+ });
115
+ findings.sort(function (a, b) { return a.line - b.line; });
116
+ return { findings: findings, checked: RULES.length, today: today };
117
+ }
118
+
119
+ var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
120
+ if (typeof module !== 'undefined' && module.exports) module.exports = API;
121
+ if (typeof window !== 'undefined') window.BULKENGINE = API;
@@ -0,0 +1,83 @@
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 = '921dac5f-c022-47b8-ae71-0a7404889687'; // 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_wVUwTqi8qqkeHlhoZHE3VCSgjwBGHxsQL9rYt24DtQP';
7
+ const SLUG = 'bulk-sender-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
+ const ALL_BENEFIT_ID = '22692551-5203-4467-b1a3-e33cdba6589d'; // s149 2026-09-17 — 팀 키(전 린터 한 키 · Polar benefit) · 상품 benefit 다음에 한 번 더 묻는다
14
+ function validate(key) {
15
+ return validate1(key, BENEFIT_ID).then(function (r) { return (r.ok || r.offline || !/^[0-9a-f-]{36}$/.test(ALL_BENEFIT_ID)) ? r : validate1(key, ALL_BENEFIT_ID); });
16
+ }
17
+ function validate1(key, ben) {
18
+ return new Promise(function (resolve) {
19
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
20
+ const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(ben) ? { key: key, organization_id: ORG_ID, benefit_id: ben } : { key: key, organization_id: ORG_ID });
21
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
22
+ headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
23
+ let buf = ''; res.on('data', function (d) { buf += d; });
24
+ res.on('end', function () {
25
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
26
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
27
+ catch (e) { resolve({ ok: false, offline: false }); }
28
+ });
29
+ });
30
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
31
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
32
+ req.write(body); req.end();
33
+ });
34
+ }
35
+ // ★s151 2026-09-19 — 키 판(키가 없거나 거절된 순간)을 익명으로 센다 (슬러그·출처·이유만) · DO_NOT_TRACK=1 · READYSTACK_NO_TELEMETRY 면 안 보낸다 · 실패는 조용히 · 프로세스당 한 번.
36
+ let _pinged = false;
37
+ function pingPaywall(why) {
38
+ try {
39
+ if (_pinged) return; _pinged = true;
40
+ if (process.env.DO_NOT_TRACK === '1' || process.env.READYSTACK_NO_TELEMETRY || process.env.CI) return; // s151: CI(깃허브 액션 등)와 우리 빌드는 손님이 아니다
41
+ if (!(process.stdout.isTTY || process.stdin.isTTY || process.env.READYSTACK_MCP === '1')) return; // s152: 사람 자리(터미널·MCP 세션)에서만 센다 - 발행 1분 뒤 남의 실행기(JP · 우리 3대는 US)가 돌린 no_key 13건은 손님이 아니다
42
+ const body = JSON.stringify({ t: 'paywall', slug: SLUG, src: process.env.READYSTACK_MCP === '1' ? 'mcp' : 'cli', why: why || 'no_key' });
43
+ const req = https.request({ hostname: 'getreadystack.com', path: '/api/ev', method: 'POST', timeout: 3000,
44
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'user-agent': 'readystack-cli/' + SLUG } }, function (res) { res.resume(); });
45
+ req.on('timeout', function () { req.destroy(); }); req.on('error', function () {});
46
+ req.write(body); req.end();
47
+ } catch (e) { /* 세는 것이 실패해도 상품은 돈다 */ }
48
+ }
49
+ async function ensure(explicitKey) {
50
+ const st = load();
51
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
52
+ if (!key) { pingPaywall('no_key'); return { ok: false, why: 'no_key' }; } // s151
53
+ const age = Date.now() - (st.okAt || 0);
54
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
55
+ const r = await validate(String(key).trim());
56
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
57
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
58
+ if (!r.offline) pingPaywall('invalid'); // s151
59
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
60
+ }
61
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
62
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
63
+ function pullFeed() {
64
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
65
+ if (!key) return Promise.resolve(cached);
66
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
67
+ return new Promise(function (resolve) {
68
+ let req;
69
+ try {
70
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
71
+ let buf = ''; res.on('data', function (d) { buf += d; });
72
+ res.on('end', function () {
73
+ if (res.statusCode !== 200) return resolve(cached);
74
+ 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); }
75
+ catch (e) { resolve(cached); }
76
+ });
77
+ });
78
+ } catch (e) { return resolve(cached); }
79
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
80
+ req.on('error', function () { resolve(cached); });
81
+ });
82
+ }
83
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@readystack/bulk-sender-lint",
3
+ "version": "1.0.5",
4
+ "description": "21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "dmarc",
11
+ "spf",
12
+ "dkim",
13
+ "email deliverability",
14
+ "list-unsubscribe",
15
+ "rfc8058",
16
+ "gmail bulk sender",
17
+ "yahoo",
18
+ "outlook",
19
+ "smtp"
20
+ ],
21
+ "homepage": "https://getreadystack.com",
22
+ "funding": "https://buy.polar.sh/polar_cl_wVUwTqi8qqkeHlhoZHE3VCSgjwBGHxsQL9rYt24DtQP",
23
+ "bin": {
24
+ "bulk-sender-lint": "cli.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "mcpName": "io.github.jmshinhwa/bulk-sender-lint",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/jmshinhwa/readystack-themes.git",
33
+ "directory": "bulk-sender-lint"
34
+ },
35
+ "files": [
36
+ "cli.js",
37
+ "license.js",
38
+ "rules.json",
39
+ "strings.json",
40
+ "README.md",
41
+ "LICENSE.txt",
42
+ "engine.js"
43
+ ]
44
+ }
@@ -0,0 +1,180 @@
1
+ [
2
+ {
3
+ "id": "spf_missing",
4
+ "sev": "error",
5
+ "kind": "require_when",
6
+ "when": "v=DMARC1|v=DKIM1|_dmarc|_domainkey",
7
+ "need": "v=spf1",
8
+ "since": "2024-02-01",
9
+ "msg": "Mail DNS is configured here but there is no SPF record (v=spf1). Gmail and Yahoo require bulk senders - 5,000+ messages a day to their users, counted per From domain - to publish SPF and DKIM and to pass DMARC alignment.",
10
+ "cite": "Google Email sender guidelines (bulk senders); Yahoo Sender Requirements"
11
+ },
12
+ {
13
+ "id": "spf_all_neutral",
14
+ "sev": "error",
15
+ "kind": "forbid",
16
+ "bad": "v=spf1[^\"'\\n]*[?+]all",
17
+ "since": "2024-02-01",
18
+ "msg": "SPF ends in ?all or +all, so every server on the internet passes SPF for this domain. A pass that anyone can get is not a pass a receiver will align on. End the record with -all, or ~all while you are still finding senders.",
19
+ "cite": "RFC 7208 section 8; Google Email sender guidelines"
20
+ },
21
+ {
22
+ "id": "spf_lookup_limit",
23
+ "sev": "error",
24
+ "kind": "fn:spf_lookups",
25
+ "since": "2024-02-01",
26
+ "msg": "SPF needs more than 10 DNS lookups to evaluate. Over the limit the receiver returns PermError, which DMARC treats as an SPF failure - the record stops working without changing.",
27
+ "cite": "RFC 7208 section 4.6.4"
28
+ },
29
+ {
30
+ "id": "spf_multiple",
31
+ "sev": "error",
32
+ "kind": "fn:spf_multiple",
33
+ "since": "2024-02-01",
34
+ "msg": "More than one SPF record is published for the same name. Two v=spf1 records is a PermError, not a merge: neither record is used.",
35
+ "cite": "RFC 7208 section 3.2"
36
+ },
37
+ {
38
+ "id": "dmarc_missing",
39
+ "sev": "error",
40
+ "kind": "require_when",
41
+ "when": "v=spf1|v=DKIM1|_domainkey",
42
+ "need": "v=DMARC1",
43
+ "since": "2024-02-01",
44
+ "msg": "No DMARC record (v=DMARC1) for the sending domain. Gmail and Yahoo have required one from bulk senders since 2024-02-01, and Microsoft Outlook.com since 2025-05-05; p=none is the accepted minimum.",
45
+ "cite": "Google Email sender guidelines; Microsoft Outlook.com sender requirements (2025-05-05)"
46
+ },
47
+ {
48
+ "id": "dmarc_p_none",
49
+ "sev": "warning",
50
+ "kind": "forbid",
51
+ "bad": "v=DMARC1[^\"'\\n]*p=none",
52
+ "since": "2024-02-01",
53
+ "msg": "DMARC policy is p=none. That clears the Gmail, Yahoo and Outlook.com minimum but instructs receivers to do nothing about mail that fails, so anyone can still send as your domain. Move to quarantine once your reports are clean.",
54
+ "cite": "RFC 7489 section 6.3; Google Email sender guidelines"
55
+ },
56
+ {
57
+ "id": "dmarc_rua_missing",
58
+ "sev": "warning",
59
+ "kind": "require_when",
60
+ "when": "v=DMARC1",
61
+ "need": "rua=mailto:",
62
+ "msg": "DMARC record has no rua= aggregate report address, so nothing tells you which of your senders fail alignment. Without rua you cannot safely raise the policy later.",
63
+ "cite": "RFC 7489 section 7.1"
64
+ },
65
+ {
66
+ "id": "dmarc_pct_partial",
67
+ "sev": "warning",
68
+ "kind": "fn:dmarc_pct",
69
+ "msg": "DMARC pct= is below 100, so the policy is applied to only part of your mail and the rest is treated as p=none. Leave pct at 100 unless you are mid-rollout.",
70
+ "cite": "RFC 7489 section 6.3"
71
+ },
72
+ {
73
+ "id": "dmarc_sp_none",
74
+ "sev": "warning",
75
+ "kind": "forbid",
76
+ "bad": "v=DMARC1[^\"'\\n]*sp=none",
77
+ "msg": "sp=none exempts every subdomain from the policy. Look-alike subdomains are the usual path for spoofed invoices; set sp to match p.",
78
+ "cite": "RFC 7489 section 6.3"
79
+ },
80
+ {
81
+ "id": "dkim_missing",
82
+ "sev": "error",
83
+ "kind": "require_when",
84
+ "when": "v=spf1|v=DMARC1|_dmarc",
85
+ "need": "v=DKIM1",
86
+ "since": "2024-02-01",
87
+ "msg": "No DKIM record (v=DKIM1) for the sending domain. SPF alone breaks on every forward and mailing list; Gmail and Yahoo ask bulk senders for both SPF and DKIM.",
88
+ "cite": "RFC 6376; Google Email sender guidelines"
89
+ },
90
+ {
91
+ "id": "dkim_key_short",
92
+ "sev": "warning",
93
+ "kind": "fn:dkim_key",
94
+ "msg": "The DKIM public key is short enough to be a 1024-bit key. 1024 is the floor; Google's guidelines say use a 2048-bit key where your DNS host allows the longer record.",
95
+ "cite": "RFC 6376 section 3.3.3; Google Email sender guidelines"
96
+ },
97
+ {
98
+ "id": "dkim_testmode",
99
+ "sev": "error",
100
+ "kind": "forbid",
101
+ "bad": "v=DKIM1[^\"'\\n]*[;\\s]t=y",
102
+ "msg": "DKIM record carries t=y, testing mode. Receivers are told to treat a signature failure as if the message were unsigned, so the signature buys you nothing - and the domain still looks configured.",
103
+ "cite": "RFC 6376 section 3.6.1"
104
+ },
105
+ {
106
+ "id": "bimi_unenforced",
107
+ "sev": "error",
108
+ "kind": "fn:bimi",
109
+ "msg": "A BIMI record is published while DMARC is missing or at p=none. BIMI is only honoured at an enforcement policy (quarantine or reject), so the logo will never show.",
110
+ "cite": "BIMI draft specification, DMARC enforcement requirement"
111
+ },
112
+ {
113
+ "id": "unsub_missing",
114
+ "sev": "error",
115
+ "kind": "require_when",
116
+ "when": "createTransport|sendMail|sgMail|mailgun|SendEmail|sesv2|resend\\.emails|smtplib|send_message",
117
+ "need": "List-Unsubscribe",
118
+ "since": "2024-02-01",
119
+ "msg": "This code sends mail but sets no List-Unsubscribe header. Bulk senders must offer one-click unsubscribe in commercial and subscribed messages, and act on it within two days.",
120
+ "cite": "RFC 8058; Google Email sender guidelines"
121
+ },
122
+ {
123
+ "id": "unsub_post_missing",
124
+ "sev": "error",
125
+ "kind": "require_when",
126
+ "when": "List-Unsubscribe",
127
+ "need": "List-Unsubscribe-Post",
128
+ "since": "2024-02-01",
129
+ "msg": "List-Unsubscribe is set but List-Unsubscribe-Post is not, so this is the old two-step header, not one-click. Gmail and Yahoo require the RFC 8058 pair on bulk mail.",
130
+ "cite": "RFC 8058 section 3.1"
131
+ },
132
+ {
133
+ "id": "unsub_post_value",
134
+ "sev": "error",
135
+ "kind": "fn:unsub_post_value",
136
+ "since": "2024-02-01",
137
+ "msg": "List-Unsubscribe-Post must carry exactly the value List-Unsubscribe=One-Click. Any other value and the mail client will not show the one-click control.",
138
+ "cite": "RFC 8058 section 3.1"
139
+ },
140
+ {
141
+ "id": "unsub_mailto_only",
142
+ "sev": "error",
143
+ "kind": "fn:unsub_mailto",
144
+ "msg": "List-Unsubscribe offers only a mailto: address. One-click unsubscribe is an HTTPS POST, so a mailto-only header cannot satisfy it - add an https:// URI to the header.",
145
+ "cite": "RFC 8058 section 1; Google Email sender guidelines"
146
+ },
147
+ {
148
+ "id": "unsub_route_get_only",
149
+ "sev": "error",
150
+ "kind": "fn:unsub_route",
151
+ "msg": "The unsubscribe endpoint answers GET but not POST. The mail client sends a POST with the body List-Unsubscribe=One-Click; a GET-only route returns 404 or 405 and the unsubscribe silently fails.",
152
+ "cite": "RFC 8058 section 3.2"
153
+ },
154
+ {
155
+ "id": "unsub_auth_required",
156
+ "sev": "error",
157
+ "kind": "forbid",
158
+ "bad": "(?:get|post|route)\\s*\\(\\s*['\"][^'\"]*unsubscribe[^'\"]*['\"]\\s*,\\s*(?:requireAuth|isAuthenticated|ensureLoggedIn|authMiddleware|login_required)",
159
+ "msg": "The unsubscribe route is behind a login. One-click unsubscribe arrives as an unauthenticated POST from the mail client, and an unsubscribe that needs a sign-in counts as an unsubscribe that does not work.",
160
+ "cite": "RFC 8058 section 3.2; Google Email sender guidelines"
161
+ },
162
+ {
163
+ "id": "smtp_tls_off",
164
+ "sev": "error",
165
+ "kind": "forbid",
166
+ "bad": "secure\\s*:\\s*false|ignoreTLS\\s*:\\s*true|rejectUnauthorized\\s*:\\s*false|port\\s*[:=]\\s*25\\b",
167
+ "since": "2024-02-01",
168
+ "msg": "This transport disables TLS, disables certificate checking, or connects on port 25. Bulk senders are required to transmit over a TLS connection.",
169
+ "cite": "Google Email sender guidelines (TLS requirement)"
170
+ },
171
+ {
172
+ "id": "from_consumer_domain",
173
+ "sev": "error",
174
+ "kind": "forbid",
175
+ "bad": "(?:from|From|sender)[^\\n]{0,40}@(?:gmail|yahoo|hotmail|outlook|aol)\\.com",
176
+ "since": "2024-02-01",
177
+ "msg": "Bulk mail is being sent with a consumer mailbox domain in From. Those domains publish DMARC p=reject for their own users, so mail you send from your servers as that address fails DMARC and is rejected outright.",
178
+ "cite": "Google Email sender guidelines (do not send from gmail.com); RFC 7489 section 6.6.2"
179
+ }
180
+ ]
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code",
3
+ "subtitle": "21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser",
4
+ "bin": "bulk-sender-lint",
5
+ "price": 29,
6
+ "free": "Check the file you have open - all 21 checks, every finding with the clause it comes from, the date it took effect and the line",
7
+ "paid": "Sweep every matching file in the workspace and write a dated evidence report you keep, for a deliverability review or a client handover",
8
+ "need_key": "This option needs a licence (Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code).",
9
+ "exts": []
10
+ }
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: readystack-bulk-sender-lint
3
+ Version: 1.0.5
4
+ Summary: 21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser (bundled Node.js runtime)
5
+ Author-email: ReadyStack <support@getreadystack.com>
6
+ License: Proprietary - see LICENSE.txt
7
+ Project-URL: Homepage, https://getreadystack.com/tools/bulk-sender-lint?ref=pypi
8
+ Project-URL: Documentation, https://getreadystack.com/tools/bulk-sender-lint?ref=pypi
9
+ Project-URL: Support, https://getreadystack.com/support
10
+ Keywords: dmarc,spf,dkim,email deliverability,list-unsubscribe,rfc8058,gmail bulk sender,yahoo,outlook,smtp
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: nodejs-wheel-binaries>=20
19
+ Dynamic: license-file
20
+
21
+ # Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
22
+
23
+ > Runs the same checker as the npm package `@readystack/bulk-sender-lint` on a Node.js runtime that pip installs for you (`nodejs-wheel-binaries`) - no system Node.js needed. Your files are checked locally.
24
+
25
+ 21 dated checks over SPF, DKIM, DMARC, RFC 8058 one-click unsubscribe and TLS, in VS Code and in the browser
26
+
27
+ ## Install
28
+
29
+ ```
30
+ bulk-sender-lint file
31
+ ```
32
+
33
+ Node 18+. The same 21 rules as the VS Code extension, from a terminal or CI.
34
+
35
+ ## Free
36
+
37
+ - Check the file you have open - all 21 checks, every finding with the clause it comes from, the date it took effect and the line
38
+ - `--rules` lists every rule
39
+
40
+ ## With a licence ($29 once)
41
+
42
+ - Sweep every matching file in the workspace and write a dated evidence report you keep, for a deliverability review or a client handover
43
+
44
+ ```
45
+ @readystack/bulk-sender-lint --dir ./templates --report html --out report.html
46
+ ```
47
+
48
+ A deliverability consultant bills $150 to $250 an hour
49
+
50
+ ## Use from an AI agent (MCP)
51
+
52
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
53
+
54
+ ```json
55
+ { "mcpServers": { "bulk-sender-lint": { "command": "npx", "args": ["-y", "@readystack/bulk-sender-lint", "--mcp"] } } }
56
+ ```
57
+
58
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence). The agent gets every finding with the line number.
59
+
60
+ ## Use in CI
61
+
62
+ ```yaml
63
+ - name: Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mailer code
64
+ run: npx -y @readystack/bulk-sender-lint --dir . --ci
65
+ ```
66
+
67
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/bulk-sender-lint --dir /work --ci`)
68
+
69
+ The folder sweep, reports and CI mode need one licence — one payment, no subscription. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
70
+
71
+ [Get a licence](https://buy.polar.sh/polar_cl_wVUwTqi8qqkeHlhoZHE3VCSgjwBGHxsQL9rYt24DtQP)
72
+
73
+
74
+ <!-- bulk sender lint -->
75
+
76
+
77
+ ## Install (PyPI)
78
+
79
+ ```
80
+ pip install readystack-bulk-sender-lint
81
+ bulk-sender-lint --help
82
+ ```
@@ -0,0 +1,17 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/readystack_bulk_sender_lint/__init__.py
5
+ src/readystack_bulk_sender_lint/__main__.py
6
+ src/readystack_bulk_sender_lint.egg-info/PKG-INFO
7
+ src/readystack_bulk_sender_lint.egg-info/SOURCES.txt
8
+ src/readystack_bulk_sender_lint.egg-info/dependency_links.txt
9
+ src/readystack_bulk_sender_lint.egg-info/entry_points.txt
10
+ src/readystack_bulk_sender_lint.egg-info/requires.txt
11
+ src/readystack_bulk_sender_lint.egg-info/top_level.txt
12
+ src/readystack_bulk_sender_lint/js/cli.js
13
+ src/readystack_bulk_sender_lint/js/engine.js
14
+ src/readystack_bulk_sender_lint/js/license.js
15
+ src/readystack_bulk_sender_lint/js/package.json
16
+ src/readystack_bulk_sender_lint/js/rules.json
17
+ src/readystack_bulk_sender_lint/js/strings.json
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bulk-sender-lint = readystack_bulk_sender_lint.__main__:main