Add tool call logging and reply fields
All checks were successful
Build And Test / publish (push) Successful in 49s

This commit is contained in:
2026-01-01 15:24:06 -08:00
parent 7966a4302d
commit 767f076048
7 changed files with 335 additions and 117 deletions

View File

@@ -433,17 +433,49 @@ class EmailService:
def save_draft(
self,
to: list[str],
subject: str,
body: str,
to: Optional[list[str]] = None,
subject: Optional[str] = None,
body: Optional[str] = None,
cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult:
try:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
if reply_to_email_id:
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
self.settings.smtp_from_email,
)
if error:
return error
if not to:
to = context["to"]
if subject is None:
subject = context["subject"]
if cc is None:
cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
else:
in_reply_to = None
references = None
if not to:
return OperationResult(success=False, message="'to' is required for drafts")
if subject is None:
return OperationResult(success=False, message="'subject' is required for drafts")
if body is None:
return OperationResult(success=False, message="'body' is required for drafts")
msg = self._build_draft_message(
to=to,
subject=subject,
@@ -452,6 +484,8 @@ class EmailService:
bcc=bcc,
reply_to=reply_to,
html_body=html_body,
in_reply_to=in_reply_to,
references=references,
)
client = self._get_imap_client()
append_result = client.append(
@@ -481,6 +515,9 @@ class EmailService:
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
try:
@@ -502,6 +539,28 @@ class EmailService:
resolved_body = body if body is not None else (existing.body_text or "")
resolved_html = html_body if html_body is not None else existing.body_html
if reply_to_email_id:
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
self.settings.smtp_from_email,
)
if error:
return error
if to is None:
resolved_to = context["to"]
if subject is None:
resolved_subject = context["subject"]
if cc is None:
resolved_cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
else:
in_reply_to = None
references = None
try:
client = self._get_imap_client()
client.select_folder(draft_mailbox)
@@ -517,6 +576,8 @@ class EmailService:
bcc=resolved_bcc,
reply_to=reply_to,
html_body=resolved_html,
in_reply_to=in_reply_to,
references=references,
)
append_result = client.append(
draft_mailbox,
@@ -536,9 +597,9 @@ class EmailService:
async def send_email(
self,
to: list[str],
subject: str,
body: str,
to: Optional[list[str]] = None,
subject: Optional[str] = None,
body: Optional[str] = None,
cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
@@ -547,8 +608,38 @@ class EmailService:
sender_name: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult:
try:
if reply_to_email_id:
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
resolved_email,
)
if error:
return error
if not to:
to = context["to"]
if subject is None:
subject = context["subject"]
if cc is None:
cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
if not to:
return OperationResult(success=False, message="'to' is required to send email")
if subject is None:
return OperationResult(success=False, message="'subject' is required to send email")
if body is None:
return OperationResult(success=False, message="'body' is required to send email")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
@@ -609,67 +700,17 @@ class EmailService:
sender_email: Optional[str] = None,
sender_name: Optional[str] = None,
) -> OperationResult:
original = self.read_email(mailbox, email_id, format="both")
if not original:
return OperationResult(
success=False,
message=f"Email {email_id} not found in {mailbox}",
id=email_id,
)
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
reply_to_header = original.headers.get("Reply-To")
reply_to_email = None
if reply_to_header:
_, reply_to_email = parseaddr(reply_to_header)
if not reply_to_email:
reply_to_email = original.from_address.email
to = [reply_to_email] if reply_to_email else []
reply_cc: list[str] = []
if reply_all:
for addr in original.to_addresses + original.cc_addresses:
if addr.email and addr.email not in to:
reply_cc.append(addr.email)
if cc:
reply_cc.extend(cc)
to = self._dedupe_emails(to, resolved_email)
reply_cc = self._dedupe_emails(reply_cc, resolved_email)
if not to and reply_cc:
to = [reply_cc.pop(0)]
if not to:
return OperationResult(
success=False,
message="No valid recipients found for reply",
id=email_id,
)
subject = original.subject or "(No Subject)"
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
in_reply_to = original.headers.get("Message-ID") or original.in_reply_to
references = list(original.references)
if in_reply_to and in_reply_to not in references:
references.append(in_reply_to)
return await self.send_email(
to=to,
subject=subject,
body=body,
cc=reply_cc or None,
bcc=bcc,
reply_to=reply_to,
html_body=html_body,
sender_email=resolved_email,
sender_name=resolved_name,
in_reply_to=in_reply_to,
references=references or None,
sender_email=sender_email,
sender_name=sender_name,
reply_to_email_id=email_id,
reply_mailbox=mailbox,
reply_all=reply_all,
cc=cc,
)
def _parse_envelope_addresses(self, addresses) -> list[EmailAddress]:
@@ -805,6 +846,8 @@ class EmailService:
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None,
) -> MIMEMultipart:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
@@ -821,12 +864,80 @@ class EmailService:
msg["Bcc"] = ", ".join(bcc)
if reply_to:
msg["Reply-To"] = reply_to
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if references:
msg["References"] = " ".join(references)
msg.attach(MIMEText(body or "", "plain", "utf-8"))
if html_body:
msg.attach(MIMEText(html_body, "html", "utf-8"))
return msg
def _get_reply_context(
self,
mailbox: str,
email_id: str,
reply_all: bool,
cc: Optional[list[str]],
sender_email: Optional[str],
) -> tuple[dict, Optional[OperationResult]]:
original = self.read_email(mailbox, email_id, format="both")
if not original:
return {}, OperationResult(
success=False,
message=f"Email {email_id} not found in {mailbox}",
id=email_id,
)
reply_to_header = original.headers.get("Reply-To")
reply_to_email = None
if reply_to_header:
_, reply_to_email = parseaddr(reply_to_header)
if not reply_to_email:
reply_to_email = original.from_address.email
to = [reply_to_email] if reply_to_email else []
reply_cc: list[str] = []
if reply_all:
for addr in original.to_addresses + original.cc_addresses:
if addr.email and addr.email not in to:
reply_cc.append(addr.email)
if cc:
reply_cc.extend(cc)
to = self._dedupe_emails(to, sender_email)
reply_cc = self._dedupe_emails(reply_cc, sender_email)
if not to and reply_cc:
to = [reply_cc.pop(0)]
if not to:
return {}, OperationResult(
success=False,
message="No valid recipients found for reply",
id=email_id,
)
subject = original.subject or "(No Subject)"
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
in_reply_to = original.headers.get("Message-ID") or original.in_reply_to
references = list(original.references)
if in_reply_to and in_reply_to not in references:
references.append(in_reply_to)
return {
"to": to,
"cc": reply_cc or None,
"subject": subject,
"in_reply_to": in_reply_to,
"references": references or None,
}, None
def _resolve_sender(
self, sender_email: Optional[str], sender_name: Optional[str]
) -> tuple[Optional[str], str]: