feat(lead-engine): Implement Teams notification and email enhancements [31988f42]

- Enhanced Teams Adaptive Card with precise email send time and re-added emojis to action buttons (" JETZT Aussenden", " STOP Aussendung").
- Modified email sending logic to include HTML signature from `signature.html` and an inline banner image from `RoboPlanetBannerWebinarEinladung.png`.
- Documented future enhancements in `lead-engine/README.md`:
    - Race-condition protection for calendar bookings with a live calendar check.
    - Integration of booking confirmation pages into the WordPress website (iFrame first, then API integration).
This commit is contained in:
2026-03-08 20:01:20 +00:00
parent 8c136174d1
commit 6d4a0564e6
5 changed files with 60 additions and 57 deletions

View File

@@ -76,7 +76,27 @@ docker exec lead-engine python /app/trading_twins/test_calendar_logic.py
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
## 📝 Credentials (.env)
## 📝 Zukünftige Erweiterungen & Todos
### Task: Race-Condition-Schutz bei Überbuchung
* **Problem:** Wenn mehrere Leads E-Mails mit denselben Terminvorschlägen erhalten, kann es zu "Race Conditions" kommen, bei denen mehrere Personen denselben Slot fast zeitgleich buchen.
* **Lösung:** Implementierung eines "Live-Checks" im Feedback-Server.
1. **Trigger:** Ein Nutzer klickt auf einen Buchungslink.
2. **Aktion:** Bevor der Termin im Kalender erstellt wird, sendet der Server eine *erneute* `getSchedule`-Anfrage an die Graph API für exakt diesen Zeit-Slot.
3. **Logik:**
* **Slot frei:** Der Termin wird wie geplant gebucht und der Job-Status auf `booked` gesetzt.
* **Slot belegt:** Der Nutzer erhält eine freundliche Nachricht ("Dieser Termin wurde gerade vergeben."). Idealerweise werden ihm dynamisch zwei neue, freie Termine vorgeschlagen, die er direkt auf der Seite buchen kann.
* **Ziel:** Sicherstellen, dass der Kalender die "Single Source of Truth" ist und doppelte Buchungen zuverlässig verhindert werden.
### Task: Integration der Buchungs-Seiten in WordPress
* **Ziel:** Eine nahtlose User Experience schaffen, bei der Termin-Bestätigungen auf der Haupt-Website (`robo-planet.de`) statt auf der direkten API-URL angezeigt werden.
* **Phase 1 (Kurzfristig): Einbettung via iFrame**
* **Umsetzung:** Eine Seite in WordPress anlegen und die URL des Feedback-Servers (z.B. `https://floke-ai.duckdns.org/feedback/book_slot/...`) in einem iFrame laden.
* **Vorteil:** Kein Programmieraufwand auf unserer Seite nötig, sofort umsetzbar.
* **Phase 2 (Langfristig): Native API-Integration**
* **Umsetzung:** Die Links in der E-Mail führen direkt zu einer WordPress-Seite (z.B. `robo-planet.de/termin-bestaetigen`). Ein Skript auf dieser Seite ruft im Hintergrund unsere `/book_slot` API auf.
* **Vorteil:** Perfekte Integration ins Corporate Design, volle Kontrolle über die Erfolgs- und Fehlermeldungen. Die API ist dafür bereits ausgelegt.
```env
# Info-Postfach (App 1 - Schreiben)

View File

@@ -28,6 +28,7 @@ DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com"
SIGNATURE_FILE_PATH = os.path.join(os.path.dirname(__file__), "signature.html")
BANNER_FILE_PATH = os.path.join(os.path.dirname(__file__), "RoboPlanetBannerWebinarEinladung.png")
# Credentials
AZURE_CLIENT_ID = os.getenv("INFO_Application_ID")
@@ -156,12 +157,25 @@ def book_slot(job_uuid: str, ts: int):
db.close(); return Response("Fehler bei Kalender.", 500)
# --- Workflow Logic ---
def send_email(subject, body, to_email, signature):
def send_email(subject, body, to_email, signature, banner_path=None):
attachments = []
if banner_path and os.path.exists(banner_path):
with open(banner_path, "rb") as f:
content_bytes = f.read()
content_b64 = base64.b64encode(content_bytes).decode("utf-8")
attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment",
"name": "RoboPlanetBannerWebinarEinladung.png",
"contentBytes": content_b64,
"isInline": True,
"contentId": "banner_image"
})
catchall = os.getenv("EMAIL_CATCHALL"); to_email = catchall if catchall else to_email
token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
if not token: return
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"message": {"subject": subject, "body": {"contentType": "HTML", "content": body + signature}, "toRecipients": [{"emailAddress": {"address": to_email}}]}, "saveToSentItems": "true"}
if attachments: payload["message"]["attachments"] = attachments
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
def process_lead(request_id, company, opener, receiver, name):
@@ -186,8 +200,10 @@ def process_lead(request_id, company, opener, receiver, name):
for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15)))
db.commit()
card = {"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": {"type": "AdaptiveCard", "version": "1.4", "body": [{"type": "TextBlock", "text": f"🤖 E-Mail an {company}?"}], "actions": [{"type": "Action.OpenUrl", "title": "STOP", "url": f"{FEEDBACK_SERVER_BASE_URL}/stop/{request_id}"},{"type": "Action.OpenUrl", "title": "JETZT", "url": f"{FEEDBACK_SERVER_BASE_URL}/send_now/{request_id}"}]}}]}
requests.post(TEAMS_WEBHOOK_URL, json=card)
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
# Using the more detailed card from teams_notification.py
from .teams_notification import send_approval_card
send_approval_card(job_uuid=request_id, customer_name=company, time_string=send_time.strftime("%H:%M"), webhook_url=TEAMS_WEBHOOK_URL, api_base_url=FEEDBACK_SERVER_BASE_URL)
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
while datetime.now(TZ_BERLIN) < send_time:
@@ -213,7 +229,7 @@ def process_lead(request_id, company, opener, receiver, name):
{booking_html}
"""
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig)
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver, sig, BANNER_FILE_PATH)
job.status = "sent"; db.commit(); db.close()
if __name__ == "__main__":

View File

@@ -1,40 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail Signatur</title>
</head>
<body>
<!--
HINWEIS:
Dieser Inhalt wird von der IT-Abteilung bereitgestellt.
Bitte den finalen HTML-Code hier einfügen.
Das Bild 'RoboPlanetBannerWebinarEinladung.png' muss sich im selben Verzeichnis befinden.
[31988f42]
-->
<p>Freundliche Grüße</p>
<p>
<b>Elizabeta Melcer</b><br>
Inside Sales Managerin
</p>
<p>
<!-- Wackler Logo -->
<b>RoboPlanet GmbH</b><br>
Freundliche Grüße<br>
Elizabeta Melcer<br>
Inside Sales Managerin<br>
<img src="https://www.robo-planet.de/wp-content/uploads/2023/07/Wackler_Logo.png" alt="Wackler Logo" width="100"><br>
RoboPlanet GmbH<br>
Schatzbogen 39, 81829 München<br>
T: +49 89 420490-402 | M: +49 175 8334071<br>
<a href="mailto:e.melcer@robo-planet.de">e.melcer@robo-planet.de</a> | <a href="http://www.robo-planet.de">www.robo-planet.de</a>
</p>
<p>
<a href="#">LinkedIn</a> | <a href="#">Instagram</a> | <a href="#">Newsletteranmeldung</a>
</p>
<p style="font-size: smaller; color: grey;">
e.melcer@robo-planet.de | www.robo-planet.de<br>
<a href="https://www.linkedin.com/company/roboplanet">LinkedIn</a> <a href="https://www.instagram.com/roboplanet.de/">Instagram</a> <a href="https://www.robo-planet.de/newsletter">Newsletteranmeldung</a><br>
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br>
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br>
<a href="#">Hinweispflichten zum Datenschutz</a>
</p>
<p>
<img src="RoboPlanetBannerWebinarEinladung.png" alt="RoboPlanet Webinar Einladung">
</p>
</body>
</html>
<a href="https://www.robo-planet.de/datenschutz">Hinweispflichten zum Datenschutz</a><br>
<img src="cid:banner_image" alt="RoboPlanet Webinar Einladung">

View File

@@ -6,13 +6,13 @@ from datetime import datetime
# Default-Webhook (Platzhalter) - sollte in .env stehen
DEFAULT_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL):
def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT_WEBHOOK_URL, api_base_url="http://localhost:8004"):
"""
Sendet eine Adaptive Card an Teams mit Approve/Deny Buttons.
"""
# Die URL unserer API (muss von außen erreichbar sein, z.B. via ngrok oder Server-IP)
api_base_url = os.getenv("API_BASE_URL", "http://localhost:8004")
card_payload = {
"type": "message",
@@ -27,33 +27,27 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT
"body": [
{
"type": "TextBlock",
"text": f"🤖 Automatisierte E-Mail an {customer_name}",
"text": f"🤖 Automatisierte E-Mail an {customer_name} (via Trading Twins) wird um {time_string} Uhr ausgesendet.",
"weight": "Bolder",
"size": "Medium"
},
{
"type": "TextBlock",
"text": f"(via Trading Twins) wird um {time_string} Uhr ausgesendet.",
"text": "Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.",
"isSubtle": True,
"wrap": True
},
{
"type": "TextBlock",
"text": "Wenn Du bis dahin NICHT reagierst, wird die E-Mail automatisch gesendet.",
"color": "Attention",
"wrap": True
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "✅ JETZT Aussenden",
"url": f"{api_base_url}/action/approve/{job_uuid}"
"url": f"{api_base_url}/send_now/{job_uuid}"
},
{
"type": "Action.OpenUrl",
"title": "❌ STOP Aussendung",
"url": f"{api_base_url}/action/cancel/{job_uuid}"
"url": f"{api_base_url}/stop/{job_uuid}"
}
]
}