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,627 @@
1
+ """Wizard draft (in-memory, one person at a time) shared by the web wizard and CLI flows.
2
+
3
+ The draft holds PII and the mailbox password only in process memory until `save_draft`.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import imaplib
8
+ import logging
9
+ import re
10
+ import smtplib
11
+ import ssl
12
+ from dataclasses import dataclass, field
13
+ from datetime import date, datetime
14
+ from typing import Any, Protocol
15
+
16
+ from ..consent import CURRENT_TEXT_VERSION
17
+ from ..engine.mail_tls import TlsPinError, check_peer, client_context, is_loopback
18
+ from ..models import (
19
+ Actor,
20
+ Address,
21
+ Consent,
22
+ ConsentBasis,
23
+ EmailMode,
24
+ Event,
25
+ Evidence,
26
+ EvidenceKind,
27
+ MailboxConfig,
28
+ Permissions,
29
+ PersonName,
30
+ Profile,
31
+ ScheduleConfig,
32
+ new_id,
33
+ utcnow,
34
+ )
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ CONSENT_VERSION = CURRENT_TEXT_VERSION # single source of truth (consent.py)
39
+ CONSENT_TEXT = [
40
+ ("Free My ID will look for the information of the person named in this setup on "
41
+ "people-search sites (websites that publish names, addresses and phone numbers) and "
42
+ "ask those sites to delete it."),
43
+ ("Everything you type here stays on this computer, encrypted. Nothing is sent to us "
44
+ "or to any server we run. There is no account, no tracking and no cloud."),
45
+ ("When Free My ID contacts a site it sends only what that site already shows about the "
46
+ "person, plus the email address for replies if you set one up. It never pays anyone."),
47
+ ("Honest limits: some sites need a step from a person, details can come back after a "
48
+ "site has deleted them, and a site saying it deleted your details is true for that one "
49
+ "site only, not for the whole internet. Free My ID does not change what Facebook, "
50
+ "Google or any other service does with your account."),
51
+ "You can pause, edit or delete everything at any time.",
52
+ ]
53
+ BASIS_LABELS = {
54
+ ConsentBasis.SELF.value: "I am this person and I am allowing Free My ID to act for me.",
55
+ ConsentBasis.GUARDIAN_OF_MINOR.value:
56
+ "I am the parent or legal guardian of this person, who is under 18.",
57
+ ConsentBasis.AUTHORIZED_AGENT.value:
58
+ "I am helping an adult who gave me signed written permission (an authorized agent).",
59
+ }
60
+
61
+ STEPS = ["consent", "name", "address", "about", "mailbox", "finish"]
62
+ STEP_TITLES = {
63
+ "consent": "Welcome. Who is this for?", "name": "What is the person's name?",
64
+ "address": "Where do they live?", "about": "A few details to recognize them",
65
+ "mailbox": "A separate email address for replies", "finish": "Run automatically?",
66
+ }
67
+ NO_CONSENT_NEEDED = ("consent",)
68
+
69
+ PRESETS: dict[str, dict[str, Any]] = {
70
+ "gmail": {"imap_host": "imap.gmail.com", "imap_port": 993, "imap_ssl": True,
71
+ "smtp_host": "smtp.gmail.com", "smtp_port": 465, "smtp_starttls": False},
72
+ "proton_bridge": {"imap_host": "127.0.0.1", "imap_port": 1143, "imap_ssl": False,
73
+ "imap_starttls": True, "smtp_host": "127.0.0.1", "smtp_port": 1025, "smtp_starttls": True},
74
+ "yahoo": {"imap_host": "imap.mail.yahoo.com", "imap_port": 993, "imap_ssl": True,
75
+ "smtp_host": "smtp.mail.yahoo.com", "smtp_port": 465, "smtp_starttls": False},
76
+ "icloud": {"imap_host": "imap.mail.me.com", "imap_port": 993, "imap_ssl": True,
77
+ "smtp_host": "smtp.mail.me.com", "smtp_port": 587, "smtp_starttls": True},
78
+ "other": {"imap_host": "", "imap_port": 993, "imap_ssl": True,
79
+ "smtp_host": "", "smtp_port": 465, "smtp_starttls": False},
80
+ }
81
+
82
+ OUTLOOK_MSG = ("Outlook.com and Hotmail no longer allow programs like Free My ID to sign in. "
83
+ "Please choose \"Skip, I'll do these by hand\" (Free My ID will prepare each "
84
+ "message and open your email for you), or pick a different email service.")
85
+ # MailboxConfig.provider only accepts these; yahoo/icloud are stored as "other" (same servers).
86
+ STORED_PROVIDERS = ("gmail", "outlook", "proton_bridge", "other")
87
+ MAX_AUTH_BYTES = 10 * 1024 * 1024
88
+ _HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$")
89
+
90
+
91
+ class FormLike(Protocol):
92
+ def get(self, key: str, /, *args: Any) -> Any: ...
93
+ def getlist(self, key: str, /) -> list[Any]: ...
94
+
95
+
96
+ @dataclass
97
+ class Draft:
98
+ id: str = field(default_factory=new_id)
99
+ editing: bool = False
100
+ imported: bool = False
101
+ saved_password: bool = False # a mailbox password really exists in the secret store
102
+ tls_pin: str = "" # approved certificate fingerprint for a local mail app (Bridge)
103
+ # consent
104
+ basis: str = ConsentBasis.SELF.value
105
+ attested_by: str = ""
106
+ consent_ack: bool = False
107
+ consent_at: datetime | None = None
108
+ auth_bytes: bytes | None = None
109
+ auth_filename: str = ""
110
+ existing_consent: Consent | None = None
111
+ # names
112
+ label: str = ""
113
+ first: str = ""
114
+ middle: str = ""
115
+ last: str = ""
116
+ suffix: str = ""
117
+ nicknames: list[str] = field(default_factory=list)
118
+ previous_names: list[str] = field(default_factory=list)
119
+ # addresses
120
+ addresses: list[dict[str, Any]] = field(default_factory=list)
121
+ # details
122
+ dob: str = ""
123
+ age: str = ""
124
+ phones: list[str] = field(default_factory=list)
125
+ emails: list[str] = field(default_factory=list)
126
+ relatives: list[str] = field(default_factory=list)
127
+ # permissions
128
+ allow_id_upload: bool = False
129
+ allow_account_creation: bool = False
130
+ allow_phone_disclosure: bool = False
131
+ # mailbox
132
+ skip_mailbox: bool = False
133
+ mailbox_answered: bool = False # the mailbox screen has been completed
134
+ provider: str = "gmail"
135
+ schedule_consent: bool = False
136
+ mailbox: dict[str, Any] = field(default_factory=dict)
137
+ password: str = ""
138
+ # email
139
+ email_mode: str = EmailMode.APPROVE_EACH.value
140
+ # schedule
141
+ weekly_recheck: bool = True
142
+ weekday: int = 6
143
+ hour: int = 10
144
+
145
+
146
+ class SaveError(RuntimeError):
147
+ pass
148
+
149
+
150
+ # --------------------------------------------------------------------------- helpers
151
+
152
+
153
+ def _lines(text: str | None) -> list[str]:
154
+ return [ln.strip() for ln in (text or "").replace(",", "\n").splitlines() if ln.strip()]
155
+
156
+
157
+ def _name_lines(text: str | None) -> list[str]:
158
+ return [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
159
+
160
+
161
+ def parse_person_name(text: str) -> PersonName:
162
+ parts = text.split()
163
+ if len(parts) < 2:
164
+ raise ValueError(f"Please give a first and last name for \"{text}\".")
165
+ return PersonName(first=parts[0], last=parts[-1],
166
+ middle=" ".join(parts[1:-1]) or None)
167
+
168
+
169
+ def age_of(d: Draft, today: date | None = None) -> int | None:
170
+ today = today or date.today()
171
+ if d.dob:
172
+ b = date.fromisoformat(d.dob)
173
+ return today.year - b.year - ((today.month, today.day) < (b.month, b.day))
174
+ if d.age.strip():
175
+ return int(d.age)
176
+ return None
177
+
178
+
179
+ def is_minor(d: Draft) -> bool:
180
+ try:
181
+ a = age_of(d)
182
+ except ValueError:
183
+ return False
184
+ return a is not None and a < 18
185
+
186
+
187
+ # --------------------------------------------------------------------------- step parsing
188
+
189
+
190
+ def apply_step(d: Draft, step: str, f: FormLike, files: dict[str, tuple[str, bytes]] | None = None
191
+ ) -> list[str]:
192
+ """Apply one wizard step's form to the draft. Returns a list of plain-language errors."""
193
+ files = files or {}
194
+ errs: list[str] = []
195
+ if step in ("consent", "sign"):
196
+ if step == "consent":
197
+ basis = f.get("basis", "")
198
+ if basis not in BASIS_LABELS:
199
+ errs.append("Please choose who is giving permission.")
200
+ else:
201
+ d.basis = basis
202
+ d.attested_by = (f.get("attested_by") or "").strip()
203
+ if not d.attested_by:
204
+ errs.append("Please type your full name to confirm.")
205
+ if not f.get("ack"):
206
+ errs.append("Please tick the box to confirm you have read and agree.")
207
+ if "authorization" in files and files["authorization"][1]:
208
+ name, data = files["authorization"]
209
+ if len(data) > MAX_AUTH_BYTES:
210
+ errs.append("That file is too large (10 MB maximum).")
211
+ else:
212
+ d.auth_filename, d.auth_bytes = name[:100], data
213
+ if not errs:
214
+ d.consent_ack = True
215
+ d.consent_at = utcnow()
216
+ elif step in ("name", "names", "othernames"):
217
+ def got(k: str) -> str | None:
218
+ v = f.get(k)
219
+ return None if v is None else str(v)
220
+ for k in ("first", "middle", "last", "suffix"):
221
+ if (v := got(k)) is not None:
222
+ setattr(d, k, v.strip())
223
+ if got("nicknames") is not None:
224
+ d.nicknames = _lines(got("nicknames"))
225
+ if got("previous_names") is not None:
226
+ d.previous_names = _name_lines(got("previous_names"))
227
+ if got("label") is not None:
228
+ d.label = str(got("label")).strip() or d.first
229
+ elif step in ("name", "names") and not d.label:
230
+ d.label = d.first
231
+ if step in ("name", "names") and (not d.first or not d.last):
232
+ errs.append("First and last name are required.")
233
+ for n in d.previous_names:
234
+ try:
235
+ parse_person_name(n)
236
+ except ValueError as e:
237
+ errs.append(str(e))
238
+ elif step in ("address", "addresses", "moreaddr"):
239
+ rows: list[dict[str, Any] | None] = (list(d.addresses) + [None] * 4)[:4]
240
+ cur = f.get("current")
241
+ for i in range(4):
242
+ if f.get(f"city{i}") is None and f.get(f"state{i}") is None:
243
+ continue
244
+ city = (f.get(f"city{i}") or "").strip()
245
+ st = (f.get(f"state{i}") or "").strip().upper()
246
+ if not city and not st:
247
+ rows[i] = None
248
+ continue
249
+ if not city or not re.fullmatch(r"[A-Z]{2}", st):
250
+ errs.append(f"Please give a city and a 2-letter state (like IL) for address {i + 1}.")
251
+ continue
252
+ old = rows[i] or {}
253
+ rows[i] = {"street": (f.get(f"street{i}") or old.get("street") or "").strip(),
254
+ "city": city, "state": st,
255
+ "zip": (f.get(f"zip{i}") or old.get("zip") or "").strip(),
256
+ "current": bool(old.get("current")) if cur is None else str(i) == cur}
257
+ kept = [r for r in rows if r]
258
+ if step in ("address", "addresses") and not kept and not errs:
259
+ errs.append("Please enter at least one city and state.")
260
+ if kept and not any(r["current"] for r in kept):
261
+ kept[0]["current"] = True
262
+ if not errs:
263
+ d.addresses = kept
264
+ elif step in ("about", "details", "birth", "phones", "emails", "relatives"):
265
+ if f.get("birth_year") is not None:
266
+ _apply_birth_year(d, str(f.get("birth_year")).strip(), errs)
267
+ # A posted "dob" is deliberately ignored (SEC-42): only a year is collected.
268
+ if f.get("age") is not None and f.get("birth_year") is None:
269
+ d.age = (f.get("age") or "").strip()
270
+ if f.get("phones") is not None:
271
+ d.phones = _lines(f.get("phones"))
272
+ if f.get("emails") is not None:
273
+ d.emails = _lines(f.get("emails"))
274
+ if f.get("relatives") is not None:
275
+ d.relatives = _name_lines(f.get("relatives"))
276
+ if d.dob:
277
+ try:
278
+ if date.fromisoformat(d.dob) > date.today():
279
+ errs.append("Date of birth is in the future.")
280
+ except ValueError:
281
+ errs.append("Date of birth should look like 1960-04-23.")
282
+ if d.age and (not d.age.isdigit() or not 0 <= int(d.age) <= 130):
283
+ errs.append("Age should be a number.")
284
+ if not errs and is_minor(d) and d.basis != ConsentBasis.GUARDIAN_OF_MINOR.value:
285
+ errs.append("This person is under 18, so a parent or guardian must give "
286
+ "permission. Go back to the first screen and choose the parent or "
287
+ "guardian option.")
288
+ elif step == "permissions":
289
+ d.allow_id_upload = bool(f.get("allow_id_upload"))
290
+ d.allow_account_creation = bool(f.get("allow_account_creation"))
291
+ d.allow_phone_disclosure = bool(f.get("allow_phone_disclosure"))
292
+ elif step == "mailbox":
293
+ prov = f.get("provider") or ("skip" if f.get("skip_mailbox") else "")
294
+ if prov == "skip":
295
+ d.skip_mailbox, d.mailbox_answered = True, True
296
+ elif prov == "outlook":
297
+ errs.append(OUTLOOK_MSG)
298
+ elif prov not in PRESETS:
299
+ errs.append("Please choose one of the options, or Skip.")
300
+ else:
301
+ d.skip_mailbox, d.provider = False, prov
302
+ errs += _apply_mailbox(d, f)
303
+ d.mailbox_answered = not errs
304
+ elif step == "mailbox_login":
305
+ d.skip_mailbox = False
306
+ errs += _apply_mailbox(d, f)
307
+ elif step == "email":
308
+ mode = f.get("email_mode", EmailMode.APPROVE_EACH.value)
309
+ if mode not in (EmailMode.APPROVE_EACH.value, EmailMode.AUTO.value):
310
+ errs.append("Please choose an email option.")
311
+ else:
312
+ d.email_mode = mode
313
+ elif step in ("schedule", "finish"):
314
+ d.weekly_recheck = bool(f.get("weekly_recheck"))
315
+ d.schedule_consent = d.weekly_recheck # ticking the box IS the consent
316
+ return errs
317
+
318
+
319
+ def _apply_birth_year(d: Draft, text: str, errs: list[str]) -> None:
320
+ if not text:
321
+ return
322
+ this_year = date.today().year
323
+ if not (text.isdigit() and len(text) == 4 and 1900 <= int(text) <= this_year):
324
+ errs.append("Birth year should be four digits, like 1952.")
325
+ return
326
+ y = int(text)
327
+ if d.dob and d.dob[:4] == text:
328
+ return # same year as the stored date of birth: keep the exact date
329
+ d.dob, d.age = "", str(this_year - y)
330
+
331
+
332
+ def birth_year_of(d: Draft) -> str:
333
+ if d.dob:
334
+ return d.dob[:4]
335
+ if d.age.isdigit():
336
+ return str(date.today().year - int(d.age))
337
+ return ""
338
+
339
+
340
+ UNENCRYPTED_MSG = ("Your password would be sent unprotected. Turn on SSL or STARTTLS "
341
+ "for incoming mail. If you are not sure, leave SSL ticked.")
342
+
343
+
344
+ def _apply_mailbox(d: Draft, f: FormLike, require_trust: bool = True) -> list[str]:
345
+ errs: list[str] = []
346
+ provider = f.get("provider") or d.provider or "other"
347
+ if provider == "outlook":
348
+ return [OUTLOOK_MSG]
349
+ if provider not in PRESETS:
350
+ provider = "other"
351
+ mb: dict[str, Any] = dict(PRESETS[provider])
352
+ mb["provider"] = provider if provider in STORED_PROVIDERS else "other"
353
+ mb["address"] = (f.get("address") or "").strip()
354
+ mb["username"] = (f.get("username") or "").strip() or mb["address"]
355
+ if provider == "other" or f.get("custom_hosts"):
356
+ try:
357
+ mb.update(imap_host=(f.get("imap_host") or "").strip(),
358
+ smtp_host=(f.get("smtp_host") or "").strip(),
359
+ imap_port=int(f.get("imap_port") or 993),
360
+ smtp_port=int(f.get("smtp_port") or 465),
361
+ imap_ssl=bool(f.get("imap_ssl")),
362
+ imap_starttls=bool(f.get("imap_starttls")),
363
+ smtp_starttls=bool(f.get("smtp_starttls")))
364
+ except ValueError:
365
+ errs.append("Ports must be numbers.")
366
+ if not (mb.get("imap_ssl") or mb.get("imap_starttls")):
367
+ errs.append(UNENCRYPTED_MSG)
368
+ if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", mb["address"]):
369
+ errs.append("Please enter the removal mailbox's email address.")
370
+ for k in ("imap_host", "smtp_host"):
371
+ if not _HOST_RE.match(mb.get(k) or ""):
372
+ errs.append("Please enter valid mail server names.")
373
+ break
374
+ pw = f.get("password") or ""
375
+ if pw:
376
+ d.password = pw
377
+ elif d.editing and d.mailbox and not d.password and (
378
+ mb["address"] != d.mailbox.get("address") or mb["imap_host"] != d.mailbox.get(
379
+ "imap_host")):
380
+ errs.append("You changed the email service, so please enter its app password.")
381
+ elif not d.password and not (d.editing and d.mailbox):
382
+ errs.append("Please enter the app password. (It is not your normal email password.)")
383
+ if not errs:
384
+ if is_loopback(mb["imap_host"]) or is_loopback(mb["smtp_host"]):
385
+ if d.tls_pin:
386
+ mb["tls_cert_sha256"] = d.tls_pin
387
+ elif require_trust:
388
+ errs.append("Please press \"Test that it works\" and approve the "
389
+ "security code first.")
390
+ else:
391
+ d.tls_pin = ""
392
+ if not errs:
393
+ d.mailbox = mb
394
+ return errs
395
+
396
+
397
+ # --------------------------------------------------------------------------- connection test
398
+
399
+
400
+ def test_mailbox_connection(mb: dict[str, Any], password: str, timeout: float = 10.0
401
+ ) -> tuple[bool, str]:
402
+ """Log in to IMAP and SMTP on exactly the hosts given. Contacts nothing else.
403
+
404
+ Returns (ok, plain-language message). The password never appears in the message.
405
+ """
406
+ ok, msg, _status = check_mailbox(mb, password, timeout)
407
+ return ok, msg
408
+
409
+
410
+ def check_mailbox(mb: dict[str, Any], password: str, timeout: float = 10.0
411
+ ) -> tuple[bool, str, str]:
412
+ """Like `test_mailbox_connection` plus a status: "ok", "fail", "needs_trust" (local mail
413
+ app with no approved certificate yet) or "cert_changed" (certificate differs from the
414
+ approved one). The password is only sent after the certificate check passes: a local app
415
+ without an approved pin is never contacted with credentials."""
416
+ for k in ("imap_host", "smtp_host"):
417
+ if not _HOST_RE.match(str(mb.get(k) or "")):
418
+ return False, "Please fill in the mail server names first.", "fail"
419
+ if not (mb.get("imap_ssl", True) or mb.get("imap_starttls")):
420
+ return False, UNENCRYPTED_MSG, "fail"
421
+ if not password:
422
+ return False, "Please enter the password first.", "fail"
423
+ pin = (mb.get("tls_cert_sha256") or None)
424
+ local = is_loopback(str(mb["imap_host"])) or is_loopback(str(mb["smtp_host"]))
425
+ if local and not pin:
426
+ return False, ("Free My ID needs you to approve this mail app's security code "
427
+ "first."), "needs_trust"
428
+
429
+ user = mb.get("username") or mb.get("address") or ""
430
+ try:
431
+ ih, ip = mb["imap_host"], int(mb["imap_port"])
432
+ ictx = client_context(ih, pin if is_loopback(ih) else None)
433
+ ipin = pin if is_loopback(ih) else None
434
+ if mb.get("imap_ssl", True):
435
+ im: imaplib.IMAP4 = imaplib.IMAP4_SSL(ih, ip, ssl_context=ictx, timeout=timeout)
436
+ else:
437
+ im = imaplib.IMAP4(ih, ip, timeout=timeout)
438
+ if mb.get("imap_starttls"):
439
+ im.starttls(ictx)
440
+ try:
441
+ check_peer(im.sock, ipin) # before any credential is sent
442
+ im.login(user, password)
443
+ finally:
444
+ try:
445
+ im.logout()
446
+ except Exception: # noqa: BLE001
447
+ log.debug("logout failed")
448
+ except TlsPinError:
449
+ return False, _CHANGED_MSG, "cert_changed"
450
+ except imaplib.IMAP4.error:
451
+ return False, ("The mail server did not accept that address and password. Please "
452
+ "check them (use an app password, not your normal one)."), "fail"
453
+ except (OSError, ssl.SSLError):
454
+ return False, unreachable_message(mb["imap_host"]), "fail"
455
+ try:
456
+ sh, sp = mb["smtp_host"], int(mb["smtp_port"])
457
+ spin = pin if is_loopback(sh) else None
458
+ sctx = client_context(sh, spin)
459
+ if mb.get("smtp_starttls"):
460
+ sm: smtplib.SMTP = smtplib.SMTP(sh, sp, timeout=timeout)
461
+ sm.ehlo()
462
+ sm.starttls(context=sctx)
463
+ sm.ehlo()
464
+ else:
465
+ sm = smtplib.SMTP_SSL(sh, sp, timeout=timeout, context=sctx)
466
+ try:
467
+ check_peer(sm.sock, spin) # before any credential is sent
468
+ sm.login(user, password)
469
+ finally:
470
+ try:
471
+ sm.quit()
472
+ except Exception: # noqa: BLE001
473
+ log.debug("quit failed")
474
+ except TlsPinError:
475
+ return False, _CHANGED_MSG, "cert_changed"
476
+ except (smtplib.SMTPException, OSError, ssl.SSLError):
477
+ msg = ("Incoming mail works, but sending did not. Please check the outgoing server "
478
+ "name and port, then press Test again.")
479
+ return False, msg, "fail"
480
+ return True, "Connection works. Free My ID can read and send from this mailbox.", "ok"
481
+
482
+
483
+ def unreachable_message(host: str) -> str:
484
+ """Calm text for a mail app/server that did not answer. Never includes an exception name."""
485
+ if is_loopback(host):
486
+ return ("Free My ID could not find Proton Mail Bridge on this computer. Please open "
487
+ "Proton Mail Bridge, sign in, wait until it says it is running, then press "
488
+ "Test again.")
489
+ return ("Free My ID could not reach that mail server. Please check the server name and "
490
+ "your internet connection, then press Test again.")
491
+
492
+
493
+ _CHANGED_MSG = ("The security code of your mail app is not the one you approved. Nothing was "
494
+ "sent. This can happen after Proton Mail Bridge is reinstalled or updated.")
495
+
496
+
497
+ # --------------------------------------------------------------------------- build + save
498
+
499
+
500
+ def build_profile(d: Draft) -> Profile:
501
+ if not d.consent_ack and d.existing_consent is None:
502
+ raise SaveError("Consent has not been given.")
503
+ minor = is_minor(d)
504
+ if d.existing_consent is not None and not d.consent_ack:
505
+ consent = d.existing_consent
506
+ else:
507
+ consent = Consent(basis=ConsentBasis(d.basis), attested_by=d.attested_by,
508
+ attested_at=d.consent_at or utcnow(), text_version=CONSENT_VERSION,
509
+ authorization_doc=(d.existing_consent.authorization_doc
510
+ if d.existing_consent else None))
511
+ if minor and consent.basis != ConsentBasis.GUARDIAN_OF_MINOR:
512
+ raise SaveError("A parent or guardian must give permission for someone under 18.")
513
+ mailbox = None
514
+ if d.mailbox and not d.skip_mailbox:
515
+ try:
516
+ mailbox = MailboxConfig(**d.mailbox, keyring_service="freemyid-mailbox")
517
+ except ValueError as e:
518
+ if not (d.mailbox.get("imap_ssl") or d.mailbox.get("imap_starttls")):
519
+ raise SaveError(UNENCRYPTED_MSG) from e
520
+ raise SaveError("The email settings look incomplete; please go back and "
521
+ "check them.") from e
522
+ try:
523
+ return Profile(
524
+ id=d.id, label=d.label or d.first, consent=consent,
525
+ name=PersonName(first=d.first, middle=d.middle or None, last=d.last,
526
+ suffix=d.suffix or None),
527
+ nicknames=d.nicknames,
528
+ previous_names=[parse_person_name(n) for n in d.previous_names],
529
+ addresses=[Address(**{k: (v or None) if k in ("street", "zip") else v # type: ignore[arg-type]
530
+ for k, v in a.items()}) for a in d.addresses],
531
+ dob=date.fromisoformat(d.dob) if d.dob else None,
532
+ age=int(d.age) if d.age and not d.dob else None,
533
+ phones=d.phones, emails=d.emails, relatives=d.relatives, is_minor=minor,
534
+ mailbox=mailbox, email_mode=EmailMode(d.email_mode),
535
+ permissions=Permissions(allow_id_upload=d.allow_id_upload,
536
+ allow_account_creation=d.allow_account_creation,
537
+ allow_phone_disclosure=d.allow_phone_disclosure),
538
+ schedule=ScheduleConfig(weekly_recheck=d.weekly_recheck, weekday=d.weekday,
539
+ hour=d.hour),
540
+ )
541
+ except ValueError as e: # pydantic ValidationError subclasses ValueError
542
+ raise SaveError("Some details are missing or invalid; please go back and check them."
543
+ ) from e
544
+
545
+
546
+ def save_authorization(store, pid: str, name: str, data: bytes) -> str:
547
+ """Encrypt the signed authorization form as evidence; return the evidence id."""
548
+ ev = Evidence(profile_id=pid, broker_id="_consent", kind=EvidenceKind.LETTER,
549
+ relpath=f"{pid}/consent/authorization-{new_id()[:8]}.bin", sha256="",
550
+ caption="Signed authorization (agent consent)")
551
+ ev = store.save_evidence(ev, data)
552
+ return ev.id
553
+
554
+
555
+ def save_draft(store, d: Draft, actor: Actor = Actor.WIZARD) -> Profile:
556
+ """Validate, store the profile, put the mailbox password in the keychain."""
557
+ from ..secrets import set_mailbox_password
558
+
559
+ prof = build_profile(d)
560
+ if d.auth_bytes:
561
+ doc = save_authorization(store, prof.id, d.auth_filename, d.auth_bytes)
562
+ prof = prof.model_copy(update={
563
+ "consent": prof.consent.model_copy(update={"authorization_doc": doc})})
564
+ if prof.mailbox and d.password:
565
+ try:
566
+ set_mailbox_password(prof.id, prof.mailbox, d.password)
567
+ except Exception as e:
568
+ raise SaveError("Could not store the mailbox password in this computer's "
569
+ "keychain. Unlock the keychain and try again.") from e
570
+ prior = store.get_profile(prof.id)
571
+ if prior:
572
+ prof = prof.model_copy(update={"created_at": prior.created_at})
573
+ store.save_profile(prof)
574
+ store.log(Event(profile_id=prof.id, actor=actor,
575
+ action="profile_updated" if d.editing else "profile_created",
576
+ detail={"consent_basis": prof.consent.basis.value,
577
+ "consent_text_version": prof.consent.text_version}))
578
+ d.password = ""
579
+ return prof
580
+
581
+
582
+ def _provider_of(p: Profile) -> str:
583
+ if not p.mailbox:
584
+ return "gmail"
585
+ for name, preset in PRESETS.items():
586
+ if preset["imap_host"] and preset["imap_host"] == p.mailbox.imap_host:
587
+ return name
588
+ return p.mailbox.provider or "other"
589
+
590
+
591
+ def _has_saved_password(p: Profile) -> bool:
592
+ """True only if the password is really in the secret store (never in the DB)."""
593
+ if not p.mailbox:
594
+ return False
595
+ try:
596
+ from ..secrets import get_mailbox_password
597
+ return bool(get_mailbox_password(p.id, p.mailbox))
598
+ except Exception: # noqa: BLE001 - a broken keychain must not break the settings page
599
+ return False
600
+
601
+
602
+ def draft_from_profile(p: Profile, *, keep_consent: bool = True) -> Draft:
603
+ d = Draft(provider=_provider_of(p), id=p.id, editing=True, label=p.label, first=p.name.first,
604
+ middle=p.name.middle or "", last=p.name.last, suffix=p.name.suffix or "",
605
+ nicknames=list(p.nicknames),
606
+ previous_names=[" ".join(x for x in (n.first, n.middle, n.last) if x)
607
+ for n in p.previous_names],
608
+ addresses=[{"street": a.street or "", "city": a.city, "state": a.state,
609
+ "zip": a.zip or "", "current": a.current} for a in p.addresses],
610
+ dob=p.dob.isoformat() if p.dob else "", age=str(p.age) if p.age is not None else "",
611
+ phones=list(p.phones), emails=list(p.emails), relatives=list(p.relatives),
612
+ allow_id_upload=p.permissions.allow_id_upload,
613
+ allow_account_creation=p.permissions.allow_account_creation,
614
+ allow_phone_disclosure=p.permissions.allow_phone_disclosure,
615
+ email_mode=p.email_mode.value, weekly_recheck=p.schedule.weekly_recheck,
616
+ weekday=p.schedule.weekday, hour=p.schedule.hour,
617
+ basis=p.consent.basis.value, attested_by=p.consent.attested_by)
618
+ if keep_consent:
619
+ d.existing_consent = p.consent
620
+ d.mailbox_answered = True
621
+ if p.mailbox:
622
+ d.saved_password = _has_saved_password(p)
623
+ d.mailbox = p.mailbox.model_dump(exclude={"keyring_service"})
624
+ d.tls_pin = p.mailbox.tls_cert_sha256 or ""
625
+ else:
626
+ d.skip_mailbox = True
627
+ return d
@@ -0,0 +1,42 @@
1
+ """First-run helper: install Playwright's Chromium only when no system Chrome/Edge exists.
2
+
3
+ Always asks first. Never runs during development or tests unless explicitly called.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _CANDIDATES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
13
+ "chrome", "msedge", "microsoft-edge"]
14
+ _PATHS = [
15
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
16
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
17
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
18
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
19
+ ]
20
+
21
+
22
+ def system_browser() -> str | None:
23
+ for n in _CANDIDATES:
24
+ if p := shutil.which(n):
25
+ return p
26
+ return next((p for p in _PATHS if Path(p).exists()), None)
27
+
28
+
29
+ def browser_install_command() -> list[str]:
30
+ return [sys.executable, "-m", "playwright", "install", "chromium"]
31
+
32
+
33
+ def ensure_browser(ask=input, run=subprocess.run) -> str:
34
+ """Returns 'system', 'declined' or 'installed'. `ask`/`run` are injectable for tests."""
35
+ if system_browser():
36
+ return "system"
37
+ ans = ask("Free My ID needs a web browser to fill in forms and none was found. Download a "
38
+ "small extra browser just for Free My ID (about 150 MB)? [y/N] ").strip().lower()
39
+ if not ans.startswith("y"):
40
+ return "declined"
41
+ run(browser_install_command(), check=True)
42
+ return "installed"