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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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
|