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:
1 parent
b6907e5343
commit
a4e36586d0
2 files changed
+115
-31
No files matched your search
@@ -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">✓</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
@@ -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 | <span style="color:#059669;font-weight:600;">{{ retained | length }} retained</span> | <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;">✓ 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 %}
|
||||
|
||||
Reference in new issue
Block a user