handoff fixes
This commit is contained in:
parent
89ddebdb60
commit
8d5e9712a6
20 changed files with 1056 additions and 141 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue