307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""AI-powered outreach email template generation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import List, Optional
|
|
from loguru import logger
|
|
|
|
from services.llm_providers.main_text_generation import llm_text_gen
|
|
|
|
|
|
SYSTEM_PROMPT = """You are an expert outreach copywriter specializing in guest post and backlink pitch emails.
|
|
Write concise, personalized outreach emails that get high response rates.
|
|
Follow these rules:
|
|
- Be specific about why you're reaching out (mention their content)
|
|
- Keep it under 200 words
|
|
- Include a clear call to action
|
|
- Sound human, not templated
|
|
- Never use spammy phrases
|
|
- Output ONLY valid JSON with "subject" and "body" keys"""
|
|
|
|
SUBJECT_LINES_PROMPT = """You are an expert email subject line writer.
|
|
Given an outreach email body, generate subject lines that are:
|
|
- Intriguing but not clickbait
|
|
- Personalized when possible
|
|
- Under 60 characters
|
|
- Varied in style (question, curiosity, value-prop)
|
|
Output ONLY valid JSON with a "subjects" key containing an array of strings."""
|
|
|
|
FOLLOW_UP_PROMPT = """You are an expert outreach copywriter.
|
|
Write a polite follow-up email for a guest post pitch that hasn't received a response.
|
|
Rules:
|
|
- Reference the original email without repeating it verbatim
|
|
- Keep it shorter than the original (under 100 words)
|
|
- Add a new angle or piece of value
|
|
- Include a clear call to action
|
|
- Sound human and respectful, never pushy
|
|
- Output ONLY valid JSON with "subject" and "body" keys"""
|
|
|
|
PERSONALIZATION_PROMPT = """You are an expert outreach personalization specialist.
|
|
Given a lead's information and a draft outreach email, personalize it for that specific lead.
|
|
Rules:
|
|
- Mention their specific content or website
|
|
- Reference something relevant from their site
|
|
- Keep the core pitch but make it feel custom-written
|
|
- Under 200 words
|
|
- Output ONLY valid JSON with "subject" and "body" keys"""
|
|
|
|
|
|
def generate_outreach_email(
|
|
topic: str,
|
|
target_site: Optional[str] = None,
|
|
tone: str = "professional",
|
|
user_id: str = "default",
|
|
existing_body: Optional[str] = None,
|
|
) -> dict:
|
|
"""Generate an outreach email using the LLM.
|
|
|
|
Args:
|
|
topic: The topic/keyword to pitch.
|
|
target_site: Optional target website name/URL.
|
|
tone: professional, friendly, casual, or formal.
|
|
user_id: Clerk user ID for subscription check.
|
|
existing_body: If provided, rewrite/improve this existing template.
|
|
|
|
Returns:
|
|
dict with "subject" and "body" keys.
|
|
"""
|
|
if existing_body:
|
|
prompt = (
|
|
f"Rewrite and improve the following outreach email for a {tone} tone. "
|
|
f"Topic: {topic}. "
|
|
f"{f'Target website: {target_site}. ' if target_site else ''}"
|
|
f"Keep the core message but make it more effective. "
|
|
f"Original email:\n\n{existing_body}\n\n"
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
else:
|
|
prompt = (
|
|
f"Write a {tone} outreach email for a guest post opportunity about: {topic}. "
|
|
f"{f'We are pitching this to: {target_site}. ' if target_site else ''}"
|
|
f"Mention specific value the guest post would bring to their audience. "
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
|
|
try:
|
|
raw = llm_text_gen(
|
|
prompt=prompt,
|
|
system_prompt=SYSTEM_PROMPT,
|
|
user_id=user_id,
|
|
temperature=0.7,
|
|
)
|
|
|
|
result = _parse_json_response(raw)
|
|
if result:
|
|
return result
|
|
|
|
return _fallback_extract(raw, topic)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate outreach email: {e}")
|
|
return {
|
|
"subject": f"Guest post opportunity: {topic}",
|
|
"body": f"Hi there,\n\nI came across your site and I'd love to contribute a guest post about {topic}. "
|
|
f"Please let me know if you're open to submissions.\n\nBest regards",
|
|
}
|
|
|
|
|
|
def generate_personalized_email(
|
|
lead_name: str,
|
|
lead_site: str,
|
|
lead_content_topic: str,
|
|
pitch_topic: str,
|
|
existing_body: str = "",
|
|
user_id: str = "default",
|
|
) -> dict:
|
|
"""Personalize an outreach email for a specific lead.
|
|
|
|
Args:
|
|
lead_name: Contact name or site owner name.
|
|
lead_site: The lead's website URL.
|
|
lead_content_topic: Topic of relevant content on their site.
|
|
pitch_topic: The topic we want to pitch.
|
|
existing_body: Optional draft to personalize further.
|
|
user_id: Clerk user ID for subscription check.
|
|
|
|
Returns:
|
|
dict with "subject" and "body" keys.
|
|
"""
|
|
if existing_body:
|
|
prompt = (
|
|
f"Personalize this outreach email for {lead_name} from {lead_site}. "
|
|
f"They have content about '{lead_content_topic}'. "
|
|
f"We want to pitch: {pitch_topic}. "
|
|
f"Mention something specific about their content on {lead_content_topic} "
|
|
f"to show we've done our research. "
|
|
f"Draft email to personalize:\n\n{existing_body}\n\n"
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
else:
|
|
prompt = (
|
|
f"Write a personalized outreach email to {lead_name} at {lead_site}. "
|
|
f"They have published content about '{lead_content_topic}'. "
|
|
f"We want to pitch a guest post about: {pitch_topic}. "
|
|
f"Reference their article on {lead_content_topic} and explain how our pitch "
|
|
f"would provide value to their audience. "
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
|
|
try:
|
|
raw = llm_text_gen(
|
|
prompt=prompt,
|
|
system_prompt=PERSONALIZATION_PROMPT,
|
|
user_id=user_id,
|
|
temperature=0.7,
|
|
)
|
|
result = _parse_json_response(raw)
|
|
if result:
|
|
return result
|
|
return _fallback_extract(raw, pitch_topic)
|
|
except Exception as e:
|
|
logger.error(f"Failed to personalize email: {e}")
|
|
return {"subject": f"Question about your content on {lead_content_topic}", "body": existing_body or f"Hi {lead_name},\n\nI enjoyed your article about {lead_content_topic}..."}
|
|
|
|
|
|
def generate_subject_lines(
|
|
body: str,
|
|
count: int = 5,
|
|
user_id: str = "default",
|
|
) -> List[str]:
|
|
"""Generate subject line suggestions for an email body.
|
|
|
|
Args:
|
|
body: The email body to generate subject lines for.
|
|
count: Number of subject lines to generate.
|
|
user_id: Clerk user ID for subscription check.
|
|
|
|
Returns:
|
|
List of subject line strings.
|
|
"""
|
|
prompt = (
|
|
f"Generate {count} subject lines for the following outreach email. "
|
|
f"Make them varied in style and optimized for open rates.\n\n"
|
|
f"Email body:\n{body}\n\n"
|
|
f"Return ONLY valid JSON with a 'subjects' key containing an array of strings."
|
|
)
|
|
|
|
try:
|
|
raw = llm_text_gen(
|
|
prompt=prompt,
|
|
system_prompt=SUBJECT_LINES_PROMPT,
|
|
user_id=user_id,
|
|
temperature=0.8,
|
|
)
|
|
if raw:
|
|
text = raw.strip()
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
text = re.sub(r"\s*```$", "", text)
|
|
try:
|
|
data = json.loads(text)
|
|
if isinstance(data, dict) and "subjects" in data and isinstance(data["subjects"], list):
|
|
return [s.strip() for s in data["subjects"][:count]]
|
|
except json.JSONDecodeError:
|
|
pass
|
|
lines = [l.strip("- ").strip() for l in raw.strip().split("\n") if l.strip() and not l.strip().startswith("```")]
|
|
return [l for l in lines if len(l) > 10][:count]
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate subject lines: {e}")
|
|
return [f"Guest post opportunity", f"Question about your content", f"Collaboration idea"]
|
|
|
|
|
|
def generate_follow_up(
|
|
original_subject: str,
|
|
original_body: str,
|
|
days_elapsed: int = 7,
|
|
reply_context: str = "",
|
|
user_id: str = "default",
|
|
) -> dict:
|
|
"""Generate a follow-up email for an outreach that hasn't received a response.
|
|
|
|
Args:
|
|
original_subject: Subject line of the original email.
|
|
original_body: Body of the original email.
|
|
days_elapsed: Number of days since the original was sent.
|
|
reply_context: If the recipient replied, context of their reply.
|
|
user_id: Clerk user ID for subscription check.
|
|
|
|
Returns:
|
|
dict with "subject" and "body" keys.
|
|
"""
|
|
if reply_context:
|
|
prompt = (
|
|
f"The recipient replied with: '{reply_context}'. "
|
|
f"Write a follow-up email that addresses their response and keeps the conversation moving. "
|
|
f"Original subject: {original_subject}.\n\n"
|
|
f"Original email:\n{original_body}\n\n"
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
else:
|
|
prompt = (
|
|
f"Write a polite follow-up email. {days_elapsed} days have passed since the original email. "
|
|
f"Do not apologize for following up. Add a new piece of value or angle. "
|
|
f"Original subject: {original_subject}.\n\n"
|
|
f"Original email:\n{original_body}\n\n"
|
|
f"Return ONLY valid JSON with 'subject' and 'body' keys."
|
|
)
|
|
|
|
try:
|
|
raw = llm_text_gen(
|
|
prompt=prompt,
|
|
system_prompt=FOLLOW_UP_PROMPT,
|
|
user_id=user_id,
|
|
temperature=0.7,
|
|
)
|
|
result = _parse_json_response(raw)
|
|
if result:
|
|
return result
|
|
return _fallback_extract(raw, original_subject)
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate follow-up: {e}")
|
|
return {
|
|
"subject": f"Re: {original_subject}",
|
|
"body": f"Hi there,\n\nI wanted to follow up on my previous email. "
|
|
f"I'd love to hear your thoughts when you have a moment.\n\nBest regards",
|
|
}
|
|
|
|
|
|
def _parse_json_response(raw: str) -> Optional[dict]:
|
|
"""Try to parse JSON from LLM response, handling markdown fences."""
|
|
if not raw:
|
|
return None
|
|
|
|
text = raw.strip()
|
|
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
text = re.sub(r"\s*```$", "", text)
|
|
|
|
try:
|
|
data = json.loads(text)
|
|
if isinstance(data, dict) and "subject" in data and "body" in data:
|
|
return {"subject": data["subject"].strip(), "body": data["body"].strip()}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def _fallback_extract(raw: str, topic: str) -> dict:
|
|
"""Fallback: try to extract subject line and body from unstructured text."""
|
|
lines = [l.strip() for l in raw.strip().split("\n") if l.strip()]
|
|
subject = topic
|
|
body_lines = []
|
|
|
|
for i, line in enumerate(lines):
|
|
lower = line.lower()
|
|
if lower.startswith("subject") or lower.startswith("subject:"):
|
|
subject = line.split(":", 1)[-1].strip()
|
|
elif lower.startswith("body") or lower.startswith("body:"):
|
|
body_lines.append(line.split(":", 1)[-1].strip())
|
|
else:
|
|
body_lines.append(line)
|
|
|
|
body = "\n".join(body_lines) if body_lines else raw
|
|
return {"subject": subject, "body": body} |