sendermap 0.1.2__tar.gz → 0.1.4__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 (170) hide show
  1. {sendermap-0.1.2 → sendermap-0.1.4}/ARCHITECTURE.md +15 -10
  2. {sendermap-0.1.2 → sendermap-0.1.4}/CHANGELOG.md +27 -0
  3. sendermap-0.1.4/CLAUDE.md +38 -0
  4. {sendermap-0.1.2 → sendermap-0.1.4}/PKG-INFO +2 -2
  5. {sendermap-0.1.2 → sendermap-0.1.4}/README.md +1 -1
  6. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/data/vendors.json +509 -7
  7. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/data/vendors_pending.json +29 -38
  8. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/engine.py +63 -13
  9. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/mcp_server.py +7 -5
  10. sendermap-0.1.4/sendermap/render/__init__.py +32 -0
  11. sendermap-0.1.4/sendermap/render/html.py +264 -0
  12. sendermap-0.1.4/sendermap/render/markdown.py +112 -0
  13. sendermap-0.1.4/sendermap/render/text.py +165 -0
  14. sendermap-0.1.4/sendermap/render/view.py +782 -0
  15. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/setup.py +17 -13
  16. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/ct.py +2 -2
  17. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/dkim.py +30 -12
  18. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/mx.py +15 -0
  19. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/networks.py +8 -2
  20. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/spf.py +4 -1
  21. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/spf_checks.py +2 -1
  22. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/subdomains.py +21 -4
  23. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/web.py +3 -1
  24. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/story.py +10 -3
  25. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/web/app.py +16 -3
  26. sendermap-0.1.4/sendermap/web/static/app.js +313 -0
  27. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/web/static/index.html +3 -14
  28. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/web/static/llms.txt +2 -1
  29. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/web/static/style.css +45 -39
  30. {sendermap-0.1.2 → sendermap-0.1.4}/server.json +3 -3
  31. {sendermap-0.1.2 → sendermap-0.1.4}/tests/conftest.py +6 -0
  32. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/allbirds.com/dns.json +29 -0
  33. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/allbirds.com/expected.json +99 -22
  34. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/casper.com/dns.json +29 -0
  35. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/casper.com/expected.json +72 -19
  36. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/example.com/dns.json +2 -0
  37. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/example.com/expected.json +3 -1
  38. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/glossier.com/dns.json +29 -0
  39. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/glossier.com/expected.json +40 -11
  40. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/mills-reeve.com/dns.json +29 -0
  41. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/mills-reeve.com/expected.json +38 -9
  42. sendermap-0.1.4/tests/snapshots/allbirds.com/markdown-evidence.md +261 -0
  43. sendermap-0.1.4/tests/snapshots/allbirds.com/markdown.md +79 -0
  44. sendermap-0.1.4/tests/snapshots/allbirds.com/page.html +553 -0
  45. sendermap-0.1.4/tests/snapshots/allbirds.com/text-evidence.txt +443 -0
  46. sendermap-0.1.4/tests/snapshots/allbirds.com/text.txt +152 -0
  47. sendermap-0.1.4/tests/snapshots/casper.com/markdown-evidence.md +201 -0
  48. sendermap-0.1.4/tests/snapshots/casper.com/markdown.md +74 -0
  49. sendermap-0.1.4/tests/snapshots/casper.com/page.html +505 -0
  50. sendermap-0.1.4/tests/snapshots/casper.com/text-evidence.txt +286 -0
  51. sendermap-0.1.4/tests/snapshots/casper.com/text.txt +141 -0
  52. sendermap-0.1.4/tests/snapshots/example.com/markdown-evidence.md +28 -0
  53. sendermap-0.1.4/tests/snapshots/example.com/markdown.md +20 -0
  54. sendermap-0.1.4/tests/snapshots/example.com/page.html +51 -0
  55. sendermap-0.1.4/tests/snapshots/example.com/text-evidence.txt +29 -0
  56. sendermap-0.1.4/tests/snapshots/example.com/text.txt +21 -0
  57. sendermap-0.1.4/tests/snapshots/glossier.com/markdown-evidence.md +151 -0
  58. sendermap-0.1.4/tests/snapshots/glossier.com/markdown.md +54 -0
  59. sendermap-0.1.4/tests/snapshots/glossier.com/page.html +363 -0
  60. sendermap-0.1.4/tests/snapshots/glossier.com/text-evidence.txt +241 -0
  61. sendermap-0.1.4/tests/snapshots/glossier.com/text.txt +99 -0
  62. sendermap-0.1.4/tests/snapshots/mills-reeve.com/markdown-evidence.md +121 -0
  63. sendermap-0.1.4/tests/snapshots/mills-reeve.com/markdown.md +60 -0
  64. sendermap-0.1.4/tests/snapshots/mills-reeve.com/page.html +262 -0
  65. sendermap-0.1.4/tests/snapshots/mills-reeve.com/text-evidence.txt +157 -0
  66. sendermap-0.1.4/tests/snapshots/mills-reeve.com/text.txt +99 -0
  67. sendermap-0.1.4/tests/test_alias_subdomains.py +48 -0
  68. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_backed_only.py +11 -1
  69. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_caps.py +7 -4
  70. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_checked.py +7 -3
  71. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_dkim_denial.py +24 -11
  72. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_engine.py +19 -1
  73. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_gaps.py +3 -1
  74. sendermap-0.1.4/tests/test_html.py +149 -0
  75. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_labels_say_what_was_seen.py +11 -9
  76. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_mcp.py +19 -1
  77. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_reconcile.py +5 -5
  78. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_resolver.py +3 -5
  79. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_setup.py +32 -17
  80. sendermap-0.1.4/tests/test_snapshots.py +54 -0
  81. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_spf_checks.py +6 -2
  82. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_story.py +43 -10
  83. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_supporting_evidence.py +4 -2
  84. sendermap-0.1.4/tests/test_view.py +190 -0
  85. sendermap-0.1.4/tests/test_web.py +170 -0
  86. {sendermap-0.1.2 → sendermap-0.1.4}/uv.lock +3 -3
  87. sendermap-0.1.2/sendermap/render/__init__.py +0 -181
  88. sendermap-0.1.2/sendermap/render/markdown.py +0 -97
  89. sendermap-0.1.2/sendermap/render/text.py +0 -152
  90. sendermap-0.1.2/sendermap/web/static/app.js +0 -500
  91. sendermap-0.1.2/tests/test_web.py +0 -55
  92. {sendermap-0.1.2 → sendermap-0.1.4}/.github/workflows/publish.yml +0 -0
  93. {sendermap-0.1.2 → sendermap-0.1.4}/.github/workflows/recheck-corpus.yml +0 -0
  94. {sendermap-0.1.2 → sendermap-0.1.4}/.github/workflows/test.yml +0 -0
  95. {sendermap-0.1.2 → sendermap-0.1.4}/.github/workflows/verify-proofs.yml +0 -0
  96. {sendermap-0.1.2 → sendermap-0.1.4}/.gitignore +0 -0
  97. {sendermap-0.1.2 → sendermap-0.1.4}/LICENSE +0 -0
  98. {sendermap-0.1.2 → sendermap-0.1.4}/data/hunt_domains.txt +0 -0
  99. {sendermap-0.1.2 → sendermap-0.1.4}/pyproject.toml +0 -0
  100. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/__init__.py +0 -0
  101. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/catalog.py +0 -0
  102. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/cli.py +0 -0
  103. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/data/schema/sendermap-1.json +0 -0
  104. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/dns.py +0 -0
  105. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/infer.py +0 -0
  106. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/model.py +0 -0
  107. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/proofs.py +0 -0
  108. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/py.typed +0 -0
  109. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/reconcile.py +0 -0
  110. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/selectors.py +0 -0
  111. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/__init__.py +0 -0
  112. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/bimi.py +0 -0
  113. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/labels.py +0 -0
  114. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/mta_sts.py +0 -0
  115. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/reports.py +0 -0
  116. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/spf_eval.py +0 -0
  117. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/sources/tokens.py +0 -0
  118. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/testing.py +0 -0
  119. {sendermap-0.1.2 → sendermap-0.1.4}/sendermap/web/__init__.py +0 -0
  120. {sendermap-0.1.2 → sendermap-0.1.4}/skills/sendermap/SKILL.md +0 -0
  121. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/README.md +0 -0
  122. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/allbirds.com/case.json +0 -0
  123. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/allbirds.com/http.json +0 -0
  124. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/casper.com/case.json +0 -0
  125. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/casper.com/http.json +0 -0
  126. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/example.com/case.json +0 -0
  127. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/example.com/http.json +0 -0
  128. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/glossier.com/case.json +0 -0
  129. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/glossier.com/http.json +0 -0
  130. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/mills-reeve.com/case.json +0 -0
  131. {sendermap-0.1.2 → sendermap-0.1.4}/tests/corpus/mills-reeve.com/http.json +0 -0
  132. {sendermap-0.1.2 → sendermap-0.1.4}/tests/fixtures/crtsh_allbirds.json +0 -0
  133. {sendermap-0.1.2 → sendermap-0.1.4}/tests/fixtures/mcp-server-2025-12-11.schema.json +0 -0
  134. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_catalog.py +0 -0
  135. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_cli.py +0 -0
  136. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_corpus.py +0 -0
  137. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_ct.py +0 -0
  138. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_delegation.py +0 -0
  139. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_determinism.py +0 -0
  140. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_dkim.py +0 -0
  141. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_evidence.py +0 -0
  142. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_gap_kinds.py +0 -0
  143. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_hunt_proofs.py +0 -0
  144. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_listing.py +0 -0
  145. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_networks.py +0 -0
  146. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_one_selector_list.py +0 -0
  147. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_package.py +0 -0
  148. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_pending_vendors.py +0 -0
  149. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_publish.py +0 -0
  150. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_recheck_corpus.py +0 -0
  151. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_record_replay.py +0 -0
  152. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_schema.py +0 -0
  153. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_selectors.py +0 -0
  154. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_skill.py +0 -0
  155. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_sources.py +0 -0
  156. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_spf.py +0 -0
  157. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_spf_eval.py +0 -0
  158. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_subprocessor_names.py +0 -0
  159. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_vendor_proofs.py +0 -0
  160. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_verify_proofs.py +0 -0
  161. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_web_guard.py +0 -0
  162. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_web_mcp.py +0 -0
  163. {sendermap-0.1.2 → sendermap-0.1.4}/tests/test_wildcard_subdomains.py +0 -0
  164. {sendermap-0.1.2 → sendermap-0.1.4}/tests/vectors/rfc7208-tests.LICENSE +0 -0
  165. {sendermap-0.1.2 → sendermap-0.1.4}/tests/vectors/rfc7208-tests.yml +0 -0
  166. {sendermap-0.1.2 → sendermap-0.1.4}/tools/fill_corpus.py +0 -0
  167. {sendermap-0.1.2 → sendermap-0.1.4}/tools/hunt_proofs.py +0 -0
  168. {sendermap-0.1.2 → sendermap-0.1.4}/tools/record_corpus.py +0 -0
  169. {sendermap-0.1.2 → sendermap-0.1.4}/tools/release_notes.py +0 -0
  170. {sendermap-0.1.2 → sendermap-0.1.4}/tools/verify_proofs.py +0 -0
@@ -137,7 +137,8 @@ Assembly
137
137
  -> SenderMap + Run
138
138
  story.write: the paragraph from the map; no query
139
139
  reconcile.with_claimed: the claimed names against the services; no query
140
- -> render.text | render.markdown | to_dict JSON | web stream
140
+ -> render.view: one View, findings first, each thing said once; no query
141
+ -> render.text | render.markdown (both from the View) | to_dict JSON | web stream
141
142
  ```
142
143
 
143
144
  Each source returns an `Observed`: claims, unknowns, findings, gaps and what it checked. Sources never raise
@@ -208,7 +209,8 @@ for the check and a private one a moment later (DNS rebinding) is not caught.
208
209
  | `sources/networks.py` | Naming SPF IP ranges by the ranges services publish, then by network owner |
209
210
  | `sources/ct.py` | Certificate transparency: the only reader of certificate logs. Reads them once per map through a provider list (Cert Spotter always, crt.sh with `--ct`), each with its own tries, timeout and one-day disk cache, into one set of names; with `--ct`, each logged name resolved and matched by the same catalog matchers as the DNS sources |
210
211
  | `sources/subdomains.py` | Finding sending subdomains from a wordlist and the names `ct.py` read |
211
- | `render/text.py`, `render/markdown.py` | The terminal and Markdown views; both show the gaps and what was asked at each name, as the web page does, and group unrecognised IP ranges by network |
212
+ | `render/view.py` | The View every view renders: findings first, then services by tier with one evidence line per kind and the setup in one clause, then what was not recognised, checked and asked, counted unless evidence is set; and the helpers only views use (unknowns grouped by network, the claimed parts, the CT lines, the resolver words) |
213
+ | `render/text.py`, `render/markdown.py` | The terminal and Markdown views of the View; they hold no logic about the map. `tests/snapshots` holds both, for every corpus case, with evidence off and on |
212
214
  | `cli.py` | The `sendermap` command, and `sendermap mcp`, which starts the MCP server |
213
215
  | `mcp_server.py` | The MCP server: its two tools, two resources and instructions, and the summary of a map |
214
216
  | `testing.py` | Recording and replaying DNS answers and pages, and replaying a recorded case |
@@ -222,7 +224,7 @@ These live in `catalog.py` and the sources, and each has a test.
222
224
  longest pattern wins: `u161779.wl030.sendgrid.net` is Klaviyo, not SendGrid.
223
225
  - A DKIM key belongs to a service by CNAME first. `selector1` and `selector2` name nobody
224
226
  without the Microsoft CNAME. Next, a backed only selector (`rules.backed_only_selectors`, each
225
- with a proof: `s1` and `s2` for Klaviyo) credits its one vendor only when SPF, MX or an NS
227
+ with a proof: `s1` and `s2` for Klaviyo, `mailing` for Optimizely Campaign) credits its one vendor only when SPF, MX or an NS
226
228
  delegation already shows that vendor, and nobody otherwise. Next, a selector on the list of a
227
229
  service that SPF, MX or an NS delegation already shows is that service's. Last, the name table, except for generic names (`default`, `mail`,
228
230
  any two-letter name not explicitly allowed).
@@ -233,7 +235,7 @@ These live in `catalog.py` and the sources, and each has a test.
233
235
  A redirect is ignored when the record has `all`. Inside a service's own record nothing more
234
236
  is credited, except inside a flattening service's record (EasyDMARC), which copies the
235
237
  domain's own senders.
236
- - A hosted SPF service (Valimail, OnDMARC, EasyDMARC) or a macro include is reported as a note,
238
+ - A hosted SPF service (Valimail, OnDMARC, EasyDMARC, DmarcDuty) or a macro include is reported as a note,
237
239
  since it can authorize services without naming them.
238
240
  - A subdomain delegated by NS is the service's when its NS records name the service's
239
241
  nameservers (`delegation_ns`), as Klaviyo's recommended setup delegates `send.example.com` to
@@ -352,8 +354,8 @@ Checked's class as `denial`.
352
354
  set of networks, an owner name and a list of publishers are one line, "2 ranges and 1 address
353
355
  in AS16509 (Amazon Web Services)", with the publishers once after it, then the ranges, the first
354
356
  record and "+ N more" (every record with `--evidence`). `render.grouped_unknowns` does this
355
- for the text and Markdown views by reading the value networks.py writes, and app.js does the
356
- same with the same pattern, `render.NETWORK_VALUE`. A range with no network, one alone in its
357
+ for every view by reading the value networks.py writes, with the pattern
358
+ `render.NETWORK_VALUE`. A range with no network, one alone in its
357
359
  network, one whose networks have different names, and any unknown that is not an IP range stay
358
360
  single lines.
359
361
  - Every provider's answer becomes rows of (name, not before, not after) that go through one
@@ -459,8 +461,8 @@ DKIM or SPF and a note for a bounce or tracking CNAME. A service whose only clai
459
461
  only is one note asking whether the account is still used, and nothing about what its setup
460
462
  lacks. Broken raises nothing new: the key and SPF findings already exist. Every view shows one
461
463
  line per setup under its service, "Setup at <where>: SPF present, DKIM missing (selector1 and
462
- selector2 no such name), bounce CNAME not needed.", built by `setup.line` and, on the web page,
463
- by the same words in app.js. An optional item that is missing reads "not used (optional, <what
464
+ selector2 no such name), bounce CNAME not needed.", built by `setup.line`; the web page shows
465
+ the same words, rendered on the server. An optional item that is missing reads "not used (optional, <what
464
466
  was asked>)" in every view, so a reader does not take an optional record for a fault; its state
465
467
  in JSON stays `missing`, with its detail and evidence.
466
468
 
@@ -532,7 +534,7 @@ claimed in. Blank names are left out and a name written twice the same way count
532
534
  claimed is no reconciliation: `null` in JSON and no section in any view. Every view shows a
533
535
  CLAIMED SENDERS section with all four parts, "none" for an empty one, each found service with
534
536
  its tier and, when the client wrote another name, "claimed as" with their words. The text and
535
- Markdown views build it with `render.claimed_parts`, and app.js with the same words.
537
+ Markdown and HTML views build it with `render.claimed_parts`.
536
538
 
537
539
  The web site caches maps by domain only; the claimed names a request carries are applied to the
538
540
  cached map for that response and kept nowhere. The page passes its own `?claimed=` on to the API,
@@ -690,7 +692,10 @@ recorder keeps the answers, not those requests.
690
692
  `tests/test_listing.py` validates `server.json` against the registry's 2025-12-11 schema, kept
691
693
  in `tests/fixtures/`, and holds the README line and `llms.txt`.
692
694
  - **Web site**: a FastAPI app. `GET /api/map?d=` returns the map as JSON, `GET /api/map.md?d=`
693
- as Markdown, and `GET /api/map/stream?d=` streams progress then the map as server-sent events.
695
+ as Markdown, `GET /api/map.html?d=` as the HTML fragment the page shows, and
696
+ `GET /api/map/stream?d=` streams progress then one `map` event carrying the map and that
697
+ fragment. The page's HTML comes from `render/html.py`, the same View the command line shows;
698
+ app.js sets it as it came and draws the ring map, and holds no rendering logic of its own.
694
699
  Each takes `claimed`, a comma separated list, for the map's reconciliation.
695
700
  Uvicorn's access log is turned off when the app starts, so no query string is logged; a test
696
701
  runs a real server and checks that a request logs nothing. Visitor addresses are held in
@@ -11,6 +11,33 @@ fails before anything is published.
11
11
 
12
12
  ## Unreleased
13
13
 
14
+ ## 0.1.4 (2026-10-24)
15
+
16
+ - The site leads with the story and the ring map side by side, above Needs attention, so the findings sit above the fold. The story prints one sentence per paragraph, its Not checked sentence muted. From 900px up the map takes a fixed 340px column beside the story; below that it stacks under the story and grows to 560px.
17
+ - Every service name and sector label fits on the ring map. The rings stop inside the box to leave an outer band for names; each label sits outward along its own angle, at 10px (12px past 600px rendered), and a label that would collide is tried one line out, then one line up or down, before it is dropped. glossier.com shows 9 of 9 names and 4 of 4 sectors at 1280, 899 and 390px, where before it showed 3 and 1. The map draws after the result is shown and redraws on resize.
18
+ - The map centre is a plain hub at half its old size. The domain no longer prints in it, since it fit only sometimes and heads the page already.
19
+ - Services on the site are rows, not cards: name, category and where, tier badge, then the deciding record with the others under it, the setup clause and All records. Four columns from 900px up, two lines below. Each row keeps its `service-<slug>` id, so a tap on the map still lands on it.
20
+ - Not checked and What was asked say each reason once in all three views. Gaps of one question, kind, cap and outcome are one group listing the names together ("DKIM selectors at marketing, receipts and send.glossier.com: 82 of 109 not asked at each"), with the cap said last. What was asked leads with the lookups and names in all.
21
+ - certifi in `uv.lock` moves to 2026.10.23, past the emSign root CAs whose intermediate keys were compromised. It comes in through httpx; no code changes.
22
+
23
+ ## 0.1.3 (2026-10-23)
24
+
25
+ - The terminal and Markdown views render one view model (`render/view.py`) and say each thing once: findings come first, each service shows one evidence line per kind ("DKIM keys: selector1, selector2 (both 2048 bit, by CNAME)") and its setup in one clause ("setup complete", or only what is missing), an item a finding names is said in the setup by its state alone, and What was asked gives counts. Full records and the names asked print with `--evidence`, the names in Markdown as a last section, Appendix: names asked. The Markdown senders table gains a Setup column, and its Setups section goes. Allbirds' Markdown view falls from 111 lines to 76.
26
+ - The site renders the same view as the command line. The page's HTML is rendered on the server from the one View (`render/html.py`), every string escaped there, and app.js keeps only the form, the stream, the ring map and the buttons, so the page holds no third copy of the rendering logic and no longer shows the old layout (services before findings, a setup line per record). Each service's records in full and the names asked sit inside details elements. The stream's `map` event now carries `{"map": ..., "html": ...}`, and `GET /api/map.html` returns the fragment with the same limits and cache as `/api/map`.
27
+ - The terminal view is easier to read. Each service is a block: its name and category, then one line per kind of record with the labels in a column, then its setup, coloured by whether it is complete. Wrapped lines keep their indent everywhere, so a long finding, gap or record no longer runs back to the left margin. The story prints one sentence per line.
28
+ - Short DKIM keys at one name are said once: "Six DKIM keys at goop.com are 1024-bit: selector1, s1, s2, smtpapi, mandrill and key1", not six findings with the same advice. The JSON still has one finding per key.
29
+ - Checks that are opt-in or turned off (crt.sh without `--ct`, `--no-web`, `--no-subdomains`) move out of Not checked into a short Not run section at the end, without the request they would have made. Not checked now holds only questions that were asked and got no usable answer. The story still names them.
30
+ - The absence for a zone with no DKIM selectors says it in one sentence: "_domainkey.bounce.allbirds.com does not exist, so the 27-name sweep was skipped; the 8 names its MX, SPF and delegations point to were asked as a check, and none exists".
31
+ - A subdomain that is a CNAME to the domain or to another sending subdomain is no longer mapped as a sending subdomain of its own. www.github.com, gist.github.com and shop.github.com are aliases of github.com and answer with its SPF and MX, so Microsoft 365 and Google Workspace showed at each with a missing DKIM key there: nine findings for setups no one made. An alias to a name outside the domain still counts, since its records are nowhere else in the map.
32
+ - A zone that answers no such name for `_domainkey.<domain>` while a key sits under it is now a note, not a gap. Every selector is still asked there, so nothing went unchecked, but the story read "one DKIM selector that could not be read". The note says what the DNS host does wrong: under RFC 8020 that answer covers every name below it. functionalmedicinecoaching.org, on Bluehost's nameservers, answers this way.
33
+ - A record that a likely service's setup lacks is a note, not a warning, and says to check the service still sends first. goop.com showed "Cloudflare Email Routing at goop.com has no SPF" as a warning from a DKIM key name alone, while its mail goes to Google.
34
+ - A domain that does not exist in DNS says so: a finding, "The domain does not exist in DNS", and a story that leads with it, instead of "No sending service shows in DNS" as for a real domain with no mail set up. The map stops there: one lookup, not about 210.
35
+ - A pasted value is refused unless it is a hostname: an IP address (`192.0.2.1` was mapped as a domain), a wildcard, characters a hostname cannot hold, a label of more than 63 characters or a numeric top-level domain now reads "not a domain" on the command line, the site and the MCP server alike, and a malformed name no longer raises an IDNA error.
36
+ - The MCP tool's text reply lists findings most severe first, with short keys at one name said once, as the other views do. It listed them in the order the sources raised them.
37
+ - 16 services added and 15 patterns for known ones, each with a live proof, from a run over 50 domains not mapped before (DTC brands, B2B software, universities, nonprofits, media). Marketing: Conversica, Maropost, Engaging Networks, ActionKit, Extole, Communicator, ClickDimensions, SPIRAL, FreshMail, Oracle Responsys. Transactional: SocketLabs (email-od.com). Business apps: Braintree, Tipalti, Impartner, and Wix's website host. Hosted SPF: DmarcDuty, read like Valimail. Known services: Qualtrics's qemailserver.com include and MX, Amazon SES's us-west-2 inbound, Postmark's bounce MX, Campaign Monitor's createsend.com include and inbound MX, Klaviyo's klaviyomail.com and Customer.io's customeriomail.com includes, GetResponse's _spf.getresponse.com, Freshdesk's fwdkim1 return path, Epsilon's epsl1.com, 250ok's report address under Validity Everest, Mailgun's report address and Stripe's custom email return path. Across the 50 domains, unknowns fall from 700 to 560.
38
+ - Intercom's and Atlassian's return path CNAMEs are read: `outbound.intercom` and `atlassian-bounces` join the labels every map asks. Klaviyo gains km1 and km2, IONOS s2-ionos, and Optimizely Campaign `mailing` as a backed only name, credited and asked only where SPF or MX already shows Optimizely.
39
+ - Listrak added (marketing): its `esp1.co` SPF include, bounce MX and tracking CNAME, and its network AS394396, each with a live proof. goop.com's bounce. and email. subdomains read as Listrak, not as seven unknowns.
40
+
14
41
  ## 0.1.2 (2026-10-15)
15
42
 
16
43
  - 31 services added and 10 patterns for known ones, each with a live proof, from a run over 50 large domains (retail, software, universities, charities, news, government). Marketing: Oracle Eloqua, Epsilon, Adobe Campaign, Microsoft Dynamics 365 Customer Insights, Blackbaud Luminate Online, Salsa Labs, Alida, Netigate, Hivebrite. Security gateway: Trend Micro Email Security, with an MX setup from its docs. Support, alerts and business apps: Khoros, Everbridge, Greenhouse, Red Points, Trustpilot, Cvent, Kibo Commerce, Docebo, ADP, SAP SuccessFactors, Fundraise Up, Q4, Madgex, Clinch. SPF flattening: AutoSPF, read like EasyDMARC. DMARC reports: DMARCLY, Report URI, Netcraft, Inbox Monster, Validity Everest, SendForensics. Known services: Sailthru's aspmx.sailthru.com and MX, Campaign Monitor's createsend.com tracking, Mandrill's inbound MX, HubSpot's MX hosts, Amazon SES's eu-west-1 inbound and Mail Manager MX, Squarespace's squarespace-mail.com, and Oracle's spf_c.oraclecloud.com. Across the 50 domains, unknowns fall from 1,008 to 727.
@@ -0,0 +1,38 @@
1
+ # Sendermap
2
+
3
+ ## Architecture
4
+
5
+ Sendermap is a library first. All DNS lookups, SPF and DMARC parsing,
6
+ MX and DKIM evidence gathering, and the reasoning that maps evidence
7
+ to sending services live in the sendermap package and nowhere else.
8
+
9
+ The CLI, the MCP server, and any web front end are thin callers. They
10
+ accept a domain, call the package, and present the result. They hold
11
+ no DNS or parsing logic of their own.
12
+
13
+ ## Do not repeat yourself
14
+
15
+ Before writing any new lookup or parsing code, check whether the
16
+ package already has it. If it does, reuse it. If dns-audit has it,
17
+ port it into the package once rather than copying it.
18
+
19
+ Design the package so dns-audit and the APRF reader can import it
20
+ later and drop their own DNS code. One engine per job.
21
+
22
+ ## Code quality
23
+
24
+ This is a portfolio project. Readable names, short functions,
25
+ docstrings on public functions, type hints throughout, tests for
26
+ every parser. Keep public functions small and pure where possible.
27
+
28
+ ## Working style
29
+
30
+ Show the package layout, one line per module, before writing code.
31
+ Finish one thing before starting the next. Ask when a design choice
32
+ is genuinely Neil's call; otherwise make the call and say what you
33
+ chose. Prefer the repeatable solution over the one-off.
34
+
35
+ ## Writing
36
+
37
+ No em dashes anywhere. Plain directives, not rules phrased as never.
38
+ No emoji in README or docs. Lead with the point and say it once.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendermap
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Type a domain, see every service that sends email as it, with the public evidence for each.
5
5
  Project-URL: Homepage, https://github.com/marmot7775/sendermap
6
6
  Author: Neil Anuskiewicz
@@ -35,7 +35,7 @@ Description-Content-Type: text/markdown
35
35
 
36
36
  Type a domain, see every service that sends email as it, and the public evidence for each one.
37
37
 
38
- **Status: early release (0.1.1).** The command-line tool works; the website is not hosted yet.
38
+ **Status: early release (0.1.4).** The command-line tool works, and the website runs at [sendermap.dev](https://sendermap.dev).
39
39
  Needs Python 3.12 or later.
40
40
 
41
41
  ## What it shows
@@ -7,7 +7,7 @@
7
7
 
8
8
  Type a domain, see every service that sends email as it, and the public evidence for each one.
9
9
 
10
- **Status: early release (0.1.1).** The command-line tool works; the website is not hosted yet.
10
+ **Status: early release (0.1.4).** The command-line tool works, and the website runs at [sendermap.dev](https://sendermap.dev).
11
11
  Needs Python 3.12 or later.
12
12
 
13
13
  ## What it shows