feat: Implement unsubscribe link for marketing automation [31188f42]

This commit introduces a new unsubscribe feature to allow contacts to opt-out
from marketing automation.

Key changes include:
- Database schema migration: Added  (UUID) to the  model.
- Data population: Implemented a script to assign unique tokens to existing contacts.
- API endpoint: Created a public GET  endpoint to handle opt-out requests.
- Automation: New contacts automatically receive an unsubscribe token upon creation.
- Integration: The full unsubscribe link is now returned via the provisioning API
  for storage in SuperOffice UDFs (ProgID: SuperOffice:9).
- Documentation: Updated  and
  to reflect the new feature and its integration requirements.
- Added  for quick overview and next steps.
This commit is contained in:
2026-02-24 12:18:13 +00:00
parent 0fd67ecc91
commit 41920b6a84
10 changed files with 185 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel
from datetime import datetime
import os
import sys
import uuid
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
@@ -102,6 +103,7 @@ class ProvisioningResponse(BaseModel):
opener: Optional[str] = None # Primary opener (Infrastructure/Cleaning)
opener_secondary: Optional[str] = None # Secondary opener (Service/Logistics)
texts: Dict[str, Optional[str]] = {}
unsubscribe_link: Optional[str] = None
# Enrichment Data for Write-Back
address_city: Optional[str] = None
@@ -205,7 +207,69 @@ def on_startup():
except Exception as e:
logger.critical(f"Database init failed: {e}", exc_info=True)
# --- Routes ---
# --- Public Routes (No Auth) ---
from fastapi.responses import HTMLResponse
@app.get("/unsubscribe/{token}", response_class=HTMLResponse)
def unsubscribe_contact(token: str, db: Session = Depends(get_db)):
contact = db.query(Contact).filter(Contact.unsubscribe_token == token).first()
success_html = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Abmeldung erfolgreich</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 40px; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>Sie wurden erfolgreich abgemeldet.</h1>
<p>Sie werden keine weiteren Marketing-E-Mails von uns erhalten.</p>
</body>
</html>
"""
error_html = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Fehler bei der Abmeldung</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 40px; }
h1 { color: #d9534f; }
</style>
</head>
<body>
<h1>Abmeldung fehlgeschlagen.</h1>
<p>Der von Ihnen verwendete Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie uns bei Problemen direkt.</p>
</body>
</html>
"""
if not contact:
logger.warning(f"Unsubscribe attempt with invalid token: {token}")
return HTMLResponse(content=error_html, status_code=404)
if contact.status == "unsubscribed":
logger.info(f"Contact {contact.id} already unsubscribed, showing success page anyway.")
return HTMLResponse(content=success_html, status_code=200)
contact.status = "unsubscribed"
contact.updated_at = datetime.utcnow()
db.commit()
logger.info(f"Contact {contact.id} ({contact.email}) unsubscribed successfully via token.")
# Here you would trigger the sync back to SuperOffice in a background task
# background_tasks.add_task(sync_unsubscribe_to_superoffice, contact.id)
return HTMLResponse(content=success_html, status_code=200)
# --- API Routes ---
@app.get("/api/health")
def health_check(username: str = Depends(authenticate_user)):
@@ -328,7 +392,8 @@ def provision_superoffice_contact(
company_id=company.id,
so_contact_id=req.so_contact_id,
so_person_id=req.so_person_id,
status="ACTIVE"
status="ACTIVE",
unsubscribe_token=str(uuid.uuid4())
)
db.add(person)
logger.info(f"Created new person {req.so_person_id} for company {company.name}")
@@ -376,6 +441,11 @@ def provision_superoffice_contact(
texts["intro"] = matrix_entry.intro
texts["social_proof"] = matrix_entry.social_proof
# 6. Construct Unsubscribe Link
unsubscribe_link = None
if person and person.unsubscribe_token:
unsubscribe_link = f"{settings.APP_BASE_URL.rstrip('/')}/unsubscribe/{person.unsubscribe_token}"
return ProvisioningResponse(
status="success",
company_name=company.name,
@@ -385,6 +455,7 @@ def provision_superoffice_contact(
opener=company.ai_opener,
opener_secondary=company.ai_opener_secondary,
texts=texts,
unsubscribe_link=unsubscribe_link,
address_city=company.city,
address_street=company.street,
address_zip=company.zip_code,

View File

@@ -24,6 +24,9 @@ try:
# Paths
LOG_DIR: str = "/app/Log_from_docker"
# Public URL
APP_BASE_URL: str = "http://localhost:8090"
class Config:
env_file = ".env"
extra = 'ignore'

View File

@@ -107,6 +107,9 @@ class Contact(Base):
role = Column(String) # Operativer Entscheider, etc.
status = Column(String, default="") # Marketing Status
# New field for unsubscribe functionality
unsubscribe_token = Column(String, unique=True, index=True, nullable=True)
is_primary = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,44 @@
import uuid
import os
import sys
# This is the crucial part to fix the import error.
# We add the 'company-explorer' directory to the path, so imports can be absolute
# from the 'backend' module.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
from backend.database import Contact, SessionLocal
def migrate_existing_contacts():
"""
Generates and adds an unsubscribe_token for all existing contacts
that do not have one yet.
"""
db = SessionLocal()
try:
contacts_to_update = db.query(Contact).filter(Contact.unsubscribe_token == None).all()
if not contacts_to_update:
print("All contacts already have an unsubscribe token. No migration needed.")
return
print(f"Found {len(contacts_to_update)} contacts without an unsubscribe token. Generating tokens...")
for contact in contacts_to_update:
token = str(uuid.uuid4())
contact.unsubscribe_token = token
print(f" - Generated token for contact ID {contact.id} ({contact.email})")
db.commit()
print("\nSuccessfully updated all contacts with new unsubscribe tokens.")
except Exception as e:
print(f"An error occurred: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
print("Starting migration: Populating unsubscribe_token for existing contacts.")
migrate_existing_contacts()
print("Migration finished.")

View File

@@ -89,6 +89,17 @@ def migrate_tables():
""")
logger.info("Table 'reported_mistakes' ensured to exist.")
# 4. Update CONTACTS Table (Two-step for SQLite compatibility)
logger.info("Checking 'contacts' table schema for unsubscribe_token...")
contacts_columns = get_table_columns(cursor, "contacts")
if 'unsubscribe_token' not in contacts_columns:
logger.info("Adding column 'unsubscribe_token' to 'contacts' table...")
cursor.execute("ALTER TABLE contacts ADD COLUMN unsubscribe_token TEXT")
logger.info("Creating UNIQUE index on 'unsubscribe_token' column...")
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_unsubscribe_token ON contacts (unsubscribe_token)")
conn.commit()
logger.info("All migrations completed successfully.")