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
freemyid/wizard/draft.py
ADDED
|
@@ -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"
|