4 Commits

Author SHA1 Message Date
895e8b5c19 [31e88f42] Keine neuen Commits in dieser Session.
Keine neuen Commits in dieser Session.
2026-03-09 08:46:33 +00:00
67094b9822 docs(connector): [31e88f42] Document webhook de-duplication shield
Document the newly implemented de-duplication logic in the SuperOffice Connector README. This explains the problem of duplicate 'contact.created' webhooks from SuperOffice and how the worker now skips redundant jobs.
2026-03-09 08:42:22 +00:00
f859d7450b fix(connector): [31e88f42] Implement de-duplication for contact.created
SuperOffice sends two 'contact.created' webhooks for a single new contact. This caused the connector to process the same entity twice, leading to duplicate entries and logs.

This commit introduces a de-duplication shield in the worker:
- A new method  is added to  to check for jobs with the same company name that are either 'PROCESSING' or 'COMPLETED' within the last 5 minutes.
- The worker now fetches the company name upon receiving a job, updates the job record with the name, and then calls the new de-duplication method.
- If a duplicate  event is detected, the job is skipped, preventing redundant processing.
2026-03-09 08:39:35 +00:00
9edfa78c26 [31988f42] Lead-Engine: Produktivsetzung und Anfrage per Teams
Implementiert:
*   **End-to-End Test-Button pro Lead:** Ein neuer Button "🧪 Test-Versand (an floke.com@gmail.com)" wurde in der Lead-Detailansicht hinzugefügt, um spezifische Leads sicher zu testen.
*   **Verbesserte E-Mail-Generierung:**
    *   Der LLM-Prompt wurde optimiert, um redundante Termin-Vorschläge und Betreffzeilen im generierten E-Mail-Text zu vermeiden.
    *   Der E-Mail-Body wurde umstrukturiert für eine klarere und leserlichere Integration des LLM-generierten Textes und der dynamischen Terminvorschläge.
*   **HTML-Signatur mit Inline-Bildern:**
    *   Ein Skript zum Extrahieren von HTML-Signaturen und eingebetteten Bildern aus -Dateien wurde erstellt und ausgeführt.
    *   Die -Funktion wurde überarbeitet, um die neue HTML-Signatur und alle zugehörigen Bilder dynamisch als Inline-Anhänge zu versenden.
*   **Bugfixes und verbesserte Diagnosefähigkeit:**
    *   Der  für  wurde durch Verschieben der Funktion in den globalen Bereich behoben.
    *   Die  im Kalender-Abruf wurde durch die explizite Übergabe der Zeitzoneninformation an die Graph API korrigiert.
    *   Fehlende Uhrzeit in Teams-Nachrichten behoben.
    *   Umfassendes Logging wurde in kritischen Funktionen (, , ) implementiert, um die Diagnosefähigkeit bei zukünftigen Problemen zu verbessern.
2026-03-09 08:21:33 +00:00
16 changed files with 430 additions and 108 deletions

View File

@@ -1 +1 @@
{"task_id": "30388f42-8544-8088-bc48-e59e9b973e91", "token": "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", "readme_path": null, "session_start_time": "2026-03-08T14:55:15.337017"} {"task_id": "31e88f42-8544-8024-ad7c-da1733e94f9a", "token": "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", "readme_path": "connector-superoffice/README.md", "session_start_time": "2026-03-09T08:46:32.104282"}

View File

@@ -32,6 +32,12 @@ This directory contains Python scripts designed to integrate with the SuperOffic
* **Solution:** **Late Name Resolution**. The worker persists the resolved Company Name and Associate Shortname to the SQLite database as soon as it fetches them from SuperOffice. * **Solution:** **Late Name Resolution**. The worker persists the resolved Company Name and Associate Shortname to the SQLite database as soon as it fetches them from SuperOffice.
* **Status Priority:** Success (`COMPLETED`) now "outshines" subsequent ignored echos (`SKIPPED`). Once an account is green, it stays green in the dashboard cluster for 15 minutes. * **Status Priority:** Success (`COMPLETED`) now "outshines" subsequent ignored echos (`SKIPPED`). Once an account is green, it stays green in the dashboard cluster for 15 minutes.
#### 🛡️ D. Webhook De-Duplication (Contact Creation)
* **Problem:** SuperOffice occasionally sends *multiple* `contact.created` webhooks for the *same* new contact within a very short timeframe. This leads to duplicate processing jobs for the same entity.
* **Solution:** A **de-duplication shield** has been implemented in `worker.py` (March 2026).
* Before extensive processing, the worker now checks `connector_queue.db` via `queue_manager.check_for_recent_duplicate()`.
* If a `contact.created` event for the same company name is already `PROCESSING` or has been `COMPLETED` within the last 5 minutes, the current job is immediately `SKIPPED` as a duplicate.
### 3. Advanced API Handling (Critical Fixes) ### 3. Advanced API Handling (Critical Fixes)
* **OData Pagination:** We implemented `odata.nextLink` support (Manuel Zierl's advice) to correctly handle large result sets (>1000 records). * **OData Pagination:** We implemented `odata.nextLink` support (Manuel Zierl's advice) to correctly handle large result sets (>1000 records).

View File

@@ -185,6 +185,31 @@ class JobQueue:
except Exception as e: except Exception as e:
logger.critical(f"❌ CRITICAL: Failed to set job {job_id} to FAILED: {e}", exc_info=True) logger.critical(f"❌ CRITICAL: Failed to set job {job_id} to FAILED: {e}", exc_info=True)
conn.rollback() conn.rollback()
def check_for_recent_duplicate(self, entity_name: str, current_job_id: int) -> bool:
"""
Checks if another job with the same entity_name has been processed or is processing recently.
"""
five_minutes_ago = datetime.utcnow() - timedelta(minutes=5)
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
cursor = conn.cursor()
cursor.execute("""
SELECT id FROM jobs
WHERE entity_name = ?
AND id != ?
AND status IN ('COMPLETED', 'PROCESSING')
AND created_at >= ?
""", (entity_name, current_job_id, five_minutes_ago))
if cursor.fetchone():
logger.warning(f"Found recent duplicate job for entity '{entity_name}' (related to job {current_job_id}).")
return True
return False
except Exception as e:
logger.error(f"❌ Failed to check for duplicate jobs for entity '{entity_name}': {e}", exc_info=True)
return False # Fail safe
def get_stats(self): def get_stats(self):
with sqlite3.connect(DB_PATH, timeout=30) as conn: with sqlite3.connect(DB_PATH, timeout=30) as conn:
cursor = conn.cursor() cursor = conn.cursor()

View File

@@ -190,6 +190,13 @@ def process_job(job, so_client: SuperOfficeClient, queue: JobQueue):
aname = assoc.get("Name", "").upper().strip() aname = assoc.get("Name", "").upper().strip()
queue.update_entity_name(job['id'], crm_name, associate_name=aname) queue.update_entity_name(job['id'], crm_name, associate_name=aname)
# --- DE-DUPLICATION SHIELD (Added March 2026) ---
if "contact.created" in event_low:
if queue.check_for_recent_duplicate(crm_name, job['id']):
msg = f"Duplicate 'contact.created' event for '{crm_name}'. This job will be skipped."
logger.info(f"🛡️ {msg}")
return ("SKIPPED", msg)
# ROBOPLANET FILTER # ROBOPLANET FILTER
is_robo = False is_robo = False
if aname in settings.ROBOPLANET_WHITELIST: if aname in settings.ROBOPLANET_WHITELIST:

View File

@@ -1,4 +1,4 @@
# Lead Engine: Multi-Source Automation v1.4 [31988f42] # Lead Engine: Multi-Source Automation v2.2 [31988f42]
## 🚀 Übersicht ## 🚀 Übersicht
Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von B2B-Anfragen. Sie fungiert als Brücke zwischen dem E-Mail-Postfach und dem **Company Explorer**, um innerhalb von Minuten hochgradig personalisierte Antwort-Entwürfe auf "Human Expert Level" zu generieren. Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von B2B-Anfragen. Sie fungiert als Brücke zwischen dem E-Mail-Postfach und dem **Company Explorer**, um innerhalb von Minuten hochgradig personalisierte Antwort-Entwürfe auf "Human Expert Level" zu generieren.
@@ -23,7 +23,7 @@ Die **Lead Engine** ist ein spezialisiertes Modul zur autonomen Verarbeitung von
* **KI-Engine:** Gemini 2.0 Flash erstellt E-Mail-Entwürfe. * **KI-Engine:** Gemini 2.0 Flash erstellt E-Mail-Entwürfe.
* **Kontext:** Kombiniert Lead-Daten + CE-Daten + Matrix-Argumente (Pains/Gains). * **Kontext:** Kombiniert Lead-Daten + CE-Daten + Matrix-Argumente (Pains/Gains).
### 5. Trading Twins Autopilot (PRODUKTIV v2.1) ### 5. Trading Twins Autopilot (PRODUKTIV v2.2)
Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen. Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
* **Human-in-the-Loop:** Elizabeta Melcer erhält eine Teams-Nachricht ("Approve/Deny"). * **Human-in-the-Loop:** Elizabeta Melcer erhält eine Teams-Nachricht ("Approve/Deny").
@@ -44,36 +44,41 @@ Der vollautomatische "Zero Touch" Workflow für Trading Twins Anfragen.
├── trading_twins/ # Autopilot Modul ├── trading_twins/ # Autopilot Modul
│ ├── manager.py # Orchestrator, FastAPI, Graph API Logic │ ├── manager.py # Orchestrator, FastAPI, Graph API Logic
│ ├── test_calendar_logic.py # Interner Test für Kalender-Zugriff │ ├── test_calendar_logic.py # Interner Test für Kalender-Zugriff
│ └── signature.html # HTML-Signatur │ └── signature.html # HTML-Signatur (mit Bildern im selben Ordner)
└── db.py # Lokale SQLite Lead-Datenbank └── db.py # Lokale SQLite Lead-Datenbank
``` ```
## 🚨 Lessons Learned & Critical Fixes ## 🚨 Lessons Learned & Critical Fixes
### 1. Microsoft Graph API: Kalender-Zugriff ### 1. Microsoft Graph API: Kalender-Zugriff
* **Problem:** `debug_calendar.py` scheiterte oft mit `Invalid parameter`. * **Problem:** `debug_calendar.py` scheiterte oft mit `TimeZoneNotSupportedException`.
* **Ursache:** URL-Encoding von Zeitstempeln (`+` wurde zu Leerzeichen) und Mikrosekunden (7 Stellen statt 6). * **Ursache:** Der API-Aufruf zur Abfrage der Verfügbarkeit (`getSchedule`) hat keine explizite Zeitzoneninformation erhalten.
* **Lösung:** Nutzung von `requests(params=...)` und Abschneiden der Mikrosekunden. * **Lösung:** Die Zeitzone ("Europe/Berlin") wird nun explizit im `payload` des API-Aufrufs mitgegeben.
* **Endpoint:** `/users/{email}/calendar/getSchedule` (POST) ist robuster als `/calendarView` (GET).
### 2. Exchange AppOnly AccessPolicy (Buchungs-Workaround) ### 2. Exchange AppOnly AccessPolicy (Buchungs-Workaround)
* **Problem:** `Calendars.ReadWrite` erlaubt einer App oft nicht, Termine in *fremden* Kalendern (`e.melcer@`) zu erstellen (`403 Forbidden`). * **Problem:** `Calendars.ReadWrite` erlaubt einer App oft nicht, Termine in *fremden* Kalendern (`e.melcer@`) zu erstellen (`403 Forbidden`).
* **Lösung:** Der Termin wird im **eigenen Kalender** des Service-Accounts (`info@`) erstellt. Der Mitarbeiter (`e.melcer@`) wird als **Teilnehmer** hinzugefügt. Das umgeht die Policy. * **Lösung:** Der Termin wird im **eigenen Kalender** des Service-Accounts (`info@`) erstellt. Der Mitarbeiter (`e.melcer@`) wird als **Teilnehmer** hinzugefügt. Das umgeht die Policy.
### 3. Docker Environment Variables ### 3. Dynamische HTML-Signatur mit Inline-Bildern
* **Problem:** Skripte im Container fanden Credentials nicht, obwohl sie in `.env` standen. * **Problem:** Eine statische Signatur in der Konfiguration war unflexibel und konnte keine Bilder enthalten.
* **Lösung:** Explizites `load_dotenv` ist in Standalone-Skripten (`test_*.py`) nötig. Im Hauptprozess (`manager.py`) reicht `os.getenv`, solange Docker Compose die Vars korrekt durchreicht. * **Lösung:** Ein Skript (`scripts/extract_signature_assets.py`) extrahiert die vollständige HTML-Signatur und alle eingebetteten Bilder aus einer `.eml`-Datei. Die `send_email`-Funktion wurde überarbeitet, um alle Bilder dynamisch als Inline-Anhänge zu versenden, was eine professionelle Darstellung sicherstellt.
## 🚀 Inbetriebnahme ### 4. Advanced Debugging & Fehlerbehebung
* **Problem:** Hintergrund-Tasks schlugen ohne klare Fehlermeldung fehl, was die Diagnose erschwerte.
* **Lösung:** Umfassendes Logging wurde in allen kritischen Funktionen implementiert. Dadurch konnten Fehler wie ein `NameError` bei der Datumsformatierung und die `TimeZoneNotSupportedException` schnell identifiziert und behoben werden.
## 🚀 Inbetriebnahme & Test
### Inbetriebnahme
```bash ```bash
# Neustart des Dienstes # Neustart des Dienstes
docker-compose up -d --build --force-recreate lead-engine docker-compose up -d --build --force-recreate lead-engine
# Manueller Test (intern)
docker exec lead-engine python /app/trading_twins/test_calendar_logic.py
``` ```
### Test & Debugging
* **Allgemeiner Test:** Die URL `https://floke-ai.duckdns.org/feedback/test_lead` löst einen generischen Test-Lead aus.
* **Spezifischer Test pro Lead:** Im Lead-Tool (`/lead/`) kann für jeden Lead mit einem generierten E-Mail-Entwurf der Button "🧪 Test-Versand (an floke.com@gmail.com)" geklickt werden. Dies startet den gesamten End-to-End-Prozess (Teams-Nachricht & E-Mail-Versand) für den ausgewählten Lead, sendet die E-Mail aber sicher an die Test-Adresse.
**Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt) **Zugriff:** `https://floke-ai.duckdns.org/lead/` (Passwortgeschützt)
## 📝 Zukünftige Erweiterungen & Todos ## 📝 Zukünftige Erweiterungen & Todos

View File

@@ -50,6 +50,21 @@ st.title("🚀 Lead Engine: TradingTwins")
# Sidebar Actions # Sidebar Actions
st.sidebar.header("Actions") st.sidebar.header("Actions")
if st.sidebar.button("🚀 Trigger Test-Lead (Teams)"):
try:
import requests
# The feedback server runs on port 8004 inside the same container
response = requests.get("http://localhost:8004/test_lead")
if response.status_code == 202:
st.sidebar.success("Test lead triggered successfully!")
st.toast("Check Teams for the notification.")
else:
st.sidebar.error(f"Error: {response.status_code} - {response.text}")
except Exception as e:
st.sidebar.error(f"Failed to trigger test: {e}")
st.sidebar.divider()
if st.sidebar.button("1. Ingest Emails (Mock)"): if st.sidebar.button("1. Ingest Emails (Mock)"):
from ingest import ingest_mock_leads from ingest import ingest_mock_leads
init_db() init_db()
@@ -246,6 +261,24 @@ if not df.empty:
# Always display the draft from the database if it exists # Always display the draft from the database if it exists
if row.get('response_draft'): if row.get('response_draft'):
st.text_area("Email Entwurf", value=row['response_draft'], height=400) st.text_area("Email Entwurf", value=row['response_draft'], height=400)
if st.button("🧪 Test-Versand (an floke.com@gmail.com)", key=f"test_send_{row['id']}"):
try:
import requests
payload = {
"company_name": row['company_name'],
"contact_name": row['contact_name'],
"opener": row['response_draft']
}
response = requests.post("http://localhost:8004/test_specific_lead", json=payload)
if response.status_code == 202:
st.success("Specific test lead triggered!")
st.toast("Check Teams for the notification.")
else:
st.error(f"Error: {response.status_code} - {response.text}")
except Exception as e:
st.error(f"Failed to trigger test: {e}")
st.button("📋 Copy to Clipboard", key=f"copy_{row['id']}", on_click=lambda: st.write("Copy functionality simulated")) st.button("📋 Copy to Clipboard", key=f"copy_{row['id']}", on_click=lambda: st.write("Copy functionality simulated"))
else: else:
st.info("Sync with Company Explorer first to generate a response.") st.info("Sync with Company Explorer first to generate a response.")

View File

@@ -190,18 +190,16 @@ def generate_email_draft(lead_data, company_data, booking_link="[IHR BUCHUNGSLIN
- Strategischer Aufhänger (CE-Opener): {ce_opener} - Strategischer Aufhänger (CE-Opener): {ce_opener}
AUFGABE: AUFGABE:
1. ANREDE: Persönlich. 1. ANREDE: Erzeuge KEINE Anrede (wie "Sehr geehrter..."). Starte direkt mit dem ersten Satz.
2. EINSTIEG: Nutze den inhaltlichen Kern von: "{ce_opener}". 2. EINSTIEG: Nutze den inhaltlichen Kern von: "{ce_opener}".
3. DER ÜBERGANG: Verknüpfe dies mit der Anfrage zu {purpose}. Erkläre, dass manuelle Prozesse bei {qualitative_area} angesichts der Dokumentationspflichten und des Fachkräftemangels zum Risiko werden. 3. DER ÜBERGANG: Verknüpfe dies mit der Anfrage zu {purpose}. Erkläre, dass manuelle Prozesse bei {qualitative_area} angesichts der Dokumentationspflichten und des Fachkräftemangels zum Risiko werden.
4. DIE LÖSUNG: Schlage die Kombination aus {solution['solution_text']} als integriertes Konzept vor, um das Team in Reinigung, Service und Patientenansprache spürbar zu entlasten. 4. DIE LÖSUNG: Schlage die Kombination aus {solution['solution_text']} als integriertes Konzept vor, um das Team in Reinigung, Service und Patientenansprache spürbar zu entlasten.
5. ROI: Sprich kurz die Amortisation (18-24 Monate) an als Argument für den wirtschaftlichen Entscheider. 5. ROI: Sprich kurz die Amortisation (18-24 Monate) an als Argument für den wirtschaftlichen Entscheider.
6. CTA: Schlag konkret den {suggested_date} vor. Alternativ: {booking_link} 6. CTA: Schließe die E-Mail ab und leite zu den nächsten Schritten über, ohne direkt Termine vorzuschlagen oder nach Links zu fragen. Erzeuge KEINE Schlussformel (wie "Mit freundlichen Grüßen").
STIL: Senior, lösungsorientiert, direkt. Keine unnötigen Füllwörter. STIL: Senior, lösungsorientiert, direkt. Keine unnötigen Füllwörter.
FORMAT: FORMAT:
Betreff: [Prägnant, z.B. Automatisierungskonzept für {company_name}]
[E-Mail Text] [E-Mail Text]
""" """
@@ -214,7 +212,9 @@ def generate_email_draft(lead_data, company_data, booking_link="[IHR BUCHUNGSLIN
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
return result['candidates'][0]['content']['parts'][0]['text'] # Remove the placeholder from the LLM-generated text
cleaned_text = result['candidates'][0]['content']['parts'][0]['text'].replace(booking_link, '').strip()
return cleaned_text
except Exception as e: except Exception as e:
return f"Error generating draft: {str(e)}" return f"Error generating draft: {str(e)}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -9,11 +9,31 @@ from datetime import datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from threading import Thread, Lock from threading import Thread, Lock
import uvicorn import uvicorn
import logging
from fastapi import FastAPI, Response, BackgroundTasks from fastapi import FastAPI, Response, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
# --- Setup Logging ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
import msal import msal
from .models import init_db, ProposalJob, ProposedSlot from .models import init_db, ProposalJob, ProposedSlot
class TestLeadPayload(BaseModel):
company_name: str
contact_name: str
opener: str
def format_date_for_email(dt: datetime) -> str:
"""Formats a datetime object to 'Heute HH:MM', 'Morgen HH:MM', or 'DD.MM. HH:MM'."""
now = datetime.now(TZ_BERLIN).date()
if dt.date() == now:
return dt.strftime("Heute %H:%M Uhr")
elif dt.date() == (now + timedelta(days=1)):
return dt.strftime("Morgen %H:%M Uhr")
else:
return dt.strftime("%d.%m. %H:%M Uhr")
# --- Setup --- # --- Setup ---
TZ_BERLIN = ZoneInfo("Europe/Berlin") TZ_BERLIN = ZoneInfo("Europe/Berlin")
DB_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "trading_twins.db") DB_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "trading_twins.db")
@@ -48,32 +68,31 @@ def get_access_token(client_id, client_secret, tenant_id):
return result.get('access_token') return result.get('access_token')
def get_availability(target_email, app_creds): def get_availability(target_email, app_creds):
print(f"DEBUG: Requesting availability for {target_email}") logging.info(f"Requesting availability for {target_email}")
token = get_access_token(*app_creds) token = get_access_token(*app_creds)
if not token: if not token:
print("DEBUG: Failed to acquire access token.") logging.error("Failed to acquire access token for calendar.")
return None return None
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'} headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'}
start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0) start_time = datetime.now(TZ_BERLIN).replace(hour=0, minute=0, second=0, microsecond=0)
end_time = start_time + timedelta(days=3) end_time = start_time + timedelta(days=3)
# Use 15-minute intervals for finer granularity # Use 15-minute intervals for finer granularity
payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat()}, "endTime": {"dateTime": end_time.isoformat()}, "availabilityViewInterval": 15} payload = {"schedules": [target_email], "startTime": {"dateTime": start_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "endTime": {"dateTime": end_time.isoformat(), "timeZone": str(TZ_BERLIN)}, "availabilityViewInterval": 15}
try: try:
url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule" url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule"
r = requests.post(url, headers=headers, json=payload) r = requests.post(url, headers=headers, json=payload)
print(f"DEBUG: API Status Code: {r.status_code}") logging.info(f"Graph API getSchedule status code: {r.status_code}")
if r.status_code == 200: if r.status_code == 200:
view = r.json()['value'][0].get('availabilityView', '') view = r.json()['value'][0].get('availabilityView', '')
print(f"DEBUG: Availability View received (Length: {len(view)})") logging.info(f"Availability View received (Length: {len(view)})")
return start_time, view, 15 return start_time, view, 15
else: else:
print(f"DEBUG: API Error Response: {r.text}") logging.error(f"Graph API Error Response: {r.text}")
except Exception as e: except Exception as e:
print(f"DEBUG: Exception during API call: {e}") logging.error(f"Exception during Graph API call: {e}")
pass
return None return None
def find_slots(start, view, interval): def find_slots(start, view, interval):
@@ -135,6 +154,22 @@ def trigger_test_lead(background_tasks: BackgroundTasks):
background_tasks.add_task(process_lead, req_id, "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL, "Max Mustermann") background_tasks.add_task(process_lead, req_id, "Testfirma GmbH", "Wir haben Ihre Anfrage erhalten.", TEST_RECEIVER_EMAIL, "Max Mustermann")
return {"status": "Test lead triggered", "id": req_id} return {"status": "Test lead triggered", "id": req_id}
@app.post("/test_specific_lead", status_code=202)
def trigger_specific_test_lead(payload: TestLeadPayload, background_tasks: BackgroundTasks):
"""Triggers a lead process with specific data but sends email to the TEST_RECEIVER_EMAIL."""
req_id = f"test_specific_{int(time.time())}"
# Key difference: Use data from payload, but force the receiver email
background_tasks.add_task(
process_lead,
request_id=req_id,
company=payload.company_name,
opener=payload.opener,
receiver=TEST_RECEIVER_EMAIL, # <--- FORCED TEST EMAIL
name=payload.contact_name
)
return {"status": "Specific test lead triggered", "id": req_id}
@app.get("/stop/{job_uuid}") @app.get("/stop/{job_uuid}")
def stop(job_uuid: str): def stop(job_uuid: str):
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first()
@@ -157,80 +192,147 @@ def book_slot(job_uuid: str, ts: int):
db.close(); return Response("Fehler bei Kalender.", 500) db.close(); return Response("Fehler bei Kalender.", 500)
# --- Workflow Logic --- # --- Workflow Logic ---
def send_email(subject, body, to_email, signature, banner_path=None): def send_email(subject, body, to_email):
"""
Sends an email using Microsoft Graph API, attaching a dynamically generated
HTML signature with multiple inline images.
"""
logging.info(f"Preparing to send email to {to_email} with subject: '{subject}'")
# 1. Read the signature file
try:
with open(SIGNATURE_FILE_PATH, 'r', encoding='utf-8') as f:
signature_html = f.read()
except Exception as e:
logging.error(f"Could not read signature file: {e}")
signature_html = "" # Fallback to no signature
# 2. Find and prepare all signature images as attachments
attachments = [] attachments = []
if banner_path and os.path.exists(banner_path): image_dir = os.path.dirname(SIGNATURE_FILE_PATH)
with open(banner_path, "rb") as f: image_files = [f for f in os.listdir(image_dir) if f.startswith('image') and f.endswith('.png')]
for filename in image_files:
try:
with open(os.path.join(image_dir, filename), "rb") as f:
content_bytes = f.read() content_bytes = f.read()
content_b64 = base64.b64encode(content_bytes).decode("utf-8") content_b64 = base64.b64encode(content_bytes).decode("utf-8")
attachments.append({ attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment", "@odata.type": "#microsoft.graph.fileAttachment",
"name": "RoboPlanetBannerWebinarEinladung.png", "name": filename,
"contentBytes": content_b64, "contentBytes": content_b64,
"isInline": True, "isInline": True,
"contentId": "banner_image" "contentId": filename
}) })
except Exception as e:
logging.error(f"Could not process image {filename}: {e}")
# 3. Get access token
catchall = os.getenv("EMAIL_CATCHALL"); to_email = catchall if catchall else to_email 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) token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
if not token: return if not token:
logging.error("Failed to get access token for sending email.")
return
# 4. Construct and send the email
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} 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"} full_body = body + signature_html
if attachments: payload["message"]["attachments"] = attachments payload = {
requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload) "message": {
"subject": subject,
"body": {"contentType": "HTML", "content": full_body},
"toRecipients": [{"emailAddress": {"address": to_email}}]
},
"saveToSentItems": "true"
}
if attachments:
payload["message"]["attachments"] = attachments
response = requests.post(f"{GRAPH_API_ENDPOINT}/users/{SENDER_EMAIL}/sendMail", headers=headers, json=payload)
logging.info(f"Send mail API response status: {response.status_code}")
if response.status_code not in [200, 202]:
logging.error(f"Error sending mail: {response.text}")
def process_lead(request_id, company, opener, receiver, name): def process_lead(request_id, company, opener, receiver, name):
logging.info(f"--- Starting process_lead for request_id: {request_id} ---")
db = SessionLocal() db = SessionLocal()
try:
job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending") job = ProposalJob(job_uuid=request_id, customer_email=receiver, customer_company=company, customer_name=name, status="pending")
db.add(job); db.commit() db.add(job)
db.commit()
logging.info(f"Job {request_id} created and saved to DB.")
cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID)) cal_data = get_availability("e.melcer@robo-planet.de", (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID))
suggestions = find_slots(*cal_data) if cal_data else [] suggestions = find_slots(*cal_data) if cal_data else []
# --- FALLBACK LOGIC ---
if not suggestions: if not suggestions:
print("WARNING: No slots found via API. Creating fallback slots.") logging.warning(f"No slots found via API for job {request_id}. Creating fallback slots.")
now = datetime.now(TZ_BERLIN) now = datetime.now(TZ_BERLIN)
# Tomorrow 10:00
tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0) tomorrow = (now + timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0)
# Day after tomorrow 14:00
overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0) overmorrow = (now + timedelta(days=2)).replace(hour=14, minute=0, second=0, microsecond=0)
suggestions = [tomorrow, overmorrow] suggestions = [tomorrow, overmorrow]
# --------------------
for s in suggestions: db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s+timedelta(minutes=15))) logging.info(f"Found/created {len(suggestions)} slot suggestions for job {request_id}.")
for s in suggestions:
db.add(ProposedSlot(job_id=job.id, start_time=s, end_time=s + timedelta(minutes=15)))
db.commit() db.commit()
send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES) send_time = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
# Using the more detailed card from teams_notification.py logging.info(f"Sending Teams approval card for job {request_id}.")
from .teams_notification import send_approval_card 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_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) logging.info(f"Waiting for response or timeout until {send_time.strftime('%H:%M:%S')} for job {request_id}")
while datetime.now(TZ_BERLIN) < send_time: wait_until = datetime.now(TZ_BERLIN) + timedelta(minutes=DEFAULT_WAIT_MINUTES)
while datetime.now(TZ_BERLIN) < wait_until:
db.refresh(job) db.refresh(job)
if job.status in ["cancelled", "send_now"]: break if job.status in ["cancelled", "send_now"]:
logging.info(f"Status for job {request_id} changed to '{job.status}'. Exiting wait loop.")
break
time.sleep(5) time.sleep(5)
if job.status == "cancelled": db.close(); return db.refresh(job)
if job.status == "cancelled":
logging.info(f"Job {request_id} was cancelled. No email will be sent.")
return
logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.")
booking_html = "<ul>" booking_html = "<ul>"
for s in suggestions: booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{s.strftime("%d.%m %H:%M")}</a></li>' for s in suggestions:
booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{format_date_for_email(s)}</a></li>'
booking_html += "</ul>" booking_html += "</ul>"
try: try:
with open(SIGNATURE_FILE_PATH, 'r') as f: sig = f.read() with open(SIGNATURE_FILE_PATH, 'r') as f:
except: sig = "" sig = f.read()
except:
sig = ""
# Format the opener text into proper HTML paragraphs
opener_html = "".join([f"<p>{line}</p>" for line in opener.split('\n') if line.strip()])
# THIS IS THE CORRECTED EMAIL BODY
email_body = f""" email_body = f"""
<p>Hallo {name},</p> <p>Hallo {name},</p>
<p>{opener}</p> {opener_html}
<p>Hätten Sie an einem dieser Termine Zeit für ein kurzes Gespräch?</p> <p>Ich freue mich auf den Austausch und schlage Ihnen hierfür konkrete Termine vor:</p>
{booking_html} <ul>
""" {booking_html}
</ul>
<p>Mit freundlichen Grüßen,</p>
"""
send_email(f"Ihr Kontakt mit RoboPlanet - {company}", email_body, receiver)
job.status = "sent"
db.commit()
logging.info(f"--- Finished process_lead for request_id: {request_id} ---")
except Exception as e:
logging.error(f"FATAL error in process_lead for request_id {request_id}: {e}", exc_info=True)
finally:
db.close()
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__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8004) uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -1,13 +1,72 @@
Freundliche Grüße<br> <table class="MsoNormalTable" border="0" cellspacing="0" cellpadding="0" width="460" style="width:345.0pt">
Elizabeta Melcer<br> <tbody>
Inside Sales Managerin<br> <tr>
<img src="https://www.robo-planet.de/wp-content/uploads/2023/07/Wackler_Logo.png" alt="Wackler Logo" width="100"><br> </td>
RoboPlanet GmbH<br> </tr>
Schatzbogen 39, 81829 München<br> </tbody>
T: +49 89 420490-402 | M: +49 175 8334071<br> </table>
e.melcer@robo-planet.de | www.robo-planet.de<br> <p class="MsoNormal"><span style="display:none"><o:p>&nbsp;</o:p></span></p>
<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> <table class="MsoNormalTable" border="0" cellspacing="0" cellpadding="0" width="460" style="width:345.0pt;border-collapse:collapse">
Sitz der Gesellschaft München | Geschäftsführung: Axel Banoth<br> <tbody>
Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410<br> <tr>
<a href="https://www.robo-planet.de/datenschutz">Hinweispflichten zum Datenschutz</a><br> <td width="380" colspan="2" style="width:285.0pt;padding:0cm 0cm .75pt 0cm"></td>
<img src="cid:banner_image" alt="RoboPlanet Webinar Einladung"> </tr>
<tr>
<td width="222" style="width:166.5pt;padding:0cm 0cm .75pt 0cm">
<p class="MsoNormal"><a href="https://www.robo-planet.de/"><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:blue;text-decoration:none"><img border="0" width="203" height="58" style="width:2.1166in;height:.6083in" id="Bild_x0020_1" src="cid:image001.png" alt="Wackler Logo"></span></a><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><o:p></o:p></span></p>
</td>
<td style="padding:0cm 0cm 0cm 0cm"></td>
</tr>
<tr>
<td width="320" colspan="2" style="width:240.0pt;padding:3.75pt 0cm 3.75pt 0cm">
<p class="MsoNormal"><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">RoboPlanet GmbH</span><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><br>
</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">Schatzbogen 39, 81829 München</span><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><br>
</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">T:</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">
<a href="tel:+49%2089%20420490-402"><span style="color:#3D3C3F;text-decoration:none">+49 89 420490-402</span></a>
</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">| M:</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">
<a href="tel:+49%20175%208334071"><span style="color:#3D3C3F;text-decoration:none">+49 175 8334071</span></a><br>
<a href="mailto:e.melcer@robo-planet.de"><span style="color:#3D3C3F;text-decoration:none">e.melcer@robo-planet.de</span></a></span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">&nbsp;|</span><span style="font-size:8.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">
<a href="https://www.robo-planet.de"><span style="color:#3D3C3F;text-decoration:none">www.robo-planet.de</span></a>
</span><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><o:p></o:p></span></p>
</td>
</tr>
</tbody>
</table>
<p class="MsoNormal"><span style="display:none"><o:p>&nbsp;</o:p></span></p>
<table class="MsoNormalTable" border="0" cellspacing="0" cellpadding="0" width="460" style="width:345.0pt;border-collapse:collapse">
<tbody>
<tr>
<td width="24" style="width:18.0pt;padding:2.25pt 1.5pt 4.5pt 0cm">
<p class="MsoNormal"><a href="https://eur03.safelinks.protection.outlook.com/?url=https%3A%2F%2Fde.linkedin.com%2Fcompany%2Frobo-planet&amp;data=05%7C02%7Cc.godelmann%40robo-planet.de%7C2fb46eff763446be654708de79274f88%7C6d85a9ef3878420b8f4338d6cb12b665%7C0%7C0%7C639081406901590486%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=BbI3CP9VyHoPVpWOr5pnH1rMGr98M0fxtgfuxWqMmW4%3D&amp;reserved=0" originalsrc="https://de.linkedin.com/company/robo-planet"><span style="font-family:&quot;Verdana&quot;,sans-serif;color:blue;text-decoration:none"><img border="0" width="20" height="20" style="width:.2083in;height:.2083in" id="Bild_x0020_2" src="cid:image002.png" alt="LinkedIn"></span></a><span style="font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">&nbsp;</span><a href="https://eur03.safelinks.protection.outlook.com/?url=https%3A%2F%2Fwww.instagram.com%2Froboplanet.de%2F&amp;data=05%7C02%7Cc.godelmann%40robo-planet.de%7C2fb46eff763446be654708de79274f88%7C6d85a9ef3878420b8f4338d6cb12b665%7C0%7C0%7C639081406901647062%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=VSVFcj7Ceebb9mpIO70Lbsf98Li%2F9rhyqLlZgAW1j1I%3D&amp;reserved=0" originalsrc="https://www.instagram.com/roboplanet.de/"><span style="font-family:&quot;Verdana&quot;,sans-serif;color:blue;text-decoration:none"><img border="0" width="20" height="20" style="width:.2083in;height:.2083in" id="Bild_x0020_3" src="cid:image003.png" alt="Instagram"></span></a><span style="font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F">&nbsp;</span><a href="https://eur03.safelinks.protection.outlook.com/?url=https%3A%2F%2Frobo-planet.de%2Fnewsletter%2F&amp;data=05%7C02%7Cc.godelmann%40robo-planet.de%7C2fb46eff763446be654708de79274f88%7C6d85a9ef3878420b8f4338d6cb12b665%7C0%7C0%7C639081406901673306%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=PaPijR42wD4nP4bDnVO%2F4ldg2IaqD%2Bl6vmx6C9blxIs%3D&amp;reserved=0" originalsrc="https://robo-planet.de/newsletter/" title="&quot;&quot;"><span style="font-family:&quot;Verdana&quot;,sans-serif;color:blue;text-decoration:none"><img border="0" width="110" height="20" style="width:1.15in;height:.2083in" id="_x0030_.n7mdoupwgbb" src="cid:image004.png" alt="Newsletteranmeldung"></span></a><span style="font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><o:p></o:p></span></p>
</td>
</tr>
</tbody>
</table>
<p class="MsoNormal"><span style="display:none"><o:p>&nbsp;</o:p></span></p>
<table class="MsoNormalTable" border="0" cellspacing="0" cellpadding="0" width="460" style="width:345.0pt;border-collapse:collapse">
<tbody>
<tr>
<td style="padding:0cm 0cm 0cm 0cm">
<p class="MsoNormal"><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B">Sitz der Gesellschaft München&nbsp;|&nbsp;Geschäftsführung: Axel Banoth</span><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B"><br>
</span><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B">Registergericht AG München, HRB 296113 | USt.-IdNr. DE400464410</span><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B"><br>
<a href="https://eur03.safelinks.protection.outlook.com/?url=https%3A%2F%2Frobo-planet.de%2Fhinweispflichten%2F&amp;data=05%7C02%7Cc.godelmann%40robo-planet.de%7C2fb46eff763446be654708de79274f88%7C6d85a9ef3878420b8f4338d6cb12b665%7C0%7C0%7C639081406901692853%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=GHfn%2Fdye4Tzfw%2BeCeq%2BQXfrhOloPoA%2FH4RfLVKcbG40%3D&amp;reserved=0" originalsrc="https://robo-planet.de/hinweispflichten/"><span style="color:#0069B4">Hinweispflichten</span></a></span><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B">&nbsp;zum
Datenschutz</span><span style="font-size:7.5pt;font-family:&quot;Verdana&quot;,sans-serif;color:#9B9B9B">
<o:p></o:p></span></p>
</td>
</tr>
</tbody>
</table>
<p class="MsoNormal"><span style="display:none"><o:p>&nbsp;</o:p></span></p>
<table class="MsoNormalTable" border="0" cellspacing="0" cellpadding="0" width="460" style="width:345.0pt;border-collapse:collapse">
<tbody>
<tr>
<td style="padding:3.75pt 0cm 3.75pt 0cm">
<p class="MsoNormal"><a href="https://eur03.safelinks.protection.outlook.com/?url=https%3A%2F%2Frobo-planet.de%2Fpraxis-webinar-einsatz-von-robotikloesungen-im-einzelhandel%2F&amp;data=05%7C02%7Cc.godelmann%40robo-planet.de%7C2fb46eff763446be654708de79274f88%7C6d85a9ef3878420b8f4338d6cb12b665%7C0%7C0%7C639081406901714427%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=rIFrWwFgYwjSZ0pPvMyePr7oTejMHYiGG1SpY%2FGaINk%3D&amp;reserved=0" originalsrc="https://robo-planet.de/praxis-webinar-einsatz-von-robotikloesungen-im-einzelhandel/" title="&quot;Webinar Einzelhandel&quot;"><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:blue;text-decoration:none"><img border="0" width="460" height="100" style="width:4.7916in;height:1.0416in" id="_x0030_.zdrws9hgwm8" src="cid:image005.png" alt="RoboPlanetBannerWebinarEinladung.png"></span></a><span style="font-size:9.0pt;font-family:&quot;Verdana&quot;,sans-serif;color:#3D3C3F"><o:p></o:p></span></p>
</td>
</tr>
</tbody>
</table>
<p class="MsoNormal"><span style="font-size:10.0pt;font-family:&quot;Verdana&quot;,sans-serif"><o:p>&nbsp;</o:p></span></p>
</div>
</body>
</html>

View File

@@ -33,7 +33,7 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT
}, },
{ {
"type": "TextBlock", "type": "TextBlock",
"text": "Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.", "text": f"Wenn Du bis {time_string} Uhr NICHT reagierst, wird die generierte E-Mail automatisch ausgesendet.",
"isSubtle": True, "isSubtle": True,
"wrap": True "wrap": True
} }
@@ -60,5 +60,5 @@ def send_approval_card(job_uuid, customer_name, time_string, webhook_url=DEFAULT
response.raise_for_status() response.raise_for_status()
return True return True
except Exception as e: except Exception as e:
print(f"Fehler beim Senden an Teams: {e}") logging.error(f"Fehler beim Senden an Teams: {e}")
return False return False

View File

@@ -0,0 +1,85 @@
import email
from email.message import Message
import os
import re
# Define paths
eml_file_path = '/app/docs/FYI .eml'
output_dir = '/app/lead-engine/trading_twins/'
signature_file_path = os.path.join(output_dir, 'signature.html')
def extract_assets():
"""
Parses an .eml file to extract the HTML signature and its embedded images.
The images are saved to disk, and the HTML is cleaned up to use simple
Content-ID (cid) references for use with the Microsoft Graph API.
"""
if not os.path.exists(eml_file_path):
print(f"Error: EML file not found at {eml_file_path}")
return
with open(eml_file_path, 'r', errors='ignore') as f:
msg = email.message_from_file(f)
html_content = ""
images = {}
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition"))
if content_type == 'text/html' and "attachment" not in content_disposition:
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'Windows-1252'
try:
html_content = payload.decode(charset)
except (UnicodeDecodeError, AttributeError):
html_content = payload.decode('latin1')
if content_type.startswith('image/') and "attachment" not in content_disposition:
content_id = part.get('Content-ID', '').strip('<>')
filename = part.get_filename()
if filename and content_id:
images[filename] = {
"data": part.get_payload(decode=True),
"original_cid": content_id
}
if not html_content:
print("Error: Could not find HTML part in the EML file.")
return
# Isolate the signature part of the HTML
signature_start = html_content.find('Freundliche Gr')
if signature_start != -1:
# Step back to the start of the table containing the greeting
table_start = html_content.rfind('<table', 0, signature_start)
if table_start != -1:
signature_html = html_content[table_start:]
else:
signature_html = html_content # Fallback
else:
print("Warning: Could not find a clear starting point for the signature. Using full HTML body.")
signature_html = html_content
# Save images and update HTML content
print(f"Found {len(images)} images to process.")
for filename, image_info in images.items():
image_path = os.path.join(output_dir, filename)
with open(image_path, 'wb') as img_file:
img_file.write(image_info['data'])
print(f"Saved image: {image_path}")
# Replace the complex cid in the HTML with the simple filename, which will be the new Content-ID
signature_html = signature_html.replace(f"cid:{image_info['original_cid']}", f"cid:{filename}")
# Clean up some quoted-printable artifacts for better readability in the file
signature_html = signature_html.replace('=3D"', '="').replace('=\r\n', '')
with open(signature_file_path, 'w', encoding='utf-8') as f:
f.write(signature_html)
print(f"Saved new signature HTML to: {signature_file_path}")
if __name__ == "__main__":
extract_assets()