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,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__)
|