Contact email: don't block Save on a slow send; redact reports still in flight

Second review follow-ups:
- Saving returns once the choice is stored; a send already under way picks up
  the newest change, or the background retry is woken.
- A problem report still being sent when its address is removed is logged as
  <removed>, checked under the same lock the removal holds.
- The prompt re-checks the privacy notice after fetching its state.
- docs/privacy.md: offline contact changes are sent later by themselves; the
  prompt never follows straight after the privacy notice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-30 10:29:17 +10:00
1 parent b8ed53f2ff
commit 3f0f09b138
5 files changed
+79 -24

No files matched your search

+5 -4
View File
@@ -141,8 +141,8 @@ are two separate choices, both off until you tick them:
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time (never while the first-run privacy notice is
showing). **No thanks** hides it for good, and it isn't
connected for the first time, and never while or straight after the
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
@@ -181,8 +181,9 @@ the environment that starts Frame Control. A copy run from a source checkout
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is only ever sent when you press its Send or Save button, so those still go
when you choose to send them; if you don't, nothing is sent.
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
## Update checks
+28
View File
@@ -30,6 +30,7 @@ class Contact(Base):
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
@@ -157,6 +158,33 @@ class Contact(Base):
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fc._removed.clear()
fr.send({**REPORT, "contact": "other@example.com", "contactFollowup": True})
self.assertIn("other@example.com", tm.SENT.read_text()) # other reports are logged as sent
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
+40 -16
View File
@@ -18,6 +18,7 @@ A change that can't be sent (offline) waits in the state file and is retried in
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
too: once it has been shown or dismissed it never comes back.
"""
import calendar
import json
import os
import re
@@ -37,6 +38,8 @@ RETRY_EVERY = 600
_lock = threading.RLock()
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
_wake = threading.Event()
_retrier = None
@@ -87,19 +90,24 @@ def _event(s):
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
def _send_pending():
"""Send the waiting change. True if nothing is left waiting."""
with _send_lock:
with _lock:
event = load()['pending']
if event is None:
return True
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
return False
_sent(event)
return True
def _send_pending(block=True):
"""Send what's waiting, including changes made while sending. True if nothing is left
waiting. Without block, a send already under way is left to pick up the newest change."""
if not _send_lock.acquire(blocking=block):
return False
try:
while True:
with _lock:
event = load()['pending']
if event is None:
return True
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
return False
_sent(event)
finally:
_send_lock.release()
def _sent(event):
@@ -119,6 +127,7 @@ def _sent(event):
def _forget_locally(email):
"""Take a removed address out of the log of what was sent (contact events and reports)."""
with frame_telemetry._lock:
_removed[email.lower()] = time.time()
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
hit = False
for e in rows:
@@ -130,6 +139,20 @@ def _forget_locally(email):
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
def redact_removed(event):
"""Before logging a report sent while its address was being removed: take the address out.
Call with frame_telemetry._lock held, so a removal can't slip between this and the log."""
p = event.get('properties') or {}
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
if removed_at is not None:
try:
started = calendar.timegm(time.strptime(event['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
except (KeyError, ValueError):
started = 0
if started <= removed_at:
p['contact'] = '<removed>'
def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
@@ -159,8 +182,8 @@ def save(body):
_forget_locally(old)
except OSError:
pass
if changed:
_send_pending()
if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it
return state()
@@ -189,7 +212,8 @@ def start():
_send_pending()
except Exception:
pass
time.sleep(RETRY_EVERY)
_wake.wait(RETRY_EVERY)
_wake.clear()
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
_retrier.start()
+3 -1
View File
@@ -128,7 +128,9 @@ def send(body):
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
frame_telemetry.record_sent([event])
with frame_telemetry._lock: # the lock a removal holds while wiping its address
frame_contact.redact_removed(event)
frame_telemetry.record_sent([event])
except OSError:
pass # it was sent; failing to log it here mustn't make the person send it again
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
+3 -3
View File
@@ -4015,13 +4015,13 @@ async function loadContact() {
try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt();
}
// One time only, never on top of the privacy notice, and only once the Frame has connected:
// checked at load and again whenever the Frame connects.
// One time only, only once the Frame has connected, and never on top of the privacy notice or
// straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects.
async function checkContactPrompt() {
if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return;
let s;
try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt) return;
if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").hidden) return;
$("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
}