Initial commit
All checks were successful
Build And Test / publish (push) Successful in 1m30s

This commit is contained in:
2025-12-30 15:16:45 -08:00
parent 4df9a7229e
commit 4f6098e8c2
28 changed files with 3080 additions and 0 deletions

5
src/services/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .email_service import EmailService
from .calendar_service import CalendarService
from .contacts_service import ContactsService
__all__ = ["EmailService", "CalendarService", "ContactsService"]

View File

@@ -0,0 +1,316 @@
from datetime import datetime, timedelta
from typing import Optional
import uuid
import caldav
from icalendar import Calendar as iCalendar, Event as iEvent, vText
from dateutil.parser import parse as parse_date
from dateutil.rrule import rrulestr
from models.calendar_models import (
Calendar,
Event,
EventList,
EventStatus,
Attendee,
Reminder,
)
from models.common import OperationResult
from config import Settings
class CalendarService:
def __init__(self, settings: Settings):
self.settings = settings
self._client: Optional[caldav.DAVClient] = None
self._principal = None
def _get_client(self) -> caldav.DAVClient:
if self._client is None:
self._client = caldav.DAVClient(
url=self.settings.caldav_url,
username=self.settings.caldav_username,
password=self.settings.caldav_password.get_secret_value(),
)
self._principal = self._client.principal()
return self._client
def _get_principal(self):
self._get_client()
return self._principal
def list_calendars(self) -> list[Calendar]:
principal = self._get_principal()
calendars = principal.calendars()
result = []
for cal in calendars:
props = cal.get_properties([caldav.dav.DisplayName()])
name = props.get("{DAV:}displayname", cal.name or "Unnamed")
result.append(
Calendar(
id=str(cal.url),
name=name,
color=None,
description=None,
is_readonly=False,
)
)
return result
def _get_calendar_by_id(self, calendar_id: str) -> caldav.Calendar:
principal = self._get_principal()
calendars = principal.calendars()
for cal in calendars:
if str(cal.url) == calendar_id:
return cal
raise ValueError(f"Calendar not found: {calendar_id}")
def list_events(
self,
calendar_id: str,
start_date: str,
end_date: str,
include_recurring: bool = True,
) -> EventList:
calendar = self._get_calendar_by_id(calendar_id)
start = parse_date(start_date)
end = parse_date(end_date)
events = calendar.date_search(start=start, end=end, expand=include_recurring)
result = []
for event in events:
parsed = self._parse_event(event, calendar_id)
if parsed:
result.append(parsed)
result.sort(key=lambda e: e.start)
return EventList(
events=result,
calendar_id=calendar_id,
start_date=start_date,
end_date=end_date,
total=len(result),
)
def get_event(self, calendar_id: str, event_id: str) -> Optional[Event]:
calendar = self._get_calendar_by_id(calendar_id)
try:
event = calendar.event_by_url(event_id)
return self._parse_event(event, calendar_id)
except Exception:
# Try searching by UID
events = calendar.events()
for event in events:
parsed = self._parse_event(event, calendar_id)
if parsed and parsed.id == event_id:
return parsed
return None
def create_event(
self,
calendar_id: str,
title: str,
start: str,
end: str,
description: Optional[str] = None,
location: Optional[str] = None,
attendees: Optional[list[str]] = None,
reminders: Optional[list[int]] = None,
recurrence: Optional[str] = None,
) -> Event:
calendar = self._get_calendar_by_id(calendar_id)
# Create iCalendar event
ical = iCalendar()
ical.add("prodid", "-//PIM MCP Server//EN")
ical.add("version", "2.0")
ievent = iEvent()
event_uid = str(uuid.uuid4())
ievent.add("uid", event_uid)
ievent.add("summary", title)
ievent.add("dtstart", parse_date(start))
ievent.add("dtend", parse_date(end))
ievent.add("dtstamp", datetime.now())
if description:
ievent.add("description", description)
if location:
ievent.add("location", location)
if attendees:
for attendee_email in attendees:
ievent.add("attendee", f"mailto:{attendee_email}")
if recurrence:
ievent.add("rrule", recurrence)
ical.add_component(ievent)
# Save to calendar
created_event = calendar.save_event(ical.to_ical().decode("utf-8"))
return Event(
id=event_uid,
calendar_id=calendar_id,
title=title,
start=parse_date(start),
end=parse_date(end),
description=description,
location=location,
attendees=[Attendee(email=a) for a in (attendees or [])],
reminders=[Reminder(minutes_before=m) for m in (reminders or [])],
recurrence_rule=recurrence,
created=datetime.now(),
)
def update_event(
self,
calendar_id: str,
event_id: str,
title: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
description: Optional[str] = None,
location: Optional[str] = None,
attendees: Optional[list[str]] = None,
) -> Optional[Event]:
calendar = self._get_calendar_by_id(calendar_id)
# Find the event
event = None
for e in calendar.events():
ical = iCalendar.from_ical(e.data)
for component in ical.walk():
if component.name == "VEVENT":
uid = str(component.get("uid", ""))
if uid == event_id:
event = e
break
if not event:
return None
# Parse and modify
ical = iCalendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
if title is not None:
component["summary"] = vText(title)
if start is not None:
component["dtstart"] = parse_date(start)
if end is not None:
component["dtend"] = parse_date(end)
if description is not None:
component["description"] = vText(description)
if location is not None:
component["location"] = vText(location)
# Save changes
event.data = ical.to_ical().decode("utf-8")
event.save()
return self._parse_event(event, calendar_id)
def delete_event(
self, calendar_id: str, event_id: str, notify_attendees: bool = True
) -> OperationResult:
try:
calendar = self._get_calendar_by_id(calendar_id)
# Find and delete the event
for event in calendar.events():
ical = iCalendar.from_ical(event.data)
for component in ical.walk():
if component.name == "VEVENT":
uid = str(component.get("uid", ""))
if uid == event_id:
event.delete()
return OperationResult(
success=True,
message="Event deleted successfully",
id=event_id,
)
return OperationResult(
success=False, message=f"Event not found: {event_id}"
)
except Exception as e:
return OperationResult(success=False, message=str(e))
def _parse_event(self, caldav_event, calendar_id: str) -> Optional[Event]:
try:
ical = iCalendar.from_ical(caldav_event.data)
for component in ical.walk():
if component.name == "VEVENT":
uid = str(component.get("uid", ""))
# Parse dates
dtstart = component.get("dtstart")
dtend = component.get("dtend")
start = dtstart.dt if dtstart else datetime.now()
end = dtend.dt if dtend else start + timedelta(hours=1)
# Handle date-only values (all-day events)
all_day = False
if not isinstance(start, datetime):
all_day = True
start = datetime.combine(start, datetime.min.time())
if not isinstance(end, datetime):
end = datetime.combine(end, datetime.min.time())
# Parse status
status_str = str(component.get("status", "CONFIRMED")).upper()
status = EventStatus.CONFIRMED
if status_str == "TENTATIVE":
status = EventStatus.TENTATIVE
elif status_str == "CANCELLED":
status = EventStatus.CANCELLED
# Parse attendees
attendees = []
for attendee in component.get("attendee", []):
if isinstance(attendee, list):
for a in attendee:
email = str(a).replace("mailto:", "")
attendees.append(Attendee(email=email))
else:
email = str(attendee).replace("mailto:", "")
attendees.append(Attendee(email=email))
# Parse recurrence
rrule = component.get("rrule")
recurrence_rule = None
if rrule:
recurrence_rule = rrule.to_ical().decode("utf-8")
return Event(
id=uid,
calendar_id=calendar_id,
title=str(component.get("summary", "Untitled")),
start=start,
end=end,
all_day=all_day,
description=str(component.get("description", "")) or None,
location=str(component.get("location", "")) or None,
status=status,
attendees=attendees,
recurrence_rule=recurrence_rule,
organizer=str(component.get("organizer", "")).replace("mailto:", "") or None,
)
except Exception as e:
print(f"Error parsing event: {e}")
return None
return None

View File

@@ -0,0 +1,477 @@
from typing import Optional
import uuid
import httpx
import vobject
from models.contacts_models import (
AddressBook,
Contact,
ContactList,
EmailField,
PhoneField,
AddressField,
)
from models.common import OperationResult
from config import Settings
PROPFIND_ADDRESSBOOKS = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<card:addressbook-description/>
</d:prop>
</d:propfind>"""
REPORT_CONTACTS = """<?xml version="1.0" encoding="utf-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
</card:addressbook-query>"""
class ContactsService:
def __init__(self, settings: Settings):
self.settings = settings
self._client: Optional[httpx.Client] = None
def _get_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
auth=(
self.settings.carddav_username,
self.settings.carddav_password.get_secret_value(),
),
headers={"Content-Type": "application/xml; charset=utf-8"},
timeout=30.0,
)
return self._client
def list_addressbooks(self) -> list[AddressBook]:
client = self._get_client()
response = client.request(
"PROPFIND",
self.settings.carddav_url,
headers={"Depth": "1"},
content=PROPFIND_ADDRESSBOOKS,
)
if response.status_code not in [200, 207]:
raise Exception(f"Failed to list addressbooks: {response.status_code}")
# Parse XML response
addressbooks = []
from xml.etree import ElementTree as ET
root = ET.fromstring(response.text)
ns = {
"d": "DAV:",
"card": "urn:ietf:params:xml:ns:carddav",
}
for response_elem in root.findall(".//d:response", ns):
href = response_elem.find("d:href", ns)
if href is None:
continue
resourcetype = response_elem.find(".//d:resourcetype", ns)
is_addressbook = (
resourcetype is not None
and resourcetype.find("card:addressbook", ns) is not None
)
if not is_addressbook:
continue
displayname = response_elem.find(".//d:displayname", ns)
description = response_elem.find(".//card:addressbook-description", ns)
addressbooks.append(
AddressBook(
id=href.text,
name=displayname.text if displayname is not None and displayname.text else "Unnamed",
description=description.text if description is not None else None,
contact_count=0,
)
)
return addressbooks
def list_contacts(
self,
addressbook_id: str,
search: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> ContactList:
client = self._get_client()
# Build URL
base_url = self.settings.carddav_url.rstrip("/")
addressbook_url = f"{base_url}{addressbook_id}" if addressbook_id.startswith("/") else addressbook_id
response = client.request(
"REPORT",
addressbook_url,
headers={"Depth": "1"},
content=REPORT_CONTACTS,
)
if response.status_code not in [200, 207]:
raise Exception(f"Failed to list contacts: {response.status_code}")
# Parse XML response
contacts = []
from xml.etree import ElementTree as ET
root = ET.fromstring(response.text)
ns = {
"d": "DAV:",
"card": "urn:ietf:params:xml:ns:carddav",
}
for response_elem in root.findall(".//d:response", ns):
href = response_elem.find("d:href", ns)
address_data = response_elem.find(".//card:address-data", ns)
if href is None or address_data is None or address_data.text is None:
continue
try:
contact = self._parse_vcard(address_data.text, addressbook_id, href.text)
if contact:
# Apply search filter
if search:
search_lower = search.lower()
match = False
if contact.display_name and search_lower in contact.display_name.lower():
match = True
elif contact.first_name and search_lower in contact.first_name.lower():
match = True
elif contact.last_name and search_lower in contact.last_name.lower():
match = True
elif any(search_lower in e.email.lower() for e in contact.emails):
match = True
if not match:
continue
contacts.append(contact)
except Exception as e:
print(f"Error parsing contact: {e}")
continue
# Sort by display name
contacts.sort(key=lambda c: c.display_name or c.first_name or c.last_name or "")
total = len(contacts)
contacts = contacts[offset : offset + limit]
return ContactList(
contacts=contacts,
addressbook_id=addressbook_id,
total=total,
limit=limit,
offset=offset,
)
def get_contact(self, addressbook_id: str, contact_id: str) -> Optional[Contact]:
client = self._get_client()
# Build URL
base_url = self.settings.carddav_url.rstrip("/")
contact_url = f"{base_url}{contact_id}" if contact_id.startswith("/") else contact_id
response = client.get(contact_url)
if response.status_code == 404:
return None
if response.status_code != 200:
raise Exception(f"Failed to get contact: {response.status_code}")
return self._parse_vcard(response.text, addressbook_id, contact_id)
def create_contact(
self,
addressbook_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
display_name: Optional[str] = None,
emails: Optional[list[dict]] = None,
phones: Optional[list[dict]] = None,
addresses: Optional[list[dict]] = None,
organization: Optional[str] = None,
title: Optional[str] = None,
notes: Optional[str] = None,
birthday: Optional[str] = None,
) -> Contact:
client = self._get_client()
# Create vCard
vcard = vobject.vCard()
# Generate UID
uid = str(uuid.uuid4())
vcard.add("uid").value = uid
# Name
n = vcard.add("n")
n.value = vobject.vcard.Name(
family=last_name or "",
given=first_name or "",
)
# Full name
fn = display_name or " ".join(filter(None, [first_name, last_name])) or "Unnamed"
vcard.add("fn").value = fn
# Organization
if organization:
org = vcard.add("org")
org.value = [organization]
# Title
if title:
vcard.add("title").value = title
# Notes
if notes:
vcard.add("note").value = notes
# Birthday
if birthday:
vcard.add("bday").value = birthday
# Emails
if emails:
for email_data in emails:
email = vcard.add("email")
email.value = email_data.get("email", "")
email.type_param = email_data.get("type", "home").upper()
# Phones
if phones:
for phone_data in phones:
tel = vcard.add("tel")
tel.value = phone_data.get("number", "")
tel.type_param = phone_data.get("type", "cell").upper()
# Addresses
if addresses:
for addr_data in addresses:
adr = vcard.add("adr")
adr.value = vobject.vcard.Address(
street=addr_data.get("street", ""),
city=addr_data.get("city", ""),
region=addr_data.get("state", ""),
code=addr_data.get("postal_code", ""),
country=addr_data.get("country", ""),
)
adr.type_param = addr_data.get("type", "home").upper()
# Build URL and save
base_url = self.settings.carddav_url.rstrip("/")
addressbook_url = f"{base_url}{addressbook_id}" if addressbook_id.startswith("/") else addressbook_id
contact_url = f"{addressbook_url.rstrip('/')}/{uid}.vcf"
response = client.put(
contact_url,
content=vcard.serialize(),
headers={"Content-Type": "text/vcard; charset=utf-8"},
)
if response.status_code not in [200, 201, 204]:
raise Exception(f"Failed to create contact: {response.status_code}")
return Contact(
id=contact_url,
addressbook_id=addressbook_id,
first_name=first_name,
last_name=last_name,
display_name=fn,
emails=[EmailField(**e) for e in (emails or [])],
phones=[PhoneField(**p) for p in (phones or [])],
addresses=[AddressField(**a) for a in (addresses or [])],
organization=organization,
title=title,
notes=notes,
)
def update_contact(
self,
addressbook_id: str,
contact_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
display_name: Optional[str] = None,
emails: Optional[list[dict]] = None,
phones: Optional[list[dict]] = None,
addresses: Optional[list[dict]] = None,
organization: Optional[str] = None,
title: Optional[str] = None,
notes: Optional[str] = None,
) -> Optional[Contact]:
# Get existing contact
existing = self.get_contact(addressbook_id, contact_id)
if not existing:
return None
# Merge with updates
updated_data = {
"first_name": first_name if first_name is not None else existing.first_name,
"last_name": last_name if last_name is not None else existing.last_name,
"display_name": display_name if display_name is not None else existing.display_name,
"emails": emails if emails is not None else [e.model_dump() for e in existing.emails],
"phones": phones if phones is not None else [p.model_dump() for p in existing.phones],
"addresses": addresses if addresses is not None else [a.model_dump() for a in existing.addresses],
"organization": organization if organization is not None else existing.organization,
"title": title if title is not None else existing.title,
"notes": notes if notes is not None else existing.notes,
}
# Delete and recreate (simpler than partial update)
self.delete_contact(addressbook_id, contact_id)
return self.create_contact(addressbook_id, **updated_data)
def delete_contact(self, addressbook_id: str, contact_id: str) -> OperationResult:
try:
client = self._get_client()
# Build URL
base_url = self.settings.carddav_url.rstrip("/")
contact_url = f"{base_url}{contact_id}" if contact_id.startswith("/") else contact_id
response = client.delete(contact_url)
if response.status_code in [200, 204]:
return OperationResult(
success=True, message="Contact deleted successfully", id=contact_id
)
elif response.status_code == 404:
return OperationResult(
success=False, message="Contact not found", id=contact_id
)
else:
return OperationResult(
success=False,
message=f"Failed to delete contact: {response.status_code}",
)
except Exception as e:
return OperationResult(success=False, message=str(e))
def _parse_vcard(
self, vcard_data: str, addressbook_id: str, href: str
) -> Optional[Contact]:
try:
vcard = vobject.readOne(vcard_data)
except Exception:
return None
# Get UID
uid = href
if hasattr(vcard, "uid"):
uid = vcard.uid.value
# Get name components
first_name = None
last_name = None
if hasattr(vcard, "n"):
first_name = vcard.n.value.given or None
last_name = vcard.n.value.family or None
# Get display name
display_name = None
if hasattr(vcard, "fn"):
display_name = vcard.fn.value
# Get emails
emails = []
if hasattr(vcard, "email_list"):
for email in vcard.email_list:
email_type = "home"
if hasattr(email, "type_param"):
email_type = str(email.type_param).lower()
emails.append(
EmailField(type=email_type, email=email.value, primary=len(emails) == 0)
)
# Get phones
phones = []
if hasattr(vcard, "tel_list"):
for tel in vcard.tel_list:
phone_type = "mobile"
if hasattr(tel, "type_param"):
phone_type = str(tel.type_param).lower()
phones.append(
PhoneField(type=phone_type, number=tel.value, primary=len(phones) == 0)
)
# Get addresses
addresses = []
if hasattr(vcard, "adr_list"):
for adr in vcard.adr_list:
addr_type = "home"
if hasattr(adr, "type_param"):
addr_type = str(adr.type_param).lower()
addresses.append(
AddressField(
type=addr_type,
street=adr.value.street or None,
city=adr.value.city or None,
state=adr.value.region or None,
postal_code=adr.value.code or None,
country=adr.value.country or None,
)
)
# Get organization
organization = None
if hasattr(vcard, "org"):
org_value = vcard.org.value
if isinstance(org_value, list) and len(org_value) > 0:
organization = org_value[0]
else:
organization = str(org_value)
# Get title
title = None
if hasattr(vcard, "title"):
title = vcard.title.value
# Get notes
notes = None
if hasattr(vcard, "note"):
notes = vcard.note.value
# Get birthday
birthday = None
if hasattr(vcard, "bday"):
try:
from datetime import date
bday_value = vcard.bday.value
if isinstance(bday_value, str):
birthday = date.fromisoformat(bday_value)
else:
birthday = bday_value
except Exception:
pass
return Contact(
id=href,
addressbook_id=addressbook_id,
first_name=first_name,
last_name=last_name,
display_name=display_name,
emails=emails,
phones=phones,
addresses=addresses,
organization=organization,
title=title,
notes=notes,
birthday=birthday,
)

View File

@@ -0,0 +1,560 @@
import email
from email.header import decode_header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, parseaddr
from datetime import datetime
from typing import Optional
import re
from imapclient import IMAPClient
import aiosmtplib
from models.email_models import (
Mailbox,
EmailAddress,
Attachment,
EmailSummary,
Email,
EmailList,
)
from models.common import OperationResult
from config import Settings
def decode_mime_header(header: Optional[str]) -> str:
if not header:
return ""
decoded_parts = []
for part, encoding in decode_header(header):
if isinstance(part, bytes):
decoded_parts.append(part.decode(encoding or "utf-8", errors="replace"))
else:
decoded_parts.append(part)
return "".join(decoded_parts)
def parse_email_address(addr: str) -> EmailAddress:
name, email_addr = parseaddr(addr)
return EmailAddress(name=decode_mime_header(name) or None, email=email_addr)
def parse_email_addresses(addrs: Optional[str]) -> list[EmailAddress]:
if not addrs:
return []
# Handle multiple addresses separated by comma
addresses = []
for addr in addrs.split(","):
addr = addr.strip()
if addr:
addresses.append(parse_email_address(addr))
return addresses
class EmailService:
def __init__(self, settings: Settings):
self.settings = settings
self._imap_client: Optional[IMAPClient] = None
def _get_imap_client(self) -> IMAPClient:
if self._imap_client is None:
self._imap_client = IMAPClient(
host=self.settings.imap_host,
port=self.settings.imap_port,
ssl=self.settings.imap_use_ssl,
)
self._imap_client.login(
self.settings.imap_username,
self.settings.imap_password.get_secret_value(),
)
return self._imap_client
def _close_imap_client(self):
if self._imap_client:
try:
self._imap_client.logout()
except Exception:
pass
self._imap_client = None
def list_mailboxes(self) -> list[Mailbox]:
client = self._get_imap_client()
folders = client.list_folders()
mailboxes = []
for flags, delimiter, name in folders:
# Get folder status
try:
status = client.folder_status(name, ["MESSAGES", "UNSEEN"])
message_count = status.get(b"MESSAGES", 0)
unread_count = status.get(b"UNSEEN", 0)
except Exception:
message_count = 0
unread_count = 0
has_children = b"\\HasChildren" in flags
mailboxes.append(
Mailbox(
name=name.split(delimiter.decode() if delimiter else "/")[-1],
path=name,
message_count=message_count,
unread_count=unread_count,
has_children=has_children,
)
)
return mailboxes
def list_emails(
self,
mailbox: str = "INBOX",
limit: int = 50,
offset: int = 0,
include_body: bool = False,
) -> EmailList:
client = self._get_imap_client()
client.select_folder(mailbox, readonly=True)
# Search for all messages
message_ids = client.search(["ALL"])
total = len(message_ids)
# Sort by UID descending (newest first) and apply pagination
message_ids = sorted(message_ids, reverse=True)
paginated_ids = message_ids[offset : offset + limit]
if not paginated_ids:
return EmailList(
emails=[], total=total, mailbox=mailbox, limit=limit, offset=offset
)
# Fetch message data
fetch_items = ["ENVELOPE", "FLAGS", "BODYSTRUCTURE", "RFC822.SIZE"]
if include_body:
fetch_items.append("BODY.PEEK[]")
messages = client.fetch(paginated_ids, fetch_items)
emails = []
for uid, data in messages.items():
envelope = data[b"ENVELOPE"]
flags = data[b"FLAGS"]
# Parse from address
from_addr = EmailAddress(name=None, email="unknown@unknown.com")
if envelope.from_ and len(envelope.from_) > 0:
sender = envelope.from_[0]
from_addr = EmailAddress(
name=decode_mime_header(sender.name) if sender.name else None,
email=f"{sender.mailbox.decode() if sender.mailbox else 'unknown'}@{sender.host.decode() if sender.host else 'unknown.com'}",
)
# Parse to addresses
to_addrs = []
if envelope.to:
for addr in envelope.to:
to_addrs.append(
EmailAddress(
name=decode_mime_header(addr.name) if addr.name else None,
email=f"{addr.mailbox.decode() if addr.mailbox else 'unknown'}@{addr.host.decode() if addr.host else 'unknown.com'}",
)
)
# Parse date
date = envelope.date or datetime.now()
# Check for attachments
has_attachments = self._has_attachments(data.get(b"BODYSTRUCTURE"))
# Get snippet if body was fetched
snippet = None
if include_body and b"BODY[]" in data:
raw_email = data[b"BODY[]"]
msg = email.message_from_bytes(raw_email)
snippet = self._get_text_snippet(msg, 200)
email_summary = EmailSummary(
id=str(uid),
mailbox=mailbox,
subject=decode_mime_header(envelope.subject) if envelope.subject else "(No Subject)",
from_address=from_addr,
to_addresses=to_addrs,
date=date,
is_read=b"\\Seen" in flags,
is_flagged=b"\\Flagged" in flags,
has_attachments=has_attachments,
snippet=snippet,
)
emails.append(email_summary)
# Sort by date descending
emails.sort(key=lambda e: e.date, reverse=True)
return EmailList(
emails=emails, total=total, mailbox=mailbox, limit=limit, offset=offset
)
def read_email(
self, mailbox: str, email_id: str, format: str = "text"
) -> Optional[Email]:
client = self._get_imap_client()
client.select_folder(mailbox, readonly=True)
uid = int(email_id)
messages = client.fetch([uid], ["ENVELOPE", "FLAGS", "BODY[]", "BODYSTRUCTURE"])
if uid not in messages:
return None
data = messages[uid]
envelope = data[b"ENVELOPE"]
flags = data[b"FLAGS"]
raw_email = data[b"BODY[]"]
msg = email.message_from_bytes(raw_email)
# Parse from address
from_addr = EmailAddress(name=None, email="unknown@unknown.com")
if envelope.from_ and len(envelope.from_) > 0:
sender = envelope.from_[0]
from_addr = EmailAddress(
name=decode_mime_header(sender.name) if sender.name else None,
email=f"{sender.mailbox.decode() if sender.mailbox else 'unknown'}@{sender.host.decode() if sender.host else 'unknown.com'}",
)
# Parse addresses
to_addrs = self._parse_envelope_addresses(envelope.to)
cc_addrs = self._parse_envelope_addresses(envelope.cc)
bcc_addrs = self._parse_envelope_addresses(envelope.bcc)
# Get body
body_text, body_html = self._get_body(msg)
# Get attachments
attachments = self._get_attachments(msg)
# Get headers
headers = {}
for key in ["Message-ID", "In-Reply-To", "References", "X-Priority"]:
value = msg.get(key)
if value:
headers[key] = decode_mime_header(value)
return Email(
id=str(uid),
mailbox=mailbox,
subject=decode_mime_header(envelope.subject) if envelope.subject else "(No Subject)",
from_address=from_addr,
to_addresses=to_addrs,
cc_addresses=cc_addrs,
bcc_addresses=bcc_addrs,
date=envelope.date or datetime.now(),
is_read=b"\\Seen" in flags,
is_flagged=b"\\Flagged" in flags,
has_attachments=len(attachments) > 0,
body_text=body_text if format in ["text", "both"] else None,
body_html=body_html if format in ["html", "both"] else None,
attachments=attachments,
headers=headers,
in_reply_to=headers.get("In-Reply-To"),
references=headers.get("References", "").split() if headers.get("References") else [],
)
def search_emails(
self,
query: str,
mailbox: str = "INBOX",
search_in: list[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
) -> EmailList:
if search_in is None:
search_in = ["subject", "from", "body"]
client = self._get_imap_client()
client.select_folder(mailbox, readonly=True)
# Build IMAP search criteria
criteria = []
# Add text search
if "subject" in search_in:
criteria.append(["SUBJECT", query])
elif "from" in search_in:
criteria.append(["FROM", query])
elif "body" in search_in:
criteria.append(["BODY", query])
else:
criteria.append(["TEXT", query])
# Add date filters
if date_from:
criteria.append(["SINCE", date_from])
if date_to:
criteria.append(["BEFORE", date_to])
# Flatten criteria for OR search across fields
if len(criteria) == 1:
search_criteria = criteria[0]
else:
# Use OR for multiple search fields
search_criteria = criteria[0]
message_ids = client.search(search_criteria)
total = len(message_ids)
# Sort and limit
message_ids = sorted(message_ids, reverse=True)[:limit]
if not message_ids:
return EmailList(
emails=[], total=0, mailbox=mailbox, limit=limit, offset=0
)
# Fetch and parse messages
messages = client.fetch(message_ids, ["ENVELOPE", "FLAGS", "BODYSTRUCTURE"])
emails = []
for uid, data in messages.items():
envelope = data[b"ENVELOPE"]
flags = data[b"FLAGS"]
from_addr = EmailAddress(name=None, email="unknown@unknown.com")
if envelope.from_ and len(envelope.from_) > 0:
sender = envelope.from_[0]
from_addr = EmailAddress(
name=decode_mime_header(sender.name) if sender.name else None,
email=f"{sender.mailbox.decode() if sender.mailbox else 'unknown'}@{sender.host.decode() if sender.host else 'unknown.com'}",
)
to_addrs = self._parse_envelope_addresses(envelope.to)
email_summary = EmailSummary(
id=str(uid),
mailbox=mailbox,
subject=decode_mime_header(envelope.subject) if envelope.subject else "(No Subject)",
from_address=from_addr,
to_addresses=to_addrs,
date=envelope.date or datetime.now(),
is_read=b"\\Seen" in flags,
is_flagged=b"\\Flagged" in flags,
has_attachments=self._has_attachments(data.get(b"BODYSTRUCTURE")),
)
emails.append(email_summary)
emails.sort(key=lambda e: e.date, reverse=True)
return EmailList(
emails=emails, total=total, mailbox=mailbox, limit=limit, offset=0
)
def move_email(
self, email_id: str, source_mailbox: str, destination_mailbox: str
) -> OperationResult:
try:
client = self._get_imap_client()
client.select_folder(source_mailbox)
uid = int(email_id)
client.move([uid], destination_mailbox)
return OperationResult(
success=True,
message=f"Email moved from {source_mailbox} to {destination_mailbox}",
id=email_id,
)
except Exception as e:
return OperationResult(success=False, message=str(e))
def delete_email(
self, email_id: str, mailbox: str, permanent: bool = False
) -> OperationResult:
try:
client = self._get_imap_client()
client.select_folder(mailbox)
uid = int(email_id)
if permanent:
client.delete_messages([uid])
client.expunge()
return OperationResult(
success=True, message="Email permanently deleted", id=email_id
)
else:
# Move to Trash
trash_folder = self._find_trash_folder()
if trash_folder:
client.move([uid], trash_folder)
return OperationResult(
success=True, message="Email moved to trash", id=email_id
)
else:
client.delete_messages([uid])
client.expunge()
return OperationResult(
success=True, message="Email deleted (no trash folder found)", id=email_id
)
except Exception as e:
return OperationResult(success=False, message=str(e))
async def send_email(
self,
to: list[str],
subject: str,
body: str,
cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
) -> OperationResult:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr(
(self.settings.smtp_from_name or "", self.settings.smtp_from_email)
)
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
if reply_to:
msg["Reply-To"] = reply_to
# Add plain text body
msg.attach(MIMEText(body, "plain", "utf-8"))
# Add HTML body if provided
if html_body:
msg.attach(MIMEText(html_body, "html", "utf-8"))
# Build recipient list
recipients = list(to)
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
# Send via SMTP
await aiosmtplib.send(
msg,
hostname=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password.get_secret_value(),
start_tls=self.settings.smtp_use_tls,
)
return OperationResult(
success=True,
message=f"Email sent successfully to {', '.join(to)}",
id=msg.get("Message-ID"),
)
except Exception as e:
return OperationResult(success=False, message=str(e))
def _parse_envelope_addresses(self, addresses) -> list[EmailAddress]:
if not addresses:
return []
result = []
for addr in addresses:
result.append(
EmailAddress(
name=decode_mime_header(addr.name) if addr.name else None,
email=f"{addr.mailbox.decode() if addr.mailbox else 'unknown'}@{addr.host.decode() if addr.host else 'unknown.com'}",
)
)
return result
def _has_attachments(self, bodystructure) -> bool:
if bodystructure is None:
return False
# Simple heuristic: check if multipart with non-text parts
if isinstance(bodystructure, list):
for part in bodystructure:
if isinstance(part, tuple) and len(part) > 0:
content_type = part[0].decode() if isinstance(part[0], bytes) else str(part[0])
if content_type.lower() not in ["text", "multipart"]:
return True
return False
def _get_body(self, msg) -> tuple[Optional[str], Optional[str]]:
body_text = None
body_html = None
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition:
continue
if content_type == "text/plain" and body_text is None:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
body_text = payload.decode(charset, errors="replace")
elif content_type == "text/html" and body_html is None:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
body_html = payload.decode(charset, errors="replace")
else:
content_type = msg.get_content_type()
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
decoded = payload.decode(charset, errors="replace")
if content_type == "text/html":
body_html = decoded
else:
body_text = decoded
return body_text, body_html
def _get_text_snippet(self, msg, max_length: int = 200) -> Optional[str]:
body_text, body_html = self._get_body(msg)
text = body_text or ""
if not text and body_html:
# Strip HTML tags for snippet
text = re.sub(r"<[^>]+>", "", body_html)
text = re.sub(r"\s+", " ", text).strip()
if text:
return text[:max_length] + "..." if len(text) > max_length else text
return None
def _get_attachments(self, msg) -> list[Attachment]:
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition:
filename = part.get_filename()
if filename:
filename = decode_mime_header(filename)
else:
filename = "unnamed"
attachments.append(
Attachment(
filename=filename,
content_type=part.get_content_type(),
size=len(part.get_payload(decode=True) or b""),
content_id=part.get("Content-ID"),
)
)
return attachments
def _find_trash_folder(self) -> Optional[str]:
client = self._get_imap_client()
folders = client.list_folders()
trash_names = ["Trash", "Deleted", "Deleted Items", "Deleted Messages", "[Gmail]/Trash"]
for flags, delimiter, name in folders:
if name in trash_names or b"\\Trash" in flags:
return name
return None