This commit is contained in:
5
src/services/__init__.py
Normal file
5
src/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .email_service import EmailService
|
||||
from .calendar_service import CalendarService
|
||||
from .contacts_service import ContactsService
|
||||
|
||||
__all__ = ["EmailService", "CalendarService", "ContactsService"]
|
||||
316
src/services/calendar_service.py
Normal file
316
src/services/calendar_service.py
Normal 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
|
||||
477
src/services/contacts_service.py
Normal file
477
src/services/contacts_service.py
Normal 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,
|
||||
)
|
||||
560
src/services/email_service.py
Normal file
560
src/services/email_service.py
Normal 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
|
||||
Reference in New Issue
Block a user