freemyid 0.2.0.dev1__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.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,208 @@
1
+ """Deterministic listing matcher (rapidfuzz). No I/O.
2
+
3
+ A broker search result is turned into a `Candidate` by a parser, then `score()` decides:
4
+ CONFIRMED = name match AND >= 2 supporting signals -> auto-submit allowed
5
+ POSSIBLE = name match AND exactly 1 signal -> REVIEW_MATCH todo
6
+ NO_MATCH = otherwise -> ignored
7
+
8
+ Signals: location (city+state, or state alone counts only together with a city), age +-2,
9
+ relative, phone, email. `match_reasons` holds signal NAMES only (never values).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import unicodedata
15
+ from dataclasses import dataclass, field
16
+ from datetime import date
17
+
18
+ from rapidfuzz import fuzz
19
+
20
+ from ..models import Listing, MatchLevel, Profile, ProfileField, utcnow
21
+
22
+ NAME_THRESHOLD = 88.0 # token_sort_ratio on normalized "first last"
23
+ RELATIVE_THRESHOLD = 85.0
24
+ USER_CONFIRMED = "user_confirmed"
25
+
26
+ _US_STATES = {
27
+ "alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
28
+ "colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
29
+ "hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
30
+ "kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
31
+ "massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS",
32
+ "missouri": "MO", "montana": "MT", "nebraska": "NE", "nevada": "NV",
33
+ "new hampshire": "NH", "new jersey": "NJ", "new mexico": "NM", "new york": "NY",
34
+ "north carolina": "NC", "north dakota": "ND", "ohio": "OH", "oklahoma": "OK",
35
+ "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
36
+ "south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
37
+ "virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI",
38
+ "wyoming": "WY", "district of columbia": "DC",
39
+ }
40
+ STATE_NAMES = {v: k.title() for k, v in _US_STATES.items()}
41
+
42
+
43
+ @dataclass
44
+ class Candidate:
45
+ """One search result as parsed from a broker page. Values are page DATA only."""
46
+ url: str
47
+ name: str | None = None
48
+ age: int | None = None
49
+ locations: list[str] = field(default_factory=list) # "Springfield, IL" style
50
+ relatives: list[str] = field(default_factory=list)
51
+ phones: list[str] = field(default_factory=list)
52
+ emails: list[str] = field(default_factory=list)
53
+ has_address: bool = False # page shows a street address
54
+ has_dob: bool = False
55
+
56
+
57
+ @dataclass
58
+ class MatchResult:
59
+ level: MatchLevel
60
+ score: float
61
+ reasons: list[str]
62
+
63
+
64
+ def norm(s: str | None) -> str:
65
+ if not s:
66
+ return ""
67
+ s = unicodedata.normalize("NFKD", s)
68
+ s = "".join(c for c in s if not unicodedata.combining(c))
69
+ s = re.sub(r"[^a-z0-9 ]+", " ", s.lower())
70
+ return re.sub(r"\s+", " ", s).strip()
71
+
72
+
73
+ def digits(s: str) -> str:
74
+ d = re.sub(r"\D", "", s or "")
75
+ return d[-10:] if len(d) >= 10 else d
76
+
77
+
78
+ def parse_age(text: str | None) -> int | None:
79
+ if not text:
80
+ return None
81
+ m = re.search(r"(?:age|aged)\D{0,3}(\d{1,3})", text, re.IGNORECASE) or re.search(r"\b(\d{2,3})\b",
82
+ text)
83
+ if m:
84
+ v = int(m.group(1))
85
+ return v if 0 < v < 131 else None
86
+ return None
87
+
88
+
89
+ def parse_location(text: str) -> tuple[str, str | None]:
90
+ """'Springfield, IL 62701' -> ('springfield', 'IL'). State may be a full name."""
91
+ t = text.strip()
92
+ m = re.match(r"^\s*(.+?)\s*,\s*([A-Za-z .]+?)(?:\s+\d{5}(?:-\d{4})?)?\s*$", t)
93
+ if not m:
94
+ return norm(t), None
95
+ city, st = m.group(1), m.group(2).strip().rstrip(".")
96
+ if len(st) == 2:
97
+ return norm(city), st.upper()
98
+ return norm(city), _US_STATES.get(st.lower())
99
+
100
+
101
+ def _profile_name_variants(p: Profile) -> list[str]:
102
+ out: list[str] = []
103
+ firsts = [p.name.first, *p.nicknames]
104
+ lasts = [n.last for n in p.all_names()]
105
+ for f in firsts:
106
+ for la in lasts:
107
+ out.append(norm(f"{f} {la}"))
108
+ for n in p.all_names():
109
+ out.append(norm(f"{n.first} {n.last}"))
110
+ if n.middle:
111
+ out.append(norm(f"{n.first} {n.middle} {n.last}"))
112
+ return sorted({o for o in out if o})
113
+
114
+
115
+ def name_score(p: Profile, shown: str | None) -> float:
116
+ s = norm(shown)
117
+ if not s:
118
+ return 0.0
119
+ toks = s.split()
120
+ # drop single-letter middle initials so "Jane Q Testperson" ~ "Jane Testperson"
121
+ s2 = " ".join(t for t in toks if len(t) > 1) or s
122
+ best = 0.0
123
+ for v in _profile_name_variants(p):
124
+ best = max(best, fuzz.token_sort_ratio(v, s), fuzz.token_sort_ratio(v, s2))
125
+ return float(best)
126
+
127
+
128
+ def score(profile: Profile, c: Candidate, today: date | None = None) -> MatchResult:
129
+ ns = name_score(profile, c.name)
130
+ if ns < NAME_THRESHOLD:
131
+ return MatchResult(MatchLevel.NO_MATCH, round(ns * 0.5, 1), [])
132
+ reasons: list[str] = []
133
+
134
+ # location: city + state from any current/previous address
135
+ prof_locs = {(norm(a.city), a.state) for a in profile.addresses}
136
+ for loc in c.locations:
137
+ city, st = parse_location(loc)
138
+ if any(city == pc and (st is None or st == ps) for pc, ps in prof_locs):
139
+ reasons.append("city")
140
+ break
141
+
142
+ est = profile.estimated_age(today)
143
+ if est is not None and c.age is not None and abs(est - c.age) <= 2:
144
+ reasons.append("age")
145
+
146
+ if profile.relatives and c.relatives:
147
+ rels = [norm(r) for r in profile.relatives]
148
+ if any(fuzz.token_sort_ratio(norm(x), r) >= RELATIVE_THRESHOLD
149
+ for x in c.relatives for r in rels):
150
+ reasons.append("relative")
151
+
152
+ pphones = {digits(x) for x in profile.phones if digits(x)}
153
+ if pphones and any(digits(x) in pphones for x in c.phones):
154
+ reasons.append("phone")
155
+
156
+ pemails = {e.strip().lower() for e in profile.emails}
157
+ if pemails and any(e.strip().lower() in pemails for e in c.emails):
158
+ reasons.append("email")
159
+
160
+ n = len(reasons)
161
+ level = MatchLevel.CONFIRMED if n >= 2 else MatchLevel.POSSIBLE if n == 1 else \
162
+ MatchLevel.NO_MATCH
163
+ sc = min(100.0, round(ns * 0.5 + 12.5 * n, 1))
164
+ return MatchResult(level, sc, reasons)
165
+
166
+
167
+ def shown_fields(c: Candidate) -> list[ProfileField]:
168
+ """What the listing itself displays (data minimization input)."""
169
+ out: list[ProfileField] = [ProfileField.LISTING_URL]
170
+ if c.name:
171
+ out += [ProfileField.FULL_NAME, ProfileField.FIRST_NAME, ProfileField.LAST_NAME]
172
+ if c.locations:
173
+ out += [ProfileField.CITY, ProfileField.STATE]
174
+ if c.age is not None:
175
+ out.append(ProfileField.AGE)
176
+ if c.phones:
177
+ out.append(ProfileField.PHONE)
178
+ if c.has_address:
179
+ out += [ProfileField.STREET_ADDRESS, ProfileField.ZIP]
180
+ if c.has_dob:
181
+ out.append(ProfileField.DOB)
182
+ return out
183
+
184
+
185
+ def to_listing(profile: Profile, broker_id: str, c: Candidate,
186
+ today: date | None = None) -> Listing:
187
+ r = score(profile, c, today)
188
+ now = utcnow()
189
+ return Listing(
190
+ profile_id=profile.id, broker_id=broker_id, url=c.url, name_shown=c.name,
191
+ age_shown=c.age, locations_shown=c.locations[:10], relatives_shown=c.relatives[:20],
192
+ match_score=r.score, match_level=r.level, match_reasons=r.reasons,
193
+ shown_fields=shown_fields(c), first_seen=now, last_seen=now,
194
+ )
195
+
196
+
197
+ def score_all(profile: Profile, broker_id: str, cands: list[Candidate],
198
+ today: date | None = None) -> list[Listing]:
199
+ """Score candidates, drop NO_MATCH, de-duplicate by URL (best score wins)."""
200
+ best: dict[str, Listing] = {}
201
+ for c in cands:
202
+ li = to_listing(profile, broker_id, c, today)
203
+ if li.match_level == MatchLevel.NO_MATCH:
204
+ continue
205
+ cur = best.get(li.url)
206
+ if cur is None or li.match_score > cur.match_score:
207
+ best[li.url] = li
208
+ return sorted(best.values(), key=lambda x: -x.match_score)
@@ -0,0 +1,421 @@
1
+ """Outgoing privacy-request email: compose, queue for approval, send. Never sends in dry runs.
2
+
3
+ Every email is a `Request`. The rendered letter is stored as encrypted LETTER evidence tied
4
+ to the request, so what the user approves is exactly what gets sent.
5
+
6
+ * dry_run -> Request(status=DRAFT, dry_run=True). Nothing is sent, ever.
7
+ * email_mode APPROVE_EACH -> QUEUED_FOR_APPROVAL (default)
8
+ * LEGAL_DEMAND -> always QUEUED_FOR_APPROVAL
9
+ * email_mode AUTO + mailer -> sent now
10
+ * after a send -> EMAIL_SENT evidence (the raw message), Request SENT.
11
+
12
+ `approve_and_send(store, request_id)` is the user-approved send (CLI `outbox approve`,
13
+ dashboard). It refuses unless `config.real_submissions_enabled` (or a mailer is injected,
14
+ e.g. the dev fixture run's FakeMailer).
15
+
16
+ The letter template follows the person's state: CA -> ccpa, a state with a privacy statute
17
+ -> state_privacy, else generic_removal. Only fields the listings already show go in
18
+ (`letters.letter_vars`).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import sys
25
+ from datetime import date, datetime
26
+ from urllib.parse import quote, urlencode
27
+
28
+ from .. import config as cfgmod
29
+ from ..models import (
30
+ Actor,
31
+ Broker,
32
+ EmailMode,
33
+ Event,
34
+ Evidence,
35
+ EvidenceKind,
36
+ Listing,
37
+ Method,
38
+ Profile,
39
+ Registry,
40
+ Request,
41
+ RequestKind,
42
+ RequestStatus,
43
+ StateLaw,
44
+ utcnow,
45
+ )
46
+ from ..models import ListingState as S
47
+ from ..state import can_transition, mark_submitted, transition
48
+ from . import letters
49
+
50
+ log = logging.getLogger(__name__)
51
+
52
+ DEV_FIXTURES_ENV = "FREEMYID_DEV_FIXTURES"
53
+ _DEV_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})
54
+ _dev_warned = False
55
+
56
+
57
+ def dev_fixture_base() -> str | None:
58
+ """Base URL of the dev fixture server, or None.
59
+
60
+ SEC-21: honored only for a plain-http loopback URL and never in a frozen (installed)
61
+ build; anything else is ignored (logged once, value not logged).
62
+ """
63
+ global _dev_warned
64
+ v = os.environ.get(DEV_FIXTURES_ENV)
65
+ if not v:
66
+ return None
67
+ from urllib.parse import urlsplit
68
+
69
+ from ..netpolicy import host_of
70
+ try:
71
+ scheme = urlsplit(v).scheme
72
+ except ValueError:
73
+ scheme = ""
74
+ if scheme == "http" and host_of(v) in _DEV_LOOPBACK and not getattr(sys, "frozen", False):
75
+ return v
76
+ if not _dev_warned:
77
+ log.warning("%s ignored (only a loopback http URL in a dev checkout)",
78
+ DEV_FIXTURES_ENV)
79
+ _dev_warned = True
80
+ return None
81
+
82
+
83
+ class OutboxError(RuntimeError):
84
+ """PII-free reason an email could not be queued or sent."""
85
+
86
+
87
+ # --------------------------------------------------------------------------- composing
88
+
89
+
90
+ def law_in_effect(state_law: StateLaw | None, today: date | None = None) -> bool:
91
+ """A verified (`last_verified` set) statute with a deletion right already in effect."""
92
+ if state_law is None or not state_law.statute_name or state_law.last_verified is None:
93
+ return False
94
+ if not any("delet" in r.lower() for r in state_law.rights):
95
+ return False
96
+ return state_law.effective_date is None or state_law.effective_date <= (today or date.today())
97
+
98
+
99
+ def effective_law(profile: Profile, state_law: StateLaw | None,
100
+ today: date | None = None) -> StateLaw | None:
101
+ """The law to cite: only the CURRENT address's state, only if verified and in effect.
102
+ Anything else -> None (generic voluntary request, no citations). PLAN_V1."""
103
+ if state_law is None or state_law.state.upper() != profile.state_of_residence:
104
+ return None
105
+ return state_law if law_in_effect(state_law, today) else None
106
+
107
+
108
+ def initial_template(profile: Profile, state_law: StateLaw | None,
109
+ today: date | None = None) -> str:
110
+ """ccpa for CA / state_privacy elsewhere, only with a verified law in effect for the
111
+ current address's state; otherwise generic_removal."""
112
+ law = effective_law(profile, state_law, today)
113
+ if law is None:
114
+ return "generic_removal"
115
+ return "ccpa" if law.state.upper() == "CA" else "state_privacy"
116
+
117
+
118
+ def template_for(kind: RequestKind, profile: Profile, state_law: StateLaw | None) -> str:
119
+ if kind == RequestKind.FOLLOW_UP:
120
+ return "follow_up"
121
+ if kind == RequestKind.LEGAL_DEMAND:
122
+ return "legal_demand"
123
+ return initial_template(profile, state_law)
124
+
125
+
126
+ def compose(profile: Profile, broker: Broker, listings: list[Listing],
127
+ state_law: StateLaw | None, kind: RequestKind = RequestKind.OPT_OUT, *,
128
+ original: Request | None = None) -> tuple[str, str]:
129
+ """(subject, body) for a request letter. Data-minimized via letters.letter_vars.
130
+ Statutes are cited only when `effective_law` says so."""
131
+ law = effective_law(profile, state_law)
132
+ v = letters.letter_vars(
133
+ profile, broker, listings, law,
134
+ original_request_date=(original.sent_at or original.created_at) if original else None,
135
+ reference=original.reference if original else None)
136
+ text = letters.render(template_for(kind, profile, law), **v)
137
+ return letters.split_subject(text)
138
+
139
+
140
+ def compose_links(to: str, subject: str, body: str) -> dict[str, str]:
141
+ """Web-mail compose URLs with to/subject/body prefilled (no-mailbox Assisted path)."""
142
+ return {
143
+ "Gmail": "https://mail.google.com/mail/?" + urlencode(
144
+ {"view": "cm", "fs": "1", "to": to, "su": subject, "body": body}, quote_via=quote),
145
+ "Outlook": "https://outlook.live.com/mail/0/deeplink/compose?" + urlencode(
146
+ {"to": to, "subject": subject, "body": body}, quote_via=quote),
147
+ "Yahoo": "https://compose.mail.yahoo.com/?" + urlencode(
148
+ {"to": to, "subject": subject, "body": body}, quote_via=quote),
149
+ "Email app": f"mailto:{quote(to, safe='@')}?" + urlencode(
150
+ {"subject": subject, "body": body}, quote_via=quote),
151
+ }
152
+
153
+
154
+ def _letter_blob(to: str, subject: str, body: str) -> bytes:
155
+ return f"To: {to}\nSubject: {subject}\n\n{body}".encode()
156
+
157
+
158
+ def _parse_letter(blob: bytes) -> tuple[str, str, str]:
159
+ head, _, body = blob.decode().partition("\n\n")
160
+ hdr = dict(line.split(": ", 1) for line in head.splitlines() if ": " in line)
161
+ return hdr.get("To", ""), hdr.get("Subject", ""), body
162
+
163
+
164
+ def letter_for(store, req: Request) -> tuple[str, str, str]:
165
+ """(to, subject, body) stored for a request."""
166
+ for ev in store.list_evidence(req.profile_id, req.broker_id):
167
+ if ev.request_id == req.id and ev.kind == EvidenceKind.LETTER:
168
+ return _parse_letter(store.read_evidence(ev))
169
+ raise OutboxError("no stored letter for this request")
170
+
171
+
172
+ def _save_blob(store, profile_id: str, broker_id: str, kind: EvidenceKind, data: bytes,
173
+ request_id: str, caption: str, ext: str) -> Evidence:
174
+ ev = Evidence(profile_id=profile_id, broker_id=broker_id, kind=kind, request_id=request_id,
175
+ relpath="x", sha256="", caption=caption)
176
+ ev = ev.model_copy(update={"relpath": f"{profile_id}/{broker_id}/{ev.id}.{ext}"})
177
+ return store.save_evidence(ev, data)
178
+
179
+
180
+ # --------------------------------------------------------------------------- queueing
181
+
182
+
183
+ def _open_request(store, profile_id: str, broker_id: str, kind: RequestKind,
184
+ dry_run: bool) -> Request | None:
185
+ for r in store.list_requests(profile_id, broker_id):
186
+ if r.kind != kind or r.dry_run != dry_run:
187
+ continue
188
+ if r.status in (RequestStatus.QUEUED_FOR_APPROVAL, RequestStatus.DRAFT):
189
+ return r
190
+ return None
191
+
192
+
193
+ HOLD_ACTION = "email_held_first_approval" # SEC-27 Event marker (detail: request_id)
194
+ RELEASE_ACTION = "held_emails_released" # SEC-27 Event marker (detail: counts)
195
+
196
+
197
+ def email_sent_before(store, profile_id: str) -> bool:
198
+ """True once a real (non-dry) EMAIL request for this profile has gone out. A web-form
199
+ submission does not count: the person must have seen one of Free My ID's emails."""
200
+ return any(r.status == RequestStatus.SENT and r.method == Method.EMAIL and not r.dry_run
201
+ for r in store.list_requests(profile_id))
202
+
203
+
204
+ def needs_approval(profile: Profile, kind: RequestKind, store=None) -> bool:
205
+ """Legal demands and every non-AUTO email wait for approval. SEC-27: in AUTO mode the
206
+ FIRST email for a profile waits too, so the person sees one before any goes out."""
207
+ if kind == RequestKind.LEGAL_DEMAND or profile.email_mode != EmailMode.AUTO:
208
+ return True
209
+ if store is None:
210
+ return True # can't prove an earlier send: be safe
211
+ return not email_sent_before(store, profile.id)
212
+
213
+
214
+ def held_by_first_approval(profile: Profile, req: Request, store) -> bool:
215
+ """A queued email that is waiting only because the profile's first email has not been
216
+ approved yet (AUTO mode). Approving any email of the profile releases it."""
217
+ return (profile.email_mode == EmailMode.AUTO and not req.dry_run
218
+ and req.method == Method.EMAIL
219
+ and req.status == RequestStatus.QUEUED_FOR_APPROVAL
220
+ and req.kind != RequestKind.LEGAL_DEMAND
221
+ and not email_sent_before(store, profile.id))
222
+
223
+
224
+ def _auto_releasable(profile: Profile, req: Request) -> bool:
225
+ return (profile.email_mode == EmailMode.AUTO and not req.dry_run
226
+ and req.method == Method.EMAIL
227
+ and req.status == RequestStatus.QUEUED_FOR_APPROVAL
228
+ and req.kind != RequestKind.LEGAL_DEMAND)
229
+
230
+
231
+ def released_by_approval(store, profile: Profile, req: Request) -> list[Request]:
232
+ """SEC-47: the OTHER queued emails that approving `req` would also send (AUTO profile,
233
+ first approval). The approve card must list or count these. Empty when approving `req`
234
+ sends only `req` (approve_each, gate already open, legal demand, dry run)."""
235
+ if (req.kind == RequestKind.LEGAL_DEMAND or req.dry_run
236
+ or profile.email_mode != EmailMode.AUTO or email_sent_before(store, profile.id)):
237
+ return []
238
+ return [r for r in store.list_requests(profile.id, status=RequestStatus.QUEUED_FOR_APPROVAL)
239
+ if r.id != req.id and _auto_releasable(profile, r)]
240
+
241
+
242
+ def first_email_pending(store, profile: Profile) -> Request | None:
243
+ """The oldest email held for the profile's first approval (dashboard: "approve this one
244
+ and the rest go out by themselves"), or None."""
245
+ for r in store.list_requests(profile.id, status=RequestStatus.QUEUED_FOR_APPROVAL):
246
+ if held_by_first_approval(profile, r, store):
247
+ return r
248
+ return None
249
+
250
+
251
+ def queue_email(store, profile: Profile, broker: Broker, listings: list[Listing], *,
252
+ kind: RequestKind = RequestKind.OPT_OUT, state_law: StateLaw | None = None,
253
+ dry_run: bool, mailer=None, original: Request | None = None,
254
+ now: datetime | None = None) -> Request:
255
+ """Create (or reuse) the Request for this letter; send it now only in AUTO mode.
256
+
257
+ Does NOT change the BrokerStatus: the runner does that from the returned request
258
+ (status SENT = a real send happened).
259
+ """
260
+ if not broker.opt_out_email:
261
+ raise OutboxError("broker has no opt-out email address")
262
+ now = now or utcnow()
263
+ existing = _open_request(store, profile.id, broker.id, kind, dry_run)
264
+ if existing:
265
+ # SEC-27: an AUTO email that was held for the first approval goes out once the
266
+ # profile has an approved send (approve_and_send also releases them right away).
267
+ if (mailer is not None and _auto_releasable(profile, existing)
268
+ and not needs_approval(profile, kind, store)):
269
+ return send_request(store, existing, profile, mailer, now=now)
270
+ return existing
271
+ subject, body = compose(profile, broker, listings, state_law, kind, original=original)
272
+ attempt = 1 + sum(1 for r in store.list_requests(profile.id, broker.id)
273
+ if r.kind == kind and r.status == RequestStatus.SENT)
274
+ req = Request(profile_id=profile.id, broker_id=broker.id, kind=kind, method=Method.EMAIL,
275
+ listing_ids=[li.id for li in listings], created_at=now, dry_run=dry_run,
276
+ attempt=attempt,
277
+ original_request_id=original.id if original else None,
278
+ status=RequestStatus.DRAFT if dry_run else RequestStatus.QUEUED_FOR_APPROVAL)
279
+ store.save_request(req)
280
+ _save_blob(store, profile.id, broker.id, EvidenceKind.LETTER,
281
+ _letter_blob(broker.opt_out_email, subject, body), req.id,
282
+ f"{kind.value} letter", "txt")
283
+ store.log(Event(profile_id=profile.id, broker_id=broker.id, actor=Actor.ENGINE,
284
+ action="email_drafted" if dry_run else "email_queued",
285
+ detail={"request_id": req.id, "kind": kind.value, "dry_run": dry_run}))
286
+ if dry_run or mailer is None:
287
+ return req
288
+ if needs_approval(profile, kind, store):
289
+ if _auto_releasable(profile, req):
290
+ store.log(Event(profile_id=profile.id, broker_id=broker.id, actor=Actor.ENGINE,
291
+ action=HOLD_ACTION, detail={"request_id": req.id}))
292
+ return req
293
+ return send_request(store, req, profile, mailer, now=now)
294
+
295
+
296
+ def send_request(store, req: Request, profile: Profile, mailer, *,
297
+ now: datetime | None = None) -> Request:
298
+ """Send a stored request letter. Never for dry-run requests."""
299
+ if req.dry_run:
300
+ raise OutboxError("dry-run requests are never sent")
301
+ if req.status not in (RequestStatus.QUEUED_FOR_APPROVAL, RequestStatus.DRAFT):
302
+ raise OutboxError(f"request is {req.status.value}, not sendable")
303
+ if profile.mailbox is None:
304
+ raise OutboxError("profile has no removal mailbox")
305
+ now = now or utcnow()
306
+ to, subject, body = letter_for(store, req)
307
+ try:
308
+ mid = mailer.send(profile, to, subject, body, req.id)
309
+ except Exception as e: # noqa: BLE001 - any mailer failure marks the request FAILED
310
+ store.save_request(req.model_copy(update={"status": RequestStatus.FAILED}))
311
+ store.log(Event(profile_id=profile.id, broker_id=req.broker_id, actor=Actor.ENGINE,
312
+ action="email_failed",
313
+ detail={"request_id": req.id, "error": type(e).__name__}))
314
+ raise OutboxError(f"send failed: {type(e).__name__}") from None
315
+ raw = getattr(mailer, "last_raw", None) or _letter_blob(to, subject, body)
316
+ _save_blob(store, profile.id, req.broker_id, EvidenceKind.EMAIL_SENT, raw, req.id,
317
+ f"{req.kind.value} email sent", "eml")
318
+ sent = req.model_copy(update={"status": RequestStatus.SENT, "sent_at": now,
319
+ "reference": req.reference})
320
+ store.save_request(sent)
321
+ store.log(Event(profile_id=profile.id, broker_id=req.broker_id, actor=Actor.ENGINE,
322
+ action="email_sent", detail={"request_id": req.id,
323
+ "kind": req.kind.value,
324
+ "has_message_id": bool(mid)}))
325
+ return sent
326
+
327
+
328
+ # --------------------------------------------------------------------------- approval
329
+
330
+
331
+ def _default_mailer(profile: Profile, registry: Registry | None):
332
+ from ..netpolicy import NetPolicy
333
+ from .mail import FakeMailer, SmtpImapMailer
334
+ if dev_fixture_base():
335
+ return FakeMailer()
336
+ if not cfgmod.load().real_submissions_enabled:
337
+ raise OutboxError("real email sending is not enabled yet "
338
+ "(freemyid config enable-submissions)")
339
+ return SmtpImapMailer(NetPolicy(registry, [profile]))
340
+
341
+
342
+ def apply_sent(store, req: Request, broker: Broker | None, now: datetime) -> None:
343
+ """After an approved OPT_OUT/RESUBMIT send, move the broker status to SUBMITTED."""
344
+ if req.kind not in (RequestKind.OPT_OUT, RequestKind.RESUBMIT):
345
+ return
346
+ st = store.get_status(req.profile_id, req.broker_id)
347
+ if not can_transition(st.state, S.SUBMITTED):
348
+ return
349
+ new = mark_submitted(st, now)
350
+ if broker is not None and broker.needs_email_verification:
351
+ new = transition(new, S.AWAITING_VERIFICATION, now)
352
+ store.save_status(new)
353
+ store.log(Event(profile_id=req.profile_id, broker_id=req.broker_id, actor=Actor.USER,
354
+ action="submitted", from_state=st.state, to_state=new.state,
355
+ detail={"request_id": req.id, "via": "email"}))
356
+
357
+
358
+ def approve_and_send(store, request_id: str, *, mailer=None, registry: Registry | None = None,
359
+ now: datetime | None = None) -> None:
360
+ """User-approved send of a QUEUED_FOR_APPROVAL email (interfaces.py entry point)."""
361
+ req = store.get_request(request_id)
362
+ if req is None:
363
+ raise OutboxError("unknown request")
364
+ if req.status != RequestStatus.QUEUED_FOR_APPROVAL or req.dry_run:
365
+ raise OutboxError("request is not waiting for approval")
366
+ profile = store.get_profile(req.profile_id)
367
+ if profile is None:
368
+ raise OutboxError("profile no longer exists")
369
+ from ..consent import consent_ok
370
+ ok, _why = consent_ok(profile, store)
371
+ if not ok:
372
+ raise OutboxError("consent check failed for this profile")
373
+ if registry is None:
374
+ from ..registry import load_registry
375
+ registry = load_registry()
376
+ try:
377
+ broker: Broker | None = registry.get(req.broker_id)
378
+ except KeyError:
379
+ broker = None
380
+ mailer = mailer or _default_mailer(profile, registry)
381
+ now = now or utcnow()
382
+ store.log(Event(profile_id=req.profile_id, broker_id=req.broker_id, actor=Actor.USER,
383
+ action="email_approved", detail={"request_id": req.id}))
384
+ sent = send_request(store, req, profile, mailer, now=now)
385
+ apply_sent(store, sent, broker, now)
386
+ if profile.email_mode == EmailMode.AUTO:
387
+ release_held(store, profile, mailer=mailer, registry=registry, now=now)
388
+
389
+
390
+ def release_held(store, profile: Profile, *, mailer, registry: Registry | None = None,
391
+ now: datetime | None = None) -> dict[str, int]:
392
+ """SEC-27: after the profile's first approved send, send the AUTO emails that were held
393
+ for it. Legal demands, dry-run and non-AUTO requests are never touched. One failure
394
+ never stops the rest (the failed request is marked FAILED by send_request)."""
395
+ counts = {"sent": 0, "failed": 0}
396
+ if profile.email_mode != EmailMode.AUTO or not email_sent_before(store, profile.id):
397
+ return counts
398
+ from ..consent import consent_ok
399
+ if not consent_ok(profile, store)[0]:
400
+ return counts
401
+ now = now or utcnow()
402
+ for r in store.list_requests(profile.id, status=RequestStatus.QUEUED_FOR_APPROVAL):
403
+ if not _auto_releasable(profile, r):
404
+ continue
405
+ try:
406
+ sent = send_request(store, r, profile, mailer, now=now)
407
+ except OutboxError:
408
+ counts["failed"] += 1
409
+ continue
410
+ broker: Broker | None = None
411
+ if registry is not None:
412
+ try:
413
+ broker = registry.get(r.broker_id)
414
+ except KeyError:
415
+ broker = None
416
+ apply_sent(store, sent, broker, now)
417
+ counts["sent"] += 1
418
+ if counts["sent"] or counts["failed"]:
419
+ store.log(Event(profile_id=profile.id, actor=Actor.ENGINE, action=RELEASE_ACTION,
420
+ detail=dict(counts)))
421
+ return counts