Implement email-address-based conversation matching

Closes the gap where parents sending a new email (instead of replying)
would lose their registration progress. All changes follow the
email-based-conversation-matching OpenSpec change.

Key changes
-----------
storage/json_store.py
  - normalize_email() helper (lowercase + trim)
  - Conversations now keyed by sender email address, not thread ID
  - Versioned registration storage: data/registrations/<email>/v<N>_<ts>.json
  - current.json always reflects the latest version
  - save_registration() returns (email_key, version) tuple
  - save_registration_version() for updates with change_summary
  - get_registration_history() returns all versions in order

models/conversation.py
  - Added last_inbound_message_id field for reply threading (not matching)

channels/email_channel.py
  - fetch_unread_messages() no longer exposes thread_id
  - Conversation matching removed from channel layer (now in agent)
  - Removed _resolve_thread_id() — threading headers kept for SMTP only

agent/core.py
  - process_message() takes parent_email + inbound_message_id (no thread ID)
  - Looks up conversation by normalized email address
  - Post-completion handler: detects intent (question / update / new_child)
  - Registration updates: diffs old vs new, versions storage, notifies admin

agent/prompts.py
  - build_system_prompt() dispatches to registration or post-completion prompt
  - Post-completion prompt guides LLM to return intent field
  - Reminder language updated: no expiration threats

notifications/notifier.py
  - notify_admin() accepts version parameter
  - notify_registration_update() sends "Registration Updated" emails with diff
  - _build_update_body() includes field-level old→new change summary

main.py
  - Poll loop passes parent_email + inbound_message_id to agent (no thread_id)

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
Claude
2026-02-20 22:15:49 +00:00
parent c37862b75e
commit 0c3b5a9033
7 changed files with 594 additions and 241 deletions
+14 -23
View File
@@ -2,9 +2,14 @@
Handles:
- Polling the inbox for unread messages (IMAP)
- Thread tracking via Message-ID / In-Reply-To / References headers
- Sending reply emails (SMTP) with proper threading headers
- Conversation matching by sender email address (NOT by thread headers)
- Sending reply emails (SMTP) with proper threading headers for email clients
- Stripping quoted reply text so the agent only sees the new content
Threading headers (Message-ID, In-Reply-To, References) are preserved for
outbound replies so messages appear threaded in Gmail/Outlook, but they are
NOT used to identify which conversation an incoming message belongs to.
Conversation matching is exclusively by normalized sender email address.
"""
import email
@@ -126,13 +131,15 @@ class EmailChannel:
"""Poll the inbox and return all unread messages as structured dicts.
Each dict contains:
from — sender email address
from — sender email address (use this as conversation key)
subject — decoded subject line
message_id — Message-ID header of this email
in_reply_to — In-Reply-To header (may be empty)
references — References header (may be empty)
thread_id — canonical ID for the email thread
message_id — Message-ID of this inbound email (for reply threading)
in_reply_to — In-Reply-To header (for reply threading, may be empty)
references — References header (for reply threading, may be empty)
body — stripped plain-text body (quoted text removed)
Note: ``thread_id`` is no longer returned. Conversation matching is done
by ``from`` (sender email address), not by threading headers.
"""
messages: list[dict] = []
try:
@@ -161,8 +168,6 @@ class EmailChannel:
imap.store(num, "+FLAGS", "\\Seen")
continue
thread_id = self._resolve_thread_id(message_id, in_reply_to, references)
messages.append(
{
"from": from_addr,
@@ -170,7 +175,6 @@ class EmailChannel:
"message_id": message_id,
"in_reply_to": in_reply_to,
"references": references,
"thread_id": thread_id,
"body": body,
}
)
@@ -274,16 +278,3 @@ class EmailChannel:
conn.login(self._username, self._password)
return conn
@staticmethod
def _resolve_thread_id(message_id: str, in_reply_to: str, references: str) -> str:
"""Determine the canonical thread ID from email threading headers.
The root of the thread is the first message ever sent — its ID is the
first token in the References header (oldest first convention).
"""
if references:
first_ref = references.strip().split()[0]
return first_ref
if in_reply_to:
return in_reply_to.strip()
return message_id.strip() or f"<unknown-{time.time():.0f}>"