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 0c7088e5fd
commit 7c82e4b5d7
10 changed files with 185 additions and 4 deletions

View File

@@ -1 +1 @@
{"task_id": "31188f42-8544-806d-bd3c-e45991a3e8c8", "token": "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", "session_start_time": "2026-02-24T08:40:20.490166"} {"task_id": "31188f42-8544-80fa-8051-cef82ce7e4d3", "token": "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", "session_start_time": "2026-02-24T11:36:12.830368"}

View File

@@ -180,7 +180,21 @@ Contacts stehen in 1:n Beziehung zu Accounts. Accounts können einen "Primary Co
**Status (Marketing Automation):** **Status (Marketing Automation):**
* *Manuell:* Soft Denied, Bounced, Redirect, Interested, Hard denied. * *Manuell:* Soft Denied, Bounced, Redirect, Interested, Hard denied.
* *Automatisch:* Init, 1st Step, 2nd Step, Not replied. * *Automatisch:* Init, 1st Step, 2nd Step, Not replied, Unsubscribed.
### 6.1 Feature: Unsubscribe-Funktionalität (v2.1 - Feb 2026)
**Konzept:**
Um DSGVO-konforme Marketing-Automatisierung zu ermöglichen, wurde eine sichere Unsubscribe-Funktion implementiert.
**Technische Umsetzung:**
1. **Token:** Jeder Kontakt in der `contacts`-Tabelle erhält ein einzigartiges, nicht erratbares `unsubscribe_token` (UUID).
2. **Link-Generierung:** Der Company Explorer generiert einen vollständigen, personalisierten Link (z.B. `https://<APP_BASE_URL>/unsubscribe/<token>`).
3. **API-Endpunkt:** Ein öffentlicher GET-Endpunkt `/unsubscribe/{token}` nimmt Abmeldungen ohne Authentifizierung entgegen.
4. **Logik:**
* Bei Aufruf des Links wird der Status des zugehörigen Kontakts auf `"unsubscribed"` gesetzt.
* Der Benutzer erhält eine simple HTML-Bestätigungsseite.
5. **CRM-Integration:** Der generierte Link wird über die Provisioning-API an den `connector-superoffice` zurückgegeben, der ihn in ein entsprechendes UDF in SuperOffice schreibt.
## 7. Historie & Fixes (Jan 2026) ## 7. Historie & Fixes (Jan 2026)

View File

@@ -63,6 +63,7 @@ Folgende Felder sollten am Objekt `Company` (bzw. `Contact` in SuperOffice-Termi
| `AI Summary` | Text (Long/Memo) | Kurze Zusammenfassung der Analyse | | `AI Summary` | Text (Long/Memo) | Kurze Zusammenfassung der Analyse |
| `AI Last Update` | Date | Zeitstempel der letzten Anreicherung | | `AI Last Update` | Date | Zeitstempel der letzten Anreicherung |
| `AI Status` | List/Select | Pending / Enriched / Error | | `AI Status` | List/Select | Pending / Enriched / Error |
| `Unsubscribe-Link` | Text/Link | **NEU:** Speichert den personalisierten Link zur Abmeldung von der Marketing-Automation. (ProgID: SuperOffice:9) |
### Benötigtes Feld im Company Explorer ### Benötigtes Feld im Company Explorer
| Feldname | Typ | Zweck | | Feldname | Typ | Zweck |

View File

@@ -0,0 +1,33 @@
# Abschluss des Features "Unsubscribe-Link"
## Zusammenfassung der Implementierung
In dieser Session wurde eine vollständige, sichere Unsubscribe-Funktion für die Marketing-Automation im `company-explorer` implementiert. Dies umfasst ein Datenbank-Update mit sicheren Tokens, einen öffentlichen API-Endpunkt zur Abmeldung und die Integration in den SuperOffice-Provisionierungsprozess.
## Nächste technische Schritte zur Inbetriebnahme
Um das Feature vollständig zu nutzen, sind die folgenden Schritte im **connector-superoffice** und der **Infrastruktur** notwendig:
1. **Konfiguration der `APP_BASE_URL`:**
* **Was?** In der Konfiguration des `company-explorer` (z.B. in einer `.env`-Datei oder direkt in der `docker-compose.yml`) muss die Umgebungsvariable `APP_BASE_URL` gesetzt werden.
* **Warum?** Diese URL ist die öffentliche Basis-Adresse, die für den Bau des Unsubscribe-Links verwendet wird (z.B. `APP_BASE_URL="https://www.ihre-domain.de"`).
* **Beispiel (in `docker-compose.yml`):**
```yaml
services:
company-explorer:
# ...
environment:
- APP_BASE_URL=https://www.robo-planet.de
# ...
```
2. **Anpassung des `connector-superoffice` Workers:**
* **Was?** Der Worker-Prozess im `connector-superoffice`, der die Daten vom `company-explorer` empfängt, muss angepasst werden. Er muss das neue Feld `unsubscribe_link` aus der API-Antwort auslesen.
* **Warum?** Aktuell kennt der Connector dieses Feld noch nicht und würde es ignorieren.
* **Wo?** In der Datei `connector-superoffice/worker.py` (oder ähnlich), in der Funktion, die die `/provision`-Antwort verarbeitet.
3. **Schreiben des Links in das SuperOffice UDF:**
* **Was?** Die Logik im `connector-superoffice` Worker, die Daten nach SuperOffice schreibt, muss erweitert werden. Der ausgelesene `unsubscribe_link` muss in das von Ihnen angelegte Textfeld mit der ProgID `SuperOffice:9` geschrieben werden.
* **Warum?** Nur so wird der Link im CRM gespeichert und kann in E-Mail-Vorlagen verwendet werden.
* **Wo?** An der Stelle, an der die `SuperOfficeAPI.update_person` (oder eine ähnliche Funktion) mit den UDF-Daten aufgerufen wird.
Nach diesen drei Schritten ist der gesamte Prozess von der Generierung des Links bis zur Speicherung im CRM und der Nutzung in E-Mails funktionsfähig.

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel
from datetime import datetime from datetime import datetime
import os import os
import sys import sys
import uuid
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets import secrets
@@ -102,6 +103,7 @@ class ProvisioningResponse(BaseModel):
opener: Optional[str] = None # Primary opener (Infrastructure/Cleaning) opener: Optional[str] = None # Primary opener (Infrastructure/Cleaning)
opener_secondary: Optional[str] = None # Secondary opener (Service/Logistics) opener_secondary: Optional[str] = None # Secondary opener (Service/Logistics)
texts: Dict[str, Optional[str]] = {} texts: Dict[str, Optional[str]] = {}
unsubscribe_link: Optional[str] = None
# Enrichment Data for Write-Back # Enrichment Data for Write-Back
address_city: Optional[str] = None address_city: Optional[str] = None
@@ -205,7 +207,69 @@ def on_startup():
except Exception as e: except Exception as e:
logger.critical(f"Database init failed: {e}", exc_info=True) 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") @app.get("/api/health")
def health_check(username: str = Depends(authenticate_user)): def health_check(username: str = Depends(authenticate_user)):
@@ -328,7 +392,8 @@ def provision_superoffice_contact(
company_id=company.id, company_id=company.id,
so_contact_id=req.so_contact_id, so_contact_id=req.so_contact_id,
so_person_id=req.so_person_id, so_person_id=req.so_person_id,
status="ACTIVE" status="ACTIVE",
unsubscribe_token=str(uuid.uuid4())
) )
db.add(person) db.add(person)
logger.info(f"Created new person {req.so_person_id} for company {company.name}") 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["intro"] = matrix_entry.intro
texts["social_proof"] = matrix_entry.social_proof 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( return ProvisioningResponse(
status="success", status="success",
company_name=company.name, company_name=company.name,
@@ -385,6 +455,7 @@ def provision_superoffice_contact(
opener=company.ai_opener, opener=company.ai_opener,
opener_secondary=company.ai_opener_secondary, opener_secondary=company.ai_opener_secondary,
texts=texts, texts=texts,
unsubscribe_link=unsubscribe_link,
address_city=company.city, address_city=company.city,
address_street=company.street, address_street=company.street,
address_zip=company.zip_code, address_zip=company.zip_code,

View File

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

View File

@@ -107,6 +107,9 @@ class Contact(Base):
role = Column(String) # Operativer Entscheider, etc. role = Column(String) # Operativer Entscheider, etc.
status = Column(String, default="") # Marketing Status 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) is_primary = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) 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.") 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() conn.commit()
logger.info("All migrations completed successfully.") logger.info("All migrations completed successfully.")

View File

@@ -97,6 +97,7 @@ Der Connector ist der Bote, der diese Daten in das CRM bringt.
* `UDF_Bridge` * `UDF_Bridge`
* `UDF_Proof` * `UDF_Proof`
* `UDF_Subject` * `UDF_Subject`
* `UDF_UnsubscribeLink`
--- ---