using MIME instead of MML

This commit is contained in:
Yanxin Lu
2026-03-18 14:36:29 -07:00
parent c4125d1145
commit 765825a8d1
3 changed files with 221 additions and 69 deletions

View File

@@ -17,7 +17,11 @@ import uuid
from datetime import datetime
from pathlib import Path
from icalendar import Calendar, Event, vText
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from icalendar import Calendar, Event, vCalAddress, vText
# ---------------------------------------------------------------------------
# Config
@@ -65,13 +69,27 @@ def _sync_calendar():
print("Warning: CalDAV sync failed (will retry on next heartbeat)")
def _send_mml(mml, account=None):
"""Send an MML message via himalaya template send."""
def _send_email(email_str, account=None):
"""Send a raw MIME email via himalaya message send (stdin)."""
cmd = ["himalaya"]
if account:
cmd += ["--account", account]
cmd += ["template", "send"]
subprocess.run(cmd, input=mml, text=True, check=True)
cmd += ["message", "send"]
subprocess.run(cmd, input=email_str, text=True, check=True)
def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"):
"""Build a MIME email with a text/calendar attachment."""
msg = MIMEMultipart('mixed')
msg['From'] = from_addr
msg['To'] = to_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8')
ics_part.set_payload(ics_bytes.decode('utf-8'))
ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"')
msg.attach(ics_part)
return msg.as_string()
def _parse_iso_datetime(dt_str):
@@ -111,8 +129,9 @@ def cmd_send(args):
event.add("summary", args.summary)
event.add("status", "CONFIRMED")
event.add("sequence", 0)
event["organizer"] = f"mailto:{args.sender}"
event["organizer"].params["CN"] = vText(organizer_name)
organizer = vCalAddress(f"mailto:{args.sender}")
organizer.params["CN"] = vText(organizer_name)
event.add("organizer", organizer)
if args.location:
event.add("location", args.location)
@@ -135,10 +154,6 @@ def cmd_send(args):
cal.add_component(event)
ics_bytes = cal.to_ical()
# Write ICS to temp file
tmp_ics = Path(f"/tmp/openclaw-invite-{int(datetime.now().timestamp())}.ics")
tmp_ics.write_bytes(ics_bytes)
# Build plain text body
body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})"
if args.location:
@@ -149,29 +164,18 @@ def cmd_send(args):
# Email goes to all attendees (including owner)
all_to = ", ".join(all_attendees)
# Build MML message
mml = (
f"From: {args.sender}\n"
f"To: {all_to}\n"
f"Subject: {args.subject}\n"
f"\n"
f"<#multipart type=mixed>\n"
f"<#part type=text/plain>\n"
f"{body}\n"
f"<#part type=text/calendar method=REQUEST filename={tmp_ics} name=invite.ics><#/part>\n"
f"<#/multipart>"
)
# Build MIME email
email_str = _build_calendar_email(args.sender, all_to, args.subject, body, ics_bytes, method="REQUEST")
if args.dry_run:
print("=== ICS Content ===")
print(ics_bytes.decode())
print("=== MML Message ===")
print(mml)
tmp_ics.unlink(missing_ok=True)
print("=== Email Message ===")
print(email_str)
return
# Send email
_send_mml(mml, args.account)
# Send email via himalaya message send (stdin)
_send_email(email_str, args.account)
print(f"Calendar invite sent to: {args.to}")
# Save to local calendar
@@ -181,8 +185,6 @@ def cmd_send(args):
print(f"Saved to local calendar: {dest}")
_sync_calendar()
tmp_ics.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Reply to invite
@@ -305,10 +307,6 @@ def cmd_reply(args):
reply_cal.add_component(reply_event)
reply_ics_bytes = reply_cal.to_ical()
# Write reply ICS to temp file
tmp_reply = Path(f"/tmp/openclaw-reply-{int(datetime.now().timestamp())}.ics")
tmp_reply.write_bytes(reply_ics_bytes)
# Build email
prefix = SUBJECT_PREFIX[partstat]
subject = f"{prefix}: {summary}"
@@ -317,17 +315,7 @@ def cmd_reply(args):
if args.comment:
body += f"\n\n{args.comment}"
mml = (
f"From: {args.sender}\n"
f"To: {organizer_email}\n"
f"Subject: {subject}\n"
f"\n"
f"<#multipart type=mixed>\n"
f"<#part type=text/plain>\n"
f"{body}\n"
f"<#part type=text/calendar method=REPLY filename={tmp_reply} name=invite.ics><#/part>\n"
f"<#/multipart>"
)
email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY")
if args.dry_run:
print("=== Original Event ===")
@@ -337,9 +325,8 @@ def cmd_reply(args):
print()
print("=== Reply ICS ===")
print(reply_ics_bytes.decode())
print("=== MML Message ===")
print(mml)
tmp_reply.unlink(missing_ok=True)
print("=== Email Message ===")
print(email_str)
if cleanup_dir:
for f in cleanup_dir.iterdir():
f.unlink()
@@ -347,30 +334,22 @@ def cmd_reply(args):
return
# Send reply
_send_mml(mml, args.account)
_send_email(email_str, args.account)
print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})")
# Forward invite to owner on accept/tentative
if partstat in ("ACCEPTED", "TENTATIVE"):
tmp_fwd = Path(f"/tmp/openclaw-fwd-{int(datetime.now().timestamp())}.ics")
tmp_fwd.write_bytes(ics_path.read_bytes())
fwd_mml = (
f"From: {args.sender}\n"
f"To: {DEFAULT_OWNER_EMAIL}\n"
f"Subject: {prefix}: {summary}\n"
f"\n"
f"<#multipart type=mixed>\n"
f"<#part type=text/plain>\n"
f"{prefix}: {summary}\n"
f"<#part type=text/calendar method=REQUEST filename={tmp_fwd} name=invite.ics><#/part>\n"
f"<#/multipart>"
fwd_body = f"{prefix}: {summary}"
fwd_email = _build_calendar_email(
args.sender, DEFAULT_OWNER_EMAIL,
f"{prefix}: {summary}", fwd_body,
ics_path.read_bytes(), method="REQUEST",
)
try:
_send_mml(fwd_mml, args.account)
_send_email(fwd_email, args.account)
print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}")
except subprocess.CalledProcessError:
print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}")
tmp_fwd.unlink(missing_ok=True)
# Save to / remove from local calendar
if CALENDAR_DIR.is_dir():
@@ -385,7 +364,6 @@ def cmd_reply(args):
_sync_calendar()
# Cleanup
tmp_reply.unlink(missing_ok=True)
if cleanup_dir:
for f in cleanup_dir.iterdir():
f.unlink()