handoff fixes
This commit is contained in:
parent
6c52cd19cb
commit
89ddebdb60
15 changed files with 991 additions and 271 deletions
|
|
@ -32,8 +32,8 @@ from app.services.thread_service import (
|
||||||
create_thread,
|
create_thread,
|
||||||
ensure_thread_for_message,
|
ensure_thread_for_message,
|
||||||
message_event_at,
|
message_event_at,
|
||||||
threading_fields_from_parent,
|
|
||||||
)
|
)
|
||||||
|
from app.services.external_reply_service import build_external_reply_plan, log_smtp_message_sending
|
||||||
from app.services.mailbox_scope import apply_external_scope
|
from app.services.mailbox_scope import apply_external_scope
|
||||||
from app.services.thread_resolver import ThreadResolution
|
from app.services.thread_resolver import ThreadResolution
|
||||||
|
|
||||||
|
|
@ -150,7 +150,6 @@ async def create_email(
|
||||||
detail="Reply target not found.",
|
detail="Reply target not found.",
|
||||||
)
|
)
|
||||||
thread_id = parent_entry.get("thread_id")
|
thread_id = parent_entry.get("thread_id")
|
||||||
threading_fields = threading_fields_from_parent(parent_message)
|
|
||||||
to_addresses, cc_addresses = reply_recipients(
|
to_addresses, cc_addresses = reply_recipients(
|
||||||
message=parent_message,
|
message=parent_message,
|
||||||
viewer_email=sender,
|
viewer_email=sender,
|
||||||
|
|
@ -187,9 +186,20 @@ async def create_email(
|
||||||
|
|
||||||
created_at = utc_now()
|
created_at = utc_now()
|
||||||
subject = payload.subject
|
subject = payload.subject
|
||||||
if parent_message and not subject.strip():
|
if parent_message:
|
||||||
parent_subject = parent_message.get("subject", "")
|
reply_plan = build_external_reply_plan(
|
||||||
subject = parent_subject if parent_subject.lower().startswith("re:") else f"Re: {parent_subject}"
|
parent_external_message=parent_message,
|
||||||
|
body_text=payload.body_text,
|
||||||
|
body_html=payload.body_html,
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
threading_fields = {
|
||||||
|
"in_reply_to": reply_plan.threading.in_reply_to,
|
||||||
|
"references": reply_plan.threading.references,
|
||||||
|
"parent_message_id": reply_plan.threading.parent_internal_message_id,
|
||||||
|
}
|
||||||
|
if not subject.strip():
|
||||||
|
subject = reply_plan.subject
|
||||||
document = {
|
document = {
|
||||||
"direction": "outbound",
|
"direction": "outbound",
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
|
@ -215,6 +225,17 @@ async def create_email(
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
log_smtp_message_sending(
|
||||||
|
generated_message_id=None,
|
||||||
|
in_reply_to=threading_fields.get("in_reply_to"),
|
||||||
|
references=threading_fields.get("references"),
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
subject=document["subject"],
|
||||||
|
external_thread_id=thread_id,
|
||||||
|
replying_to_internal_message_id=threading_fields.get("parent_message_id"),
|
||||||
|
replying_to_internet_message_id=threading_fields.get("in_reply_to"),
|
||||||
|
)
|
||||||
internet_message_id = await deliver_email(
|
internet_message_id = await deliver_email(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
recipients=recipients,
|
recipients=recipients,
|
||||||
|
|
@ -228,7 +249,20 @@ async def create_email(
|
||||||
references=threading_fields.get("references"),
|
references=threading_fields.get("references"),
|
||||||
)
|
)
|
||||||
document["status"] = "accepted"
|
document["status"] = "accepted"
|
||||||
document["message_id"] = normalize_message_id(internet_message_id)
|
normalized_id = normalize_message_id(internet_message_id)
|
||||||
|
document["message_id"] = normalized_id
|
||||||
|
document["internet_message_id"] = normalized_id
|
||||||
|
log_smtp_message_sending(
|
||||||
|
generated_message_id=normalized_id,
|
||||||
|
in_reply_to=threading_fields.get("in_reply_to"),
|
||||||
|
references=threading_fields.get("references"),
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
subject=document["subject"],
|
||||||
|
external_thread_id=thread_id,
|
||||||
|
replying_to_internal_message_id=threading_fields.get("parent_message_id"),
|
||||||
|
replying_to_internet_message_id=threading_fields.get("in_reply_to"),
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
document["status"] = "failed"
|
document["status"] = "failed"
|
||||||
document["error"] = str(error)
|
document["error"] = str(error)
|
||||||
|
|
|
||||||
210
backend/app/services/external_reply_service.py
Normal file
210
backend/app/services/external_reply_service.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from app.models import MailDomain
|
||||||
|
from app.services.handoff_email_templates import external_reply_subject
|
||||||
|
from app.services.mail_sender import deliver_email
|
||||||
|
from app.services.message_ids import normalize_message_id
|
||||||
|
from app.services.thread_service import (
|
||||||
|
attach_message_to_thread,
|
||||||
|
message_event_at,
|
||||||
|
threading_fields_from_parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExternalReplyThreading:
|
||||||
|
in_reply_to: str | None
|
||||||
|
references: list[str]
|
||||||
|
parent_internal_message_id: ObjectId
|
||||||
|
replying_to_internet_message_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExternalReplyPlan:
|
||||||
|
subject: str
|
||||||
|
body_text: str
|
||||||
|
body_html: str | None
|
||||||
|
threading: ExternalReplyThreading
|
||||||
|
recipients: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def internet_message_id_of(message: dict) -> str | None:
|
||||||
|
return normalize_message_id(
|
||||||
|
message.get("internet_message_id") or message.get("message_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_external_reply_plan(
|
||||||
|
*,
|
||||||
|
parent_external_message: dict,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None = None,
|
||||||
|
subject_override: str | None = None,
|
||||||
|
recipients: list[str],
|
||||||
|
) -> ExternalReplyPlan:
|
||||||
|
threading_fields = threading_fields_from_parent(parent_external_message)
|
||||||
|
parent_internet_id = internet_message_id_of(parent_external_message)
|
||||||
|
if subject_override:
|
||||||
|
subject = subject_override
|
||||||
|
else:
|
||||||
|
parent_subject = parent_external_message.get("subject", "")
|
||||||
|
subject = (
|
||||||
|
parent_subject
|
||||||
|
if re.match(r"^re:\s*", parent_subject, flags=re.IGNORECASE)
|
||||||
|
else external_reply_subject(parent_subject)
|
||||||
|
)
|
||||||
|
return ExternalReplyPlan(
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
threading=ExternalReplyThreading(
|
||||||
|
in_reply_to=threading_fields.get("in_reply_to"),
|
||||||
|
references=threading_fields.get("references", []),
|
||||||
|
parent_internal_message_id=parent_external_message["_id"],
|
||||||
|
replying_to_internet_message_id=parent_internet_id,
|
||||||
|
),
|
||||||
|
recipients=recipients,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_smtp_message_sending(
|
||||||
|
*,
|
||||||
|
generated_message_id: str | None,
|
||||||
|
in_reply_to: str | None,
|
||||||
|
references: list[str] | None,
|
||||||
|
sender: str,
|
||||||
|
recipients: list[str],
|
||||||
|
subject: str,
|
||||||
|
external_thread_id: ObjectId | str | None = None,
|
||||||
|
replying_to_internal_message_id: ObjectId | str | None = None,
|
||||||
|
replying_to_internet_message_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
"smtp_message_sending generated_message_id=%s in_reply_to=%s references=%s "
|
||||||
|
"from=%s to=%s subject=%s external_thread_id=%s "
|
||||||
|
"replying_to_internal_message_id=%s replying_to_internet_message_id=%s",
|
||||||
|
generated_message_id,
|
||||||
|
in_reply_to,
|
||||||
|
" ".join(references or []),
|
||||||
|
sender,
|
||||||
|
",".join(recipients),
|
||||||
|
subject,
|
||||||
|
external_thread_id,
|
||||||
|
replying_to_internal_message_id,
|
||||||
|
replying_to_internet_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_external_reply(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
sender: str,
|
||||||
|
sender_name: str,
|
||||||
|
external_thread_id: ObjectId,
|
||||||
|
plan: ExternalReplyPlan,
|
||||||
|
domain: MailDomain,
|
||||||
|
resolution_method: str = "in_reply_to",
|
||||||
|
) -> dict:
|
||||||
|
log_smtp_message_sending(
|
||||||
|
generated_message_id=None,
|
||||||
|
in_reply_to=plan.threading.in_reply_to,
|
||||||
|
references=plan.threading.references,
|
||||||
|
sender=sender,
|
||||||
|
recipients=plan.recipients,
|
||||||
|
subject=plan.subject,
|
||||||
|
external_thread_id=external_thread_id,
|
||||||
|
replying_to_internal_message_id=plan.threading.parent_internal_message_id,
|
||||||
|
replying_to_internet_message_id=plan.threading.replying_to_internet_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
internet_message_id = await deliver_email(
|
||||||
|
sender=sender,
|
||||||
|
recipients=plan.recipients,
|
||||||
|
cc=[],
|
||||||
|
bcc=[],
|
||||||
|
subject=plan.subject,
|
||||||
|
body_text=plan.body_text,
|
||||||
|
body_html=plan.body_html,
|
||||||
|
domain=domain,
|
||||||
|
in_reply_to=plan.threading.in_reply_to,
|
||||||
|
references=plan.threading.references,
|
||||||
|
)
|
||||||
|
normalized_id = normalize_message_id(internet_message_id)
|
||||||
|
log_smtp_message_sending(
|
||||||
|
generated_message_id=normalized_id,
|
||||||
|
in_reply_to=plan.threading.in_reply_to,
|
||||||
|
references=plan.threading.references,
|
||||||
|
sender=sender,
|
||||||
|
recipients=plan.recipients,
|
||||||
|
subject=plan.subject,
|
||||||
|
external_thread_id=external_thread_id,
|
||||||
|
replying_to_internal_message_id=plan.threading.parent_internal_message_id,
|
||||||
|
replying_to_internet_message_id=plan.threading.replying_to_internet_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
document = {
|
||||||
|
"direction": "outbound",
|
||||||
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
"account_id": account_id,
|
||||||
|
"sender": {"name": sender_name, "address": sender},
|
||||||
|
"to": [{"name": value.split("@")[0], "address": value} for value in plan.recipients],
|
||||||
|
"subject": plan.subject,
|
||||||
|
"snippet": re.sub(r"\s+", " ", plan.body_text).strip()[:240],
|
||||||
|
"body_text": plan.body_text,
|
||||||
|
"body_html": plan.body_html,
|
||||||
|
"attachments": [],
|
||||||
|
"created_at": now,
|
||||||
|
"sent_at": now,
|
||||||
|
"status": "accepted",
|
||||||
|
"message_id": normalized_id,
|
||||||
|
"internet_message_id": normalized_id,
|
||||||
|
"in_reply_to": plan.threading.in_reply_to,
|
||||||
|
"references": plan.threading.references,
|
||||||
|
"parent_message_id": plan.threading.parent_internal_message_id,
|
||||||
|
"thread_relation": "reply",
|
||||||
|
"thread_resolution_method": resolution_method,
|
||||||
|
}
|
||||||
|
result = await database.messages.insert_one(document)
|
||||||
|
document["_id"] = result.inserted_id
|
||||||
|
await attach_message_to_thread(
|
||||||
|
database,
|
||||||
|
account_id=account_id,
|
||||||
|
thread_id=external_thread_id,
|
||||||
|
message_id=result.inserted_id,
|
||||||
|
subject=plan.subject,
|
||||||
|
event_at=now,
|
||||||
|
)
|
||||||
|
await database.mailbox_entries.insert_one(
|
||||||
|
{
|
||||||
|
"account_id": account_id,
|
||||||
|
"message_id": result.inserted_id,
|
||||||
|
"thread_id": external_thread_id,
|
||||||
|
"mailbox": "sent",
|
||||||
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
"direction": "outbound",
|
||||||
|
"sender": document["sender"],
|
||||||
|
"recipients": document["to"],
|
||||||
|
"subject": plan.subject,
|
||||||
|
"snippet": document["snippet"],
|
||||||
|
"attachments": [],
|
||||||
|
"status": "accepted",
|
||||||
|
"is_read": True,
|
||||||
|
"is_starred": False,
|
||||||
|
"event_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"trashed_at": None,
|
||||||
|
"deletion_state": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return document
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
DEBUG_REASON_MARKERS = (
|
|
||||||
"step passes",
|
|
||||||
"classifier",
|
|
||||||
"rule evaluation",
|
|
||||||
"no handoff instructions were set",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_external_subject(subject: str) -> str:
|
def _clean_external_subject(subject: str) -> str:
|
||||||
value = (subject or "(No subject)").strip()
|
value = (subject or "(No subject)").strip()
|
||||||
|
|
@ -28,171 +21,129 @@ def external_reply_subject(external_subject: str) -> str:
|
||||||
return f"Re: {cleaned}"
|
return f"Re: {cleaned}"
|
||||||
|
|
||||||
|
|
||||||
def format_reason_for_human(reason: str | None, *, is_update: bool = False) -> str | None:
|
def format_sender_line(sender: dict) -> str:
|
||||||
if is_update:
|
name = (sender.get("name") or "").strip()
|
||||||
return None
|
address = (sender.get("address") or "").strip()
|
||||||
if not reason or not reason.strip():
|
if not address:
|
||||||
return None
|
return "unknown sender"
|
||||||
lower = reason.strip().lower()
|
local = address.split("@", 1)[0].lower()
|
||||||
if any(marker in lower for marker in DEBUG_REASON_MARKERS):
|
if name and name.lower() not in {local, address.lower()}:
|
||||||
return "This conversation matched your Human Handoff settings."
|
return f"{name} <{address}>"
|
||||||
if lower in {
|
return address
|
||||||
"matched handoff rules.",
|
|
||||||
"new message in the linked conversation.",
|
|
||||||
}:
|
|
||||||
return "This conversation requires human attention."
|
|
||||||
return reason.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def build_initial_handoff_text(
|
def _footer_html(agent_email: str) -> str:
|
||||||
*,
|
return f"""
|
||||||
external_sender: str,
|
<div class="gentic-handoff-footer" style="background:#F8F7FC;border-top:1px solid #E9E5F2;padding:12px 20px;">
|
||||||
external_subject: str,
|
<p style="margin:0 0 6px;font-size:12px;font-weight:650;color:#6750E8;">↩ Reply normally</p>
|
||||||
external_body: str,
|
<p style="margin:0;font-size:11px;line-height:1.45;color:#77758A;">
|
||||||
agent_email: str,
|
Your reply will be sent to the original sender through
|
||||||
handoff_reason: str | None,
|
<span style="color:#292536;">{escape(agent_email)}</span>.
|
||||||
) -> str:
|
Only the new text you write will be sent.
|
||||||
reason = format_reason_for_human(handoff_reason)
|
</p>
|
||||||
lines = [
|
</div>"""
|
||||||
"GENTIC — HUMAN HANDOFF",
|
|
||||||
"",
|
|
||||||
"This conversation needs your attention.",
|
def _footer_text(agent_email: str) -> str:
|
||||||
"",
|
return (
|
||||||
f"From: {external_sender}",
|
"↩ Reply normally\n\n"
|
||||||
f"Subject: {_clean_external_subject(external_subject)}",
|
f"Your reply will be sent to the original sender through {agent_email}.\n"
|
||||||
"",
|
"Only the new text you write will be sent."
|
||||||
"LATEST MESSAGE",
|
|
||||||
"",
|
|
||||||
external_body.strip(),
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
if reason:
|
|
||||||
lines.extend(["WHY YOU'RE RECEIVING THIS", "", reason, ""])
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
"REPLY NORMALLY",
|
|
||||||
"",
|
|
||||||
f"Reply to this email and Gentic will send only your new reply to the "
|
|
||||||
f"original sender through {agent_email}.",
|
|
||||||
"",
|
|
||||||
"This private handoff conversation and quoted history will not be shared "
|
|
||||||
"with the sender.",
|
|
||||||
"",
|
|
||||||
"—",
|
|
||||||
"Gentic Human Handoff",
|
|
||||||
"Private Agent ↔ Human conversation",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_initial_handoff_html(
|
def _header_html(sender_line: str, external_subject: str) -> str:
|
||||||
|
subject = escape(_clean_external_subject(external_subject))
|
||||||
|
return f"""
|
||||||
|
<div class="gentic-handoff-header" style="padding:16px 20px 12px;border-bottom:1px solid #ECE9EF;">
|
||||||
|
<div style="font-size:14px;font-weight:650;color:#6750E8;">✦ Human Handoff</div>
|
||||||
|
<div style="margin-top:5px;font-size:12px;color:#6F6B7C;">
|
||||||
|
<span>{escape(sender_line)}</span>
|
||||||
|
<span style="color:#292536;font-weight:550;"> | {subject}</span>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _header_text(sender_line: str, external_subject: str) -> str:
|
||||||
|
return f"✦ Human Handoff\n{sender_line} | {_clean_external_subject(external_subject)}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_mirror_handoff_html(
|
||||||
*,
|
*,
|
||||||
external_sender: str,
|
sender_line: str,
|
||||||
external_subject: str,
|
external_subject: str,
|
||||||
external_body: str,
|
original_html: str,
|
||||||
agent_email: str,
|
agent_email: str,
|
||||||
handoff_reason: str | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
reason = format_reason_for_human(handoff_reason)
|
|
||||||
reason_block = ""
|
|
||||||
if reason:
|
|
||||||
reason_block = f"""
|
|
||||||
<tr><td style="padding:18px 24px 0;">
|
|
||||||
<p style="margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Why you're receiving this</p>
|
|
||||||
<p style="margin:0;font-size:13px;line-height:1.55;color:#4A4652;">{escape(reason)}</p>
|
|
||||||
</td></tr>"""
|
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html><body style="margin:0;padding:0;background:#F5F4F2;">
|
<html><body style="margin:0;padding:0;background:#FFFFFF;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F5F4F2;padding:24px 12px;">
|
{_header_html(sender_line, external_subject)}
|
||||||
<tr><td align="center">
|
<div class="gentic-handoff-original" style="padding:20px 20px 24px;">
|
||||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;background:#FFFFFF;border:1px solid #E8E5EA;border-radius:12px;overflow:hidden;">
|
{original_html}
|
||||||
<tr><td style="padding:24px 24px 8px;">
|
</div>
|
||||||
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#7357F6;">✦ Gentic</p>
|
{_footer_html(agent_email)}
|
||||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#171424;">Human Handoff</h1>
|
|
||||||
<p style="margin:0;font-size:13px;line-height:1.5;color:#77758A;">This conversation needs your attention.</p>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:8px 24px 0;">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#FAF9FB;border:1px solid #ECE9EF;border-radius:8px;">
|
|
||||||
<tr><td style="padding:14px;">
|
|
||||||
<p style="margin:0 0 10px;font-size:10px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">From</p>
|
|
||||||
<p style="margin:0 0 14px;font-size:13px;font-weight:500;color:#292536;">{escape(external_sender)}</p>
|
|
||||||
<p style="margin:0 0 10px;font-size:10px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Subject</p>
|
|
||||||
<p style="margin:0;font-size:13px;font-weight:500;color:#292536;">{escape(_clean_external_subject(external_subject))}</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:18px 24px 0;">
|
|
||||||
<p style="margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Latest message</p>
|
|
||||||
<div style="border-left:3px solid #7357F6;padding:4px 0 4px 14px;font-size:14px;line-height:1.55;color:#292536;white-space:pre-wrap;">{escape(external_body.strip())}</div>
|
|
||||||
</td></tr>
|
|
||||||
{reason_block}
|
|
||||||
<tr><td style="padding:18px 24px 24px;">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F7F5FF;border:1px solid #E5DFFF;border-radius:8px;">
|
|
||||||
<tr><td style="padding:14px;">
|
|
||||||
<p style="margin:0 0 8px;font-size:13px;font-weight:650;color:#5633CE;">↩ Reply normally</p>
|
|
||||||
<p style="margin:0;font-size:12px;line-height:1.5;color:#625D70;">
|
|
||||||
Reply to this email and Gentic will send your response to the original sender as
|
|
||||||
<strong>{escape(agent_email)}</strong>. Only the new text you write will be sent.
|
|
||||||
This handoff email and quoted history stay private.
|
|
||||||
</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:0 24px 24px;text-align:center;">
|
|
||||||
<p style="margin:0;font-size:10px;color:#A19DAA;">Gentic Human Handoff<br>Private Agent ↔ Human conversation</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
def build_update_handoff_text(
|
def build_mirror_handoff_text(
|
||||||
*,
|
*,
|
||||||
external_sender: str,
|
sender_line: str,
|
||||||
external_body: str,
|
external_subject: str,
|
||||||
|
original_body_text: str,
|
||||||
agent_email: str,
|
agent_email: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
return "\n".join(
|
return "\n\n".join(
|
||||||
[
|
[
|
||||||
"Customer replied",
|
_header_text(sender_line, external_subject),
|
||||||
"",
|
"─" * 56,
|
||||||
external_sender,
|
original_body_text.strip(),
|
||||||
"",
|
"─" * 56,
|
||||||
external_body.strip(),
|
_footer_text(agent_email),
|
||||||
"",
|
|
||||||
f"Reply normally to respond through {agent_email}.",
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_update_handoff_html(
|
def build_mirror_update_html(
|
||||||
*,
|
*,
|
||||||
external_sender: str,
|
sender_line: str,
|
||||||
external_body: str,
|
external_subject: str,
|
||||||
|
original_html: str,
|
||||||
agent_email: str,
|
agent_email: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
subject = escape(_clean_external_subject(external_subject))
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html><body style="margin:0;padding:0;background:#F5F4F2;">
|
<html><body style="margin:0;padding:0;background:#FFFFFF;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F5F4F2;padding:24px 12px;">
|
<div class="gentic-handoff-header" style="padding:16px 20px 12px;border-bottom:1px solid #ECE9EF;">
|
||||||
<tr><td align="center">
|
<div style="font-size:14px;font-weight:650;color:#6750E8;">✦ Customer replied</div>
|
||||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;background:#FFFFFF;border:1px solid #E8E5EA;border-radius:12px;">
|
<div style="margin-top:5px;font-size:12px;color:#6F6B7C;">
|
||||||
<tr><td style="padding:24px;">
|
<span>{escape(sender_line)}</span>
|
||||||
<p style="margin:0 0 8px;font-size:12px;font-weight:600;color:#7357F6;">✦ Gentic</p>
|
<span style="color:#292536;font-weight:550;"> | {subject}</span>
|
||||||
<h1 style="margin:0 0 16px;font-size:18px;font-weight:700;color:#171424;">Customer replied</h1>
|
</div>
|
||||||
<p style="margin:0 0 12px;font-size:13px;font-weight:500;color:#292536;">{escape(external_sender)}</p>
|
</div>
|
||||||
<div style="border-left:3px solid #7357F6;padding:4px 0 4px 14px;font-size:14px;line-height:1.55;color:#292536;white-space:pre-wrap;">{escape(external_body.strip())}</div>
|
<div class="gentic-handoff-original" style="padding:20px 20px 24px;">
|
||||||
<p style="margin:18px 0 0;font-size:12px;line-height:1.5;color:#625D70;">
|
{original_html}
|
||||||
Reply normally to respond through <strong>{escape(agent_email)}</strong>.
|
</div>
|
||||||
</p>
|
{_footer_html(agent_email)}
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_mirror_update_text(
|
||||||
|
*,
|
||||||
|
sender_line: str,
|
||||||
|
external_subject: str,
|
||||||
|
original_body_text: str,
|
||||||
|
agent_email: str,
|
||||||
|
) -> str:
|
||||||
|
return "\n\n".join(
|
||||||
|
[
|
||||||
|
f"✦ Customer replied\n{sender_line} | {_clean_external_subject(external_subject)}",
|
||||||
|
"─" * 56,
|
||||||
|
original_body_text.strip(),
|
||||||
|
"─" * 56,
|
||||||
|
_footer_text(agent_email),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_relay_confirmation_text(customer_body: str) -> str:
|
def build_relay_confirmation_text(customer_body: str) -> str:
|
||||||
return f"Sent to customer:\n\n{customer_body.strip()}"
|
return f"Sent to customer:\n\n{customer_body.strip()}"
|
||||||
|
|
|
||||||
108
backend/app/services/handoff_mirror.py
Normal file
108
backend/app/services/handoff_mirror.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.services.handoff_email_templates import (
|
||||||
|
build_mirror_handoff_html,
|
||||||
|
build_mirror_handoff_text,
|
||||||
|
build_mirror_update_html,
|
||||||
|
build_mirror_update_text,
|
||||||
|
format_sender_line,
|
||||||
|
)
|
||||||
|
from app.services.html_sanitizer import plain_text_to_html, sanitize_email_html
|
||||||
|
from app.services.mime_body_extractor import ExtractedMimePart, MirroredMimeContent, extract_mirror_content
|
||||||
|
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HandoffMirrorPayload:
|
||||||
|
body_text: str
|
||||||
|
body_html: str | None
|
||||||
|
inline_parts: list[ExtractedMimePart] = field(default_factory=list)
|
||||||
|
file_attachments: list[ExtractedMimePart] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_mirror_payload(external_message: dict) -> HandoffMirrorPayload:
|
||||||
|
sender = external_message.get("sender") or {}
|
||||||
|
sender_line = format_sender_line(sender)
|
||||||
|
external_subject = external_message.get("subject", "")
|
||||||
|
agent_email = external_message.get("_agent_email", "")
|
||||||
|
|
||||||
|
body_text = (external_message.get("body_text") or external_message.get("snippet") or "").strip()
|
||||||
|
body_html = external_message.get("body_html")
|
||||||
|
inline_parts: list[ExtractedMimePart] = []
|
||||||
|
file_attachments: list[ExtractedMimePart] = []
|
||||||
|
|
||||||
|
if external_message.get("raw_mime"):
|
||||||
|
try:
|
||||||
|
raw = await get_raw_mime_storage().get(external_message["raw_mime"])
|
||||||
|
extracted: MirroredMimeContent = extract_mirror_content(raw)
|
||||||
|
if extracted.body_text:
|
||||||
|
body_text = extracted.body_text
|
||||||
|
if extracted.body_html:
|
||||||
|
body_html = extracted.body_html
|
||||||
|
inline_parts = extracted.inline_parts
|
||||||
|
file_attachments = extracted.file_attachments
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if body_html:
|
||||||
|
original_html = sanitize_email_html(body_html)
|
||||||
|
elif body_text:
|
||||||
|
original_html = plain_text_to_html(body_text)
|
||||||
|
else:
|
||||||
|
original_html = plain_text_to_html("(No message body)")
|
||||||
|
|
||||||
|
if not body_text:
|
||||||
|
body_text = external_message.get("snippet") or "(No message body)"
|
||||||
|
|
||||||
|
return HandoffMirrorPayload(
|
||||||
|
body_text=build_mirror_handoff_text(
|
||||||
|
sender_line=sender_line,
|
||||||
|
external_subject=external_subject,
|
||||||
|
original_body_text=body_text,
|
||||||
|
agent_email=agent_email,
|
||||||
|
),
|
||||||
|
body_html=build_mirror_handoff_html(
|
||||||
|
sender_line=sender_line,
|
||||||
|
external_subject=external_subject,
|
||||||
|
original_html=original_html,
|
||||||
|
agent_email=agent_email,
|
||||||
|
),
|
||||||
|
inline_parts=inline_parts,
|
||||||
|
file_attachments=file_attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_mirror_update_payload(
|
||||||
|
external_message: dict,
|
||||||
|
*,
|
||||||
|
agent_email: str,
|
||||||
|
) -> HandoffMirrorPayload:
|
||||||
|
external_message = {**external_message, "_agent_email": agent_email}
|
||||||
|
payload = await load_mirror_payload(external_message)
|
||||||
|
sender = external_message.get("sender") or {}
|
||||||
|
sender_line = format_sender_line(sender)
|
||||||
|
external_subject = external_message.get("subject", "")
|
||||||
|
body_text = (external_message.get("body_text") or external_message.get("snippet") or "").strip()
|
||||||
|
|
||||||
|
body_html = external_message.get("body_html")
|
||||||
|
if body_html:
|
||||||
|
original_html = sanitize_email_html(body_html)
|
||||||
|
else:
|
||||||
|
original_html = plain_text_to_html(body_text or "(No message body)")
|
||||||
|
|
||||||
|
return HandoffMirrorPayload(
|
||||||
|
body_text=build_mirror_update_text(
|
||||||
|
sender_line=sender_line,
|
||||||
|
external_subject=external_subject,
|
||||||
|
original_body_text=body_text,
|
||||||
|
agent_email=agent_email,
|
||||||
|
),
|
||||||
|
body_html=build_mirror_update_html(
|
||||||
|
sender_line=sender_line,
|
||||||
|
external_subject=external_subject,
|
||||||
|
original_html=original_html,
|
||||||
|
agent_email=agent_email,
|
||||||
|
),
|
||||||
|
inline_parts=payload.inline_parts,
|
||||||
|
file_attachments=payload.file_attachments,
|
||||||
|
)
|
||||||
27
backend/app/services/html_sanitizer.py
Normal file
27
backend/app/services/html_sanitizer.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import re
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
_UNSAFE_TAGS = re.compile(
|
||||||
|
r"<\s*(script|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_UNSAFE_OPEN_TAGS = re.compile(
|
||||||
|
r"<\s*(script|iframe|object|embed|form|base)\b[^>]*/?>",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_EVENT_HANDLERS = re.compile(r"\s+on[a-z]+\s*=\s*(['\"]).*?\1", re.IGNORECASE)
|
||||||
|
_JS_URLS = re.compile(r"""(href|src)\s*=\s*(['"])\s*javascript:.*?\2""", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_email_html(html: str) -> str:
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
cleaned = _UNSAFE_TAGS.sub("", html)
|
||||||
|
cleaned = _UNSAFE_OPEN_TAGS.sub("", cleaned)
|
||||||
|
cleaned = _EVENT_HANDLERS.sub("", cleaned)
|
||||||
|
cleaned = _JS_URLS.sub("", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text_to_html(text: str) -> str:
|
||||||
|
return f'<pre style="margin:0;white-space:pre-wrap;font-family:inherit;">{escape(text)}</pre>'
|
||||||
|
|
@ -4,15 +4,16 @@ from datetime import datetime, timezone
|
||||||
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from app.services.external_reply_service import (
|
||||||
|
build_external_reply_plan,
|
||||||
|
send_external_reply,
|
||||||
|
)
|
||||||
from app.services.handoff_email_templates import (
|
from app.services.handoff_email_templates import (
|
||||||
build_initial_handoff_html,
|
|
||||||
build_initial_handoff_text,
|
|
||||||
build_relay_confirmation_text,
|
build_relay_confirmation_text,
|
||||||
build_update_handoff_html,
|
|
||||||
build_update_handoff_text,
|
|
||||||
external_reply_subject,
|
external_reply_subject,
|
||||||
handoff_email_subject,
|
handoff_email_subject,
|
||||||
)
|
)
|
||||||
|
from app.services.handoff_mirror import load_mirror_payload, load_mirror_update_payload
|
||||||
from app.services.handoff_evaluator import evaluate_handoff
|
from app.services.handoff_evaluator import evaluate_handoff
|
||||||
from app.services.mail_sender import deliver_email
|
from app.services.mail_sender import deliver_email
|
||||||
from app.services.message_ids import normalize_message_id, parse_message_id_list
|
from app.services.message_ids import normalize_message_id, parse_message_id_list
|
||||||
|
|
@ -297,6 +298,7 @@ async def store_handoff_message(
|
||||||
"received_at": now if direction == "inbound" else None,
|
"received_at": now if direction == "inbound" else None,
|
||||||
"status": "received" if direction == "inbound" else "accepted",
|
"status": "received" if direction == "inbound" else "accepted",
|
||||||
"message_id": normalize_message_id(internet_message_id),
|
"message_id": normalize_message_id(internet_message_id),
|
||||||
|
"internet_message_id": normalize_message_id(internet_message_id),
|
||||||
"in_reply_to": normalize_message_id(in_reply_to),
|
"in_reply_to": normalize_message_id(in_reply_to),
|
||||||
"references": references or [],
|
"references": references or [],
|
||||||
}
|
}
|
||||||
|
|
@ -355,28 +357,17 @@ async def send_handoff_to_human(
|
||||||
domain,
|
domain,
|
||||||
existing_handoff: dict | None = None,
|
existing_handoff: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
external_sender = external_message.get("sender", {})
|
|
||||||
external_sender_text = external_sender.get("address") or "unknown sender"
|
|
||||||
external_subject = external_message.get("subject", "")
|
external_subject = external_message.get("subject", "")
|
||||||
external_body = external_message.get("body_text") or external_message.get("snippet") or ""
|
|
||||||
|
|
||||||
stable_subject = handoff_email_subject(external_subject)
|
stable_subject = handoff_email_subject(external_subject)
|
||||||
|
mirror_source = {**external_message, "_agent_email": agent_email}
|
||||||
|
|
||||||
if existing_handoff:
|
if existing_handoff:
|
||||||
handoff_id = existing_handoff["_id"]
|
handoff_id = existing_handoff["_id"]
|
||||||
handoff_thread_id = existing_handoff["handoff_thread_id"]
|
handoff_thread_id = existing_handoff["handoff_thread_id"]
|
||||||
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
|
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
|
||||||
threading = threading_fields_from_parent(parent) if parent else {}
|
threading = threading_fields_from_parent(parent) if parent else {}
|
||||||
subject = _handoff_reply_subject(external_subject)
|
subject = _handoff_reply_subject(external_subject)
|
||||||
body_text = build_update_handoff_text(
|
mirror = await load_mirror_update_payload(mirror_source, agent_email=agent_email)
|
||||||
external_sender=external_sender_text,
|
|
||||||
external_body=external_body,
|
|
||||||
agent_email=agent_email,
|
|
||||||
)
|
|
||||||
body_html = build_update_handoff_html(
|
|
||||||
external_sender=external_sender_text,
|
|
||||||
external_body=external_body,
|
|
||||||
agent_email=agent_email,
|
|
||||||
)
|
|
||||||
relationship = LINK_EXTERNAL_UPDATE_RELAYED
|
relationship = LINK_EXTERNAL_UPDATE_RELAYED
|
||||||
else:
|
else:
|
||||||
handoff_thread_id = await create_empty_handoff_thread(
|
handoff_thread_id = await create_empty_handoff_thread(
|
||||||
|
|
@ -386,20 +377,8 @@ async def send_handoff_to_human(
|
||||||
)
|
)
|
||||||
threading = {}
|
threading = {}
|
||||||
subject = stable_subject
|
subject = stable_subject
|
||||||
body_text = build_initial_handoff_text(
|
mirror = await load_mirror_payload(mirror_source)
|
||||||
external_sender=external_sender_text,
|
external_sender_text = (external_message.get("sender") or {}).get("address") or "unknown sender"
|
||||||
external_subject=external_subject,
|
|
||||||
external_body=external_body,
|
|
||||||
handoff_reason=handoff_reason,
|
|
||||||
agent_email=agent_email,
|
|
||||||
)
|
|
||||||
body_html = build_initial_handoff_html(
|
|
||||||
external_sender=external_sender_text,
|
|
||||||
external_subject=external_subject,
|
|
||||||
external_body=external_body,
|
|
||||||
handoff_reason=handoff_reason,
|
|
||||||
agent_email=agent_email,
|
|
||||||
)
|
|
||||||
handoff_doc = {
|
handoff_doc = {
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
"agent_email": agent_email.lower(),
|
"agent_email": agent_email.lower(),
|
||||||
|
|
@ -426,11 +405,13 @@ async def send_handoff_to_human(
|
||||||
cc=[],
|
cc=[],
|
||||||
bcc=[],
|
bcc=[],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body_text,
|
body_text=mirror.body_text,
|
||||||
body_html=body_html,
|
body_html=mirror.body_html,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
in_reply_to=threading.get("in_reply_to"),
|
||||||
references=threading.get("references"),
|
references=threading.get("references"),
|
||||||
|
inline_parts=mirror.inline_parts,
|
||||||
|
file_attachments=mirror.file_attachments,
|
||||||
)
|
)
|
||||||
|
|
||||||
handoff_message = await store_handoff_message(
|
handoff_message = await store_handoff_message(
|
||||||
|
|
@ -442,8 +423,8 @@ async def send_handoff_to_human(
|
||||||
sender={"name": "Gentic Agent", "address": agent_email},
|
sender={"name": "Gentic Agent", "address": agent_email},
|
||||||
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body_text,
|
body_text=mirror.body_text,
|
||||||
body_html=body_html,
|
body_html=mirror.body_html,
|
||||||
internet_message_id=internet_message_id,
|
internet_message_id=internet_message_id,
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
in_reply_to=threading.get("in_reply_to"),
|
||||||
references=threading.get("references"),
|
references=threading.get("references"),
|
||||||
|
|
@ -581,81 +562,28 @@ async def process_human_reply(
|
||||||
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
|
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
|
||||||
return
|
return
|
||||||
|
|
||||||
threading = threading_fields_from_parent(external_parent)
|
|
||||||
external_subject = external_reply_subject(
|
|
||||||
handoff.get("external_subject") or external_parent.get("subject", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
external_recipient = external_recipient or external_parent.get("sender", {}).get("address")
|
external_recipient = external_recipient or external_parent.get("sender", {}).get("address")
|
||||||
if not external_recipient:
|
if not external_recipient:
|
||||||
return
|
return
|
||||||
|
|
||||||
customer_body = new_reply_content
|
customer_body = new_reply_content
|
||||||
internet_message_id = await deliver_email(
|
reply_plan = build_external_reply_plan(
|
||||||
sender=agent_email,
|
parent_external_message=external_parent,
|
||||||
recipients=[external_recipient],
|
|
||||||
cc=[],
|
|
||||||
bcc=[],
|
|
||||||
subject=external_subject,
|
|
||||||
body_text=customer_body,
|
body_text=customer_body,
|
||||||
body_html=None,
|
subject_override=external_reply_subject(
|
||||||
domain=domain,
|
handoff.get("external_subject") or external_parent.get("subject", "")
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
),
|
||||||
references=threading.get("references"),
|
recipients=[external_recipient],
|
||||||
)
|
)
|
||||||
|
external_doc = await send_external_reply(
|
||||||
external_doc = {
|
|
||||||
"direction": "outbound",
|
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
|
||||||
"account_id": account_id,
|
|
||||||
"sender": {"name": "Gentic Agent", "address": agent_email},
|
|
||||||
"to": [{"name": external_recipient.split("@")[0], "address": external_recipient}],
|
|
||||||
"subject": external_subject,
|
|
||||||
"snippet": _snippet(customer_body),
|
|
||||||
"body_text": customer_body,
|
|
||||||
"body_html": None,
|
|
||||||
"attachments": [],
|
|
||||||
"created_at": now,
|
|
||||||
"sent_at": now,
|
|
||||||
"status": "accepted",
|
|
||||||
"message_id": normalize_message_id(internet_message_id),
|
|
||||||
"in_reply_to": threading.get("in_reply_to"),
|
|
||||||
"references": threading.get("references", []),
|
|
||||||
"parent_message_id": external_parent["_id"],
|
|
||||||
"thread_relation": "reply",
|
|
||||||
"thread_resolution_method": "human_handoff_bridge",
|
|
||||||
}
|
|
||||||
ext_result = await database.messages.insert_one(external_doc)
|
|
||||||
external_doc["_id"] = ext_result.inserted_id
|
|
||||||
await attach_message_to_thread(
|
|
||||||
database,
|
database,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
thread_id=external_thread_id,
|
sender=agent_email,
|
||||||
message_id=ext_result.inserted_id,
|
sender_name="Gentic Agent",
|
||||||
subject=external_subject,
|
external_thread_id=external_thread_id,
|
||||||
event_at=now,
|
plan=reply_plan,
|
||||||
)
|
domain=domain,
|
||||||
await database.mailbox_entries.insert_one(
|
resolution_method="human_handoff_bridge",
|
||||||
{
|
|
||||||
"account_id": account_id,
|
|
||||||
"message_id": ext_result.inserted_id,
|
|
||||||
"thread_id": external_thread_id,
|
|
||||||
"mailbox": "sent",
|
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
|
||||||
"direction": "outbound",
|
|
||||||
"sender": external_doc["sender"],
|
|
||||||
"recipients": external_doc["to"],
|
|
||||||
"subject": external_subject,
|
|
||||||
"snippet": external_doc["snippet"],
|
|
||||||
"attachments": [],
|
|
||||||
"status": "accepted",
|
|
||||||
"is_read": True,
|
|
||||||
"is_starred": False,
|
|
||||||
"event_at": now,
|
|
||||||
"created_at": now,
|
|
||||||
"trashed_at": None,
|
|
||||||
"deletion_state": None,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
relay_body = build_relay_confirmation_text(customer_body)
|
relay_body = build_relay_confirmation_text(customer_body)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.services.human_handoff_service import (
|
||||||
resolve_handoff_from_inbound,
|
resolve_handoff_from_inbound,
|
||||||
maybe_trigger_handoff,
|
maybe_trigger_handoff,
|
||||||
)
|
)
|
||||||
|
from app.services.message_ids import normalize_message_id
|
||||||
from app.services.mime_parser import parse_mime
|
from app.services.mime_parser import parse_mime
|
||||||
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
|
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
|
||||||
from app.services.thread_resolver import participants_from_parsed, resolve_thread
|
from app.services.thread_resolver import participants_from_parsed, resolve_thread
|
||||||
|
|
@ -38,6 +39,38 @@ def peer_is_allowed(session) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def log_smtp_message_received(
|
||||||
|
*,
|
||||||
|
message_id: str | None,
|
||||||
|
in_reply_to: str | None,
|
||||||
|
references: list[str],
|
||||||
|
sender: str,
|
||||||
|
recipient: str,
|
||||||
|
subject: str,
|
||||||
|
resolved_scope: str,
|
||||||
|
resolved_thread_id,
|
||||||
|
resolution_method: str,
|
||||||
|
stored_message_id,
|
||||||
|
mailbox_id: str = "inbox",
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
"smtp_message_received message_id=%s in_reply_to=%s references=%s "
|
||||||
|
"from=%s to=%s subject=%s resolved_scope=%s resolved_thread_id=%s "
|
||||||
|
"resolution_method=%s stored_message_id=%s mailbox_id=%s",
|
||||||
|
message_id,
|
||||||
|
in_reply_to,
|
||||||
|
" ".join(references or []),
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
resolved_scope,
|
||||||
|
resolved_thread_id,
|
||||||
|
resolution_method,
|
||||||
|
stored_message_id,
|
||||||
|
mailbox_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InboundHandler:
|
class InboundHandler:
|
||||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||||
if not peer_is_allowed(session):
|
if not peer_is_allowed(session):
|
||||||
|
|
@ -55,6 +88,12 @@ class InboundHandler:
|
||||||
if not hasattr(envelope, "account_ids"):
|
if not hasattr(envelope, "account_ids"):
|
||||||
envelope.account_ids = {}
|
envelope.account_ids = {}
|
||||||
envelope.account_ids[normalized] = str(account.id)
|
envelope.account_ids[normalized] = str(account.id)
|
||||||
|
logger.info(
|
||||||
|
"smtp_rcpt_accepted mail_from=%s rcpt_to=%s account_id=%s",
|
||||||
|
envelope.mail_from.lower(),
|
||||||
|
normalized,
|
||||||
|
envelope.account_ids[normalized],
|
||||||
|
)
|
||||||
return "250 2.1.5 Recipient OK"
|
return "250 2.1.5 Recipient OK"
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
async def handle_DATA(self, server, session, envelope):
|
||||||
|
|
@ -74,12 +113,22 @@ class InboundHandler:
|
||||||
{"_id": 1},
|
{"_id": 1},
|
||||||
):
|
):
|
||||||
return f"250 2.0.0 Message previously accepted as {delivery_key}"
|
return f"250 2.0.0 Message previously accepted as {delivery_key}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"smtp_data_accepted mail_from=%s rcpt_to=%s delivery_key=%s bytes=%s",
|
||||||
|
envelope.mail_from.lower(),
|
||||||
|
",".join(recipients),
|
||||||
|
delivery_key,
|
||||||
|
len(raw),
|
||||||
|
)
|
||||||
|
|
||||||
parsed = parse_mime(raw)
|
parsed = parse_mime(raw)
|
||||||
body_text = parsed.pop("body_text")
|
body_text = parsed.pop("body_text")
|
||||||
body_html = parsed.pop("body_html")
|
body_html = parsed.pop("body_html")
|
||||||
stored = await get_raw_mime_storage().put(raw, delivery_key)
|
stored = await get_raw_mime_storage().put(raw, delivery_key)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
sender_address = (parsed.get("sender") or {}).get("address", "").lower()
|
sender_address = (parsed.get("sender") or {}).get("address", "").lower()
|
||||||
|
internet_message_id = normalize_message_id(parsed.get("message_id"))
|
||||||
|
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
account_id = envelope.account_ids[recipient]
|
account_id = envelope.account_ids[recipient]
|
||||||
|
|
@ -111,6 +160,8 @@ class InboundHandler:
|
||||||
"delivery_key": delivery_key,
|
"delivery_key": delivery_key,
|
||||||
"received_at": now,
|
"received_at": now,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
|
"message_id": internet_message_id,
|
||||||
|
"internet_message_id": internet_message_id,
|
||||||
}
|
}
|
||||||
await process_human_reply(
|
await process_human_reply(
|
||||||
mongo_db,
|
mongo_db,
|
||||||
|
|
@ -121,10 +172,18 @@ class InboundHandler:
|
||||||
inbound_body=body_text,
|
inbound_body=body_text,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
)
|
)
|
||||||
logger.info(
|
log_smtp_message_received(
|
||||||
"handoff_human_reply handoff_id=%s account=%s",
|
message_id=internet_message_id,
|
||||||
handoff["_id"],
|
in_reply_to=parsed.get("in_reply_to"),
|
||||||
account_id,
|
references=parsed.get("references", []),
|
||||||
|
sender=sender_address,
|
||||||
|
recipient=recipient,
|
||||||
|
subject=parsed.get("subject", ""),
|
||||||
|
resolved_scope=MESSAGE_SCOPE_HANDOFF,
|
||||||
|
resolved_thread_id=handoff.get("handoff_thread_id"),
|
||||||
|
resolution_method="handoff_in_reply_to",
|
||||||
|
stored_message_id=None,
|
||||||
|
mailbox_id="handoff",
|
||||||
)
|
)
|
||||||
return f"250 2.0.0 Handoff reply accepted as {delivery_key}"
|
return f"250 2.0.0 Handoff reply accepted as {delivery_key}"
|
||||||
|
|
||||||
|
|
@ -135,6 +194,8 @@ class InboundHandler:
|
||||||
"envelope_sender": envelope.mail_from.lower(),
|
"envelope_sender": envelope.mail_from.lower(),
|
||||||
"envelope_recipients": recipients,
|
"envelope_recipients": recipients,
|
||||||
"delivery_key": delivery_key,
|
"delivery_key": delivery_key,
|
||||||
|
"message_id": internet_message_id,
|
||||||
|
"internet_message_id": internet_message_id,
|
||||||
"raw_mime": {
|
"raw_mime": {
|
||||||
"bucket": stored.bucket,
|
"bucket": stored.bucket,
|
||||||
"name": stored.name,
|
"name": stored.name,
|
||||||
|
|
@ -156,7 +217,7 @@ class InboundHandler:
|
||||||
if persisted is None:
|
if persisted is None:
|
||||||
raise RuntimeError("Inbound message was not persisted.")
|
raise RuntimeError("Inbound message was not persisted.")
|
||||||
|
|
||||||
if body_text:
|
if body_text or body_html:
|
||||||
await mongo_db.messages.update_one(
|
await mongo_db.messages.update_one(
|
||||||
{"_id": persisted["_id"]},
|
{"_id": persisted["_id"]},
|
||||||
{"$set": {"body_text": body_text, "body_html": body_html}},
|
{"$set": {"body_text": body_text, "body_html": body_html}},
|
||||||
|
|
@ -191,7 +252,9 @@ class InboundHandler:
|
||||||
{
|
{
|
||||||
"$set": {
|
"$set": {
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
"in_reply_to": parsed.get("in_reply_to"),
|
"message_id": internet_message_id,
|
||||||
|
"internet_message_id": internet_message_id,
|
||||||
|
"in_reply_to": normalize_message_id(parsed.get("in_reply_to")),
|
||||||
"references": parsed.get("references", []),
|
"references": parsed.get("references", []),
|
||||||
"raw_headers": parsed.get("raw_headers", {}),
|
"raw_headers": parsed.get("raw_headers", {}),
|
||||||
"parent_message_id": resolution.parent_message_id,
|
"parent_message_id": resolution.parent_message_id,
|
||||||
|
|
@ -200,7 +263,7 @@ class InboundHandler:
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await mongo_db.mailbox_entries.update_one(
|
mailbox_result = await mongo_db.mailbox_entries.update_one(
|
||||||
{
|
{
|
||||||
"account_id": account_id,
|
"account_id": account_id,
|
||||||
"message_id": persisted["_id"],
|
"message_id": persisted["_id"],
|
||||||
|
|
@ -244,10 +307,26 @@ class InboundHandler:
|
||||||
external_thread_id=thread_id,
|
external_thread_id=thread_id,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
)
|
)
|
||||||
|
log_smtp_message_received(
|
||||||
|
message_id=internet_message_id,
|
||||||
|
in_reply_to=parsed.get("in_reply_to"),
|
||||||
|
references=parsed.get("references", []),
|
||||||
|
sender=sender_address,
|
||||||
|
recipient=recipient,
|
||||||
|
subject=parsed.get("subject", ""),
|
||||||
|
resolved_scope=MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
resolved_thread_id=thread_id,
|
||||||
|
resolution_method=resolution.thread_resolution_method,
|
||||||
|
stored_message_id=persisted["_id"],
|
||||||
|
mailbox_id="inbox",
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"message_received message_id=%s thread_id=%s scope=EXTERNAL",
|
"message_received message_id=%s internet_message_id=%s thread_id=%s "
|
||||||
|
"scope=EXTERNAL mailbox_entry_upserted=%s",
|
||||||
persisted["_id"],
|
persisted["_id"],
|
||||||
|
internet_message_id,
|
||||||
thread_id,
|
thread_id,
|
||||||
|
mailbox_result.upserted_id is not None or mailbox_result.modified_count > 0,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Temporary failure accepting inbound message")
|
logger.exception("Temporary failure accepting inbound message")
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import email.policy
|
import email.policy
|
||||||
import email.utils
|
import email.utils
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage, Message
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
import dkim
|
import dkim
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import MailDomain
|
from app.models import MailDomain
|
||||||
|
from app.services.mime_body_extractor import ExtractedMimePart
|
||||||
|
|
||||||
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
|
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_attachment(part: ExtractedMimePart) -> MIMEBase:
|
||||||
|
maintype, _, subtype = part.content_type.partition("/")
|
||||||
|
if not subtype:
|
||||||
|
maintype, subtype = "application", "octet-stream"
|
||||||
|
mime = MIMEBase(maintype, subtype, policy=SMTP_POLICY)
|
||||||
|
mime.set_payload(part.payload)
|
||||||
|
email.utils.encode_base64(mime)
|
||||||
|
mime.add_header("Content-Disposition", part.disposition, filename=part.filename)
|
||||||
|
if part.content_id:
|
||||||
|
mime.add_header("Content-ID", f"<{part.content_id}>")
|
||||||
|
return mime
|
||||||
|
|
||||||
|
|
||||||
def build_message(
|
def build_message(
|
||||||
*,
|
*,
|
||||||
sender: str,
|
sender: str,
|
||||||
|
|
@ -23,7 +40,47 @@ def build_message(
|
||||||
in_reply_to: str | None = None,
|
in_reply_to: str | None = None,
|
||||||
references: list[str] | None = None,
|
references: list[str] | None = None,
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
) -> EmailMessage:
|
inline_parts: list[ExtractedMimePart] | None = None,
|
||||||
|
file_attachments: list[ExtractedMimePart] | None = None,
|
||||||
|
) -> Message:
|
||||||
|
inline_parts = inline_parts or []
|
||||||
|
file_attachments = file_attachments or []
|
||||||
|
|
||||||
|
if inline_parts or file_attachments:
|
||||||
|
root = MIMEMultipart("mixed", policy=SMTP_POLICY)
|
||||||
|
root["From"] = sender
|
||||||
|
root["To"] = ", ".join(recipients)
|
||||||
|
if cc:
|
||||||
|
root["Cc"] = ", ".join(cc)
|
||||||
|
if reply_to:
|
||||||
|
root["Reply-To"] = reply_to
|
||||||
|
root["Subject"] = subject
|
||||||
|
root["Date"] = email.utils.formatdate(localtime=True)
|
||||||
|
generated_id = message_id or email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||||
|
root["Message-ID"] = generated_id
|
||||||
|
if in_reply_to:
|
||||||
|
root["In-Reply-To"] = in_reply_to
|
||||||
|
if references:
|
||||||
|
root["References"] = " ".join(references)
|
||||||
|
|
||||||
|
alternative = MIMEMultipart("alternative", policy=SMTP_POLICY)
|
||||||
|
alternative.attach(MIMEText(body_text, "plain", "utf-8", policy=SMTP_POLICY))
|
||||||
|
|
||||||
|
if body_html:
|
||||||
|
if inline_parts:
|
||||||
|
related = MIMEMultipart("related", policy=SMTP_POLICY)
|
||||||
|
related.attach(MIMEText(body_html, "html", "utf-8", policy=SMTP_POLICY))
|
||||||
|
for part in inline_parts:
|
||||||
|
related.attach(_mime_attachment(part))
|
||||||
|
alternative.attach(related)
|
||||||
|
else:
|
||||||
|
alternative.attach(MIMEText(body_html, "html", "utf-8", policy=SMTP_POLICY))
|
||||||
|
|
||||||
|
root.attach(alternative)
|
||||||
|
for attachment in file_attachments:
|
||||||
|
root.attach(_mime_attachment(attachment))
|
||||||
|
return root
|
||||||
|
|
||||||
message = EmailMessage(policy=SMTP_POLICY)
|
message = EmailMessage(policy=SMTP_POLICY)
|
||||||
message["From"] = sender
|
message["From"] = sender
|
||||||
message["To"] = ", ".join(recipients)
|
message["To"] = ", ".join(recipients)
|
||||||
|
|
@ -45,7 +102,7 @@ def build_message(
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
def sign_message(message: EmailMessage, domain: MailDomain) -> bytes:
|
def sign_message(message: Message, domain: MailDomain) -> bytes:
|
||||||
raw_message = message.as_bytes(policy=SMTP_POLICY)
|
raw_message = message.as_bytes(policy=SMTP_POLICY)
|
||||||
signed_headers = [
|
signed_headers = [
|
||||||
b"from",
|
b"from",
|
||||||
|
|
@ -106,6 +163,8 @@ async def deliver_email(
|
||||||
in_reply_to: str | None = None,
|
in_reply_to: str | None = None,
|
||||||
references: list[str] | None = None,
|
references: list[str] | None = None,
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
|
inline_parts: list[ExtractedMimePart] | None = None,
|
||||||
|
file_attachments: list[ExtractedMimePart] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
message = build_message(
|
message = build_message(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
|
@ -118,6 +177,8 @@ async def deliver_email(
|
||||||
in_reply_to=in_reply_to,
|
in_reply_to=in_reply_to,
|
||||||
references=references,
|
references=references,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
|
inline_parts=inline_parts,
|
||||||
|
file_attachments=file_attachments,
|
||||||
)
|
)
|
||||||
signed = sign_message(message, domain)
|
signed = sign_message(message, domain)
|
||||||
await send_via_smtp(
|
await send_via_smtp(
|
||||||
|
|
|
||||||
106
backend/app/services/mime_body_extractor.py
Normal file
106
backend/app/services/mime_body_extractor.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from email import policy
|
||||||
|
from email.message import EmailMessage, Message
|
||||||
|
from email.parser import BytesParser
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedMimePart:
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
payload: bytes
|
||||||
|
content_id: str | None = None
|
||||||
|
disposition: str = "attachment"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MirroredMimeContent:
|
||||||
|
body_text: str = ""
|
||||||
|
body_html: str | None = None
|
||||||
|
inline_parts: list[ExtractedMimePart] = field(default_factory=list)
|
||||||
|
file_attachments: list[ExtractedMimePart] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _part_payload(part: Message) -> bytes:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
return payload
|
||||||
|
if isinstance(payload, str):
|
||||||
|
return payload.encode("utf-8", errors="replace")
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def _part_text(part: Message) -> str:
|
||||||
|
try:
|
||||||
|
value = part.get_content()
|
||||||
|
except (LookupError, UnicodeError):
|
||||||
|
value = _part_payload(part).decode("utf-8", errors="replace")
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _content_id(part: Message) -> str | None:
|
||||||
|
header = part.get("Content-ID")
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
return header.strip().strip("<>")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_attachment_part(part: Message) -> bool:
|
||||||
|
disposition = (part.get_content_disposition() or "").lower()
|
||||||
|
if disposition == "attachment":
|
||||||
|
return True
|
||||||
|
if disposition == "inline":
|
||||||
|
return False
|
||||||
|
filename = part.get_filename()
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
if filename and content_type not in {"text/plain", "text/html"}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_parts(message: Message, collector: MirroredMimeContent) -> None:
|
||||||
|
if message.is_multipart():
|
||||||
|
for part in message.get_payload():
|
||||||
|
if isinstance(part, Message):
|
||||||
|
_walk_parts(part, collector)
|
||||||
|
return
|
||||||
|
|
||||||
|
content_type = message.get_content_type()
|
||||||
|
cid = _content_id(message)
|
||||||
|
filename = message.get_filename() or (f"inline-{cid}" if cid else "attachment")
|
||||||
|
|
||||||
|
if content_type == "text/plain" and not _is_attachment_part(message):
|
||||||
|
text = _part_text(message).strip()
|
||||||
|
if text and not collector.body_text:
|
||||||
|
collector.body_text = text
|
||||||
|
return
|
||||||
|
|
||||||
|
if content_type == "text/html" and not _is_attachment_part(message):
|
||||||
|
html = _part_text(message).strip()
|
||||||
|
if html:
|
||||||
|
collector.body_html = html
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = _part_payload(message)
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
|
||||||
|
extracted = ExtractedMimePart(
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
payload=payload,
|
||||||
|
content_id=cid,
|
||||||
|
disposition="inline" if cid or message.get_content_disposition() == "inline" else "attachment",
|
||||||
|
)
|
||||||
|
|
||||||
|
if cid or extracted.disposition == "inline":
|
||||||
|
collector.inline_parts.append(extracted)
|
||||||
|
elif _is_attachment_part(message):
|
||||||
|
collector.file_attachments.append(extracted)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_mirror_content(raw: bytes) -> MirroredMimeContent:
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
|
collector = MirroredMimeContent()
|
||||||
|
_walk_parts(message, collector)
|
||||||
|
return collector
|
||||||
|
|
@ -196,7 +196,7 @@ async def lookup_known_messages(
|
||||||
{"$match": {"entry.0": {"$exists": True}}},
|
{"$match": {"entry.0": {"$exists": True}}},
|
||||||
]
|
]
|
||||||
known: dict[str, KnownMessage] = {}
|
known: dict[str, KnownMessage] = {}
|
||||||
cursor = database.messages.aggregate(pipeline)
|
cursor = await database.messages.aggregate(pipeline)
|
||||||
async for document in cursor:
|
async for document in cursor:
|
||||||
internet_id = normalize_message_id(document.get("message_id"))
|
internet_id = normalize_message_id(document.get("message_id"))
|
||||||
if not internet_id:
|
if not internet_id:
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,9 @@ async def ensure_thread_for_message(
|
||||||
|
|
||||||
|
|
||||||
def threading_fields_from_parent(parent: dict) -> dict:
|
def threading_fields_from_parent(parent: dict) -> dict:
|
||||||
parent_internet_id = normalize_message_id(parent.get("message_id"))
|
parent_internet_id = normalize_message_id(
|
||||||
|
parent.get("internet_message_id") or parent.get("message_id")
|
||||||
|
)
|
||||||
parent_references = parent.get("references") or []
|
parent_references = parent.get("references") or []
|
||||||
if isinstance(parent_references, str):
|
if isinstance(parent_references, str):
|
||||||
parent_references = [parent_references]
|
parent_references = [parent_references]
|
||||||
|
|
|
||||||
76
backend/tests/test_email_threading_roundtrip.py
Normal file
76
backend/tests/test_email_threading_roundtrip.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import pytest
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from app.services.thread_resolver import KnownMessage, resolve_thread_from_known
|
||||||
|
|
||||||
|
|
||||||
|
def _known(
|
||||||
|
internet_id: str,
|
||||||
|
thread_id: ObjectId,
|
||||||
|
*,
|
||||||
|
references: list[str] | None = None,
|
||||||
|
) -> KnownMessage:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services.message_ids import normalize_message_id
|
||||||
|
from app.services.subject_normalize import normalize_subject
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return KnownMessage(
|
||||||
|
id=ObjectId(),
|
||||||
|
thread_id=thread_id,
|
||||||
|
internet_message_id=normalize_message_id(internet_id) or "",
|
||||||
|
account_id="acct-1",
|
||||||
|
sent_at=now,
|
||||||
|
received_at=now,
|
||||||
|
created_at=now,
|
||||||
|
participants=frozenset({"customer@gmail.com"}),
|
||||||
|
normalized_subject=normalize_subject("lets try one more time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gmail_reply_to_gentic_resolves_same_thread():
|
||||||
|
thread_id = ObjectId()
|
||||||
|
a1 = _known("<a1@gmail.com>", thread_id)
|
||||||
|
g1 = _known("<g1@gentic.email>", thread_id, references=["<a1@gmail.com>"])
|
||||||
|
known = {
|
||||||
|
a1.internet_message_id: a1,
|
||||||
|
g1.internet_message_id: g1,
|
||||||
|
}
|
||||||
|
resolution = resolve_thread_from_known(
|
||||||
|
account_id="acct-1",
|
||||||
|
in_reply_to="<g1@gentic.email>",
|
||||||
|
references=["<a1@gmail.com>", "<g1@gentic.email>"],
|
||||||
|
subject="Re: lets try one more time",
|
||||||
|
participants={"customer@gmail.com"},
|
||||||
|
known_by_internet_id=known,
|
||||||
|
)
|
||||||
|
assert resolution.thread_id == thread_id
|
||||||
|
assert resolution.thread_resolution_method == "in_reply_to"
|
||||||
|
assert resolution.parent_message_id == g1.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lookup_known_messages_awaits_aggregate():
|
||||||
|
from app.services.thread_resolver import lookup_known_messages
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
raise StopAsyncIteration
|
||||||
|
|
||||||
|
class FakeMessages:
|
||||||
|
def __init__(self):
|
||||||
|
self.aggregate_awaited = False
|
||||||
|
|
||||||
|
async def aggregate(self, pipeline):
|
||||||
|
self.aggregate_awaited = True
|
||||||
|
return FakeCursor()
|
||||||
|
|
||||||
|
messages = FakeMessages()
|
||||||
|
database = type("DB", (), {"messages": messages})()
|
||||||
|
result = await lookup_known_messages(database, "acct-1", ["<g1@gentic.email>"])
|
||||||
|
assert result == {}
|
||||||
|
assert messages.aggregate_awaited is True
|
||||||
57
backend/tests/test_external_reply_service.py
Normal file
57
backend/tests/test_external_reply_service.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from app.services.external_reply_service import build_external_reply_plan
|
||||||
|
from app.services.thread_service import threading_fields_from_parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_external_reply_references_gmail_parent():
|
||||||
|
parent = {
|
||||||
|
"_id": ObjectId(),
|
||||||
|
"internet_message_id": "<gmail-a@mail.gmail.com>",
|
||||||
|
"message_id": "<gmail-a@mail.gmail.com>",
|
||||||
|
"references": [],
|
||||||
|
"subject": "lets try one more time",
|
||||||
|
}
|
||||||
|
plan = build_external_reply_plan(
|
||||||
|
parent_external_message=parent,
|
||||||
|
body_text="I hear you.",
|
||||||
|
recipients=["customer@gmail.com"],
|
||||||
|
)
|
||||||
|
assert plan.threading.in_reply_to == "<gmail-a@mail.gmail.com>"
|
||||||
|
assert plan.threading.references == ["<gmail-a@mail.gmail.com>"]
|
||||||
|
assert plan.subject == "Re: lets try one more time"
|
||||||
|
assert plan.threading.replying_to_internet_message_id == "<gmail-a@mail.gmail.com>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_external_reply_chains_references_for_multi_round():
|
||||||
|
parent = {
|
||||||
|
"_id": ObjectId(),
|
||||||
|
"internet_message_id": "<a2@gmail.com>",
|
||||||
|
"message_id": "<a2@gmail.com>",
|
||||||
|
"references": ["<a1@gmail.com>", "<g1@gentic.email>"],
|
||||||
|
"subject": "Re: lets try one more time",
|
||||||
|
}
|
||||||
|
threading = threading_fields_from_parent(parent)
|
||||||
|
assert threading["in_reply_to"] == "<a2@gmail.com>"
|
||||||
|
assert threading["references"] == [
|
||||||
|
"<a1@gmail.com>",
|
||||||
|
"<g1@gentic.email>",
|
||||||
|
"<a2@gmail.com>",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_reply_never_uses_internal_object_id_as_in_reply_to():
|
||||||
|
parent = {
|
||||||
|
"_id": ObjectId(),
|
||||||
|
"internet_message_id": "<gmail-a@mail.gmail.com>",
|
||||||
|
"message_id": "<gmail-a@mail.gmail.com>",
|
||||||
|
"references": [],
|
||||||
|
"subject": "hello",
|
||||||
|
}
|
||||||
|
plan = build_external_reply_plan(
|
||||||
|
parent_external_message=parent,
|
||||||
|
body_text="reply",
|
||||||
|
recipients=["customer@gmail.com"],
|
||||||
|
)
|
||||||
|
assert str(parent["_id"]) not in (plan.threading.in_reply_to or "")
|
||||||
|
assert all(str(parent["_id"]) not in ref for ref in plan.threading.references)
|
||||||
85
backend/tests/test_handoff_mirror.py
Normal file
85
backend/tests/test_handoff_mirror.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from app.services.handoff_email_templates import (
|
||||||
|
build_mirror_handoff_html,
|
||||||
|
build_mirror_handoff_text,
|
||||||
|
external_reply_subject,
|
||||||
|
format_sender_line,
|
||||||
|
handoff_email_subject,
|
||||||
|
)
|
||||||
|
from app.services.html_sanitizer import sanitize_email_html
|
||||||
|
from app.services.mime_body_extractor import extract_mirror_content
|
||||||
|
from app.services.thread_service import threading_fields_from_parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_template_is_minimal():
|
||||||
|
html = build_mirror_handoff_html(
|
||||||
|
sender_line="091rabbytes@gmail.com",
|
||||||
|
external_subject="lets try one more time",
|
||||||
|
original_html="<p>one last attempt. i am tired</p>",
|
||||||
|
agent_email="suyash@gentic.email",
|
||||||
|
)
|
||||||
|
assert "LATEST MESSAGE" not in html
|
||||||
|
assert "Why you" not in html
|
||||||
|
assert "gentic-handoff-original" in html
|
||||||
|
assert "one last attempt. i am tired" in html
|
||||||
|
assert "✦ Human Handoff" in html
|
||||||
|
assert "091rabbytes@gmail.com" in html
|
||||||
|
assert "lets try one more time" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_plain_text_template():
|
||||||
|
text = build_mirror_handoff_text(
|
||||||
|
sender_line="091rabbytes@gmail.com",
|
||||||
|
external_subject="lets try one more time",
|
||||||
|
original_body_text="one last attempt. i am tired",
|
||||||
|
agent_email="suyash@gentic.email",
|
||||||
|
)
|
||||||
|
assert "one last attempt. i am tired" in text
|
||||||
|
assert "LATEST MESSAGE" not in text
|
||||||
|
assert "Reply normally" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_sender_with_name():
|
||||||
|
assert (
|
||||||
|
format_sender_line({"name": "Rahul Sharma", "address": "rahul@example.com"})
|
||||||
|
== "Rahul Sharma <rahul@example.com>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_threading_headers_from_customer_message():
|
||||||
|
parent = {
|
||||||
|
"_id": "mongo-id",
|
||||||
|
"message_id": "<a@gmail.com>",
|
||||||
|
"references": [],
|
||||||
|
}
|
||||||
|
threading = threading_fields_from_parent(parent)
|
||||||
|
assert threading["in_reply_to"] == "<a@gmail.com>"
|
||||||
|
assert threading["references"] == ["<a@gmail.com>"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_reply_subject_uses_customer_subject():
|
||||||
|
assert external_reply_subject("lets try one more time") == "Re: lets try one more time"
|
||||||
|
assert handoff_email_subject("lets try one more time") == "Human Handoff · lets try one more time"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_removes_script():
|
||||||
|
dirty = '<p>Hello</p><script>alert(1)</script><img src="x" onerror="alert(1)">'
|
||||||
|
clean = sanitize_email_html(dirty)
|
||||||
|
assert "<script" not in clean.lower()
|
||||||
|
assert "onerror" not in clean.lower()
|
||||||
|
assert "Hello" in clean
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_html_from_multipart():
|
||||||
|
raw = (
|
||||||
|
b"From: sender@example.com\r\n"
|
||||||
|
b"To: agent@gentic.email\r\n"
|
||||||
|
b"Subject: Invoice approval\r\n"
|
||||||
|
b'MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
|
||||||
|
b"--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nPlain fallback\r\n"
|
||||||
|
b"--b\r\nContent-Type: text/html; charset=utf-8\r\n\r\n"
|
||||||
|
b"<h2>Invoice</h2><table><tr><td>Amount</td></tr></table>\r\n"
|
||||||
|
b"--b--\r\n"
|
||||||
|
)
|
||||||
|
extracted = extract_mirror_content(raw)
|
||||||
|
assert "Invoice" in (extracted.body_html or "")
|
||||||
|
assert "<table>" in (extracted.body_html or "")
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from app.services.handoff_email_templates import external_reply_subject, handoff_email_subject
|
from app.services.handoff_email_templates import external_reply_subject
|
||||||
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
|
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
|
||||||
|
|
||||||
SCREENSHOT_REPLY = """ahan, did you receive a response?
|
SCREENSHOT_REPLY = """ahan, did you receive a response?
|
||||||
|
|
@ -49,10 +49,6 @@ def test_external_reply_subject_uses_customer_subject():
|
||||||
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
||||||
|
|
||||||
|
|
||||||
def test_handoff_subject_format():
|
|
||||||
assert handoff_email_subject("are you ready?") == "Human Handoff · are you ready?"
|
|
||||||
|
|
||||||
|
|
||||||
def test_outlook_original_message_split():
|
def test_outlook_original_message_split():
|
||||||
body = """Thanks for the update.
|
body = """Thanks for the update.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue