diff --git a/.dev_session/SESSION_INFO b/.dev_session/SESSION_INFO index c53d7681f..3dc90e5f6 100644 --- a/.dev_session/SESSION_INFO +++ b/.dev_session/SESSION_INFO @@ -1 +1 @@ -{"task_id": "32788f42-8544-80e1-a13a-c26114cf9b34", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-08T16:39:28.516327"} \ No newline at end of file +{"task_id": "33e88f42-8544-80a2-8cee-e0b11287c523", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-10T21:51:09.122501"} \ No newline at end of file diff --git a/fotograf-de-scraper/README.md b/fotograf-de-scraper/README.md index b7f40b88a..36903fbcb 100644 --- a/fotograf-de-scraper/README.md +++ b/fotograf-de-scraper/README.md @@ -1,6 +1,6 @@ # Fotograf.de Scraper & Management UI -**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards & Shooting Schedule) +**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards, Shooting Schedule & **Gmail API Integration**) Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft. @@ -10,13 +10,13 @@ Der Service besteht aus zwei Hauptkomponenten: 1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):** * **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`. - * **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. + * **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. Speichert außerdem OAuth-Tokens (`GmailToken`) für persistente E-Mail-Sitzungen. * **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten). - * **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten. + * **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten sowie an die **Gmail API** für direkten E-Mail-Versand. 2. **Frontend (TypeScript / React / Vite / TailwindCSS):** * **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule). - * **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung). + * **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung, E-Mail-Kampagnen). ## ✨ Core Features @@ -36,16 +36,40 @@ Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftrags * Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt. * Füllt Lücken für nicht gebuchte Slots automatisch leer auf. -### Feature 3: Nachfass-E-Mails (Vollständig) -* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer. +### Feature 3: Nachfass-E-Mails & Gmail Direkt-Versand (Vollständig) +Identifizierung von potenziellen Käufern und automatisierter Kontakt. +* **Analyse-Logik:** Sucht nach Personen mit 0-1 Logins, die noch keine Bilder gekauft haben. +* **Supermailer Export:** Generierung einer fertigen CSV-Liste. +* **Direkter Gmail-Versand (Neu):** + * Volle OAuth 2.0 Integration. Einmaliger Login, das Refresh-Token hält die Sitzung aktiv. + * Inline-Editor für Betreff und HTML-Nachricht mit Live-Platzhaltern (`{Name Käufer}`, `{Kindernamen}`, `{LinksHTML}`). + * Massensendungs-API (Bulk Send) schickt Mails direkt über das verbundene Postfach. ### Feature 4: Verkaufs-Statistiken (Vollständig) * Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser. +--- + +## 🎯 Nächste Session: "Freigabeanfragen" (Feature 5) + +Das nächste große Ziel ist der automatische Versand von Freigabeanfragen via Gmail. + +**Der geplante Workflow (Bestätigt):** +Anstatt Bild für Bild auf `fotograf.de` zu prüfen, nutzen wir die bereits erteilte **Einwilligung aus Calendly**. +* **Logik:** Das System prüft die Calendly-Buchungen des Shootings auf die Frage *"Dürfen wir einige schöne Aufnahmen veröffentlichen?"*. +* Alle Kunden, die hier "Ja" (bzw. eine positive Antwort) angegeben haben, werden extrahiert und erhalten automatisiert über die Gmail-API die Freigabeanfrage. +* *Text & Template werden in der nächsten Sitzung definiert.* + +--- + ## 🛠️ Technische Details & Fixes (April 2026) +* **E-Mail Signatur:** Die offizielle HTML-Signatur von "Kinderfotos Erding" (inkl. Logo via Google Drive Link) ist hartcodiert hinterlegt und wird bei jedem Massen- oder Testversand unsichtbar im Hintergrund an den HTML-Body angehängt. +* **Gmail OAuth Architektur:** Strikt getrennt. App-Credentials (`client_id`, `client_secret`) liegen in der `.env`. User-Credentials (Access & Refresh Tokens) werden dynamisch in der SQLite-Datenbank (`GmailToken`) gespeichert. +* **Routing / Reverse Proxy:** + * Das Frontend (React/Vite) nutzt den `base: '/fotograf-de/'` Pfad, um Assets korrekt hinter dem Nginx-Proxy zu laden. + * API-Aufrufe nutzen relative Pfade (`/fotograf-de-api/`), welche von Nginx sauber zum internen Backend-Port `8000` umgeschrieben (`rewrite`) werden. Dies verhindert Mixed-Content und CORS-Fehler im Produktionsbetrieb. * **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`). * **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten. -* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente. ## 🚀 Deployment & Konfiguration @@ -55,8 +79,9 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein: * `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de. * `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly. +* `google_fotograf_client_id` / `google_fotograf_secret`: Die OAuth-App-Credentials aus der Google Cloud Console. +* `GOOGLE_REDIRECT_URI`: (Optional) Standard ist `https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback`. ### URLs & Ports -* **Scraper Frontend:** `http://192.168.178.6:3009` -* **Zentrales Dashboard:** `http://192.168.178.6:8090` -* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`. \ No newline at end of file +* **Produktion / Nginx:** `https://floke-ai.duckdns.org/fotograf-de/` +* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`. diff --git a/fotograf-de-scraper/backend/database.py b/fotograf-de-scraper/backend/database.py index 69b342acc..64d6d88f7 100644 --- a/fotograf-de-scraper/backend/database.py +++ b/fotograf-de-scraper/backend/database.py @@ -22,6 +22,12 @@ class Job(Base): account_type = Column(String, index=True) # 'kiga' or 'schule' last_updated = Column(DateTime, default=datetime.datetime.utcnow) +class GmailToken(Base): + __tablename__ = "gmail_tokens" + id = Column(Integer, primary_key=True) + token_json = Column(String) # Stores the full credentials JSON + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + Base.metadata.create_all(bind=engine) def get_db(): diff --git a/fotograf-de-scraper/backend/gmail_service.py b/fotograf-de-scraper/backend/gmail_service.py new file mode 100644 index 000000000..fdde08273 --- /dev/null +++ b/fotograf-de-scraper/backend/gmail_service.py @@ -0,0 +1,129 @@ +import os +import json +import logging +import datetime +from typing import Optional, List, Dict, Any +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build +from google.auth.transport.requests import Request +from sqlalchemy.orm import Session +from database import GmailToken +import base64 +from email.mime.text import MIMEText + +logger = logging.getLogger("gmail-service") + +# Scopes required for sending emails +SCOPES = ['https://www.googleapis.com/auth/gmail.send'] + +class GmailService: + def __init__(self, db: Session): + self.db = db + self.client_id = os.getenv("google_fotograf_client_id") + self.client_secret = os.getenv("google_fotograf_secret") + + # Redirect URI - must match what was configured in Google Console + # We try to detect the public URL, fallback to duckdns + self.redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback") + + def _get_client_config(self) -> Dict[str, Any]: + return { + "web": { + "client_id": self.client_id, + "project_id": "fotograf-tool", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": self.client_secret, + "redirect_uris": [self.redirect_uri] + } + } + + def get_auth_url(self) -> str: + flow = Flow.from_client_config( + self._get_client_config(), + scopes=SCOPES, + redirect_uri=self.redirect_uri + ) + auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline') + return auth_url + + def handle_callback(self, code: str): + flow = Flow.from_client_config( + self._get_client_config(), + scopes=SCOPES, + redirect_uri=self.redirect_uri + ) + flow.fetch_token(code=code) + credentials = flow.credentials + self._save_token(credentials) + return credentials + + def _save_token(self, credentials): + token_data = { + 'token': credentials.token, + 'refresh_token': credentials.refresh_token, + 'token_uri': credentials.token_uri, + 'client_id': credentials.client_id, + 'client_secret': credentials.client_secret, + 'scopes': credentials.scopes + } + + db_token = self.db.query(GmailToken).first() + if not db_token: + db_token = GmailToken(token_json=json.dumps(token_data)) + self.db.add(db_token) + else: + db_token.token_json = json.dumps(token_data) + + self.db.commit() + logger.info("Gmail OAuth token saved to database.") + + def get_credentials(self) -> Optional[Credentials]: + db_token = self.db.query(GmailToken).first() + if not db_token: + return None + + token_data = json.loads(db_token.token_json) + creds = Credentials.from_authorized_user_info(token_data, SCOPES) + + if creds and creds.expired and creds.refresh_token: + logger.info("Gmail token expired, refreshing...") + creds.refresh(Request()) + self._save_token(creds) + + return creds + + def is_authenticated(self) -> bool: + try: + creds = self.get_credentials() + return creds is not None and creds.valid + except Exception as e: + logger.error(f"Auth check failed: {e}") + return False + + def send_email(self, to: str, subject: str, body_html: str) -> bool: + creds = self.get_credentials() + if not creds: + logger.error("Cannot send email: Not authenticated.") + return False + + try: + service = build('gmail', 'v1', credentials=creds) + message = MIMEText(body_html, 'html') + message['to'] = to + message['subject'] = subject + + raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode() + + send_result = service.users().messages().send( + userId='me', + body={'raw': raw_message} + ).execute() + + logger.info(f"Email sent to {to}. Message ID: {send_result['id']}") + return True + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + return False diff --git a/fotograf-de-scraper/backend/main.py b/fotograf-de-scraper/backend/main.py index 62bff1bfd..9fc1000ed 100644 --- a/fotograf-de-scraper/backend/main.py +++ b/fotograf-de-scraper/backend/main.py @@ -746,14 +746,16 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str): from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from typing import List, Dict, Any, Optional +from pydantic import BaseModel from sqlalchemy.orm import Session from database import get_db, Job as DBJob, engine, Base import math import uuid from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types +from gmail_service import GmailService # --- API Endpoints --- @@ -914,6 +916,31 @@ async def download_latest_file(): async def health_check(): return {"status": "ok"} +# --- Gmail API Endpoints --- + +@app.get("/api/auth/google") +async def get_google_auth_url(db: Session = Depends(get_db)): + service = GmailService(db) + return {"url": service.get_auth_url()} + +@app.get("/api/auth/callback") +async def google_auth_callback(code: str, db: Session = Depends(get_db)): + service = GmailService(db) + try: + service.handle_callback(code) + # Redirect back to frontend + # The frontend lives at /fotograf-de/ through NGINX + frontend_url = os.getenv("FRONTEND_URL", "https://floke-ai.duckdns.org/fotograf-de/") + return RedirectResponse(url=frontend_url) + except Exception as e: + logger.error(f"Auth callback failed: {e}") + return JSONResponse(status_code=500, content={"message": f"Authentifizierung fehlgeschlagen: {str(e)}"}) + +@app.get("/api/gmail/status") +async def get_gmail_status(db: Session = Depends(get_db)): + service = GmailService(db) + return {"authenticated": service.is_authenticated()} + @app.get("/api/jobs", response_model=List[Dict[str, Any]]) async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)): logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})") @@ -1034,6 +1061,34 @@ async def download_task_csv(task_id: str): logger.error(f"Export error: {e}") raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.") +class BulkEmailRequest(BaseModel): + emails: List[Dict[str, str]] + +@app.post("/api/gmail/send-bulk") +async def send_bulk_emails(request: BulkEmailRequest, db: Session = Depends(get_db)): + service = GmailService(db) + if not service.is_authenticated(): + raise HTTPException(status_code=401, detail="Gmail nicht authentifiziert.") + + success_count = 0 + failed_emails = [] + + for email_data in request.emails: + to = email_data.get("to") + subject = email_data.get("subject") + body = email_data.get("body") + + if service.send_email(to, subject, body): + success_count += 1 + else: + failed_emails.append(to) + + return { + "total": len(request.emails), + "success": success_count, + "failed": failed_emails + } + @app.get("/api/jobs/{job_id}/generate-pdf") async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)): logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})") diff --git a/fotograf-de-scraper/backend/requirements.txt b/fotograf-de-scraper/backend/requirements.txt index f3893db76..537d5e558 100644 --- a/fotograf-de-scraper/backend/requirements.txt +++ b/fotograf-de-scraper/backend/requirements.txt @@ -12,3 +12,6 @@ requests==2.31.0 reportlab==4.0.9 PyPDF2==3.0.1 tzdata +google-api-python-client==2.122.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.0 diff --git a/fotograf-de-scraper/frontend/src/App.tsx b/fotograf-de-scraper/frontend/src/App.tsx index bd329d26c..e803f1a5e 100644 --- a/fotograf-de-scraper/frontend/src/App.tsx +++ b/fotograf-de-scraper/frontend/src/App.tsx @@ -42,8 +42,64 @@ function App() { const [reminderProgress, setReminderProgress] = useState(''); const [isReminderRunning, setIsReminderRunning] = useState(false); const [latestFile, setLatestFile] = useState(null); + const [isGmailAuthenticated, setIsGmailAuthenticated] = useState(false); + + // Email States + const [reminderResult, setReminderResult] = useState(null); + const [emailSubject, setEmailSubject] = useState("Fotos von {Kindernamen}"); + const [emailBody, setEmailBody] = useState("Hallo {Name Käufer},

deine Fotos sind fertig und warten auf dich! Klicke einfach auf die Links unten, um direkt zu den Galerien zu gelangen:

{LinksHTML}

Viel Spaß beim Anschauen!"); + const [isSendingEmails, setIsSendingEmails] = useState(false); + const [emailSendStatus, setEmailSendStatus] = useState(null); - const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002'; + // Email Signature (Cleaned up from user input) + const SIGNATURE_HTML = ` +

+ --
+
+ + + + + + + +
+ Kinderfotos Erding Logo + +

Kinderfotos Erding | www.kinderfotos-erding.de

+

Gartenstr. 10 | 85445 Oberding | 08122-8470867

+
+
+ `; + + // If we are on duckdns, use the relative path, otherwise use local IP + const API_BASE_URL = window.location.hostname.includes('duckdns.org') + ? '/fotograf-de-api' + : (import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002'); + + const checkGmailAuth = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/gmail/status`); + if (response.ok) { + const data = await response.json(); + setIsGmailAuthenticated(data.authenticated); + } + } catch (err) { + console.error("Failed to check Gmail auth status"); + } + }; + + const handleGmailLogin = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/auth/google`); + if (response.ok) { + const data = await response.json(); + window.location.href = data.url; + } + } catch (err) { + setError("Konnte Gmail-Anmeldung nicht starten."); + } + }; const fetchLatestFile = async () => { try { @@ -83,6 +139,7 @@ function App() { fetchJobs(activeTab, false); } fetchLatestFile(); + checkGmailAuth(); }, [activeTab]); const handleRefresh = () => fetchJobs(activeTab, true); @@ -157,6 +214,18 @@ function App() { }; }, [statsTaskId, isStatsRunning]); + const handleFetchReminderResult = async (taskId: string) => { + try { + const res = await fetch(`${API_BASE_URL}/api/tasks/${taskId}`); + if (res.ok) { + const data = await res.json(); + setReminderResult(data.result); + } + } catch (err) { + console.error("Failed to fetch reminder results"); + } + }; + useEffect(() => { let interval: any; if (reminderTaskId && isReminderRunning) { @@ -168,8 +237,7 @@ function App() { setReminderProgress(data.progress || 'Verarbeite...'); if (data.status === 'completed') { setIsReminderRunning(false); - // Auto-trigger download or show button? The user wants a CSV. - // Let's keep the task ID so we can show a download button. + handleFetchReminderResult(reminderTaskId); } else if (data.status === 'error') { setError(data.progress || 'Ein Fehler ist aufgetreten.'); setIsReminderRunning(false); @@ -296,6 +364,78 @@ function App() { setError("Download fehlgeschlagen."); } }; + + const handleSendEmails = async () => { + if (!reminderResult || !isGmailAuthenticated) return; + + setIsSendingEmails(true); + setEmailSendStatus("Sende..."); + + const emailsToSend = reminderResult.map(row => { + let subject = emailSubject.replace(/{Kindernamen}/g, row["Kindernamen"]); + let body = emailBody + .replace(/{Name Käufer}/g, row["Name Käufer"]) + .replace(/{Kindernamen}/g, row["Kindernamen"]) + .replace(/{LinksHTML}/g, row["LinksHTML"]) + .replace(/\n/g, "
"); + + return { + to: row["E-Mail-Adresse Käufer"], + subject: subject, + body: body + SIGNATURE_HTML + }; + }); + + try { + const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emails: emailsToSend }) + }); + + if (response.ok) { + const data = await response.json(); + setEmailSendStatus(`✅ Fertig! ${data.success} gesendet.`); + if (data.failed.length > 0) { + setEmailSendStatus(prev => `${prev} (${data.failed.length} Fehler)`); + } + } else { + throw new Error("Sende-Fehler"); + } + } catch (err) { + setEmailSendStatus("❌ Fehler beim Senden"); + } finally { + setIsSendingEmails(false); + } + }; + + const handleSendTestEmail = async () => { + if (!isGmailAuthenticated) return; + setIsSendingEmails(true); + try { + const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + emails: [{ + to: "floke.com@gmail.com", + subject: "Test-E-Mail vom Fotograf Tool (inkl. Signatur)", + body: "Hallo! Das ist eine Test-E-Mail, um die Gmail-API-Integration zu verifizieren. Wenn du das liest, funktioniert alles perfekt! 🚀" + SIGNATURE_HTML + }] + }) + }); + if (response.ok) { + alert("Test-E-Mail erfolgreich an floke.com@gmail.com gesendet!"); + } else { + alert("Fehler beim Senden der Test-E-Mail."); + } + } catch (err) { + alert("Netzwerkfehler beim Senden der Test-E-Mail."); + } finally { + setIsSendingEmails(false); + } + }; + const currentJobs = jobsCache[activeTab]; return ( @@ -317,6 +457,28 @@ function App() { Zum Dashboard + + + {isGmailAuthenticated && ( + + )} + {latestFile && (
Letzte Datei: @@ -623,13 +785,70 @@ function App() {

{reminderProgress}

) : reminderTaskId ? ( - +
+ + + {reminderResult && isGmailAuthenticated && ( +
+
+ 🚀 Gmail Direkt-Versand +
+

+ {reminderResult.length} Empfänger identifiziert. +

+ +
+ + setEmailSubject(e.target.value)} + className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ +