dmarc-report 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """DMARC report parser and display tool."""
@@ -0,0 +1,565 @@
1
+ """Parse DMARC XML reports and display the results using Rich tables and panels."""
2
+
3
+ import gzip
4
+ import zipfile
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import ClassVar, Optional
10
+ from xml.etree.ElementTree import Element
11
+
12
+ from defusedxml import ElementTree
13
+ from rich import box
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+
19
+ class PolicyType(Enum):
20
+ """Policy types for DMARC."""
21
+
22
+ NONE = "none"
23
+ QUARANTINE = "quarantine"
24
+ REJECT = "reject"
25
+
26
+
27
+ class AlignmentMode(Enum):
28
+ """Alignment modes for DKIM and SPF."""
29
+
30
+ RELAXED = "r"
31
+ STRICT = "s"
32
+
33
+
34
+ class AuthResultType(Enum):
35
+ """Authentication result types for DKIM and SPF.
36
+
37
+ The AuthResultType enum corresponds to the <result> element in the <dkim> and <spf> elements of the DMARC XML
38
+ report.
39
+ """
40
+
41
+ NONE = "none"
42
+ PASS = "pass" # noqa: S105
43
+ FAIL = "fail"
44
+ POLICY = "policy"
45
+ NEUTRAL = "neutral"
46
+ TEMPERROR = "temperror"
47
+ PERMERROR = "permerror"
48
+ SOFTFAIL = "softfail" # SPF only
49
+
50
+
51
+ @dataclass
52
+ class DateRange:
53
+ """Date range object containing begin and end timestamps.
54
+
55
+ The DateRange object corresponds to the <date_range> element in the DMARC XML report.
56
+ """
57
+
58
+ begin: int
59
+ end: int
60
+
61
+ def __str__(self) -> str:
62
+ """Return formatted date range string."""
63
+ return f"{self.format_timestamp(self.begin)} to {self.format_timestamp(self.end)}"
64
+
65
+ @staticmethod
66
+ def format_timestamp(timestamp: int) -> str:
67
+ """Convert UTC Unix timestamp to formatted UTC date string."""
68
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") # noqa: UP017
69
+
70
+
71
+ @dataclass
72
+ class ReportMetadata:
73
+ """Report metadata object containing report recipient details.
74
+
75
+ The ReportMetadata object corresponds to the <report_metadata> element in the DMARC XML report.
76
+ """
77
+
78
+ org_name: str
79
+ email: str
80
+ report_id: str
81
+ date_range: DateRange
82
+ extra_contact_info: Optional[str] = None
83
+ errors: Optional[list[str]] = None # Not yet implemented
84
+
85
+
86
+ @dataclass
87
+ class PolicyPublished:
88
+ """Policy published object containing DMARC policy details.
89
+
90
+ The PolicyPublished object corresponds to the <policy_published> element in the DMARC XML report.
91
+ """
92
+
93
+ domain: str
94
+ p: PolicyType
95
+ sp: PolicyType
96
+ pct: int # 0-100
97
+ adkim: AlignmentMode
98
+ aspf: AlignmentMode
99
+ fo: Optional[str] = None
100
+
101
+ def __post_init__(self) -> None:
102
+ """Convert string values to enums if needed and validate percentage."""
103
+ if isinstance(self.p, str):
104
+ self.p = PolicyType(self.p)
105
+ if isinstance(self.sp, str):
106
+ self.sp = PolicyType(self.sp)
107
+ if isinstance(self.adkim, str):
108
+ self.adkim = AlignmentMode(self.adkim)
109
+ if isinstance(self.aspf, str):
110
+ self.aspf = AlignmentMode(self.aspf)
111
+
112
+ # Validate percentage
113
+ if not 0 <= self.pct <= 100: # noqa: PLR2004
114
+ msg = f"Percentage must be between 0 and 100, got {self.pct}"
115
+ raise ValueError(msg)
116
+
117
+
118
+ @dataclass
119
+ class PolicyEvaluated:
120
+ """Policy evaluated object containing disposition and authentication results.
121
+
122
+ The PolicyEvaluated object corresponds to the <policy_evaluated> element in the <record> element of DMARC XML
123
+ report.
124
+ """
125
+
126
+ disposition: PolicyType
127
+ dkim: AuthResultType
128
+ spf: AuthResultType
129
+ reason: Optional[list[dict]] = None # type and comment. Not yet implemented
130
+
131
+ def __post_init__(self) -> None:
132
+ """Convert string values to enums if needed."""
133
+ if isinstance(self.disposition, str):
134
+ self.disposition = PolicyType(self.disposition)
135
+ if isinstance(self.dkim, str):
136
+ self.dkim = AuthResultType(self.dkim)
137
+ if isinstance(self.spf, str):
138
+ self.spf = AuthResultType(self.spf)
139
+
140
+
141
+ @dataclass
142
+ class Identifier:
143
+ """Identifier object containing message header and envelope details.
144
+
145
+ The Identifier object corresponds to the <identifiers> element in the <record> element of DMARC XML report.
146
+ """
147
+
148
+ header_from: str
149
+ envelope_to: Optional[str] = None
150
+ envelope_from: Optional[str] = None
151
+
152
+
153
+ @dataclass
154
+ class DKIMAuthResult:
155
+ """DKIM authentication result object.
156
+
157
+ The DKIMAuthResult object corresponds to the <dkim> element in the <auth_results> element of DMARC XML report.
158
+ """
159
+
160
+ domain: str
161
+ result: AuthResultType
162
+ selector: Optional[str] = None
163
+ human_result: Optional[str] = None
164
+
165
+ def __post_init__(self) -> None:
166
+ """Convert string values to enums if needed."""
167
+ if isinstance(self.result, str):
168
+ self.result = AuthResultType(self.result)
169
+
170
+
171
+ @dataclass
172
+ class SPFAuthResult:
173
+ """SPF authentication result object.
174
+
175
+ The SPFAuthResult object corresponds to the <spf> element in the <auth_results> element of DMARC XML report.
176
+ """
177
+
178
+ domain: str
179
+ result: AuthResultType
180
+ scope: Optional[str] = None
181
+ human_result: Optional[str] = None
182
+
183
+ VALID_SCOPES: ClassVar[set[str]] = {"helo", "mfrom"}
184
+
185
+ def __post_init__(self) -> None:
186
+ """Convert string values to enums if needed and validate scope."""
187
+ if isinstance(self.result, str):
188
+ self.result = AuthResultType(self.result)
189
+ if self.scope and self.scope not in self.VALID_SCOPES:
190
+ msg = f"Invalid scope: {self.scope}. Must be one of {self.VALID_SCOPES}"
191
+ raise ValueError(msg)
192
+
193
+
194
+ @dataclass
195
+ class AuthResults:
196
+ """Authentication results for DKIM and SPF.
197
+
198
+ The AuthResults object corresponds to the <auth_results> element in the DMARC XML report.
199
+ """
200
+
201
+ dkim: list[DKIMAuthResult]
202
+ spf: list[SPFAuthResult]
203
+
204
+
205
+ @dataclass
206
+ class Record:
207
+ """DMARC record object containing message details and authentication results.
208
+
209
+ The Record object corresponds to the <record> element in the DMARC XML report.
210
+ """
211
+
212
+ source_ip: str
213
+ count: int
214
+ policy_evaluated: PolicyEvaluated
215
+ identifiers: Identifier
216
+ auth_results: AuthResults
217
+
218
+
219
+ @dataclass
220
+ class Report:
221
+ """DMARC report object containing metadata, policy, and records.
222
+
223
+ This class provides properties and methods to access and display the DMARC report data.
224
+ """
225
+
226
+ report_metadata: ReportMetadata
227
+ policy_published: PolicyPublished
228
+ records: list[Record]
229
+
230
+ @property
231
+ def org_name(self) -> str:
232
+ """Return the organization name of the report recipient."""
233
+ return self.report_metadata.org_name
234
+
235
+ @property
236
+ def email(self) -> str:
237
+ """Return the email address of the report recipient."""
238
+ return self.report_metadata.email
239
+
240
+ @property
241
+ def report_id(self) -> str:
242
+ """Return the report ID."""
243
+ return self.report_metadata.report_id
244
+
245
+ @property
246
+ def date_range(self) -> tuple[int, int]:
247
+ """Return the date range of the DMARC report."""
248
+ return (self.report_metadata.date_range.begin, self.report_metadata.date_range.end)
249
+
250
+ @property
251
+ def domain(self) -> str:
252
+ """Return the domain of the DMARC report."""
253
+ return self.policy_published.domain
254
+
255
+ def get_summary_stats(self) -> dict:
256
+ """Generate summary statistics for the report."""
257
+ total_messages = sum(record.count for record in self.records)
258
+
259
+ # Calculate pass rates
260
+ dkim_pass = sum(record.count for record in self.records if record.policy_evaluated.dkim == "pass")
261
+ spf_pass = sum(record.count for record in self.records if record.policy_evaluated.spf == "pass")
262
+
263
+ # Count dispositions
264
+ dispositions = {}
265
+ for record in self.records:
266
+ disp = record.policy_evaluated.disposition.value
267
+ dispositions[disp] = dispositions.get(disp, 0) + record.count
268
+
269
+ return {
270
+ "total_messages": total_messages,
271
+ "unique_sources": len({record.source_ip for record in self.records}),
272
+ "dkim_pass_rate": dkim_pass / total_messages if total_messages > 0 else 0,
273
+ "spf_pass_rate": spf_pass / total_messages if total_messages > 0 else 0,
274
+ "dispositions": dispositions,
275
+ "report_period": str(self.report_metadata.date_range),
276
+ }
277
+
278
+ def display(self) -> None:
279
+ """Display the DMARC report using Rich tables and panels."""
280
+ console = Console()
281
+
282
+ # Create header panel with today's date and time
283
+ header = f"""
284
+ [bold]Domain:[/bold] [green]{self.policy_published.domain}[/green]
285
+ [bold]Report Period:[/bold] [green]{self.report_metadata.date_range}[/green]
286
+ """
287
+ console.print(
288
+ Panel(
289
+ header,
290
+ title=f"DMARC Report for {self.policy_published.domain}",
291
+ border_style="blue",
292
+ ),
293
+ )
294
+
295
+ # Display summary statistics
296
+ stats = self.get_summary_stats()
297
+ stats_table = Table(title="Summary Statistics", box=box.ROUNDED, show_header=False)
298
+ stats_table.add_column("Metric", style="cyan")
299
+ stats_table.add_column("Value", style="green")
300
+
301
+ stats_table.add_row("Total Messages", str(stats["total_messages"]))
302
+ stats_table.add_row("Unique Sources", str(stats["unique_sources"]))
303
+ stats_table.add_row("DKIM Pass Rate", f"{stats['dkim_pass_rate']:.1%}")
304
+ stats_table.add_row("SPF Pass Rate", f"{stats['spf_pass_rate']:.1%}")
305
+
306
+ console.print(stats_table)
307
+
308
+ # Report Details table
309
+ details_table = Table(title="DMARC Report Metadata", box=box.ROUNDED, show_header=False)
310
+ details_table.add_column("Field", style="cyan")
311
+ details_table.add_column("Value", style="green")
312
+ details_table.add_row("Org name", self.report_metadata.org_name)
313
+ details_table.add_row("Email", self.report_metadata.email)
314
+ details_table.add_row("Extra contact info", self.report_metadata.extra_contact_info)
315
+ details_table.add_row("Report ID", self.report_metadata.report_id)
316
+ details_table.add_row("Date range", str(self.report_metadata.date_range))
317
+
318
+ console.print(details_table)
319
+ console.print()
320
+
321
+ # Create policy table
322
+ policy_table = Table(title="DMARC Policy Details", box=box.ROUNDED, show_header=False)
323
+ policy_table.add_column("Setting", style="cyan")
324
+ policy_table.add_column("Value", style="green")
325
+
326
+ policy_table.add_row("Domain", self.policy_published.domain)
327
+ policy_table.add_row("DKIM Alignment", self.policy_published.adkim.value)
328
+ policy_table.add_row("SPF Alignment", self.policy_published.aspf.value)
329
+ policy_table.add_row("Policy", self.policy_published.p.value)
330
+ policy_table.add_row("Subdomain Policy", self.policy_published.sp.value)
331
+ policy_table.add_row("Percent", str(self.policy_published.pct))
332
+ if self.policy_published.fo:
333
+ policy_table.add_row("Failure Options", self.policy_published.fo)
334
+
335
+ console.print(policy_table)
336
+ console.print()
337
+
338
+ # Create records table
339
+ records_table = Table(title="Message Records", box=box.ROUNDED)
340
+ records_table.add_column("Source IP", style="cyan")
341
+ records_table.add_column("Count", style="magenta")
342
+ records_table.add_column("Disposition", style="green")
343
+ records_table.add_column("DKIM", style="yellow")
344
+ records_table.add_column("SPF", style="yellow")
345
+ records_table.add_column("Header From", style="blue")
346
+ records_table.add_column("Auth Results", style="green")
347
+
348
+ for record in self.records:
349
+ dkim_auth_results_str = "\n".join(
350
+ f"dkim: {ar_d.domain} ({ar_d.result.value})" for ar_d in record.auth_results.dkim
351
+ )
352
+ spf_auth_results_str = "\n".join(
353
+ f"spf: {ar_s.domain} ({ar_s.result.value})" for ar_s in record.auth_results.spf
354
+ )
355
+
356
+ records_table.add_row(
357
+ record.source_ip,
358
+ str(record.count),
359
+ record.policy_evaluated.disposition.value,
360
+ record.policy_evaluated.dkim.value,
361
+ record.policy_evaluated.spf.value,
362
+ record.identifiers.header_from,
363
+ dkim_auth_results_str + "\n" + spf_auth_results_str,
364
+ )
365
+
366
+ console.print(records_table)
367
+
368
+ console.print("\n :rocket: [bold]End of report[/bold]")
369
+ console.rule()
370
+
371
+
372
+ class DMARCParser:
373
+ """Parse DMARC XML reports.
374
+
375
+ This class provides methods to parse DMARC XML reports from files and strings.
376
+
377
+ When used with the `parse_file` method, it can handle .xml, .xml.gz, and .zip file types, and will return a Report
378
+ object.
379
+ """
380
+
381
+ @staticmethod
382
+ def parse_file(filepath: str) -> Report:
383
+ """Parse a DMARC report file and return a Report object.
384
+
385
+ Handles .xml, .xml.gz, and .zip file types.
386
+
387
+ Args:
388
+ filepath (str): Path to the DMARC report file.
389
+
390
+ Returns:
391
+ Report: A Report object containing the parsed DMARC report data.
392
+
393
+ Raises:
394
+ ValueError: If the file type is not supported.
395
+ """
396
+ filepath = Path(filepath)
397
+ suffix = filepath.suffix.lower()
398
+
399
+ if suffix == ".zip":
400
+ content = DMARCParser._read_zip(filepath)
401
+ elif suffix == ".gz":
402
+ content = DMARCParser._read_gzip(filepath)
403
+ elif suffix == ".xml":
404
+ content = DMARCParser._read_xml(filepath)
405
+ else:
406
+ msg = f"Unsupported file type: {filepath.suffix}"
407
+ raise ValueError(msg)
408
+
409
+ # Parse the content into a Report object
410
+ root = ElementTree.fromstring(content)
411
+ return DMARCParser.parse_xml(root)
412
+
413
+ @staticmethod
414
+ def _read_zip(filepath: str) -> str:
415
+ """Parse a zipped DMARC XML report file and return the content.
416
+
417
+ Looks for the first .xml file in the zip archive.
418
+
419
+ Args:
420
+ filepath (str): Path to the zip archive containing the DMARC XML report.
421
+
422
+ Returns:
423
+ str: The content of the first XML file found in the zip archive.
424
+
425
+ Raises:
426
+ ValueError: If no XML file is found in the zip archive.
427
+ """
428
+ with zipfile.ZipFile(filepath) as zip_file:
429
+ # Find the first XML file in the archive
430
+ xml_files = [f for f in zip_file.namelist() if f.lower().endswith(".xml")]
431
+ if not xml_files:
432
+ msg = f"No XML file found in zip archive: {filepath}"
433
+ raise ValueError(msg)
434
+
435
+ # Read the first XML file
436
+ with zip_file.open(xml_files[0]) as f:
437
+ return f.read().decode("utf-8")
438
+
439
+ @staticmethod
440
+ def _read_gzip(filepath: str) -> str:
441
+ """Parse a gzipped DMARC XML report file and return the content.
442
+
443
+ Args:
444
+ filepath (str): Path to the gzipped DMARC XML report file.
445
+
446
+ Returns:
447
+ str: The content of the gzipped XML file.
448
+ """
449
+ with gzip.open(filepath, "rt", encoding="utf-8") as f:
450
+ return f.read()
451
+
452
+ @staticmethod
453
+ def _read_xml(filepath: str) -> str:
454
+ """Parse a DMARC XML report file and return the content.
455
+
456
+ Args:
457
+ filepath (str): Path to the DMARC XML report file.
458
+
459
+ Returns:
460
+ str: The content of the XML file.
461
+ """
462
+ with Path.open(filepath, encoding="utf-8") as f:
463
+ return f.read()
464
+
465
+ @staticmethod
466
+ def parse_xml(root: Element) -> Report:
467
+ """Parse an XML ElementTree and return a Report object.
468
+
469
+ This is the main logic that parses the DMARC XML report.
470
+
471
+ Args:
472
+ root (Element): The root of the XML ElementTree.
473
+
474
+ Returns:
475
+ Report: A Report object containing the parsed DMARC report data.
476
+
477
+ Raises:
478
+ ValueError: If the XML structure is invalid or missing required fields.
479
+ """
480
+ # Extract report metadata
481
+ report_metadata = root.find("report_metadata")
482
+ metadata = ReportMetadata(
483
+ org_name=report_metadata.findtext("org_name"),
484
+ email=report_metadata.findtext("email"),
485
+ report_id=report_metadata.findtext("report_id"),
486
+ date_range=DateRange(
487
+ begin=int(report_metadata.find("date_range").findtext("begin")),
488
+ end=int(report_metadata.find("date_range").findtext("end")),
489
+ ),
490
+ extra_contact_info=report_metadata.findtext("extra_contact_info"),
491
+ )
492
+
493
+ # Extract policy published
494
+ policy_published = root.find("policy_published")
495
+ policy = PolicyPublished(
496
+ domain=policy_published.findtext("domain"),
497
+ p=policy_published.findtext("p"),
498
+ sp=policy_published.findtext("sp", "none"),
499
+ pct=int(policy_published.findtext("pct", "100")),
500
+ adkim=policy_published.findtext("adkim", "r"),
501
+ aspf=policy_published.findtext("aspf", "r"),
502
+ fo=policy_published.findtext("fo"),
503
+ )
504
+
505
+ # Extract records
506
+ records: list[Record] = []
507
+ for record in root.findall(".//record"):
508
+ # Parse authentication results
509
+ auth_results_elem = record.find("auth_results")
510
+
511
+ dkim_results_elem = auth_results_elem.findall("dkim")
512
+ dkim_auth_results = [
513
+ DKIMAuthResult(
514
+ domain=dkim_result.findtext("domain"),
515
+ result=dkim_result.findtext("result"),
516
+ selector=dkim_result.findtext("selector"),
517
+ human_result=dkim_result.findtext("human_result"),
518
+ )
519
+ for dkim_result in dkim_results_elem
520
+ ]
521
+
522
+ spf_results_elem = auth_results_elem.findall("spf")
523
+ spf_auth_results = [
524
+ SPFAuthResult(
525
+ domain=spf_result.findtext("domain"),
526
+ result=spf_result.findtext("result"),
527
+ scope=spf_result.findtext("scope"),
528
+ human_result=spf_result.findtext("human_result"),
529
+ )
530
+ for spf_result in spf_results_elem
531
+ ]
532
+
533
+ auth_results = AuthResults(
534
+ dkim=dkim_auth_results,
535
+ spf=spf_auth_results,
536
+ )
537
+
538
+ # Create row object
539
+ row = Record(
540
+ source_ip=record.findtext(".//source_ip"),
541
+ count=int(record.findtext(".//count")),
542
+ policy_evaluated=PolicyEvaluated(
543
+ disposition=record.findtext(".//disposition"),
544
+ dkim=record.findtext(".//dkim"),
545
+ spf=record.findtext(".//spf"),
546
+ ),
547
+ identifiers=Identifier(
548
+ header_from=record.findtext(".//identifier/header_from"),
549
+ envelope_from=record.findtext(".//identifier/envelope_from"),
550
+ envelope_to=record.findtext(".//identifier/envelope_to"),
551
+ ),
552
+ auth_results=auth_results,
553
+ )
554
+ records.append(row)
555
+
556
+ return Report(
557
+ report_metadata=metadata,
558
+ policy_published=policy,
559
+ records=records,
560
+ )
561
+
562
+ @staticmethod
563
+ def format_date_range(timestamp: int) -> str:
564
+ """Convert UTC Unix timestamp to formatted UTC date string."""
565
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") # noqa: UP017
dmarc_report/report.py ADDED
@@ -0,0 +1,34 @@
1
+ """Command line interface for parsing and displaying DMARC reports."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from dmarc_report.dmarc_parser import DMARCParser
10
+
11
+
12
+ def report() -> None:
13
+ """Parse and display DMARC reports from the command line."""
14
+ parser = argparse.ArgumentParser(description="Parse and display DMARC reports")
15
+ parser.add_argument("filepath", type=str, help="Path to DMARC report file (.xml, .xml.gz, or .zip)")
16
+ args = parser.parse_args()
17
+
18
+ console = Console(stderr=True)
19
+ filepath = Path(args.filepath)
20
+
21
+ if not filepath.exists():
22
+ console.print(f"[red]Error:[/red] File not found: {filepath}")
23
+ sys.exit(1)
24
+
25
+ try:
26
+ report = DMARCParser.parse_file(args.filepath)
27
+ report.display()
28
+ except Exception as e: # noqa: BLE001
29
+ console.print(f"[red]Error:[/red] Failed to process {filepath}: {e!s}")
30
+ sys.exit(1)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ report()
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.3
2
+ Name: dmarc-report
3
+ Version: 0.1.0
4
+ Summary: Displays a nicely formatted report in your terminal from a DMARC XML report.
5
+ Author-email: Stuart Maxwell <stuart@amanzi.nz>
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: defusedxml>=0.7.1
8
+ Requires-Dist: rich>=13.9.4
9
+ Provides-Extra: test
10
+ Requires-Dist: nox>=2024.10.9; extra == 'test'
11
+ Requires-Dist: pytest>=8.3.4; extra == 'test'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # DMARC Report Tool
15
+
16
+ Displays a nicely formatted report in your terminal from a DMARC XML report.
17
+
18
+ ## Installation
19
+
20
+ This works best when installed with `uvx` or `pipx`.
21
+
22
+ ```bash
23
+ # With uvx:
24
+ uvx install dmarc-report
25
+
26
+ # Or with pipx:
27
+
28
+ pipx install dmarc-report
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run the `dmarc-report` command-line utility followed by a DMARC report file.
34
+ The DMARC report can have one of the following file extensions:
35
+
36
+ - `.xml.gz`
37
+ - `.zip`
38
+ - `.xml`
39
+
40
+ ```bash
41
+ dmarc-report long-dmarc-report-filename.xml.gz
42
+ # or
43
+ dmarc-report long-dmarc-report-filename.xml
44
+ # or
45
+ dmarc-report long-dmarc-report-filename.zip
46
+ ```
47
+
48
+ Note: if using a zip file, it will only parse the first xml file found in the zip file.
@@ -0,0 +1,8 @@
1
+ dmarc_report/__init__.py,sha256=7iOlJdYqe-00BSc9rSBmzqLJ2X8KFE0EJz31OFVBiRY,44
2
+ dmarc_report/dmarc_parser.py,sha256=dgcgxQQelA5XSu4NlmzGmZBWfg_ZIG0uFujy6mfees8,19512
3
+ dmarc_report/report.py,sha256=OxUWRRt7k1U1SsgfVZyfqfbOQ4thQt-N1HLEB2iTwgk,986
4
+ dmarc_report-0.1.0.dist-info/METADATA,sha256=GY0TRhkn-JDO6p7DYue5MAp4NsPfCbkagQohYFq9m7s,1122
5
+ dmarc_report-0.1.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
6
+ dmarc_report-0.1.0.dist-info/entry_points.txt,sha256=9vsq4RbYEzwVN7LvPndZRHN8ae-xftpBUtr1S9_Sx2g,60
7
+ dmarc_report-0.1.0.dist-info/licenses/LICENSE,sha256=qf3Fmk-FBH_G5vkcfb6KiOrJHhsO3SO0tGHD24ps5so,1071
8
+ dmarc_report-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dmarc-report = dmarc_report.report:report
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Stuart Maxwell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.