diff --git a/salesflow-saas/backend/app/api/v1/automation.py b/salesflow-saas/backend/app/api/v1/automation.py index 6862ec15..6d03635e 100644 --- a/salesflow-saas/backend/app/api/v1/automation.py +++ b/salesflow-saas/backend/app/api/v1/automation.py @@ -99,6 +99,7 @@ class EmailGenerateRequest(BaseModel): pain_hypothesis: str = "" website: str = "" signals: List[str] = [] + language: str = "ar" # ar | en — auto-detected from website or default def _is_personal_email(email: str) -> bool: @@ -124,8 +125,45 @@ def _compliance_check(req: ComplianceCheckRequest) -> Dict[str, Any]: return {"allowed": True, "reason": "compliant", "action": "send"} +SECTOR_PAIN_MAP_EN = { + "real_estate": { + "pain_en": "Inquiries about prices, locations, and sizes are lost due to slow response times", + "angle_en": "Dealix responds within 45 seconds, asks about budget and preferred location, and books viewings automatically", + }, + "construction": { + "pain_en": "Quote requests need quick screening before reaching engineers", + "angle_en": "Dealix receives the request, asks about project type and budget, and classifies urgency", + }, + "agency": { + "pain_en": "Your clients' ad-generated leads go cold because follow-up is slow", + "angle_en": "Dealix becomes a new service you sell: AI response + qualification + booking", + }, + "saas": { + "pain_en": "Leads from your website and ads go cold because the sales team is small", + "angle_en": "Dealix responds in Arabic within 45 seconds, qualifies, and books demos automatically", + }, +} + + +def _detect_language(req: EmailGenerateRequest) -> str: + """Detect preferred language from signals or explicit setting.""" + if req.language and req.language in ("ar", "en"): + return req.language + if req.website: + domain = req.website.lower() + if any(d in domain for d in [".sa", ".com.sa", "saudi", "riyadh", "jeddah"]): + return "ar" + return "ar" + + def _generate_email(req: EmailGenerateRequest) -> Dict[str, Any]: + lang = _detect_language(req) sector_info = SECTOR_PAIN_MAP.get(req.sector, SECTOR_PAIN_MAP.get("saas", {})) + sector_info_en = SECTOR_PAIN_MAP_EN.get(req.sector, SECTOR_PAIN_MAP_EN.get("saas", {})) + + if lang == "en": + return _generate_email_en(req, sector_info_en) + pain = req.pain_hypothesis or sector_info.get("pain_ar", "تأخر الرد على العملاء المحتملين") angle = sector_info.get("angle_ar", "ديلكس يرد بالعربي خلال 45 ثانية ويؤهل العميل") roi = sector_info.get("roi_ar", "الرد السريع يحفظ فرص كانت بتضيع") @@ -205,6 +243,7 @@ calendly.com/sami-assiri11/dealix-demo return { "company": req.company, "sector": req.sector, + "language": lang, "subject_ar": subject, "body_ar": body, "followup_day_2": followup_2, @@ -217,6 +256,77 @@ calendly.com/sami-assiri11/dealix-demo } +def _generate_email_en(req: EmailGenerateRequest, sector_info_en: Dict) -> Dict[str, Any]: + """Generate English version of outreach email.""" + pain = sector_info_en.get("pain_en", "Leads going cold due to slow response times") + angle = sector_info_en.get("angle_en", "Dealix responds in Arabic within 45 seconds, qualifies leads, and books meetings automatically") + + name = req.contact_name or f"{req.company} team" + signal_line = "" + if "hubspot" in [s.lower() for s in req.signals]: + signal_line = f"I noticed {req.company} uses HubSpot — " + elif "whatsapp_widget" in [s.lower() for s in req.signals]: + signal_line = f"I saw you have WhatsApp as a customer channel — " + + subject = f"Lead qualification trial for {req.company}" + body = f"""Hi {name}, + +{signal_line}{pain}. + +I'm Sami from Dealix. {angle}. + +We offer a 7-day trial on 10-25 of your leads with daily reporting. +Launch price: 499 SAR. + +Would you like me to show you an example based on your business? + +To stop receiving these emails, reply "STOP". + +Sami Alassiri +Dealix — AI Sales Rep for Saudi Businesses +dealix.me""" + + followup_2 = f"""Hi {name}, + +Following up on my email 2 days ago about Dealix. + +Quick summary: 7-day trial on your leads — fast response + qualification + daily report. + +Would 10 minutes this week work? +calendly.com/sami-assiri11/dealix-demo + +Reply "STOP" to unsubscribe. + +Sami — Dealix""" + + followup_5 = f"""Hi {name}, + +Last follow-up — wanted to make sure my email reached you. + +Dealix helps {req.sector} companies respond to inquiries faster and convert more leads. + +If timing isn't right, no worries. If it is, I'm available anytime. + +Reply "STOP" to unsubscribe. + +Sami — Dealix""" + + return { + "company": req.company, + "sector": req.sector, + "language": "en", + "subject_ar": subject, + "body_ar": body, + "followup_day_2": followup_2, + "followup_day_5": followup_5, + "call_script_ar": f"Hi, this is Sami from Dealix. We help {req.sector} companies respond to leads faster. Do you have 5 minutes?", + "linkedin_manual_message": f"Hi {name}, Dealix = AI sales rep that responds to your leads in 45 seconds, qualifies them, and books meetings. 20-min demo? calendly.com/sami-assiri11/dealix-demo", + "opt_out_included": True, + "word_count": len(body.split()), + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + class DailyPipelineRequest(BaseModel): sectors: List[str] = ["real_estate", "construction", "hospitality", "logistics", "agency"] cities: List[str] = ["الرياض", "جدة", "الدمام"] diff --git a/salesflow-saas/backend/app/config.py b/salesflow-saas/backend/app/config.py index d59f318e..d8f5340e 100644 --- a/salesflow-saas/backend/app/config.py +++ b/salesflow-saas/backend/app/config.py @@ -156,6 +156,23 @@ class Settings(BaseSettings): DLQ_MAX_RETRIES: int = 5 DLQ_DRAIN_BATCH_SIZE: int = 10 + # ── Local LLM (Ollama) ────────────────────────────── + OLLAMA_BASE_URL: str = "http://localhost:11434/v1" + OLLAMA_MODEL: str = "qwen2.5:7b" + LLM_CACHE_ENABLED: bool = True + LLM_CACHE_TTL: int = 3600 + LLM_RATE_LIMIT_RPM: int = 60 + + # ── Green API (WhatsApp) ──────────────────────────── + GREEN_API_INSTANCE_ID: str = "" + GREEN_API_TOKEN: str = "" + + # ── Outreach Rate Limits ──────────────────────────── + WHATSAPP_DAILY_LIMIT: int = 15 + EMAIL_DAILY_LIMIT: int = 50 + EMAIL_BATCH_SIZE: int = 10 + EMAIL_BATCH_DELAY_MINUTES: int = 90 + # ── Rate Limiting ──────────────────────────────────── RATE_LIMIT_PER_MINUTE: int = 60 RATE_LIMIT_PER_HOUR: int = 1000 diff --git a/salesflow-saas/backend/app/integrations/email_sender.py b/salesflow-saas/backend/app/integrations/email_sender.py index bca912e9..92b000d4 100644 --- a/salesflow-saas/backend/app/integrations/email_sender.py +++ b/salesflow-saas/backend/app/integrations/email_sender.py @@ -1,29 +1,134 @@ +"""Email Sender — SMTP with rate limiting, HTML wrapper, and compliance headers. + +Supports Gmail app passwords. Adds professional HTML wrapper for Arabic +RTL emails and List-Unsubscribe header for compliance. +""" + +import asyncio +import logging import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from typing import Optional + from app.config import get_settings +logger = logging.getLogger("dealix.email") settings = get_settings() +UNSUBSCRIBE_AR = "\n\n---\nإذا ما يناسبكم، اكتبوا \"إيقاف\" ولن نتواصل مرة ثانية." +UNSUBSCRIBE_EN = "\n\n---\nTo stop receiving these emails, reply with \"STOP\"." -async def send_email(to_email: str, subject: str, body_html: str, from_name: str = None) -> dict: - """Send email via SMTP.""" + +def _wrap_html(body: str, direction: str = "rtl", lang: str = "ar") -> str: + """Wrap plain text or simple HTML in a professional email template.""" + body_html = body.replace("\n", "
") if "<" not in body else body + return f""" + + + +{body_html} +
+ Dealix — dealix.me +
+""" + + +async def send_email( + to_email: str, + subject: str, + body_html: str, + from_name: Optional[str] = None, + language: str = "ar", + add_unsubscribe: bool = True, + delay_seconds: float = 0, +) -> dict: + """Send email via SMTP with compliance headers. + + Args: + to_email: Recipient email + subject: Email subject + body_html: Email body (plain text or HTML) + from_name: Sender display name + language: 'ar' or 'en' — affects direction and unsubscribe text + add_unsubscribe: Whether to append unsubscribe line + delay_seconds: Wait before sending (for rate limiting in batch) + """ if not settings.SMTP_USER or not settings.SMTP_PASSWORD: - return {"status": "error", "detail": "Email not configured"} + return {"status": "error", "detail": "SMTP_USER and SMTP_PASSWORD not configured. Add Gmail app password in Railway env."} + + if delay_seconds > 0: + await asyncio.sleep(delay_seconds) + + if add_unsubscribe: + unsub = UNSUBSCRIBE_AR if language == "ar" else UNSUBSCRIBE_EN + body_html = body_html + unsub + + direction = "rtl" if language == "ar" else "ltr" + wrapped = _wrap_html(body_html, direction=direction, lang=language) + + sender_name = from_name or settings.EMAIL_FROM_NAME or settings.APP_NAME + from_addr = getattr(settings, "EMAIL_FROM_ADDRESS", settings.SMTP_USER) - sender_name = from_name or settings.APP_NAME msg = MIMEMultipart("alternative") msg["Subject"] = subject - msg["From"] = f"{sender_name} <{settings.SMTP_USER}>" + msg["From"] = f"{sender_name} <{from_addr}>" msg["To"] = to_email + msg["List-Unsubscribe"] = f"" + msg["X-Mailer"] = "Dealix/1.0" - msg.attach(MIMEText(body_html, "html", "utf-8")) + msg.attach(MIMEText(body_html, "plain", "utf-8")) + msg.attach(MIMEText(wrapped, "html", "utf-8")) try: with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: server.starttls() server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) - server.sendmail(settings.SMTP_USER, to_email, msg.as_string()) - return {"status": "sent"} + server.sendmail(from_addr, to_email, msg.as_string()) + logger.info("Email sent to %s: %s", to_email, subject[:50]) + return {"status": "sent", "to": to_email} + except smtplib.SMTPAuthenticationError: + logger.error("SMTP auth failed — check SMTP_USER and SMTP_PASSWORD (Gmail app password)") + return {"status": "error", "detail": "SMTP authentication failed. Use Gmail App Password, not regular password."} except Exception as e: - return {"status": "error", "detail": str(e)} + logger.error("Email send failed: %s", e) + return {"status": "error", "detail": str(e)[:200]} + + +async def send_email_batch( + emails: list[dict], + delay_between: float = 2.0, + max_batch: int = 10, +) -> dict: + """Send a batch of emails with delays between each. + + Args: + emails: List of dicts with {to, subject, body, language} + delay_between: Seconds between each email (default 2) + max_batch: Max emails per batch (default 10) + + Returns: {sent, failed, results} + """ + sent = 0 + failed = 0 + results = [] + + for i, email in enumerate(emails[:max_batch]): + delay = delay_between if i > 0 else 0 + result = await send_email( + to_email=email["to"], + subject=email["subject"], + body_html=email["body"], + language=email.get("language", "ar"), + delay_seconds=delay, + ) + results.append({"to": email["to"], **result}) + if result["status"] == "sent": + sent += 1 + else: + failed += 1 + + return {"sent": sent, "failed": failed, "total": len(results), "results": results}