This commit is contained in:
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