Track KEEP opt-in per attendee, skip retained emails on deletion

- emails.json now stores {email, token, retained} dicts instead of strings
- Confirmation email contains a personalised /keep/<token> link
- Clicking the link marks retained=True in emails.json and shows a
  confirmation page to the attendee
- Delete and Notify All skips retained emails entirely, only notifies
  and removes those with retained=False
- Admin page splits emails into Retained (opted in) and Pending deletion
  with colour-coded counts
- Added BASE_URL env var for constructing keep-links in emails
- Backward-compatible: old plain-string emails.json is migrated on load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 4.6 committed 2026-06-07 16:34:16 +02:00
1 parent b6907e5343
commit a4e36586d0
2 files changed
+115 -31

No files matched your search

+98 -25
View File
@@ -213,21 +213,44 @@ answers = load_answers()
EMAILS_FILE = os.path.join(os.path.dirname(__file__), 'emails.json')
def load_emails():
# emails.json stores a list of dicts: {email, token, retained}
# retained=True means the attendee clicked the keep link and must NOT be deleted automatically.
def load_email_records():
if os.path.exists(EMAILS_FILE):
try:
with open(EMAILS_FILE, 'r') as f:
return json.load(f)
data = json.load(f)
# backward-compat: old format was a plain list of strings
if data and isinstance(data[0], str):
return [{'email': e, 'token': str(uuid.uuid4()), 'retained': False} for e in data]
return data
except Exception:
pass
return []
def load_emails():
return [r['email'] for r in load_email_records()]
def store_email(email):
existing = load_emails()
if email not in existing:
existing.append(email)
records = load_email_records()
if any(r['email'] == email for r in records):
return None # already stored, return None so caller knows no token was generated
token = str(uuid.uuid4())
records.append({'email': email, 'token': token, 'retained': False})
with open(EMAILS_FILE, 'w') as f:
json.dump(existing, f)
json.dump(records, f)
return token
def mark_retained(token):
records = load_email_records()
for r in records:
if r['token'] == token:
r['retained'] = True
with open(EMAILS_FILE, 'w') as f:
json.dump(records, f)
return r['email']
return None
COMMENTS_FILE = os.path.join(os.path.dirname(__file__), 'comments.json')
@@ -267,6 +290,10 @@ MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USER = os.environ.get('MAIL_USER', '')
MAIL_PASS = os.environ.get('MAIL_PASS', '')
# Set this to your public URL so keep-links in emails work correctly.
# On PythonAnywhere: Web tab -> Environment variables -> BASE_URL = https://yourusername.pythonanywhere.com
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5001')
def get_respondents(snapshot):
"""Build per-respondent dicts using positional alignment across all questions."""
@@ -894,12 +921,14 @@ def generate_analysis_pdf():
return bytes(pdf.output())
def send_confirmation_email(to_email):
"""Sends an immediate data-retention notice to the attendee. Runs in a background thread."""
def send_confirmation_email(to_email, token):
"""Sends an immediate data-retention notice with a personalised keep-link. Runs in a background thread."""
if not MAIL_HOST or not MAIL_USER or not MAIL_PASS:
print(f'[email] SMTP not configured, skipping confirmation to {to_email}')
return
try:
keep_url = f'{BASE_URL}/keep/{token}'
msg = MIMEMultipart()
msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>'
msg['To'] = to_email
@@ -915,21 +944,32 @@ def send_confirmation_email(to_email):
<p>We have received your email address and will send you the full Innovation Challenge Arena analysis report after the event.</p>
<div style="background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:16px 20px;margin:20px 0">
<p style="font-weight:700;color:#92400e;margin:0 0 8px">Your data and your rights</p>
<p style="font-weight:700;color:#92400e;margin:0 0 8px">Your data and your rights (GDPR)</p>
<p style="color:#78350f;font-size:13px;margin:0;line-height:1.7">
Your email address is held by TXproducts GmbH solely for the purpose of sending you this report.
It will not be shared with or sold to any third party.
It will be permanently deleted by <strong>10 July 2026</strong> unless you ask us to keep it.
It will be permanently deleted by <strong>10 July 2026</strong> unless you choose to keep it on file.
</p>
</div>
<p style="font-size:14px;color:#4a5568;">
<strong>If you would like TXproducts to retain your contact details</strong> for future updates
and communications, simply reply to this email with the word <strong>KEEP</strong>.
If you do nothing, your email will be deleted by 10 July 2026 as stated.
<strong>Would you like TXproducts to keep your contact details</strong> for future updates and communications?
If yes, click the button below. If you do nothing, your email will be deleted by 10 July 2026 as stated.
</p>
<p style="font-size:14px;color:#4a5568;">
<div style="text-align:center;margin:24px 0;">
<a href="{keep_url}"
style="background:linear-gradient(135deg,#2563eb,#7c3aed);color:white;text-decoration:none;
padding:14px 32px;border-radius:10px;font-weight:700;font-size:15px;display:inline-block;">
Yes, keep my contact details
</a>
</div>
<p style="font-size:13px;color:#8a9baf;text-align:center;">
Or ignore this email and your data will be deleted automatically.
</p>
<p style="font-size:14px;color:#4a5568;margin-top:20px;">
To request immediate deletion or to exercise any of your rights under GDPR,
contact us at <a href="mailto:mblack@txproducts.de" style="color:#2563eb;">mblack@txproducts.de</a>.
</p>
@@ -1175,12 +1215,36 @@ def capture_email():
data = request.json
email = (data.get('email') or '').strip()
if email and '@' in email and '.' in email:
store_email(email)
t = threading.Thread(target=send_confirmation_email, args=(email,), daemon=True)
t.start()
token = store_email(email)
if token: # None means duplicate, no need to resend
t = threading.Thread(target=send_confirmation_email, args=(email, token), daemon=True)
t.start()
return jsonify({'ok': True})
# Attendee clicks the keep-link from their confirmation email to opt into retention
@app.route('/keep/<token>')
def keep_data(token):
email = mark_retained(token)
if email:
return f'''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Preference saved</title>
<style>body{{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8}}
.box{{background:white;border-radius:16px;padding:40px 48px;max-width:440px;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,.12)}}
h2{{color:#1a365d;margin-bottom:12px}}p{{color:#4a5568;line-height:1.7}}.tick{{font-size:48px;margin-bottom:16px}}</style>
</head><body><div class="box"><div class="tick">&#10003;</div>
<h2>Preference saved</h2>
<p>Your contact details will be kept on file by TXproducts GmbH.<br>
You will not receive a deletion notice.<br><br>
To change your mind at any time, email <a href="mailto:mblack@txproducts.de">mblack@txproducts.de</a>.</p>
</div></body></html>'''
return '''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Link not found</title></head>
<body style="font-family:sans-serif;text-align:center;padding:60px;color:#4a5568">
<p>This link has already been used or is no longer valid.</p>
</body></html>''', 404
# Sends the full PDF analysis report to every collected email address (run this after the conference)
@app.route('/admin/send_all_reports', methods=['POST'])
def send_all_reports():
@@ -1196,16 +1260,21 @@ def send_all_reports():
# Sends a deletion confirmation to every stored email, then wipes emails.json
@app.route('/admin/delete_and_notify', methods=['POST'])
def delete_and_notify():
emails = load_emails()
if not emails:
return jsonify({'ok': False, 'message': 'No emails to delete'})
records = load_email_records()
to_delete = [r for r in records if not r.get('retained', False)]
if not to_delete:
return jsonify({'ok': False, 'message': 'No emails to delete (all retained or none collected)'})
emails = to_delete # used by notify_then_delete closure below
def notify_then_delete():
for email in emails:
send_deletion_confirmation(email)
records = load_email_records()
to_delete = [r for r in records if not r.get('retained', False)]
to_keep = [r for r in records if r.get('retained', False)]
for r in to_delete:
send_deletion_confirmation(r['email'])
with open(EMAILS_FILE, 'w') as f:
json.dump([], f)
print(f'[email] emails.json cleared after notifying {len(emails)} address(es)')
json.dump(to_keep, f)
print(f'[email] Deleted {len(to_delete)} address(es), kept {len(to_keep)} retained address(es)')
t = threading.Thread(target=notify_then_delete, daemon=True)
t.start()
@@ -1299,7 +1368,11 @@ def admin():
'total': len(q_answers),
'counts': dict(Counter(q_answers).most_common())
})
return render_template('admin.html', processed=processed, emails=load_emails(), comments=load_comments())
email_records = load_email_records()
return render_template('admin.html', processed=processed,
email_records=email_records,
emails=load_emails(),
comments=load_comments())
# Clears all answers — used by the admin page reset button
+17 -6
View File
@@ -246,18 +246,29 @@
<div style="margin-top:28px;">
<div class="section-title">Emails collected (full report requests)</div>
<div style="background:white;border-radius:12px;border:1px solid #e8eef4;padding:16px 20px;">
{% if emails %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<div style="font-size:13px;color:#4a5568;">{{ emails | length }} address{{ 'es' if emails | length != 1 else '' }} collected</div>
{% if email_records %}
{% set pending = email_records | selectattr('retained', 'equalto', false) | list %}
{% set retained = email_records | selectattr('retained', 'equalto', true) | list %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<div style="font-size:13px;color:#4a5568;">{{ email_records | length }} total &nbsp;|&nbsp; <span style="color:#059669;font-weight:600;">{{ retained | length }} retained</span> &nbsp;|&nbsp; <span style="color:#e53e3e;font-weight:600;">{{ pending | length }} pending deletion</span></div>
<div style="display:flex;gap:8px;">
<button onclick="sendAllReports()" style="background:#7c3aed;color:white;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Send Full Report to All</button>
<button onclick="deleteAndNotify()" style="background:#e53e3e;color:white;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Delete and Notify All</button>
<button onclick="deleteAndNotify()" style="background:#e53e3e;color:white;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Delete Pending and Notify</button>
</div>
</div>
<div id="sendReportMsg" style="display:none;font-size:12px;color:#059669;margin-bottom:8px;font-weight:600;"></div>
{% for email in emails %}
<div style="font-size:13px;color:#1a365d;padding:6px 0;border-bottom:1px solid #f0f4f8;font-family:'SF Mono','Fira Code',monospace;">{{ email }}</div>
{% if retained %}
<div style="font-size:11px;font-weight:700;color:#059669;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:4px;">Retained (opted in)</div>
{% for r in retained %}
<div style="font-size:13px;color:#1a365d;padding:6px 0;border-bottom:1px solid #f0f4f8;font-family:'SF Mono','Fira Code',monospace;">{{ r.email }} <span style="font-size:11px;color:#059669;font-family:inherit;">&#10003; keeping</span></div>
{% endfor %}
{% endif %}
{% if pending %}
<div style="font-size:11px;font-weight:700;color:#e53e3e;text-transform:uppercase;letter-spacing:0.8px;margin-top:10px;margin-bottom:4px;">Pending deletion</div>
{% for r in pending %}
<div style="font-size:13px;color:#1a365d;padding:6px 0;border-bottom:1px solid #f0f4f8;font-family:'SF Mono','Fira Code',monospace;">{{ r.email }}</div>
{% endfor %}
{% endif %}
{% else %}
<div style="font-size:13px;color:#a0aec0;">No emails collected yet. They appear here when attendees request the full report on the analysis page.</div>
{% endif %}