handoff fixes

This commit is contained in:
debu-2 2026-10-06 03:14:53 +05:30
parent 89ddebdb60
commit 8d5e9712a6
20 changed files with 1056 additions and 141 deletions

View file

@ -7,17 +7,27 @@ 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,
from app.services.mail_sender import deliver_email, log_outbound_mime_headers
from app.services.message_ids import (
build_references_header,
canonical_message_id,
folded_message_id,
first_message_id,
internet_message_id_of_message,
message_id_fields,
)
from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import get_raw_mime_storage
from app.services.thread_service import threading_fields_from_parent
logger = logging.getLogger(__name__)
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
MESSAGE_SCOPE_HANDOFF = "HANDOFF"
class ExternalReplyThreadingError(RuntimeError):
pass
@dataclass(frozen=True)
@ -38,9 +48,169 @@ class ExternalReplyPlan:
def internet_message_id_of(message: dict) -> str | None:
return normalize_message_id(
stored = canonical_message_id(
message.get("internet_message_id") or message.get("message_id")
)
raw_header = (message.get("raw_headers") or {}).get("message_id")
raw_canonical = canonical_message_id(raw_header)
if raw_canonical and stored:
if folded_message_id(raw_canonical) == folded_message_id(stored):
return raw_canonical
return stored or raw_canonical
async def resolve_external_reply_parent(
database,
*,
account_id: str,
external_thread_id: ObjectId,
customer_address: str,
handoff: dict | None = None,
) -> dict | None:
"""Pick the latest external inbound customer message with a real Message-ID."""
customer = customer_address.lower()
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": external_thread_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"deletion_state": {"$in": [None, ""]},
}
},
{"$sort": {"event_at": -1, "_id": -1}},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
{
"$match": {
"message.message_scope": {"$in": [MESSAGE_SCOPE_EXTERNAL, None]},
"message.direction": "inbound",
"$or": [
{"message.internet_message_id": {"$gt": ""}},
{"message.message_id": {"$gt": ""}},
],
}
},
{"$limit": 1},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
if rows:
return rows[0]["message"]
if handoff and handoff.get("trigger_external_message_id"):
trigger = await database.messages.find_one(
{
"_id": handoff["trigger_external_message_id"],
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
if trigger and internet_message_id_of(trigger):
return trigger
trigger_internet = handoff.get("trigger_internet_message_id") if handoff else None
if trigger_internet:
folded = folded_message_id(trigger_internet)
trigger = await database.messages.find_one(
{
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_id_folded": folded,
}
)
if trigger:
return trigger
return None
def validate_external_reply_parent(parent_external_message: dict) -> None:
parent_id = internet_message_id_of(parent_external_message)
if not parent_id:
raise ExternalReplyThreadingError(
"External reply parent is missing internet_message_id"
)
scope = parent_external_message.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
"External reply parent must not be a HANDOFF-scoped message"
)
async def assert_in_reply_to_resolves_to_external_parent(
database,
in_reply_to: str | None,
) -> dict:
"""Ensure In-Reply-To points at an EXTERNAL message, never HANDOFF."""
canonical = canonical_message_id(in_reply_to)
folded = folded_message_id(canonical)
if not canonical or not folded:
raise ExternalReplyThreadingError("External reply is missing In-Reply-To")
parent = await database.messages.find_one(
{
"$or": [
{"message_id_folded": folded},
{"message_id": canonical},
{"internet_message_id": canonical},
],
}
)
if parent is None:
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} does not resolve to a known message"
)
scope = parent.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} resolves to HANDOFF-scoped message"
)
if scope not in (MESSAGE_SCOPE_EXTERNAL, None):
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} resolves to non-external message scope={scope}"
)
return parent
async def assert_external_threading_chain(
database,
*,
in_reply_to: str | None,
references: list[str] | None,
) -> None:
"""Ensure outbound headers only reference EXTERNAL messages."""
await assert_in_reply_to_resolves_to_external_parent(database, in_reply_to)
folded_targets = {
folded_message_id(value)
for value in [*(references or [])]
if folded_message_id(value)
}
in_reply_folded = folded_message_id(in_reply_to)
if in_reply_folded:
folded_targets.discard(in_reply_folded)
for folded in folded_targets:
handoff_hit = await database.messages.find_one(
{
"$or": [
{"message_id_folded": folded},
{"message_id": folded},
],
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
{"_id": 1},
)
if handoff_hit:
raise ExternalReplyThreadingError(
"External reply threading references a HANDOFF message"
)
def build_external_reply_plan(
@ -50,9 +220,31 @@ def build_external_reply_plan(
body_html: str | None = None,
subject_override: str | None = None,
recipients: list[str],
parent_internet_id_override: str | None = None,
) -> ExternalReplyPlan:
parent_internet_id = canonical_message_id(
parent_internet_id_override
) or internet_message_id_of(parent_external_message)
if not parent_internet_id:
raise ExternalReplyThreadingError(
"External reply parent is missing internet_message_id"
)
scope = parent_external_message.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
"External reply parent must not be a HANDOFF-scoped message"
)
threading_fields = threading_fields_from_parent(parent_external_message)
parent_internet_id = internet_message_id_of(parent_external_message)
in_reply_to = parent_internet_id or threading_fields.get("in_reply_to")
references = threading_fields.get("references", [])
if parent_internet_id:
references = build_references_header(
[ref for ref in references if folded_message_id(ref) != folded_message_id(parent_internet_id)],
parent_internet_id,
).split()
elif not references and in_reply_to:
references = [in_reply_to]
if subject_override:
subject = subject_override
else:
@ -67,8 +259,8 @@ def build_external_reply_plan(
body_text=body_text,
body_html=body_html,
threading=ExternalReplyThreading(
in_reply_to=threading_fields.get("in_reply_to"),
references=threading_fields.get("references", []),
in_reply_to=in_reply_to,
references=references,
parent_internal_message_id=parent_external_message["_id"],
replying_to_internet_message_id=parent_internet_id,
),
@ -76,6 +268,33 @@ def build_external_reply_plan(
)
def log_original_external_message(
*,
internal_id: ObjectId | str,
thread_id: ObjectId | str | None,
internet_message_id: str | None,
sender: str | None,
recipient: str | None,
subject: str | None,
in_reply_to: str | None,
references: list[str] | None,
raw_message_available: bool,
) -> None:
logger.info(
"original_external_message internal_id=%s thread_id=%s internet_message_id=%s "
"from=%s to=%s subject=%s in_reply_to=%s references=%s raw_message_available=%s",
internal_id,
thread_id,
internet_message_id,
sender,
recipient,
subject,
in_reply_to,
references,
raw_message_available,
)
def log_smtp_message_sending(
*,
generated_message_id: str | None,
@ -114,7 +333,30 @@ async def send_external_reply(
plan: ExternalReplyPlan,
domain: MailDomain,
resolution_method: str = "in_reply_to",
generated_by: str | None = None,
generated_from_handoff_message_id: ObjectId | None = None,
message_origin: str | None = None,
) -> dict:
if generated_from_handoff_message_id:
existing = await database.messages.find_one(
{
"generated_from_handoff_message_id": generated_from_handoff_message_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
if existing:
logger.info(
"external_reply_idempotent_skip handoff_message=%s external=%s",
generated_from_handoff_message_id,
existing["_id"],
)
return existing
await assert_external_threading_chain(
database,
in_reply_to=plan.threading.in_reply_to,
references=plan.threading.references,
)
log_smtp_message_sending(
generated_message_id=None,
in_reply_to=plan.threading.in_reply_to,
@ -139,7 +381,8 @@ async def send_external_reply(
in_reply_to=plan.threading.in_reply_to,
references=plan.threading.references,
)
normalized_id = normalize_message_id(internet_message_id)
normalized_id = canonical_message_id(internet_message_id)
id_fields = message_id_fields(normalized_id)
log_smtp_message_sending(
generated_message_id=normalized_id,
in_reply_to=plan.threading.in_reply_to,
@ -167,16 +410,23 @@ async def send_external_reply(
"created_at": now,
"sent_at": now,
"status": "accepted",
"message_id": normalized_id,
"internet_message_id": normalized_id,
**id_fields,
"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,
}
if generated_by:
document["generated_by"] = generated_by
if generated_from_handoff_message_id:
document["generated_from_handoff_message_id"] = generated_from_handoff_message_id
if message_origin:
document["message_origin"] = message_origin
result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id
from app.services.thread_service import attach_message_to_thread
await attach_message_to_thread(
database,
account_id=account_id,