From 86d4d27f8002e85f29e3cea1faff07bbaf65ee43 Mon Sep 17 00:00:00 2001
From: Luke Steward <29278153+LukeSteward@users.noreply.github.com>
Date: Fri, 15 May 2026 19:09:57 +0100
Subject: [PATCH 1/3] Add DMARC issue reporting functionality
- Introduced functions to analyze DMARC records and generate issue links for the Wall of Shame.
- Updated the modal to request the organization name instead of the company name for DMARC submissions.
- Enhanced URL parameters for new issue creation to include DMARC record snippets and issue types.
---
entrypoints/popup/main.ts | 64 +++++++++++++++++++++++++++++----------
1 file changed, 48 insertions(+), 16 deletions(-)
diff --git a/entrypoints/popup/main.ts b/entrypoints/popup/main.ts
index 4624dfb..ccb0891 100644
--- a/entrypoints/popup/main.ts
+++ b/entrypoints/popup/main.ts
@@ -7,6 +7,7 @@ import {
} from '@/lib/checkDomain';
import { filterMailInfraLinesWhenCompact } from '@/lib/checks/mailInfra';
import type { SpfMailProviderHint } from '@/lib/checks/mailProviderSpfHint';
+import { analyzeDmarc } from '@/lib/parse/dmarc';
import { getActiveTabHostname } from '@/lib/tabHost';
import {
filterBreakdownForCompactMode,
@@ -115,26 +116,57 @@ function mxtoolboxEmailHealthUrl(domain: string): string {
return `https://mxtoolbox.com/emailhealth/${encodeURIComponent(domain)}`;
}
+/** DMARC SuperTool deep link (matches Wall of Shame issue template placeholder). */
+function mxtoolboxDmarcLookupUrl(domain: string): string {
+ return `https://mxtoolbox.com/SuperTool.aspx?action=${encodeURIComponent(`dmarc:${domain}`)}`;
+}
+
const DNS_TECHNIQUE_DISCLOSURE =
'DNS queries use DNS-over-HTTPS (Cloudflare / Google). Entra probe uses HTTPS only; no MTA-STS policy files or cert inspection. DKIM probes _domainkey for null DKIM, then provider/common selectors, then *._domainkey.';
-const WALL_OF_SHAME_REPO = 'jkerai1/DMARC-WallOfShame';
+const WALL_OF_SHAME_NEW_ISSUE =
+ 'https://github.com/jkerai1/DMARC-WallOfShame/issues/new';
+
+/** Max chars for DMARC TXT prefilled via URL (avoid GitHub URI limits). */
+const WALL_OF_SHAME_DMARC_RECORD_URL_MAX = 3500;
+function wallOfShameDmarcIssueType(result: CheckResult): string {
+ const a = analyzeDmarc(result.dmarcRecords);
+ if (a.multipleRecords) {
+ return 'Malformed / invalid DMARC record';
+ }
+ if (!a.present) {
+ return 'No DMARC record (missing)';
+ }
+ if (a.policy === 'none') {
+ return "DMARC policy set to 'none' (p=none)";
+ }
+ return 'Malformed / invalid DMARC record';
+}
+
+function wallOfShameDmarcRecordSnippet(result: CheckResult): string {
+ if (!result.dmarcRecords.length) return '';
+ const joined =
+ result.dmarcRecords.length === 1
+ ? result.dmarcRecords[0]
+ : result.dmarcRecords.join('\n---\n');
+ return truncate(joined, WALL_OF_SHAME_DMARC_RECORD_URL_MAX);
+}
-function wallOfShameNewIssueUrl(company: string, result: CheckResult): string {
+function wallOfShameNewIssueUrl(orgName: string, result: CheckResult): string {
const domain = result.dmarcLookupHost;
- const title = `${company} (${domain})`;
- const bodyParts = [
- `**Company:** ${company}`,
- `**Domain:** ${domain}`,
- ];
- if (result.queryHostname !== result.dmarcLookupHost) {
- bodyParts.push(`**Checked hostname:** ${result.queryHostname}`);
+ const params = new URLSearchParams();
+ params.set('template', 'dmarc_submission.yml');
+ params.set('title', `[DMARC] ${domain}`);
+ params.set('org_name', orgName);
+ params.set('domain', domain);
+ params.set('issue_type', wallOfShameDmarcIssueType(result));
+ const dmarcSnippet = wallOfShameDmarcRecordSnippet(result);
+ if (dmarcSnippet) {
+ params.set('dmarc_record', dmarcSnippet);
}
- bodyParts.push('', '_Submitted via JayQuery browser extension._');
- const body = bodyParts.join('\n');
- const params = new URLSearchParams({ title, body });
- return `https://github.com/${WALL_OF_SHAME_REPO}/issues/new?${params}`;
+ params.set('lookup_url', mxtoolboxDmarcLookupUrl(domain));
+ return `${WALL_OF_SHAME_NEW_ISSUE}?${params}`;
}
/** Opens a URL from a user gesture (e.g. modal submit) without extra extension permissions. */
@@ -174,10 +206,10 @@ function renderCastShameModal(result: CheckResult): string {
Report DMARC issue
-
Please submit the company name for the DMARC issue.
-
+
Enter the organisation name for the DMARC submission form.
+
-
Enter a company name.
+
Enter an organisation name.
From 7072e67e9f5b00d239c2a5baf002d4b569be2d73 Mon Sep 17 00:00:00 2001
From: Luke Steward <29278153+LukeSteward@users.noreply.github.com>
Date: Fri, 15 May 2026 19:15:29 +0100
Subject: [PATCH 2/3] Refactor DMARC issue reporting functionality
- Removed deprecated functions related to DMARC issue reporting.
- Introduced a new function to build the Wall of Shame DMARC issue URL.
- Updated the modal to utilize the new URL building function for issue submissions.
---
entrypoints/popup/main.ts | 60 ++----------
lib/wallOfShameDmarcIssue.test.ts | 153 ++++++++++++++++++++++++++++++
lib/wallOfShameDmarcIssue.ts | 68 +++++++++++++
3 files changed, 229 insertions(+), 52 deletions(-)
create mode 100644 lib/wallOfShameDmarcIssue.test.ts
create mode 100644 lib/wallOfShameDmarcIssue.ts
diff --git a/entrypoints/popup/main.ts b/entrypoints/popup/main.ts
index ccb0891..e6540e3 100644
--- a/entrypoints/popup/main.ts
+++ b/entrypoints/popup/main.ts
@@ -7,8 +7,8 @@ import {
} from '@/lib/checkDomain';
import { filterMailInfraLinesWhenCompact } from '@/lib/checks/mailInfra';
import type { SpfMailProviderHint } from '@/lib/checks/mailProviderSpfHint';
-import { analyzeDmarc } from '@/lib/parse/dmarc';
import { getActiveTabHostname } from '@/lib/tabHost';
+import { buildWallOfShameDmarcIssueUrl } from '@/lib/wallOfShameDmarcIssue';
import {
filterBreakdownForCompactMode,
type FullScore,
@@ -116,59 +116,9 @@ function mxtoolboxEmailHealthUrl(domain: string): string {
return `https://mxtoolbox.com/emailhealth/${encodeURIComponent(domain)}`;
}
-/** DMARC SuperTool deep link (matches Wall of Shame issue template placeholder). */
-function mxtoolboxDmarcLookupUrl(domain: string): string {
- return `https://mxtoolbox.com/SuperTool.aspx?action=${encodeURIComponent(`dmarc:${domain}`)}`;
-}
-
const DNS_TECHNIQUE_DISCLOSURE =
'DNS queries use DNS-over-HTTPS (Cloudflare / Google). Entra probe uses HTTPS only; no MTA-STS policy files or cert inspection. DKIM probes _domainkey for null DKIM, then provider/common selectors, then *._domainkey.';
-const WALL_OF_SHAME_NEW_ISSUE =
- 'https://github.com/jkerai1/DMARC-WallOfShame/issues/new';
-
-/** Max chars for DMARC TXT prefilled via URL (avoid GitHub URI limits). */
-const WALL_OF_SHAME_DMARC_RECORD_URL_MAX = 3500;
-
-function wallOfShameDmarcIssueType(result: CheckResult): string {
- const a = analyzeDmarc(result.dmarcRecords);
- if (a.multipleRecords) {
- return 'Malformed / invalid DMARC record';
- }
- if (!a.present) {
- return 'No DMARC record (missing)';
- }
- if (a.policy === 'none') {
- return "DMARC policy set to 'none' (p=none)";
- }
- return 'Malformed / invalid DMARC record';
-}
-
-function wallOfShameDmarcRecordSnippet(result: CheckResult): string {
- if (!result.dmarcRecords.length) return '';
- const joined =
- result.dmarcRecords.length === 1
- ? result.dmarcRecords[0]
- : result.dmarcRecords.join('\n---\n');
- return truncate(joined, WALL_OF_SHAME_DMARC_RECORD_URL_MAX);
-}
-
-function wallOfShameNewIssueUrl(orgName: string, result: CheckResult): string {
- const domain = result.dmarcLookupHost;
- const params = new URLSearchParams();
- params.set('template', 'dmarc_submission.yml');
- params.set('title', `[DMARC] ${domain}`);
- params.set('org_name', orgName);
- params.set('domain', domain);
- params.set('issue_type', wallOfShameDmarcIssueType(result));
- const dmarcSnippet = wallOfShameDmarcRecordSnippet(result);
- if (dmarcSnippet) {
- params.set('dmarc_record', dmarcSnippet);
- }
- params.set('lookup_url', mxtoolboxDmarcLookupUrl(domain));
- return `${WALL_OF_SHAME_NEW_ISSUE}?${params}`;
-}
-
/** Opens a URL from a user gesture (e.g. modal submit) without extra extension permissions. */
function openUrlInNewTab(url: string): void {
const a = document.createElement('a');
@@ -270,7 +220,13 @@ function bindCastShameModal(result: CheckResult): void {
shameInput.focus();
return;
}
- openUrlInNewTab(wallOfShameNewIssueUrl(trimmed, result));
+ openUrlInNewTab(
+ buildWallOfShameDmarcIssueUrl(
+ trimmed,
+ result.dmarcLookupHost,
+ result.dmarcRecords,
+ ),
+ );
closeModal();
});
diff --git a/lib/wallOfShameDmarcIssue.test.ts b/lib/wallOfShameDmarcIssue.test.ts
new file mode 100644
index 0000000..4cfdd59
--- /dev/null
+++ b/lib/wallOfShameDmarcIssue.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest';
+import {
+ WALL_OF_SHAME_DMARC_ISSUES_NEW,
+ buildWallOfShameDmarcIssueUrl,
+ formatWallOfShameDmarcRecordForUrl,
+ inferWallOfShameDmarcIssueType,
+ mxtoolboxDmarcLookupUrl,
+ WALL_OF_SHAME_DMARC_RECORD_URL_MAX,
+} from '@/lib/wallOfShameDmarcIssue';
+
+describe('mxtoolboxDmarcLookupUrl', () => {
+ it('builds SuperTool action dmarc: with encoded action param', () => {
+ expect(mxtoolboxDmarcLookupUrl('example.com')).toBe(
+ 'https://mxtoolbox.com/SuperTool.aspx?action=dmarc%3Aexample.com',
+ );
+ });
+
+ it('encodes special characters in the domain', () => {
+ expect(mxtoolboxDmarcLookupUrl('bad host')).toContain(
+ encodeURIComponent('dmarc:bad host'),
+ );
+ });
+});
+
+describe('inferWallOfShameDmarcIssueType', () => {
+ it('returns missing when there are no DMARC TXT records', () => {
+ expect(inferWallOfShameDmarcIssueType([])).toBe(
+ 'No DMARC record (missing)',
+ );
+ });
+
+ it('returns missing when TXT is not DMARC-shaped', () => {
+ expect(inferWallOfShameDmarcIssueType(['v=spf1 ~all'])).toBe(
+ 'No DMARC record (missing)',
+ );
+ });
+
+ it('returns p=none option when policy is none', () => {
+ expect(
+ inferWallOfShameDmarcIssueType(['v=DMARC1; p=none; rua=mailto:a@b.co']),
+ ).toBe("DMARC policy set to 'none' (p=none)");
+ });
+
+ it('returns malformed when multiple DMARC records exist', () => {
+ expect(
+ inferWallOfShameDmarcIssueType([
+ 'v=DMARC1; p=reject;',
+ 'v=DMARC1; p=none;',
+ ]),
+ ).toBe('Malformed / invalid DMARC record');
+ });
+
+ it('returns malformed when DMARC exists but policy tag is invalid', () => {
+ expect(
+ inferWallOfShameDmarcIssueType(['v=DMARC1; p=monitor;']),
+ ).toBe('Malformed / invalid DMARC record');
+ });
+
+ it('returns malformed for strict reject when used as catch-all for non-none weak configs', () => {
+ expect(
+ inferWallOfShameDmarcIssueType(['v=DMARC1; p=reject;']),
+ ).toBe('Malformed / invalid DMARC record');
+ });
+});
+
+describe('formatWallOfShameDmarcRecordForUrl', () => {
+ it('returns empty string when there are no records', () => {
+ expect(formatWallOfShameDmarcRecordForUrl([])).toBe('');
+ });
+
+ it('returns the single record verbatim when within limit', () => {
+ const rec = 'v=DMARC1; p=none;';
+ expect(formatWallOfShameDmarcRecordForUrl([rec])).toBe(rec);
+ });
+
+ it('joins multiple records with a separator line', () => {
+ const a = 'v=DMARC1; p=reject;';
+ const b = 'v=DMARC1; p=none;';
+ expect(formatWallOfShameDmarcRecordForUrl([a, b])).toBe(`${a}\n---\n${b}`);
+ });
+
+ it('truncates long records with an ellipsis suffix', () => {
+ const inner = 'x'.repeat(100);
+ const rec = `v=DMARC1; p=none; note=${inner}`;
+ const max = 40;
+ const out = formatWallOfShameDmarcRecordForUrl([rec], max);
+ expect(out.length).toBe(max);
+ expect(out.endsWith('…')).toBe(true);
+ expect(out.startsWith('v=DMARC1;')).toBe(true);
+ });
+
+ it('respects default max length constant behaviour', () => {
+ const long = 'v=DMARC1; ' + 'z'.repeat(WALL_OF_SHAME_DMARC_RECORD_URL_MAX);
+ const out = formatWallOfShameDmarcRecordForUrl([long]);
+ expect(out.length).toBe(WALL_OF_SHAME_DMARC_RECORD_URL_MAX);
+ expect(out.endsWith('…')).toBe(true);
+ });
+});
+
+describe('buildWallOfShameDmarcIssueUrl', () => {
+ function parseIssueUrl(url: string): URLSearchParams {
+ const u = new URL(url);
+ expect(u.origin + u.pathname).toBe(WALL_OF_SHAME_DMARC_ISSUES_NEW);
+ return u.searchParams;
+ }
+
+ it('sets template, title, org, domain, issue type, lookup, and omits dmarc_record when absent', () => {
+ const url = buildWallOfShameDmarcIssueUrl(
+ 'ACME Corp',
+ 'example.com',
+ [],
+ );
+ const q = parseIssueUrl(url);
+ expect(q.get('template')).toBe('dmarc_submission.yml');
+ expect(q.get('title')).toBe('[DMARC] example.com');
+ expect(q.get('org_name')).toBe('ACME Corp');
+ expect(q.get('domain')).toBe('example.com');
+ expect(q.get('issue_type')).toBe('No DMARC record (missing)');
+ expect(q.has('dmarc_record')).toBe(false);
+ expect(q.get('lookup_url')).toBe(mxtoolboxDmarcLookupUrl('example.com'));
+ });
+
+ it('includes dmarc_record when records exist', () => {
+ const rec = 'v=DMARC1; p=none;';
+ const url = buildWallOfShameDmarcIssueUrl('Co', 'x.test', [rec]);
+ const q = parseIssueUrl(url);
+ expect(q.get('dmarc_record')).toBe(rec);
+ expect(q.get('issue_type')).toBe("DMARC policy set to 'none' (p=none)");
+ });
+
+ it('encodes organisation names with ampersands and preserves round-trip via URLSearchParams', () => {
+ const url = buildWallOfShameDmarcIssueUrl(
+ 'Foo & Bar Ltd',
+ 'brand.example',
+ [],
+ );
+ const q = parseIssueUrl(url);
+ expect(q.get('org_name')).toBe('Foo & Bar Ltd');
+ expect(url).toContain('org_name=');
+ });
+
+ it('uses malformed issue type for multiple DMARC TXTs', () => {
+ const url = buildWallOfShameDmarcIssueUrl(
+ 'Co',
+ 'dup.example',
+ ['v=DMARC1; p=reject;', 'v=DMARC1; p=none;'],
+ );
+ expect(parseIssueUrl(url).get('issue_type')).toBe(
+ 'Malformed / invalid DMARC record',
+ );
+ expect(parseIssueUrl(url).get('dmarc_record')).toContain('\n---\n');
+ });
+});
diff --git a/lib/wallOfShameDmarcIssue.ts b/lib/wallOfShameDmarcIssue.ts
new file mode 100644
index 0000000..bf5c99c
--- /dev/null
+++ b/lib/wallOfShameDmarcIssue.ts
@@ -0,0 +1,68 @@
+import { analyzeDmarc } from '@/lib/parse/dmarc';
+
+export const WALL_OF_SHAME_DMARC_ISSUES_NEW =
+ 'https://github.com/jkerai1/DMARC-WallOfShame/issues/new';
+
+/** Max chars for DMARC TXT prefilled via URL (avoid GitHub URI limits). */
+export const WALL_OF_SHAME_DMARC_RECORD_URL_MAX = 3500;
+
+function truncateForUrlSnippet(s: string, max: number): string {
+ const t = s.trim();
+ if (t.length <= max) return t;
+ return `${t.slice(0, max - 1)}…`;
+}
+
+/** MXToolbox SuperTool deep link (matches Wall of Shame issue template placeholder). */
+export function mxtoolboxDmarcLookupUrl(domain: string): string {
+ return `https://mxtoolbox.com/SuperTool.aspx?action=${encodeURIComponent(`dmarc:${domain}`)}`;
+}
+
+/** Maps analysed DMARC TXT to the Wall of Shame issue form dropdown value. */
+export function inferWallOfShameDmarcIssueType(
+ dmarcRecords: readonly string[],
+): string {
+ const a = analyzeDmarc([...dmarcRecords]);
+ if (a.multipleRecords) {
+ return 'Malformed / invalid DMARC record';
+ }
+ if (!a.present) {
+ return 'No DMARC record (missing)';
+ }
+ if (a.policy === 'none') {
+ return "DMARC policy set to 'none' (p=none)";
+ }
+ return 'Malformed / invalid DMARC record';
+}
+
+/** DMARC TXT snippet for GitHub issue URL prefilling (may be empty). */
+export function formatWallOfShameDmarcRecordForUrl(
+ dmarcRecords: readonly string[],
+ maxChars: number = WALL_OF_SHAME_DMARC_RECORD_URL_MAX,
+): string {
+ if (!dmarcRecords.length) return '';
+ const joined =
+ dmarcRecords.length === 1
+ ? dmarcRecords[0]
+ : dmarcRecords.join('\n---\n');
+ return truncateForUrlSnippet(joined, maxChars);
+}
+
+/** Builds the prefilled DMARC Wall of Shame GitHub issue form URL. */
+export function buildWallOfShameDmarcIssueUrl(
+ orgName: string,
+ domain: string,
+ dmarcRecords: readonly string[],
+): string {
+ const params = new URLSearchParams();
+ params.set('template', 'dmarc_submission.yml');
+ params.set('title', `[DMARC] ${domain}`);
+ params.set('org_name', orgName);
+ params.set('domain', domain);
+ params.set('issue_type', inferWallOfShameDmarcIssueType(dmarcRecords));
+ const dmarcSnippet = formatWallOfShameDmarcRecordForUrl(dmarcRecords);
+ if (dmarcSnippet) {
+ params.set('dmarc_record', dmarcSnippet);
+ }
+ params.set('lookup_url', mxtoolboxDmarcLookupUrl(domain));
+ return `${WALL_OF_SHAME_DMARC_ISSUES_NEW}?${params}`;
+}
From 2951fb9cf1559c7b4b2030041a5653de014969da Mon Sep 17 00:00:00 2001
From: Luke Steward <29278153+LukeSteward@users.noreply.github.com>
Date: Fri, 15 May 2026 19:33:49 +0100
Subject: [PATCH 3/3] Enhance DMARC issue handling and URL formatting
- Updated the inferWallOfShameDmarcIssueType function to correctly identify valid strict DMARC policies.
- Added new test cases for valid strict policies and edge cases in URL formatting.
- Implemented handling for non-positive maxChars in formatWallOfShameDmarcRecordForUrl function.
---
lib/wallOfShameDmarcIssue.test.ts | 18 ++++++++++++++++--
lib/wallOfShameDmarcIssue.ts | 5 +++++
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/lib/wallOfShameDmarcIssue.test.ts b/lib/wallOfShameDmarcIssue.test.ts
index 4cfdd59..e81b0d3 100644
--- a/lib/wallOfShameDmarcIssue.test.ts
+++ b/lib/wallOfShameDmarcIssue.test.ts
@@ -56,10 +56,13 @@ describe('inferWallOfShameDmarcIssueType', () => {
).toBe('Malformed / invalid DMARC record');
});
- it('returns malformed for strict reject when used as catch-all for non-none weak configs', () => {
+ it('returns not a Wall of Shame issue for valid strict policies', () => {
expect(
inferWallOfShameDmarcIssueType(['v=DMARC1; p=reject;']),
- ).toBe('Malformed / invalid DMARC record');
+ ).toBe('Not a Wall of Shame issue (valid DMARC policy)');
+ expect(
+ inferWallOfShameDmarcIssueType(['v=DMARC1; p=quarantine;']),
+ ).toBe('Not a Wall of Shame issue (valid DMARC policy)');
});
});
@@ -68,6 +71,17 @@ describe('formatWallOfShameDmarcRecordForUrl', () => {
expect(formatWallOfShameDmarcRecordForUrl([])).toBe('');
});
+ it('returns empty when maxChars is non-positive (no unsafe slice)', () => {
+ const rec = 'v=DMARC1; p=none;';
+ expect(formatWallOfShameDmarcRecordForUrl([rec], 0)).toBe('');
+ expect(formatWallOfShameDmarcRecordForUrl([rec], -1)).toBe('');
+ });
+
+ it('truncates to a single ellipsis character when maxChars is 1', () => {
+ expect(formatWallOfShameDmarcRecordForUrl(['v=DMARC1;'], 1)).toBe('…');
+ expect(formatWallOfShameDmarcRecordForUrl(['v=DMARC1;'], 1).length).toBe(1);
+ });
+
it('returns the single record verbatim when within limit', () => {
const rec = 'v=DMARC1; p=none;';
expect(formatWallOfShameDmarcRecordForUrl([rec])).toBe(rec);
diff --git a/lib/wallOfShameDmarcIssue.ts b/lib/wallOfShameDmarcIssue.ts
index bf5c99c..b11a867 100644
--- a/lib/wallOfShameDmarcIssue.ts
+++ b/lib/wallOfShameDmarcIssue.ts
@@ -7,6 +7,7 @@ export const WALL_OF_SHAME_DMARC_ISSUES_NEW =
export const WALL_OF_SHAME_DMARC_RECORD_URL_MAX = 3500;
function truncateForUrlSnippet(s: string, max: number): string {
+ if (max <= 0) return '';
const t = s.trim();
if (t.length <= max) return t;
return `${t.slice(0, max - 1)}…`;
@@ -31,6 +32,9 @@ export function inferWallOfShameDmarcIssueType(
if (a.policy === 'none') {
return "DMARC policy set to 'none' (p=none)";
}
+ if (a.policy === 'quarantine' || a.policy === 'reject') {
+ return 'Not a Wall of Shame issue (valid DMARC policy)';
+ }
return 'Malformed / invalid DMARC record';
}
@@ -39,6 +43,7 @@ export function formatWallOfShameDmarcRecordForUrl(
dmarcRecords: readonly string[],
maxChars: number = WALL_OF_SHAME_DMARC_RECORD_URL_MAX,
): string {
+ if (maxChars <= 0) return '';
if (!dmarcRecords.length) return '';
const joined =
dmarcRecords.length === 1