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
File without changes
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ (function () {
3
+ document.documentElement.className += " js";
4
+
5
+ // Show or hide blocks depending on a radio choice: data-when="name:value1,value2".
6
+ var conds = document.querySelectorAll("[data-when]");
7
+ function sync() {
8
+ conds.forEach(function (el) {
9
+ var spec = el.getAttribute("data-when").split(":");
10
+ var checked = document.querySelector('input[name="' + spec[0] + '"]:checked');
11
+ el.hidden = !(checked && spec[1].split(",").indexOf(checked.value) !== -1);
12
+ });
13
+ }
14
+ if (conds.length) {
15
+ document.addEventListener("change", function (e) {
16
+ if (e.target && e.target.type === "radio") { sync(); }
17
+ });
18
+ sync();
19
+ }
20
+
21
+ var back = document.querySelector("[data-back]");
22
+ if (back && window.history.length > 1) {
23
+ back.addEventListener("click", function (e) { e.preventDefault(); window.history.back(); });
24
+ }
25
+
26
+ var btn = document.getElementById("test-conn");
27
+ if (btn) {
28
+ var out = document.getElementById("test-result");
29
+ var box = document.getElementById("trust-box");
30
+ var form = btn.form;
31
+ var post = function (url, body) {
32
+ return fetch(url, { method: "POST", credentials: "same-origin",
33
+ headers: { "X-CSRF": form.csrf.value }, body: body }).then(function (r) { return r.json(); });
34
+ };
35
+ var runTest = function () {
36
+ out.textContent = "Testing..."; out.className = ""; box.hidden = true;
37
+ post("/wizard/test-connection", new FormData(form)).then(function (j) {
38
+ if (j.status === "trust") {
39
+ out.textContent = "";
40
+ document.getElementById("trust-text").textContent = j.message;
41
+ document.getElementById("trust-code").textContent = j.display;
42
+ document.getElementById("trust-question").firstChild.textContent = j.question;
43
+ box.hidden = false;
44
+ document.getElementById("trust-yes").focus();
45
+ return;
46
+ }
47
+ out.textContent = j.message;
48
+ out.className = j.ok ? "note" : "error";
49
+ }).catch(function () { out.textContent = "The test did not finish."; out.className = "error"; });
50
+ };
51
+ btn.addEventListener("click", runTest);
52
+ document.getElementById("trust-yes").addEventListener("click", function () {
53
+ var fd = new FormData(); fd.append("answer", "yes");
54
+ post("/wizard/trust-certificate", fd).then(function (j) { if (j.ok) { runTest(); } });
55
+ });
56
+ document.getElementById("trust-no").addEventListener("click", function () {
57
+ var fd = new FormData(); fd.append("answer", "no");
58
+ post("/wizard/trust-certificate", fd).then(function (j) {
59
+ box.hidden = true; out.textContent = j.message; out.className = "error";
60
+ });
61
+ });
62
+ }
63
+
64
+ // Copy buttons: data-copy holds the text.
65
+ function copyText(text) {
66
+ if (navigator.clipboard && navigator.clipboard.writeText) {
67
+ return navigator.clipboard.writeText(text);
68
+ }
69
+ return new Promise(function (resolve, reject) {
70
+ var ta = document.createElement("textarea");
71
+ ta.value = text; ta.setAttribute("readonly", "");
72
+ ta.style.position = "fixed"; ta.style.opacity = "0";
73
+ document.body.appendChild(ta); ta.select();
74
+ var ok = false;
75
+ try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
76
+ document.body.removeChild(ta);
77
+ if (ok) { resolve(); } else { reject(); }
78
+ });
79
+ }
80
+ document.querySelectorAll("[data-copy]").forEach(function (el) {
81
+ el.addEventListener("click", function () {
82
+ var old = el.textContent;
83
+ copyText(el.getAttribute("data-copy")).then(function () {
84
+ el.textContent = "Copied!";
85
+ }, function () { el.textContent = "Select and copy by hand"; });
86
+ setTimeout(function () { el.textContent = old; }, 2000);
87
+ });
88
+ });
89
+
90
+ document.querySelectorAll("[data-print]").forEach(function (el) {
91
+ el.addEventListener("click", function () { window.print(); });
92
+ });
93
+ document.querySelectorAll("[data-confirm]").forEach(function (el) {
94
+ el.addEventListener("click", function (e) {
95
+ if (!window.confirm(el.getAttribute("data-confirm"))) { e.preventDefault(); }
96
+ });
97
+ });
98
+ })();
Binary file
@@ -0,0 +1,85 @@
1
+ :root { --fg:#1d2733; --bg:#f6f7f9; --card:#fff; --accent:#1b5fb4; --line:#c9d1da; --ok:#1d7a3a;
2
+ --warn:#9a5b00; --bad:#a8271f; }
3
+ * { box-sizing:border-box; }
4
+ body { font:22px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:var(--fg);
5
+ background:var(--bg); margin:0; }
6
+ header { background:var(--accent); color:#fff; padding:14px 28px; display:flex; gap:24px;
7
+ align-items:center; flex-wrap:wrap; }
8
+ header a { color:#fff; text-decoration:none; font-weight:600; }
9
+ header .brand { font-size:1.4em; }
10
+ main { max-width:860px; margin:24px auto; padding:0 20px 60px; }
11
+ .card { background:var(--card); border:1px solid var(--line); border-radius:12px;
12
+ padding:20px 24px; margin:18px 0; }
13
+ h1 { font-size:1.7em; margin:.4em 0; } h2 { font-size:1.3em; margin:.2em 0 .5em; }
14
+ label { display:block; font-weight:600; margin:14px 0 4px; }
15
+ input[type=text], input[type=email], input[type=password], input[type=number], input[type=date],
16
+ select, textarea, input[type=file] { width:100%; font:inherit; padding:10px 12px;
17
+ border:2px solid var(--line); border-radius:8px; background:#fff; }
18
+ input:focus, select:focus, textarea:focus, button:focus, a:focus { outline:3px solid #f2b705; }
19
+ .row { display:flex; gap:12px; flex-wrap:wrap; } .row > * { flex:1 1 140px; }
20
+ .check { display:flex; gap:12px; align-items:flex-start; font-weight:400; }
21
+ .check input { width:26px; height:26px; flex:none; margin-top:4px; }
22
+ button, .btn { font:inherit; font-weight:700; padding:12px 24px; border-radius:10px; border:0;
23
+ background:var(--accent); color:#fff; cursor:pointer; text-decoration:none; display:inline-block; }
24
+ button.secondary, .btn.secondary { background:#e6ebf1; color:var(--fg); }
25
+ button.danger { background:var(--bad); }
26
+ .hint { color:#4a5868; font-size:.85em; font-weight:400; }
27
+ .error { background:#fde8e6; border:2px solid var(--bad); border-radius:8px; padding:10px 16px;
28
+ margin:12px 0; }
29
+ .note { background:#fff7e0; border:2px solid #e0b64b; border-radius:8px; padding:10px 16px;
30
+ margin:12px 0; }
31
+ .steps { display:flex; gap:6px; flex-wrap:wrap; margin:0 0 12px; padding:0; list-style:none;
32
+ font-size:.7em; }
33
+ .steps li { padding:4px 10px; border-radius:20px; background:#e6ebf1; }
34
+ .steps li.cur { background:var(--accent); color:#fff; font-weight:700; }
35
+ .chip { display:inline-block; padding:2px 12px; border-radius:20px; font-size:.8em;
36
+ font-weight:700; background:#e6ebf1; }
37
+ .chip.removed { background:#d6f0dd; color:var(--ok); }
38
+ .chip.found, .chip.needs_human, .chip.reappeared, .chip.error { background:#fde8e6;
39
+ color:var(--bad); }
40
+ .chip.submitted, .chip.awaiting_verification, .chip.confirmed { background:#fff1cc;
41
+ color:var(--warn); }
42
+ table { border-collapse:collapse; width:100%; font-size:.85em; }
43
+ td, th { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
44
+ .gallery { display:flex; flex-wrap:wrap; gap:12px; }
45
+ .gallery img { max-width:240px; border:1px solid var(--line); border-radius:6px; }
46
+ .actions { display:flex; gap:12px; margin-top:22px; flex-wrap:wrap; }
47
+ form.card > .actions { position:sticky; bottom:0; background:var(--card); padding:12px 0;
48
+ border-top:1px solid var(--line); }
49
+ .printable { background:#fff; padding:30px; }
50
+ @media print { header, .noprint { display:none; } body { background:#fff; font-size:12pt; } }
51
+
52
+ .sr { position:absolute; left:-9999px; }
53
+ .dots { display:flex; gap:12px; justify-content:center; list-style:none; padding:0; margin:8px 0 0; }
54
+ .dots li { width:18px; height:18px; border-radius:50%; background:#d5dbe3; }
55
+ .dots li.done { background:#7fa6d9; } .dots li.cur { background:var(--accent); transform:scale(1.35); }
56
+ .stepcount { text-align:center; margin:4px 0 0; font-size:.8em; color:#4a5868; }
57
+ .lead { font-size:1.1em; }
58
+ .card.soft { background:#f0f4fa; }
59
+ .choice { border:2px solid var(--line); border-radius:10px; padding:12px 16px; margin:10px 0; }
60
+ .choice:has(input:checked) { border-color:var(--accent); background:#eef4fc; }
61
+ .choice .hint { display:block; }
62
+ .guide ol, .big-list { padding-left:1.4em; } .guide li, .big-list li { margin:.5em 0; }
63
+ details.more { margin:16px 0; } details.more summary { cursor:pointer; font-weight:600;
64
+ color:var(--accent); padding:6px 0; }
65
+ [hidden] { display:none !important; }
66
+ button.big, .btn.big { font-size:1.15em; padding:16px 34px; }
67
+ .status-line { font-size:1.5em; font-weight:700; margin:.6em 0; }
68
+ .status-line.ok { color:var(--ok); }
69
+ .todo { border-left:8px solid var(--accent); }
70
+ .todo h2 { margin-top:0; }
71
+ .todo .primary { font-size:1.3em; padding:18px 36px; }
72
+ .todo ol { padding-left:1.4em; } .todo ol li { margin:.5em 0; }
73
+ .copyrow { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin:8px 0;
74
+ padding:8px 12px; background:#f0f4fa; border-radius:8px; }
75
+ .copyrow .lbl { font-weight:700; min-width:7em; } .copyrow .val { flex:1 1 160px;
76
+ overflow-wrap:anywhere; }
77
+ .letter { white-space:pre-wrap; background:#fff; border:2px solid var(--line); border-radius:8px;
78
+ padding:12px 16px; max-height:300px; overflow:auto; font-size:.8em; }
79
+ .banner { background:#fff7e0; border:2px solid #e0b64b; border-radius:10px; padding:12px 18px;
80
+ margin:14px 0; }
81
+ p.big { font-size:1.3em; line-height:1.5; }
82
+
83
+ .code { font-family:Consolas,monospace; font-size:1.05em; font-weight:700; word-break:break-all;
84
+ background:#fff; border:2px solid #e0b64b; border-radius:8px; padding:10px 14px; }
85
+ [hidden] { display:none !important; }
freemyid/wizard/ui.py ADDED
@@ -0,0 +1,343 @@
1
+ """HTML rendering helpers. Plain server-side HTML, no external assets."""
2
+ from __future__ import annotations
3
+
4
+ from html import escape as _e
5
+ from typing import Any
6
+
7
+ from ..models import ListingState
8
+ from .draft import (
9
+ BASIS_LABELS,
10
+ CONSENT_TEXT,
11
+ CONSENT_VERSION,
12
+ STEP_TITLES,
13
+ STEPS,
14
+ Draft,
15
+ birth_year_of,
16
+ )
17
+
18
+ HONESTY = ("Good to know: a few sites need a step from you. Sites can put your details back "
19
+ "up, which is why Free My ID keeps checking. When a site says it has deleted your "
20
+ "details, that is true for that one site only. Free My ID works on people-search "
21
+ "sites. It does not change anything on Facebook or Google.")
22
+
23
+ CHIP_LABEL = {
24
+ ListingState.UNKNOWN: "not checked yet", ListingState.NOT_FOUND: "not found",
25
+ ListingState.FOUND: "your details are listed", ListingState.SUBMITTED: "asked",
26
+ ListingState.AWAITING_VERIFICATION: "waiting for the site to reply",
27
+ ListingState.CONFIRMED: "site confirmed", ListingState.REMOVED: "not listed now",
28
+ ListingState.REAPPEARED: "reappeared", ListingState.NEEDS_HUMAN: "needs you",
29
+ ListingState.ERROR: "error",
30
+ }
31
+ WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
32
+
33
+
34
+ def esc(v: Any) -> str:
35
+ return _e(str(v if v is not None else ""), quote=True)
36
+
37
+
38
+ def chip(state: ListingState) -> str:
39
+ return f'<span class="chip {esc(state.value)}">{esc(CHIP_LABEL.get(state, state.value))}</span>'
40
+
41
+
42
+ def layout(title: str, body: str, nav: bool = True, refresh: int = 0) -> str:
43
+ links = ""
44
+ if nav:
45
+ links = ('<a href="/">Home</a><a href="/outbox">Emails to approve</a>'
46
+ '<a href="/wizard/consent?new=1">Add a person</a>'
47
+ '<a href="/settings">Settings</a>')
48
+ return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
49
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
50
+ + (f'<meta http-equiv="refresh" content="{int(refresh)}">' if refresh else "")
51
+ + f'<title>{esc(title)} - Free My ID</title><link rel="stylesheet" href="/static/style.css">'
52
+ f'</head><body><header><a class="brand" href="/">Free My ID</a>{links}</header>'
53
+ f'<main>{body}</main><script src="/static/app.js"></script></body></html>')
54
+
55
+
56
+ def error_page(heading: str, lines: list[str]) -> str:
57
+ """Calm, plain-English page for a refused request (shown with its 403 status)."""
58
+ paras = "".join(f"<p>{esc(x)}</p>" for x in lines)
59
+ return layout("Please try again", (
60
+ f'<div class="card"><h1>{esc(heading)}</h1>{paras}'
61
+ '<p>Nothing was changed and nothing was sent.</p><div class="actions">'
62
+ '<a class="btn primary" href="/" data-back="1">Go back and try again</a></div></div>'),
63
+ nav=False)
64
+
65
+
66
+ def csrf_field(csrf: str) -> str:
67
+ return f'<input type="hidden" name="csrf" value="{esc(csrf)}">'
68
+
69
+
70
+ def errors_html(errs: list[str]) -> str:
71
+ return "".join(f'<div class="error" role="alert">{esc(x)}</div>' for x in errs)
72
+
73
+
74
+ def _inp(name: str, label: str, value: Any = "", typ: str = "text", hint: str = "",
75
+ req: bool = False, extra: str = "") -> str:
76
+ h = f'<span class="hint"> {esc(hint)}</span>' if hint else ""
77
+ r = " required" if req else ""
78
+ return (f'<label for="{name}">{esc(label)}{h}</label>'
79
+ f'<input id="{name}" name="{name}" type="{typ}" value="{esc(value)}"{r} {extra}>')
80
+
81
+
82
+ def _area(name: str, label: str, lines: list[str], hint: str = "") -> str:
83
+ h = f'<span class="hint"> {esc(hint)}</span>' if hint else ""
84
+ return (f'<label for="{name}">{esc(label)}{h}</label>'
85
+ f'<textarea id="{name}" name="{name}" rows="3">{esc(chr(10).join(lines))}</textarea>')
86
+
87
+
88
+ def _check(name: str, text: str, on: bool, hint: str = "") -> str:
89
+ h = f'<div class="hint">{esc(hint)}</div>' if hint else ""
90
+ return (f'<label class="check"><input type="checkbox" name="{name}" value="1"'
91
+ f'{" checked" if on else ""}><span>{esc(text)}{h}</span></label>')
92
+
93
+
94
+ def visible_steps(d: Draft) -> list[str]:
95
+ if d.editing: # editing skips consent and the mailbox (that lives in Settings)
96
+ return [s for s in STEPS if s not in ("consent", "mailbox")]
97
+ return list(STEPS)
98
+
99
+
100
+ def steps_bar(cur: str, d: Draft) -> str:
101
+ vs = visible_steps(d)
102
+ n = vs.index(cur) + 1 if cur in vs else 0
103
+ dots = "".join(f'<li class="{"cur" if s == cur else ("done" if vs.index(s) < n - 1 else "")}">'
104
+ f'<span class="sr">{esc(STEP_TITLES[s])}</span></li>' for s in vs)
105
+ return (f'<ol class="dots" aria-label="Progress">{dots}</ol>'
106
+ f'<p class="stepcount">Step {n} of {len(vs)}</p>')
107
+
108
+
109
+ def _radio(name: str, value: str, label: str, current: str, hint: str = "") -> str:
110
+ h = f'<span class="hint">{esc(hint)}</span>' if hint else ""
111
+ return (f'<label class="check choice"><input type="radio" name="{name}" value="{esc(value)}"'
112
+ f'{" checked" if value == current else ""}><span>{esc(label)}{h}</span></label>')
113
+
114
+
115
+ _AREA_HINT = "Separate with commas if there is more than one. You can leave this empty."
116
+
117
+ MAILBOX_CHOICES = (
118
+ ("gmail", "Gmail", "A free Gmail address made just for Free My ID works best."),
119
+ ("yahoo", "Yahoo Mail", ""),
120
+ ("icloud", "iCloud Mail (Apple)", ""),
121
+ ("proton_bridge", "Proton Mail (needs the Proton Bridge app)", ""),
122
+ ("other", "Another email service", ""),
123
+ ("outlook", "Outlook.com or Hotmail", "Not possible, see below."),
124
+ ("skip", "Skip, I'll do these by hand", ("Free My ID prepares each message and opens your "
125
+ "email for you. You press Send yourself.")),
126
+ )
127
+
128
+ # (title, steps) for each service. Plain words, one action per line.
129
+ MAILBOX_GUIDES: dict[str, tuple[str, list[str]]] = {
130
+ "gmail": ("How to get a Gmail app password (about 3 minutes)", [
131
+ ("Open a new browser tab and go to myaccount.google.com. Sign in to the Gmail "
132
+ "address you made for Free My ID."),
133
+ "Click Security on the left. Turn on 2-Step Verification if it is not already on.",
134
+ "Click the search box at the top and type: App passwords. Open it.",
135
+ "Type Free My ID as the name and click Create.",
136
+ ("Google shows a box of 16 letters. Copy them, then paste them in the Password box "
137
+ "below. This is not your usual Gmail password.")]),
138
+ "yahoo": ("How to get a Yahoo app password (about 3 minutes)", [
139
+ "Open a new browser tab and go to login.yahoo.com/account/security. Sign in.",
140
+ "Find Generate app password (or App passwords) and click it.",
141
+ "Type Free My ID as the app name and click Generate password.",
142
+ ("Yahoo shows a password. Copy it and paste it in the Password box below. This is "
143
+ "not your usual Yahoo password.")]),
144
+ "icloud": ("How to get an Apple app-specific password (about 3 minutes)", [
145
+ "Open a new browser tab and go to account.apple.com. Sign in.",
146
+ "Click Sign-In and Security, then App-Specific Passwords.",
147
+ "Click the plus button, type Free My ID, and click Create.",
148
+ ("Apple shows a password like abcd-efgh-ijkl-mnop. Copy it and paste it in the "
149
+ "Password box below.")]),
150
+ "proton_bridge": ("Using Proton Mail", [
151
+ ("Proton needs a free helper app called Proton Mail Bridge. Install it from "
152
+ "proton.me/mail/bridge and sign in. It must be running when Free My ID checks mail."),
153
+ "Bridge shows a username and a password just for programs. Copy them below."]),
154
+ "other": ("Using another email service", [
155
+ "Ask your email company for an app password (sometimes called a program password).",
156
+ "Their help page lists the mail server names. Fill them in below."]),
157
+ "outlook": ("Why Outlook.com does not work", [
158
+ "Microsoft no longer lets programs like Free My ID sign in to Outlook.com and Hotmail.",
159
+ ("Please choose Skip, I'll do these by hand, above. Free My ID will prepare each message "
160
+ "and open Outlook for you. You press Send. Or choose another service.")]),
161
+ }
162
+
163
+
164
+ def _when(name: str, values: str) -> str:
165
+ return f' data-when="{esc(name)}:{esc(values)}"'
166
+
167
+
168
+ def _screen_body(step: str, d: Draft) -> str:
169
+ if step == "consent":
170
+ radios = "".join(_radio("basis", v, t, d.basis) for v, t in BASIS_LABELS.items())
171
+ paras = "".join(f"<p>{esc(t)}</p>" for t in CONSENT_TEXT)
172
+ up = ('<div class="note"' + _when("basis", "authorized_agent") + '>If you are helping '
173
+ 'an adult, you need their signed permission. <a href="/wizard/authorization-form" '
174
+ 'target="_blank">Open the form to print</a>. Then attach a photo or scan of it '
175
+ 'here (or later, from their page).<label for="authorization">Photo or scan of the '
176
+ 'signed form</label><input id="authorization" name="authorization" type="file" '
177
+ 'accept="image/*,application/pdf"></div>')
178
+ return ('<p class="lead"><b>That post didn\'t stop anyone. This does.</b><br>'
179
+ 'Take your name off the websites that sell it.</p>'
180
+ '<p>Free My ID asks people-search sites (websites that publish names, '
181
+ 'addresses and phone numbers, also called data brokers) to delete your '
182
+ 'details, in the written form they have to answer. Then it keeps checking.</p>'
183
+ '<div class="card soft trust"><p><b>No account. No cloud. Never asks for your '
184
+ 'Social Security number, bank or card details. Free and open source. '
185
+ 'Uninstall anytime.</b></p></div>'
186
+ '<p class="hint">Free My ID works on people-search sites. It does not change '
187
+ 'what Facebook itself does with your account.</p>'
188
+ '<details class="more"><summary>Read the full wording</summary>'
189
+ '<div class="card soft">' + paras + '</div></details>'
190
+ '<h2>Who is this for?</h2>' + radios + up
191
+ + _inp("attested_by", "Type your full name to sign", d.attested_by,
192
+ req=True)
193
+ + _check("ack", "I have read this and I agree.", d.consent_ack)
194
+ + f'<p class="hint">Wording version {esc(CONSENT_VERSION)}. '
195
+ '<a href="/wizard/import">I have a family setup file</a></p>')
196
+ if step == "name":
197
+ return (_inp("first", "First name", d.first, req=True)
198
+ + _inp("middle", "Middle name (if any)", d.middle)
199
+ + _inp("last", "Last name", d.last, req=True)
200
+ + '<details class="more"><summary>Any other names? (nicknames, maiden name)'
201
+ '</summary>'
202
+ + _inp("suffix", "Jr., Sr., III", d.suffix)
203
+ + _area("nicknames", "Nicknames", d.nicknames, "Like Bill for William.")
204
+ + _area("previous_names", "Maiden or earlier names", d.previous_names,
205
+ "Full names, one per line. Leave empty if none.")
206
+ + _inp("label", "What should we call them on screen?", d.label,
207
+ hint="For example: Mom. Only shown on this computer.")
208
+ + "</details>")
209
+ if step == "address":
210
+ rows = list(d.addresses) + [{}] * 4
211
+
212
+ def row(i: int, req: bool) -> str:
213
+ a = rows[i]
214
+ return ('<div class="row">' + _inp(f"city{i}", "City", a.get("city", ""), req=req)
215
+ + _inp(f"state{i}", "State (2 letters, like IL)", a.get("state", ""),
216
+ req=req, extra='maxlength="2" size="3"') + "</div>")
217
+ return ('<p>A city and state is enough. People-search sites show the town, so that is what '
218
+ 'Free My ID looks for.</p>' + row(0, True)
219
+ + '<details class="more"' + (" open" if any(rows[1:4]) else "")
220
+ + '><summary>Have they lived anywhere else in the last 10 years?</summary>'
221
+ '<p class="hint">Websites often show old addresses too. Add up to three.</p>'
222
+ + row(1, False) + row(2, False) + row(3, False) + "</details>")
223
+ if step == "about":
224
+ return ('<div class="note">These details help Free My ID pick out the right listings. A site '
225
+ 'is only sent what its own listing already shows, plus the removal email address '
226
+ 'if you set one up. A birth year, relatives and personal email addresses are '
227
+ 'never sent.</div>'
228
+ + _inp("birth_year", "Year of birth", birth_year_of(d), typ="number",
229
+ hint="Four digits, like 1952.", extra='min="1900" max="2100"')
230
+ + _inp("phones", "Phone number", ", ".join(d.phones), hint=_AREA_HINT)
231
+ + _inp("emails", "Email address they use", ", ".join(d.emails), hint=_AREA_HINT)
232
+ + '<details class="more"><summary>Names of relatives (helps with common '
233
+ 'names)</summary>'
234
+ + _area("relatives", "Relatives (full names)", d.relatives,
235
+ "Websites often list relatives. One per line. Leave empty if unsure.")
236
+ + "</details>")
237
+ if step == "mailbox":
238
+ return _mailbox_body(d)
239
+ if step == "finish":
240
+ items = ["Free My ID looks for the person on people-search sites.",
241
+ "Where it can, it sends the request asking the site to delete the details.",
242
+ ("When a website needs a real person, a card appears with one big button "
243
+ "to help you."),
244
+ "Details can come back, so Free My ID keeps checking."]
245
+ lis = "".join(f"<li>{esc(x)}</li>" for x in items)
246
+ auto = ("" if d.editing else _check(
247
+ "weekly_recheck", "Yes, keep checking for me automatically", d.weekly_recheck,
248
+ "This adds one small Free My ID task to Windows (Task Scheduler) that runs once a "
249
+ "day, even when this window is closed. If the computer was off, it runs when "
250
+ "it is next on. Quiet unless it needs you. You can turn it off in Settings."))
251
+ from .. import config as cfgmod
252
+ practice = ("" if d.editing or cfgmod.load().real_submissions_enabled else
253
+ '<div class="note"><b>Practice mode is on.</b> Free My ID will look for '
254
+ 'listings but will not send anything to any site until you turn that on '
255
+ 'in Settings.</div>')
256
+ return (practice + f'<div class="card soft"><p><b>What happens next</b></p><ol class="big-list">'
257
+ f'{lis}</ol></div>' + auto)
258
+ return ""
259
+
260
+
261
+ def _mailbox_body(d: Draft) -> str:
262
+ mb = d.mailbox or {}
263
+ cur = ("skip" if d.skip_mailbox else d.provider) if d.mailbox_answered else ""
264
+ radios = "".join(_radio("provider", k, n, cur, h) for k, n, h in MAILBOX_CHOICES)
265
+ guides = ""
266
+ for k, (title, steps) in MAILBOX_GUIDES.items():
267
+ lis = "".join(f"<li>{esc(x)}</li>" for x in steps)
268
+ guides += (f'<div class="note guide"{_when("provider", k)}><b>{esc(title)}</b>'
269
+ f'<ol class="big-list">{lis}</ol></div>')
270
+ if d.saved_password:
271
+ pw_hint = "A password is saved. Leave empty to keep it."
272
+ elif d.password: # typed in this session, held in memory only until you finish
273
+ pw_hint = "Your password is ready but not saved yet. Leave empty to use it."
274
+ else:
275
+ pw_hint = "Paste the app password here."
276
+ creds = (f'<div{_when("provider", "gmail,yahoo,icloud,proton_bridge,other")}>'
277
+ + _inp("address", "Email address", mb.get("address", ""), typ="email")
278
+ + _inp("password", "App password", "", typ="password", hint=pw_hint))
279
+ creds += (f'<div{_when("provider", "proton_bridge")}>'
280
+ + _inp("username", "Bridge username", mb.get("username", "")) + "</div>")
281
+ creds += (f'<div{_when("provider", "other")}><div class="row">'
282
+ + _inp("imap_host", "Incoming mail server", mb.get("imap_host", ""))
283
+ + _inp("imap_port", "Port", mb.get("imap_port", 993), typ="number")
284
+ + '</div><div class="row">'
285
+ + _inp("smtp_host", "Outgoing mail server", mb.get("smtp_host", ""))
286
+ + _inp("smtp_port", "Port", mb.get("smtp_port", 465), typ="number") + "</div>"
287
+ + _check("imap_ssl", "Incoming is secure (SSL)", mb.get("imap_ssl", True))
288
+ + _check("imap_starttls", "Incoming uses STARTTLS (only if SSL is off)",
289
+ mb.get("imap_starttls", False),
290
+ "At least one of these two must be on, or your password would be "
291
+ "sent unprotected. If unsure, leave SSL ticked.")
292
+ + _check("smtp_starttls", "Outgoing uses STARTTLS", mb.get("smtp_starttls", False))
293
+ + "</div>")
294
+ creds += ('<div class="actions"><button type="button" class="secondary" id="test-conn">'
295
+ 'Test that it works</button></div><div id="test-result" role="status"></div>'
296
+ '<div id="trust-box" class="note" hidden role="alertdialog" aria-live="polite">'
297
+ '<p id="trust-text"></p><p class="code" id="trust-code"></p>'
298
+ '<p id="trust-question"><b></b></p><div class="actions">'
299
+ '<button type="button" class="big" id="trust-yes">Yes</button>'
300
+ '<button type="button" class="big secondary" id="trust-no">No</button>'
301
+ '</div></div>'
302
+ '<p class="hint">The test only talks to your email company. The password is kept '
303
+ "in your computer's protected store, not in Free My ID's files.</p></div>")
304
+ return ('<p class="lead">Free My ID can use a separate email address to send '
305
+ 'deletion requests and click the confirmation links for you. Pick yours, or skip it.</p>'
306
+ + radios + guides + creds)
307
+
308
+
309
+ def step_page(step: str, d: Draft, csrf: str, errs: list[str], summary: str = "") -> str:
310
+ body = summary or _screen_body(step, d)
311
+ multipart = ' enctype="multipart/form-data"' if step == "consent" else ""
312
+ vs = visible_steps(d)
313
+ i = vs.index(step) if step in vs else 0
314
+ back = (f'<a class="btn secondary big" href="/wizard/{vs[i - 1]}">Back</a>' if i > 0 else "")
315
+ last = step == "finish"
316
+ honesty = f'<p class="hint">{esc(HONESTY)}</p>' if i == 0 else "" # once, on the first screen
317
+ nxt = ("Save changes" if d.editing else "Finish") if last else "Next"
318
+ return layout(STEP_TITLES[step],
319
+ f'{steps_bar(step, d)}<h1>{esc(STEP_TITLES[step])}</h1>{errors_html(errs)}'
320
+ f'<form method="post" action="/wizard/{step}"{multipart} class="card" '
321
+ f'autocomplete="off">{csrf_field(csrf)}{body}'
322
+ f'<div class="actions">{back}<button class="big" type="submit">{nxt}</button>'
323
+ f'</div></form>{honesty}', nav=False)
324
+
325
+
326
+ def authorization_form(person: str, agent: str) -> str:
327
+ p, a = esc(person) or "______________________", esc(agent) or "______________________"
328
+ return layout("Authorization form", (
329
+ '<div class="printable"><h1>Authorization to act on my behalf</h1>'
330
+ f'<p>I, <b>{p}</b>, give <b>{a}</b> my permission to act for me. They may ask '
331
+ 'people-search websites (websites that publish names, addresses and phone numbers) and '
332
+ 'other companies that sell personal information to find, delete, and stop selling my '
333
+ 'personal information, including by making requests under state privacy laws, using the '
334
+ 'Free My ID software on their computer.</p>'
335
+ '<p>This permission covers requests to delete or stop selling my information only. '
336
+ 'It does not allow any payment, and it does not allow changes to my accounts on '
337
+ 'Facebook, Google or any other service. I may withdraw it at any time by telling '
338
+ 'them in writing.</p>'
339
+ '<p>Signature: ______________________________ Date: ________________</p>'
340
+ f'<p>Printed name: {p}</p><p>Agent signature: ____________________________</p>'
341
+ '<p class="noprint"><button data-print="1">Print this page</button></p>'
342
+ '</div>'), nav=False)
343
+