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,791 @@
1
+ """The removal loop (interfaces.py entry point `run`). PLAN_V1 D1-D4.
2
+
3
+ For each consenting profile (the consent gate is re-checked here):
4
+ 0. CA resident -> California DROP GOVERNMENT_PORTAL to-do first (D3).
5
+ For each enabled broker, highest priority first, one at a time:
6
+ 1. load BrokerStatus; with only_due skip brokers whose next_check_at is in the future
7
+ (an IMAP confirmation check still runs for submitted ones: it is cheap);
8
+ 2. compute the tier (tiers.tier_for) and store it on the status;
9
+ 3. scan if the broker is scannable (verified search spec), else treat it as "blind":
10
+ never-contacted brokers count as FOUND so a request can go out;
11
+ 4. submit if state.should_submit and not scan_only. covered_by brokers go through the
12
+ parent once; dry runs never send or submit (email -> DRAFT, automatic -> logged only);
13
+ 5. IMAP: confirmation links on confirmation_domains only, clicked in the allowlisted
14
+ browser (never in dry runs);
15
+ 6. escalation ladder (state.next_escalation): resubmit, follow-up letter, legal demand
16
+ (always queued for approval), COMPLAINT to-do with AG/CPPA links;
17
+ 7. next_check_at.
18
+ One broker's failure never stops the run. Events hold ids/enums/counts only.
19
+ At the end: ONE PII-free notification if anything needs the person (D4).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ from collections import Counter
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from .. import config as cfgmod
31
+ from ..consent import consent_ok
32
+ from ..interfaces import RunContext
33
+ from ..models import (
34
+ Actor,
35
+ Broker,
36
+ Event,
37
+ EvidenceKind,
38
+ Listing,
39
+ ListingState,
40
+ MatchLevel,
41
+ Profile,
42
+ Registry,
43
+ Request,
44
+ RequestKind,
45
+ RequestStatus,
46
+ StateLaw,
47
+ SubmitOutcome,
48
+ Tier,
49
+ Todo,
50
+ TodoKind,
51
+ utcnow,
52
+ )
53
+ from ..models import ListingState as S
54
+ from ..state import (
55
+ Escalation,
56
+ after_scan,
57
+ can_transition,
58
+ mark_submitted,
59
+ next_check_at,
60
+ next_escalation,
61
+ should_submit,
62
+ transition,
63
+ )
64
+ from ..submitters import assisted
65
+ from ..submitters import base as sbase
66
+ from . import handoff as hmod
67
+ from . import plugins, tiers
68
+ from .mail import ReplyScan
69
+ from .outbox import OutboxError, dev_fixture_base
70
+
71
+ if TYPE_CHECKING:
72
+ from ..storage import Store
73
+ from .browser import BrowserManager
74
+
75
+ log = logging.getLogger(__name__)
76
+
77
+ _SUBMITTED_STATES = (S.SUBMITTED, S.AWAITING_VERIFICATION, S.CONFIRMED)
78
+ ID_REQUEST_REASON = "id_requested"
79
+ CPPA_COMPLAINT_URL = "https://cppa.ca.gov/webapplications/complaint"
80
+ AG_DIRECTORY_URL = "https://www.usa.gov/state-attorney-general"
81
+
82
+
83
+ @dataclass
84
+ class RunReport:
85
+ per_profile: dict[str, dict[ListingState, int]] = field(default_factory=dict)
86
+ needs_human: int = 0
87
+ errors: int = 0
88
+ tiers: dict[str, dict[Tier, int]] = field(default_factory=dict)
89
+ skipped_not_due: int = 0
90
+ queued_emails: int = 0
91
+ drafted_emails: int = 0
92
+ submitted: int = 0
93
+ open_todos: int = 0
94
+ skipped_profiles: int = 0
95
+ labels: dict[str, str] = field(default_factory=dict)
96
+ dry_run: bool = False
97
+
98
+ def summary_lines(self) -> list[str]:
99
+ """PII-free (profile labels like 'Mom' are user-chosen display names)."""
100
+ out = ["Dry run: nothing was sent or submitted." if self.dry_run else "Run finished."]
101
+ for pid, counts in self.per_profile.items():
102
+ label = self.labels.get(pid, pid[:8])
103
+ parts = ", ".join(f"{k.value.replace('_', ' ')}: {v}"
104
+ for k, v in sorted(counts.items(), key=lambda kv: kv[0].value))
105
+ tc = self.tiers.get(pid, {})
106
+ tparts = ", ".join(f"{t.value}: {n}" for t, n in sorted(tc.items()))
107
+ out.append(f"{label}: {parts or 'nothing to do'}")
108
+ if tparts:
109
+ out.append(f" how: {tparts}")
110
+ if self.skipped_profiles:
111
+ out.append(f"{self.skipped_profiles} profile(s) skipped (consent check).")
112
+ if self.skipped_not_due:
113
+ out.append(f"{self.skipped_not_due} site(s) not due for a check yet.")
114
+ if self.submitted:
115
+ out.append(f"{self.submitted} removal request(s) sent.")
116
+ if self.queued_emails:
117
+ out.append(f"{self.queued_emails} email(s) waiting for your approval.")
118
+ if self.drafted_emails:
119
+ out.append(f"{self.drafted_emails} email(s) drafted (dry run, not sent).")
120
+ if self.open_todos:
121
+ out.append(f"{self.open_todos} thing(s) need you. Open the dashboard to see them.")
122
+ if self.errors:
123
+ out.append(f"{self.errors} site(s) had errors; they will be retried next run.")
124
+ return out
125
+
126
+
127
+ class NullNotifier:
128
+ def notify(self, title: str, body: str, url: str | None = None) -> None:
129
+ return None
130
+
131
+
132
+ # --------------------------------------------------------------------------- entry point
133
+
134
+
135
+ def run(store: Store, registry: Registry, profiles: list[Profile], *, scan_only: bool,
136
+ dry_run: bool, interactive: bool = True, broker_ids: list[str] | None = None,
137
+ only_due: bool = False, scheduled: bool | None = None, mailer: Any = None,
138
+ notifier: Any = None, browser: BrowserManager | None = None,
139
+ state_laws: dict[str, StateLaw] | None = None, now: datetime | None = None,
140
+ cfg: cfgmod.Config | None = None) -> RunReport:
141
+ """Run the loop. `scheduled=True` == quiet, headless, non-blocking, only_due."""
142
+ if scheduled:
143
+ interactive, only_due = False, True
144
+ r = Runner(store, registry, scan_only=scan_only, dry_run=dry_run, interactive=interactive,
145
+ broker_ids=broker_ids, only_due=only_due, mailer=mailer, notifier=notifier,
146
+ browser=browser, state_laws=state_laws, now=now, cfg=cfg)
147
+ return asyncio.run(r.run(profiles))
148
+
149
+
150
+ class Runner:
151
+ def __init__(self, store: Store, registry: Registry, *, scan_only: bool, dry_run: bool,
152
+ interactive: bool, broker_ids: list[str] | None, only_due: bool,
153
+ mailer: Any = None, notifier: Any = None,
154
+ browser: BrowserManager | None = None,
155
+ state_laws: dict[str, StateLaw] | None = None, now: datetime | None = None,
156
+ cfg: cfgmod.Config | None = None) -> None:
157
+ self.store, self.registry = store, registry
158
+ self.cfg = cfg or cfgmod.load()
159
+ self.fixture_base = dev_fixture_base()
160
+ # defence in depth: the CLI also refuses, but never submit for real unless enabled
161
+ self.dry_run = dry_run or scan_only or (
162
+ not self.cfg.real_submissions_enabled and not self.fixture_base)
163
+ self.scan_only = scan_only
164
+ self.interactive = interactive
165
+ self.broker_ids = set(broker_ids) if broker_ids else None
166
+ self.only_due = only_due
167
+ self._mailer = mailer
168
+ self.notifier = notifier
169
+ self._browser = browser
170
+ self._browser_failed = False
171
+ self._own_browser = browser is None
172
+ self.state_laws = state_laws if state_laws is not None else self._load_laws()
173
+ self.now = now or utcnow()
174
+ self.report = RunReport(dry_run=self.dry_run)
175
+ # (profile_id, target_id) -> outcome this run: sent | queued | draft | human
176
+ self._target_outcome: dict[tuple[str, str], str] = {}
177
+
178
+ # ------------------------------------------------------------------ services
179
+ @staticmethod
180
+ def _load_laws() -> dict[str, StateLaw]:
181
+ try:
182
+ from ..registry import load_state_laws
183
+ return load_state_laws()
184
+ except Exception as e: # noqa: BLE001 - missing/invalid file: generic letters
185
+ log.info("state laws unavailable: %s", type(e).__name__)
186
+ return {}
187
+
188
+ def mailer_for(self, profile: Profile) -> Any:
189
+ if self._mailer is not None:
190
+ return self._mailer
191
+ from .mail import FakeMailer, SmtpImapMailer
192
+ if self.dry_run or self.fixture_base or profile.mailbox is None:
193
+ self._mailer = FakeMailer(inbox=self._fixture_inbox())
194
+ return self._mailer
195
+ from ..netpolicy import NetPolicy
196
+ return SmtpImapMailer(NetPolicy(self.registry, [profile]))
197
+
198
+ def _fixture_inbox(self) -> dict[str, list[bytes]]:
199
+ if not self.fixture_base:
200
+ return {}
201
+ try:
202
+ from . import _dev
203
+ return _dev.fixture_inbox()
204
+ except Exception: # noqa: BLE001
205
+ return {}
206
+
207
+ async def browser(self) -> BrowserManager | None:
208
+ if self._browser is not None:
209
+ return self._browser
210
+ if self._browser_failed or not tiers.browser_automation_allowed(self.cfg):
211
+ return None
212
+ bm = None
213
+ try:
214
+ from ..netpolicy import NetPolicy
215
+ from .browser import BrowserManager
216
+ pol = NetPolicy(self.registry)
217
+ bm = BrowserManager(pol, headless=True if not self.interactive else None,
218
+ cfg=self.cfg, fixture_base=self.fixture_base)
219
+ await bm.start()
220
+ self._browser = bm
221
+ return bm
222
+ except Exception as e: # noqa: BLE001 - no browser: Automatic drops to Assisted
223
+ if bm is not None: # SEC-26: never leak the Playwright driver
224
+ try:
225
+ await bm.close()
226
+ except Exception as ce: # noqa: BLE001
227
+ log.debug("browser cleanup failed: %s", type(ce).__name__)
228
+ log.warning("browser unavailable (%s); automatic sites become assisted",
229
+ type(e).__name__)
230
+ self._browser_failed = True
231
+ return None
232
+
233
+ def _notifier(self) -> Any:
234
+ if self.notifier is None:
235
+ self.notifier = hmod.get_notifier()
236
+ return self.notifier
237
+
238
+ def _handoff(self, profile: Profile) -> Any:
239
+ if self.interactive:
240
+ return hmod.make_handoff(self.store, self._notifier(), True, self._browser,
241
+ profile.label)
242
+ return hmod.QueuedHandoff(self.store, NullNotifier(), profile.label)
243
+
244
+ def _law(self, profile: Profile) -> StateLaw | None:
245
+ return self.state_laws.get(profile.state_of_residence)
246
+
247
+ def _event(self, profile: Profile, broker_id: str | None, action: str,
248
+ frm: S | None = None, to: S | None = None, **detail: Any) -> None:
249
+ self.store.log(Event(profile_id=profile.id, broker_id=broker_id, actor=Actor.ENGINE,
250
+ action=action, from_state=frm, to_state=to, detail=detail))
251
+
252
+ # ------------------------------------------------------------------ main loop
253
+ async def run(self, profiles: list[Profile]) -> RunReport:
254
+ try:
255
+ for p in profiles:
256
+ ok, _why = consent_ok(p, self.store)
257
+ if not ok:
258
+ self.report.skipped_profiles += 1
259
+ self.store.log(Event(profile_id=p.id, actor=Actor.ENGINE,
260
+ action="profile_skipped_consent"))
261
+ continue
262
+ self.report.labels[p.id] = p.label
263
+ await self.run_profile(p)
264
+ finally:
265
+ if self._browser is not None and self._own_browser:
266
+ try:
267
+ await self._browser.close()
268
+ except Exception as e: # noqa: BLE001
269
+ log.debug("browser close failed: %s", type(e).__name__)
270
+ self._finish(profiles)
271
+ return self.report
272
+
273
+ def brokers(self) -> list[Broker]:
274
+ bs = [b for b in self.registry.brokers if b.enabled and
275
+ (self.broker_ids is None or b.id in self.broker_ids)]
276
+ return sorted(bs, key=lambda b: (-b.priority, b.id))
277
+
278
+ async def run_profile(self, profile: Profile) -> None:
279
+ counts: Counter[ListingState] = Counter()
280
+ tcounts: Counter[Tier] = Counter()
281
+ if not self.scan_only:
282
+ self._drop_first(profile)
283
+ for broker in self.brokers():
284
+ if broker.id == assisted.DROP_BROKER_ID:
285
+ # handled by the GOVERNMENT_PORTAL to-do (CA only); record the tier only
286
+ dst = self.store.get_status(profile.id, broker.id)
287
+ if dst.tier != Tier.ASSISTED:
288
+ self.store.save_status(dst.model_copy(update={"tier": Tier.ASSISTED}))
289
+ continue
290
+ try:
291
+ st = await self.process(profile, broker)
292
+ except Exception as e:
293
+ self.report.errors += 1
294
+ log.warning("broker %s failed: %s", broker.id, type(e).__name__)
295
+ log.debug("broker failure detail", exc_info=True)
296
+ st = self._mark_error(profile, broker, type(e).__name__)
297
+ if st is None:
298
+ continue
299
+ counts[st.state] += 1
300
+ if st.tier:
301
+ tcounts[st.tier] += 1
302
+ if st.state == S.NEEDS_HUMAN:
303
+ self.report.needs_human += 1
304
+ self.report.per_profile[profile.id] = dict(counts)
305
+ self.report.tiers[profile.id] = dict(tcounts)
306
+
307
+ def _mark_error(self, profile: Profile, broker: Broker, err: str) -> Any:
308
+ try:
309
+ st = self.store.get_status(profile.id, broker.id)
310
+ new = transition(st, S.ERROR, self.now)
311
+ new.next_check_at = next_check_at(broker, S.ERROR, self.now)
312
+ self.store.save_status(new)
313
+ self._event(profile, broker.id, "error", st.state, S.ERROR, error=err)
314
+ return new
315
+ except Exception: # noqa: BLE001
316
+ return None
317
+
318
+ def _drop_first(self, profile: Profile) -> None:
319
+ """D3: California DROP first for CA residents (current address)."""
320
+ if profile.state_of_residence != "CA":
321
+ return
322
+ broker = None
323
+ try:
324
+ broker = self.registry.get(assisted.DROP_BROKER_ID)
325
+ except KeyError:
326
+ pass
327
+ for t in self.store.list_todos(profile.id, open_only=False):
328
+ if t.kind == TodoKind.GOVERNMENT_PORTAL and t.resume_token == "gov:ca_drop":
329
+ return # open or already done: never nag twice
330
+ todo = assisted.drop_todo(profile, broker)
331
+ self.store.save_todo(todo)
332
+ self._event(profile, todo.broker_id, "todo_created", todo_kind=todo.kind.value)
333
+
334
+ # ------------------------------------------------------------------ one broker
335
+ async def process(self, profile: Profile, broker: Broker) -> Any:
336
+ st = self.store.get_status(profile.id, broker.id)
337
+ tier = tiers.tier_for(broker, profile, self.cfg, registry=self.registry)
338
+ target = tiers.submit_target(broker, self.registry)
339
+ if self.only_due and st.next_check_at and st.next_check_at > self.now:
340
+ self.report.skipped_not_due += 1
341
+ st = st.model_copy(update={"tier": tier})
342
+ if st.state in (S.SUBMITTED, S.AWAITING_VERIFICATION):
343
+ st = await self._verify(profile, broker, target, st, tier)
344
+ self.store.save_status(st)
345
+ return st
346
+ st = st.model_copy(update={"tier": tier})
347
+ ctx = self._ctx(profile, broker)
348
+
349
+ # 3. scan (or blind)
350
+ listings, scanned = await self._scan(ctx, st, tier)
351
+ st = self._apply_scan(profile, broker, st, listings, scanned)
352
+
353
+ # 4. submit (paused while the site's request for ID waits on a person)
354
+ if should_submit(st.state) and not self.scan_only and self._id_hold(profile, target):
355
+ st = self._set(profile, st, S.NEEDS_HUMAN, "id_request_open")
356
+ st = st.model_copy(update={"needs_human_reason": ID_REQUEST_REASON})
357
+ if should_submit(st.state) and not self.scan_only:
358
+ st = await self._submit(profile, broker, target, st, tier,
359
+ sbase.confirmed(listings) or
360
+ ([] if scanned else listings))
361
+
362
+ # 5. verification mail
363
+ if st.state in (S.SUBMITTED, S.AWAITING_VERIFICATION):
364
+ st = await self._verify(profile, broker, target, st, tier)
365
+
366
+ # 6. escalation
367
+ if not self.scan_only:
368
+ st = await self._escalate(profile, broker, target, st, tier, scanned)
369
+
370
+ # 7. next check
371
+ st = st.model_copy(update={"next_check_at": next_check_at(
372
+ broker, st.state, self.now, weekly=profile.schedule.weekly_recheck)})
373
+ self.store.save_status(st)
374
+ return st
375
+
376
+ def _ctx(self, profile: Profile, broker: Broker, **scratch: Any) -> RunContext:
377
+ from ..netpolicy import NetPolicy
378
+ return RunContext(profile=profile, broker=broker, store=self.store,
379
+ netpolicy=NetPolicy(self.registry), handoff=self._handoff(profile),
380
+ notifier=NullNotifier(), mailer=self.mailer_for(profile),
381
+ dry_run=self.dry_run, interactive=self.interactive,
382
+ state_law=self._law(profile), now=self.now, scratch=dict(scratch))
383
+
384
+ def _set(self, profile: Profile, st: Any, dst: S, action: str, **detail: Any) -> Any:
385
+ if st.state == dst:
386
+ return st
387
+ if not can_transition(st.state, dst):
388
+ self._event(profile, st.broker_id, "transition_skipped", st.state, dst, **detail)
389
+ return st
390
+ new = transition(st, dst, self.now)
391
+ self._event(profile, st.broker_id, action, st.state, dst, **detail)
392
+ return new
393
+
394
+ # ------------------------------------------------------------------ scan
395
+ async def _scan(self, ctx: RunContext, st: Any, tier: Tier) -> tuple[list[Listing], bool]:
396
+ if not tiers.can_scan(ctx.broker, self.cfg):
397
+ return [], False
398
+ bm = await self.browser()
399
+ if bm is None:
400
+ return [], False
401
+ from ..submitters.web_form import search_listings
402
+ ctx.scratch["browser"] = bm
403
+ try:
404
+ found = await search_listings(ctx)
405
+ except Exception as e: # noqa: BLE001 - challenge/unexpected page: treat as blind
406
+ self._event(ctx.profile, ctx.broker.id, "scan_failed", error=type(e).__name__)
407
+ return [], False
408
+ listings = self._merge_listings(ctx.profile, ctx.broker, found)
409
+ shot = ctx.scratch.pop("search_screenshot", None)
410
+ if shot and listings:
411
+ ev = sbase.evidence(ctx.profile.id, ctx.broker.id, EvidenceKind.SCREENSHOT, "png",
412
+ listing_id=listings[0].id, caption="search results")
413
+ self.store.save_evidence(ev, shot)
414
+ return listings, True
415
+
416
+ def _merge_listings(self, profile: Profile, broker: Broker,
417
+ found: list[Listing]) -> list[Listing]:
418
+ """Keep ids/first_seen and the user's REVIEW_MATCH answers across re-scans."""
419
+ old = {li.url: li for li in self.store.list_listings(profile.id, broker.id)}
420
+ out: list[Listing] = []
421
+ seen = set()
422
+ for li in found:
423
+ prev = old.get(li.url)
424
+ if prev is not None:
425
+ upd: dict[str, Any] = {"id": prev.id, "first_seen": prev.first_seen,
426
+ "gone_since": None}
427
+ if "user_confirmed" in prev.match_reasons:
428
+ upd.update(match_level=MatchLevel.CONFIRMED,
429
+ match_reasons=sorted({*li.match_reasons, "user_confirmed"}))
430
+ elif "user_rejected" in prev.match_reasons:
431
+ upd.update(match_level=MatchLevel.NO_MATCH, match_reasons=["user_rejected"])
432
+ li = li.model_copy(update=upd)
433
+ seen.add(li.url)
434
+ self.store.save_listing(li)
435
+ if li.match_level != MatchLevel.NO_MATCH:
436
+ out.append(li)
437
+ for url, prev in old.items():
438
+ if url not in seen and prev.gone_since is None:
439
+ self.store.save_listing(prev.model_copy(update={"gone_since": self.now}))
440
+ return out
441
+
442
+ def _apply_scan(self, profile: Profile, broker: Broker, st: Any, listings: list[Listing],
443
+ scanned: bool) -> Any:
444
+ if scanned:
445
+ conf = sbase.confirmed(listings)
446
+ possible = [li for li in listings if li.match_level == MatchLevel.POSSIBLE]
447
+ new_state = after_scan(st, bool(conf), self.now)
448
+ st = self._set(profile, st, new_state, "scanned", confirmed=len(conf),
449
+ possible=len(possible))
450
+ st = st.model_copy(update={"last_scan_at": self.now})
451
+ if possible and not conf:
452
+ self._review_todo(profile, broker, possible[0], len(possible))
453
+ return st
454
+ # blind broker: no search. A broker we never contacted is presumed to hold data.
455
+ if st.last_submitted_at is None and st.state in (S.UNKNOWN, S.ERROR, S.NOT_FOUND):
456
+ return self._set(profile, st, S.FOUND, "presumed_listed", blind=True)
457
+ if st.state == S.NEEDS_HUMAN and st.last_submitted_at is None and \
458
+ not self._open_human_todo(profile, broker):
459
+ return self._set(profile, st, S.FOUND, "presumed_listed", blind=True)
460
+ return st
461
+
462
+ def _open_human_todo(self, profile: Profile, broker: Broker) -> bool:
463
+ target = tiers.submit_target(broker, self.registry)
464
+ ids = {broker.id, target.id}
465
+ return any(t.broker_id in ids and t.kind != TodoKind.REVIEW_MATCH
466
+ for t in self.store.list_todos(profile.id))
467
+
468
+ def _review_todo(self, profile: Profile, broker: Broker, li: Listing, n: int) -> None:
469
+ todo = Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.REVIEW_MATCH,
470
+ title=f"Is this {profile.label}? ({broker.name})",
471
+ body=(f"Free My ID found {n} listing(s) on {broker.name} that might be "
472
+ f"{profile.label}. Open the link and tell us if it is them."),
473
+ links=[li.url], resume_token=f"review:{li.id}")
474
+ hmod.persist_todo(self.store, todo)
475
+
476
+ # ------------------------------------------------------------------ submit
477
+ async def _dispatch(self, profile: Profile, target: Broker, tier: Tier,
478
+ listings: list[Listing], **scratch: Any) -> SubmitOutcome:
479
+ """Run the tier's submitter against `target`. AUTOMATIC falls back to ASSISTED when
480
+ the browser is unavailable. In dry runs AUTOMATIC submits nothing."""
481
+ ctx = self._ctx(profile, target, **scratch)
482
+ if tier == Tier.AUTOMATIC:
483
+ if self.dry_run:
484
+ return SubmitOutcome(new_state=S.FOUND, message="dry_run_would_submit")
485
+ bm = await self.browser()
486
+ if bm is None:
487
+ tier = Tier.ASSISTED
488
+ ctx.scratch["reason"] = "error"
489
+ else:
490
+ ctx.scratch["browser"] = bm
491
+ sub = plugins.get_submitter(target, tier)
492
+ return await sub.submit(ctx, listings)
493
+
494
+ async def _submit(self, profile: Profile, broker: Broker, target: Broker, st: Any,
495
+ tier: Tier, listings: list[Listing]) -> Any:
496
+ key = (profile.id, target.id)
497
+ if target.id != broker.id:
498
+ # covered_by: one submission to the parent covers this site (PLAN_V1)
499
+ pst = self.store.get_status(profile.id, target.id)
500
+ done = self._target_outcome.get(key)
501
+ if done is None and pst.state in _SUBMITTED_STATES:
502
+ done = "sent"
503
+ if done is None and self._open_human_todo(profile, target):
504
+ done = "human"
505
+ if done == "sent" and not self.dry_run:
506
+ new = mark_submitted(st, self.now)
507
+ self._event(profile, broker.id, "covered_by_parent", st.state, new.state,
508
+ parent=target.id)
509
+ return new
510
+ if done == "human":
511
+ new = self._set(profile, st, S.NEEDS_HUMAN, "covered_by_parent",
512
+ parent=target.id)
513
+ return new.model_copy(update={"needs_human_reason": f"via:{target.id}"})
514
+ if done is not None:
515
+ return st
516
+ elif key in self._target_outcome:
517
+ return st
518
+ out = await self._dispatch(profile, target, tier, listings)
519
+ return self._apply_outcome(profile, broker, st, out, key)
520
+
521
+ def _apply_outcome(self, profile: Profile, broker: Broker, st: Any, out: SubmitOutcome,
522
+ key: tuple[str, str]) -> Any:
523
+ for t in out.todos:
524
+ hmod.persist_todo(self.store, t)
525
+ self._event(profile, t.broker_id, "todo_created", todo_kind=t.kind.value)
526
+ req = out.request
527
+ if req is not None and req.status == RequestStatus.SENT:
528
+ self._target_outcome[key] = "sent"
529
+ self.report.submitted += 1
530
+ new = mark_submitted(st, self.now)
531
+ if out.new_state == S.AWAITING_VERIFICATION:
532
+ new = transition(new, S.AWAITING_VERIFICATION, self.now)
533
+ self._event(profile, broker.id, "submitted", st.state, new.state,
534
+ request_id=req.id, method=req.method.value)
535
+ self._mirror_parent(profile, broker, key, new)
536
+ return new
537
+ if req is not None:
538
+ if req.status == RequestStatus.QUEUED_FOR_APPROVAL:
539
+ self.report.queued_emails += 1
540
+ elif req.dry_run:
541
+ self.report.drafted_emails += 1
542
+ self._target_outcome[key] = "draft" if req.dry_run else "queued"
543
+ return st
544
+ if out.new_state == S.NEEDS_HUMAN:
545
+ self._target_outcome[key] = "human"
546
+ new = self._set(profile, st, S.NEEDS_HUMAN, "needs_human", why=out.message[:60])
547
+ return new.model_copy(update={"needs_human_reason": out.message[:60]})
548
+ if out.message:
549
+ self._event(profile, broker.id, out.message[:60])
550
+ return st
551
+
552
+ def _mirror_parent(self, profile: Profile, broker: Broker, key: tuple[str, str],
553
+ child_new: Any) -> None:
554
+ """A child's submission went to the parent: record it on the parent too."""
555
+ if key[1] == broker.id:
556
+ return
557
+ pst = self.store.get_status(profile.id, key[1])
558
+ if pst.state in _SUBMITTED_STATES:
559
+ return
560
+ if pst.state in (S.UNKNOWN, S.NOT_FOUND, S.ERROR):
561
+ pst = transition(pst, S.FOUND, self.now)
562
+ if can_transition(pst.state, S.SUBMITTED):
563
+ self.store.save_status(mark_submitted(pst, self.now))
564
+
565
+ # ------------------------------------------------------------------ verify (IMAP)
566
+ async def _verify(self, profile: Profile, broker: Broker, target: Broker, st: Any,
567
+ tier: Tier) -> Any:
568
+ if profile.mailbox is None or st.last_submitted_at is None:
569
+ return st
570
+ if not (target.needs_email_verification or tier == Tier.EMAIL):
571
+ return st
572
+ if target.id != broker.id:
573
+ return st # the parent's own status handles its confirmation mail
574
+ mailer = self.mailer_for(profile)
575
+ try:
576
+ if hasattr(mailer, "scan_replies"):
577
+ scan = await mailer.scan_replies(profile, target, st.last_submitted_at,
578
+ timeout_s=0)
579
+ else: # a Mailer that only implements the interfaces.py protocol
580
+ scan = ReplyScan(links=await mailer.wait_for_confirmation(
581
+ profile, target, st.last_submitted_at, timeout_s=0))
582
+ except Exception as e: # noqa: BLE001
583
+ self._event(profile, broker.id, "mail_check_failed", error=type(e).__name__)
584
+ return st
585
+ if scan.duplicates or scan.stale:
586
+ self._event(profile, broker.id, "mail_replies_skipped",
587
+ duplicates=scan.duplicates, stale=scan.stale)
588
+ if scan.id_requested:
589
+ return self._id_requested(profile, broker, st)
590
+ links = scan.links
591
+ if not links:
592
+ return st
593
+ if self.dry_run:
594
+ self._event(profile, broker.id, "confirmation_link_found", links=len(links),
595
+ clicked=False)
596
+ return st
597
+ clicked = 0
598
+ for u in links[:1]: # SEC-18: only the best-ranked confirm link
599
+ if await self._click(profile, target, u):
600
+ clicked += 1
601
+ if clicked:
602
+ return self._set(profile, st, S.CONFIRMED, "confirmed", links=len(links),
603
+ clicked=clicked)
604
+ return self._set(profile, st, S.AWAITING_VERIFICATION, "confirmation_pending")
605
+
606
+ def _id_requested(self, profile: Profile, broker: Broker, st: Any) -> Any:
607
+ """The broker's reply asks for an ID document. Free My ID never sends ID and never
608
+ clicks anything in that mail: a person decides (ID_UPLOAD to-do)."""
609
+ if self.dry_run:
610
+ self._event(profile, broker.id, ID_REQUEST_REASON, todo=False)
611
+ return st
612
+ hmod.persist_todo(self.store, Todo(
613
+ profile_id=profile.id, broker_id=broker.id, kind=TodoKind.ID_UPLOAD,
614
+ title=f"{broker.name} asked for a copy of {profile.label}'s ID",
615
+ body=("The site wrote back asking for an ID document before it will remove the "
616
+ "listing. Free My ID never sends ID for you. Read their email in your "
617
+ "removal mailbox and decide yourself. Many sites remove listings without "
618
+ "ID if you reply that you only want your data deleted. When you have "
619
+ "dealt with it, press \"I did it\"."),
620
+ resume_token=f"{ID_REQUEST_REASON}:{broker.id}"))
621
+ new = self._set(profile, st, S.NEEDS_HUMAN, ID_REQUEST_REASON, todo=True)
622
+ return new.model_copy(update={"needs_human_reason": ID_REQUEST_REASON})
623
+
624
+ def _id_hold(self, profile: Profile, target: Broker) -> bool:
625
+ """An open ID-request to-do pauses every new letter/form to that site."""
626
+ return any(t.kind == TodoKind.ID_UPLOAD and t.broker_id == target.id
627
+ and (t.resume_token or "").startswith(ID_REQUEST_REASON)
628
+ for t in self.store.list_todos(profile.id))
629
+
630
+ async def _click(self, profile: Profile, broker: Broker, url: str) -> bool:
631
+ from ..netpolicy import host_of
632
+ from .mail import _host_in, confirmation_hosts
633
+ hosts = confirmation_hosts(broker)
634
+ if not url.lower().startswith("https://") or not _host_in(host_of(url), hosts):
635
+ return False
636
+ bm = await self.browser()
637
+ if bm is None:
638
+ return False
639
+ bctx = await bm.new_context(broker.domains, extra=hosts)
640
+ try:
641
+ page = await bctx.new_page()
642
+ await bm.goto(page, url)
643
+ png = await page.screenshot(full_page=True)
644
+ ev = sbase.evidence(profile.id, broker.id, EvidenceKind.SCREENSHOT, "png",
645
+ caption="confirmation link page")
646
+ self.store.save_evidence(ev, png)
647
+ return True
648
+ except Exception as e: # noqa: BLE001
649
+ self._event(profile, broker.id, "confirmation_click_failed", error=type(e).__name__)
650
+ hmod.persist_todo(self.store, Todo(
651
+ profile_id=profile.id, broker_id=broker.id, kind=TodoKind.UNEXPECTED_PAGE,
652
+ title=f"Confirm {profile.label}'s removal at {broker.name}",
653
+ body="Open the link and finish the confirmation yourself.", links=[url]))
654
+ return False
655
+ finally:
656
+ await bctx.close()
657
+
658
+ # ------------------------------------------------------------------ escalation
659
+ def _last_sent(self, profile: Profile, target: Broker) -> Request | None:
660
+ sent = [r for r in self.store.list_requests(profile.id, target.id)
661
+ if r.status == RequestStatus.SENT and
662
+ r.kind in (RequestKind.OPT_OUT, RequestKind.RESUBMIT)]
663
+ return sent[-1] if sent else None
664
+
665
+ async def _escalate(self, profile: Profile, broker: Broker, target: Broker, st: Any,
666
+ tier: Tier, scanned: bool) -> Any:
667
+ if target.id != broker.id:
668
+ return st # the parent's own ladder covers this site
669
+ if not scanned and st.state == S.CONFIRMED:
670
+ return st # blind + confirmed: no sign it is still listed
671
+ dec = next_escalation(self._ladder_view(profile, broker, st), broker, self.now)
672
+ if dec.action == Escalation.NONE:
673
+ return st
674
+ waiting = self._escalation_waiting(profile, broker)
675
+ if waiting:
676
+ # the previous rung has not actually gone out yet: never climb past it
677
+ self._event(profile, broker.id, "escalation_waiting", rung=dec.action.value,
678
+ why=waiting)
679
+ return st
680
+ if self.dry_run:
681
+ self._event(profile, broker.id, "escalation_due", rung=dec.action.value,
682
+ level=dec.new_level)
683
+ return st
684
+ law = self._law(profile)
685
+ listings = [li for li in self.store.list_listings(profile.id, broker.id)
686
+ if li.gone_since is None and li.match_level == MatchLevel.CONFIRMED]
687
+ original = self._last_sent(profile, broker)
688
+ done = "ok"
689
+ try:
690
+ if dec.action == Escalation.RESUBMIT:
691
+ out = await self._dispatch(profile, broker, tier, listings,
692
+ kind=RequestKind.RESUBMIT, again=True)
693
+ for t in out.todos:
694
+ hmod.persist_todo(self.store, t)
695
+ if out.request is not None and out.request.status == RequestStatus.SENT:
696
+ st = mark_submitted(st, self.now)
697
+ self.report.submitted += 1
698
+ elif out.request is not None:
699
+ self.report.queued_emails += 1
700
+ elif dec.action in (Escalation.FOLLOW_UP, Escalation.LEGAL_DEMAND):
701
+ kind = (RequestKind.FOLLOW_UP if dec.action == Escalation.FOLLOW_UP
702
+ else RequestKind.LEGAL_DEMAND)
703
+ done = self._letter(profile, broker, listings, law, kind, original)
704
+ elif dec.action == Escalation.COMPLAINT:
705
+ hmod.persist_todo(self.store, self._complaint_todo(profile, broker, law))
706
+ except OutboxError as e:
707
+ done = f"failed:{str(e)[:40]}"
708
+ if done.startswith("failed:"):
709
+ # retry this same rung one window from now; never skip to the next one
710
+ self._event(profile, broker.id, "escalation_failed", rung=dec.action.value,
711
+ level=st.escalation_level, result=done)
712
+ return st.model_copy(update={"last_escalated_at": self.now})
713
+ self._event(profile, broker.id, "escalated", rung=dec.action.value,
714
+ level=dec.new_level, result=done)
715
+ return st.model_copy(update={"escalation_level": dec.new_level,
716
+ "last_escalated_at": self.now})
717
+
718
+ def _ladder_view(self, profile: Profile, broker: Broker, st: Any) -> Any:
719
+ """Status as the ladder should see it: a letter approved (and sent) long after it
720
+ was queued restarts the wait from its real send time, so the next rung never comes
721
+ days after the previous one went out."""
722
+ sent = [r.sent_at for r in self.store.list_requests(profile.id, broker.id,
723
+ status=RequestStatus.SENT)
724
+ if r.sent_at is not None and not r.dry_run]
725
+ latest = max(sent, default=None)
726
+ if latest is None or (st.last_escalated_at and st.last_escalated_at >= latest):
727
+ return st
728
+ if st.last_submitted_at and st.last_submitted_at >= latest:
729
+ return st
730
+ return st.model_copy(update={"last_escalated_at": latest})
731
+
732
+ def _escalation_waiting(self, profile: Profile, broker: Broker) -> str | None:
733
+ if any(not r.dry_run for r in self.store.list_requests(
734
+ profile.id, broker.id, status=RequestStatus.QUEUED_FOR_APPROVAL)):
735
+ return "awaiting_approval"
736
+ tok = f"manual_email:{broker.id}:"
737
+ if any((t.resume_token or "").startswith(tok) for t in self.store.list_todos(profile.id)):
738
+ return "awaiting_manual_letter"
739
+ return None
740
+
741
+ def _letter(self, profile: Profile, broker: Broker, listings: list[Listing],
742
+ law: StateLaw | None, kind: RequestKind, original: Request | None) -> str:
743
+ if not tiers.has_email_route(broker):
744
+ return "no_email_route"
745
+ from .outbox import compose, queue_email
746
+ if profile.mailbox is None:
747
+ subject, body = compose(profile, broker, listings, law, kind, original=original)
748
+ word = "follow-up" if kind == RequestKind.FOLLOW_UP else "formal demand"
749
+ hmod.persist_todo(self.store, assisted.manual_email_todo(
750
+ profile, broker, subject, body,
751
+ title=f"Send a {word} to {broker.name} for {profile.label}").model_copy(
752
+ update={"resume_token": f"manual_email:{broker.id}:{kind.value}"}))
753
+ return "manual_todo"
754
+ req = queue_email(self.store, profile, broker, listings, kind=kind, state_law=law,
755
+ dry_run=False, mailer=self.mailer_for(profile), original=original,
756
+ now=self.now)
757
+ if req.status == RequestStatus.QUEUED_FOR_APPROVAL:
758
+ self.report.queued_emails += 1
759
+ return req.status.value
760
+
761
+ def _complaint_todo(self, profile: Profile, broker: Broker, law: StateLaw | None) -> Todo:
762
+ links = [str(u) for u in ([law.ag_complaint_url, *law.other_complaint_urls]
763
+ if law else []) if u]
764
+ if profile.state_of_residence == "CA" and CPPA_COMPLAINT_URL not in links:
765
+ links.append(CPPA_COMPLAINT_URL)
766
+ if not links:
767
+ links = [AG_DIRECTORY_URL]
768
+ return Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.COMPLAINT,
769
+ title=f"{broker.name} ignored {profile.label}'s requests: file a complaint",
770
+ body=("Free My ID asked several times and the site did not act. You can report "
771
+ "it to your state's consumer protection office. The links below "
772
+ "open the complaint forms. Your sent letters are saved as proof."),
773
+ links=links, resume_token=f"complaint:{broker.id}")
774
+
775
+ # ------------------------------------------------------------------ finish
776
+ def _finish(self, profiles: list[Profile]) -> None:
777
+ ids = {p.id for p in profiles}
778
+ todos = [t for t in self.store.list_todos() if t.profile_id in ids]
779
+ queued = [r for r in self.store.list_requests(status=RequestStatus.QUEUED_FOR_APPROVAL)
780
+ if r.profile_id in ids]
781
+ self.report.open_todos = len(todos)
782
+ n = len(todos) + len(queued)
783
+ if n == 0:
784
+ return
785
+ try:
786
+ # SEC-28: no URL (a bare dashboard URL has no session token and lands on 403);
787
+ # the notifier's click handler opens the app itself.
788
+ body = f"{n} thing{'s' if n != 1 else ''} need you. Open Free My ID to see them."
789
+ self._notifier().notify("Free My ID needs 5 minutes of your help", body, None)
790
+ except Exception as e: # noqa: BLE001
791
+ log.info("notification failed: %s", type(e).__name__)