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:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
company-explorer/backend/scripts/add_unsubscribe_tokens.py
Normal file
44
company-explorer/backend/scripts/add_unsubscribe_tokens.py
Normal 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.")
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user