Spaces:
Sleeping
Sleeping
| """Email drafting + sending MCP tool. | |
| Two modes: | |
| * ``mode="draft"`` — given a free-form natural-language brief, returns three | |
| tonal variants (formal / casual / polite) that the user can pick from. | |
| * ``mode="send"`` — given a final subject + body + recipient(s), delivers the | |
| email through the configured SMTP relay (reusing the same settings as the | |
| interview-invite flow). | |
| The 3 tone variants are produced by Gemini using the project's ``GEMINI_API_KEY``. | |
| If Gemini isn't reachable we fall back to deterministic template wrappers so the | |
| flow still works for demos. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import re | |
| from fastmcp import FastMCP | |
| from core.config import get_settings | |
| from services.notification_service import send_plain_email | |
| logger = logging.getLogger(__name__) | |
| _TONE_LABELS = ("formal", "casual", "polite") | |
| def _strip_code_fences(text: str) -> str: | |
| fence = re.search(r"```(?:json)?\s*(\{[\s\S]*\}|\[[\s\S]*\])\s*```", text) | |
| if fence: | |
| return fence.group(1) | |
| return text.strip() | |
| def _fallback_draft(brief: str, tone_label: str, recipient_name: str | None) -> dict: | |
| name = recipient_name or "there" | |
| if tone_label == "formal": | |
| opener = f"Dear {name}," | |
| closer = "Best regards,\nRecruitment OS" | |
| elif tone_label == "casual": | |
| opener = f"Hi {name.split()[0] if recipient_name else 'there'}," | |
| closer = "Cheers,\nRecruitment OS" | |
| else: | |
| opener = f"Hello {name}," | |
| closer = "Thank you for your time,\nRecruitment OS" | |
| subject = brief.strip().split("\n")[0][:90] or "Following up" | |
| body = f"{opener}\n\n{brief.strip()}\n\n{closer}" | |
| return {"tone": tone_label, "subject": subject, "body": body} | |
| def _build_drafts(brief: str, recipient_name: str | None) -> list[dict]: | |
| """Generate all three tonal variants in a single Gemini call. | |
| Previously we did three sequential ``generate_content`` calls (one per tone). | |
| On the HF Space free tier this took 30–60 s wall-clock and looked like the | |
| UI was hanging. One JSON-array call cuts it to a single request. | |
| """ | |
| settings = get_settings() | |
| if not settings.gemini_api_key: | |
| return [_fallback_draft(brief, tone, recipient_name) for tone in _TONE_LABELS] | |
| addressee = recipient_name or "the recipient" | |
| prompt = ( | |
| "You are drafting a professional recruiter email. Produce THREE tonal " | |
| "variants of the same email — formal, casual, and polite — based on the " | |
| "brief below.\n\n" | |
| f"USER BRIEF:\n{brief}\n\n" | |
| f"Addressee placeholder: {addressee}\n\n" | |
| "Return STRICT JSON only — a JSON array of three objects, in this exact " | |
| "order: formal, casual, polite. Each object has keys: tone, subject, " | |
| "body. The body must end with a sign-off line. No prose outside the JSON.\n\n" | |
| "Example shape:\n" | |
| '[{"tone":"formal","subject":"…","body":"…"},' | |
| '{"tone":"casual","subject":"…","body":"…"},' | |
| '{"tone":"polite","subject":"…","body":"…"}]' | |
| ) | |
| try: | |
| from google import genai | |
| client = genai.Client(api_key=settings.gemini_api_key) | |
| response = client.models.generate_content( | |
| model=settings.gemini_model or "gemini-2.5-pro", | |
| contents=[prompt], | |
| ) | |
| raw = (getattr(response, "text", None) or "").strip() | |
| if not raw: | |
| raise RuntimeError("Gemini returned an empty response.") | |
| data = json.loads(_strip_code_fences(raw)) | |
| if not isinstance(data, list) or len(data) < 3: | |
| raise RuntimeError("Gemini response was not a 3-element array.") | |
| drafts: list[dict] = [] | |
| for tone, item in zip(_TONE_LABELS, data[:3]): | |
| if not isinstance(item, dict): | |
| drafts.append(_fallback_draft(brief, tone, recipient_name)) | |
| continue | |
| drafts.append( | |
| { | |
| "tone": tone, | |
| "subject": str(item.get("subject") or "").strip() | |
| or _fallback_draft(brief, tone, recipient_name)["subject"], | |
| "body": str(item.get("body") or "").strip() | |
| or _fallback_draft(brief, tone, recipient_name)["body"], | |
| } | |
| ) | |
| return drafts | |
| except Exception as exc: | |
| logger.warning("Gemini email draft failed; using template fallback: %s", exc) | |
| return [_fallback_draft(brief, tone, recipient_name) for tone in _TONE_LABELS] | |
| def register(mcp: FastMCP) -> None: | |
| def email_compose( | |
| mode: str, | |
| brief: str | None = None, | |
| recipient_name: str | None = None, | |
| recipient_email: str | None = None, | |
| cc: str | None = None, | |
| subject: str | None = None, | |
| body: str | None = None, | |
| tone: str | None = None, | |
| ) -> dict: | |
| """Draft or send recruiter emails. | |
| ``mode="draft"`` requires ``brief``. Returns three tone variants | |
| (formal / casual / polite) that the user can choose from. | |
| ``mode="send"`` requires ``recipient_email`` (comma-separated allowed), | |
| ``subject`` and ``body`` and delivers the message via SMTP. | |
| """ | |
| normalized = (mode or "").strip().lower() | |
| if normalized == "draft": | |
| text = (brief or "").strip() | |
| if not text: | |
| return { | |
| "status": "error", | |
| "message": "brief is required when mode='draft'.", | |
| } | |
| drafts = _build_drafts(text, recipient_name) | |
| cards = [] | |
| for draft in drafts: | |
| preview = draft["body"][:200] + ("…" if len(draft["body"]) > 200 else "") | |
| cards.append( | |
| { | |
| "title": f"{draft['tone'].title()} draft", | |
| "subtitle": f"Subject: {draft['subject']}", | |
| "tags": [draft["tone"].title()], | |
| "meta": {"preview": preview}, | |
| "actions": [ | |
| { | |
| "label": f"Use {draft['tone']} tone", | |
| "action": ( | |
| f"Use the {draft['tone']} tone draft. " | |
| "What's the recipient's email address?" | |
| ), | |
| } | |
| ], | |
| } | |
| ) | |
| markdown_sections = [f"# Email drafts for: {text[:90]}\n"] | |
| for draft in drafts: | |
| markdown_sections.append( | |
| f"## {draft['tone'].title()} tone\n" | |
| f"**Subject:** {draft['subject']}\n\n" | |
| f"{draft['body']}\n" | |
| ) | |
| markdown_body = "\n---\n".join(markdown_sections) | |
| return { | |
| "status": "success", | |
| "drafts": drafts, | |
| "ui": { | |
| "summary": ( | |
| "Three tone variants ready. Reply with your choice — " | |
| "'use formal', 'use casual', or 'use polite' — and the " | |
| "recipient email to send." | |
| ), | |
| "cards": cards, | |
| "markdown": markdown_body, | |
| }, | |
| } | |
| if normalized == "send": | |
| if not recipient_email or not subject or not body: | |
| return { | |
| "status": "error", | |
| "message": "recipient_email, subject, and body are required when mode='send'.", | |
| } | |
| to_list = [addr.strip() for addr in recipient_email.split(",") if addr.strip()] | |
| cc_list = [addr.strip() for addr in (cc or "").split(",") if addr.strip()] | |
| result = send_plain_email( | |
| to=to_list, | |
| subject=subject, | |
| body=body, | |
| cc=cc_list, | |
| ) | |
| tone_label = (tone or "selected").lower() | |
| return { | |
| "status": "success" if result.sent else "error", | |
| "delivery": result.delivery, | |
| "message": result.detail, | |
| "ui": { | |
| "summary": ( | |
| f"Email ({tone_label} tone) {'sent' if result.sent else 'NOT sent'} — " | |
| f"{result.detail}" | |
| ), | |
| "cards": [ | |
| { | |
| "title": "Email delivery", | |
| "subtitle": result.detail, | |
| "tags": [ | |
| tone_label.title() or "Email", | |
| "Delivered" if result.sent else "Failed", | |
| ], | |
| "meta": { | |
| "to": ", ".join(to_list), | |
| "cc": ", ".join(cc_list) if cc_list else "—", | |
| "subject": subject, | |
| }, | |
| "actions": [], | |
| } | |
| ], | |
| "markdown": ( | |
| f"# {subject}\n\n" | |
| f"**To:** {', '.join(to_list)} \n" | |
| f"**Cc:** {', '.join(cc_list) if cc_list else '—'}\n\n" | |
| f"{body}" | |
| ), | |
| }, | |
| } | |
| return { | |
| "status": "error", | |
| "message": "mode must be 'draft' or 'send'.", | |
| } | |