Files
2026-06-20 15:01:44 +05:30

328 lines
12 KiB
Python

from __future__ import annotations
import email
import imaplib
import re
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone
from email.header import decode_header, make_header
from email.message import Message
from email.utils import getaddresses, parsedate_to_datetime
from pathlib import Path
from typing import Iterable
from uuid import uuid4
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.modules.clients.models import Client
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.iam.models import User
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailSetting
from app.modules.email_integration.mapping_service import apply_email_mapping
UPLOAD_ROOT = Path("app/ui/static/uploads/incoming_emails")
MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
@dataclass
class ImapFetchResult:
fetched: int = 0
imported: int = 0
skipped_existing: int = 0
failed: int = 0
error: str | None = None
def _decode_mime(value: str | None) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
def _normalise_email(value: str | None) -> str:
return (value or "").strip().lower()
def _addresses(header_value: str | None) -> list[tuple[str, str]]:
decoded = _decode_mime(header_value)
return [(name, addr.lower()) for name, addr in getaddresses([decoded]) if addr]
def _joined_addresses(header_value: str | None) -> str:
return ", ".join(addr for _name, addr in _addresses(header_value))
def _received_at(msg: Message) -> datetime | None:
raw_date = msg.get("Date")
if not raw_date:
return None
try:
parsed = parsedate_to_datetime(raw_date)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except Exception:
return None
def _extract_bodies(msg: Message) -> tuple[str | None, str | None]:
text_parts: list[str] = []
html_parts: list[str] = []
def decode_part(part: Message) -> str:
payload = part.get_payload(decode=True)
if payload is None:
raw = part.get_payload()
return raw if isinstance(raw, str) else ""
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except Exception:
return payload.decode("utf-8", errors="replace")
if msg.is_multipart():
for part in msg.walk():
if part.get_content_maintype() == "multipart":
continue
if part.get_filename():
continue
ctype = part.get_content_type().lower()
content = decode_part(part).strip()
if not content:
continue
if ctype == "text/plain":
text_parts.append(content)
elif ctype == "text/html":
html_parts.append(content)
else:
ctype = msg.get_content_type().lower()
content = decode_part(msg).strip()
if ctype == "text/html":
html_parts.append(content)
else:
text_parts.append(content)
text = "\n\n".join(text_parts).strip() or None
html = "\n\n".join(html_parts).strip() or None
return text, html
def _safe_filename(name: str | None) -> str:
decoded = _decode_mime(name or "attachment") or "attachment"
cleaned = re.sub(r"[^A-Za-z0-9._ -]", "_", decoded).strip(" .")
return cleaned[:180] or "attachment"
def _save_attachments(msg: Message, tenant_id: int | None, branch_id: int | None, incoming_id: int) -> list[EmailIncomingAttachment]:
saved: list[EmailIncomingAttachment] = []
base = UPLOAD_ROOT / str(tenant_id or "system") / str(branch_id or "all") / str(incoming_id)
base.mkdir(parents=True, exist_ok=True)
for part in msg.walk() if msg.is_multipart() else []:
if part.get_content_maintype() == "multipart":
continue
filename = part.get_filename()
disposition = (part.get("Content-Disposition") or "").lower()
if not filename and "attachment" not in disposition:
continue
payload = part.get_payload(decode=True) or b""
if not payload:
continue
if len(payload) > MAX_ATTACHMENT_BYTES:
continue
safe = _safe_filename(filename)
target_name = f"{uuid4().hex}_{safe}"
target = base / target_name
target.write_bytes(payload)
saved.append(
EmailIncomingAttachment(
incoming_message_id=incoming_id,
tenant_id=tenant_id,
branch_id=branch_id,
filename=safe,
content_type=part.get_content_type(),
size_bytes=len(payload),
storage_path=str(target).replace("\\", "/"),
)
)
return saved
def _match_sender(db: Session, tenant_id: int | None, branch_id: int | None, sender_email: str | None) -> tuple[int | None, int | None, int | None, str]:
email_value = _normalise_email(sender_email)
if not email_value:
return None, None, None, "NEW"
user_id = None
client_id = None
consultant_id = None
user = db.execute(select(User).where(User.email == email_value).limit(1)).scalar_one_or_none()
if user:
user_id = int(user.id)
if tenant_id:
client_filters = [Client.tenant_id == tenant_id, or_(Client.email == email_value, Client.alternate_email == email_value)]
if branch_id:
client_filters.append(Client.branch_id == branch_id)
client = db.execute(select(Client).where(*client_filters).limit(1)).scalar_one_or_none()
if client:
client_id = int(client.id)
consultant_filters = [ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.email == email_value]
if branch_id:
consultant_filters.append(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None)))
consultant = db.execute(select(ConsultantProfile).where(*consultant_filters).limit(1)).scalar_one_or_none()
if consultant:
consultant_id = int(consultant.id)
status = "MATCHED" if any([user_id, client_id, consultant_id]) else "NEW"
return user_id, client_id, consultant_id, status
def _connect(setting: EmailSetting):
host = (setting.imap_host or "").strip()
port = int(setting.imap_port or 993)
security = (setting.imap_security or "SSL").upper()
if not host or not setting.imap_username or not setting.imap_password:
raise RuntimeError("IMAP settings are incomplete. Please configure IMAP host, username and password.")
if security == "SSL":
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context())
else:
conn = imaplib.IMAP4(host, port)
if security == "STARTTLS":
conn.starttls(ssl_context=ssl.create_default_context())
conn.login(setting.imap_username, setting.imap_password)
return conn
def fetch_incoming_emails(
db: Session,
*,
setting: EmailSetting,
tenant_id: int | None,
branch_id: int | None,
folder: str = "INBOX",
unread_only: bool = True,
limit: int = 25,
mark_seen: bool = False,
) -> ImapFetchResult:
result = ImapFetchResult()
mailbox_email = _normalise_email(setting.imap_username or setting.from_email or "mailbox")
conn = None
try:
conn = _connect(setting)
typ, _ = conn.select(folder, readonly=not mark_seen)
if typ != "OK":
raise RuntimeError(f"Unable to open IMAP folder: {folder}")
criteria = "UNSEEN" if unread_only else "ALL"
typ, data = conn.search(None, criteria)
if typ != "OK":
raise RuntimeError("IMAP search failed.")
ids = (data[0] or b"").split()
ids = ids[-max(1, min(int(limit or 25), 100)):]
result.fetched = len(ids)
for msg_seq in ids:
try:
typ, uid_data = conn.fetch(msg_seq, "(UID)")
uid_text = uid_data[0].decode(errors="ignore") if uid_data and uid_data[0] else msg_seq.decode()
uid_match = re.search(r"UID (\d+)", uid_text)
provider_uid = uid_match.group(1) if uid_match else msg_seq.decode()
exists = db.execute(
select(EmailIncomingMessage).where(
EmailIncomingMessage.tenant_id == tenant_id,
EmailIncomingMessage.branch_id == branch_id,
EmailIncomingMessage.mailbox_email == mailbox_email,
EmailIncomingMessage.folder_name == folder,
EmailIncomingMessage.provider_uid == provider_uid,
)
).scalar_one_or_none()
if exists:
result.skipped_existing += 1
continue
typ, msg_data = conn.fetch(msg_seq, "(RFC822)")
if typ != "OK" or not msg_data:
result.failed += 1
continue
raw = None
for item in msg_data:
if isinstance(item, tuple):
raw = item[1]
break
if not raw:
result.failed += 1
continue
msg = email.message_from_bytes(raw)
from_rows = _addresses(msg.get("From"))
sender_name, sender_email = from_rows[0] if from_rows else ("", "")
body_text, body_html = _extract_bodies(msg)
user_id, client_id, consultant_id, status = _match_sender(db, tenant_id, branch_id, sender_email)
incoming = EmailIncomingMessage(
tenant_id=tenant_id,
branch_id=branch_id,
mailbox_email=mailbox_email,
folder_name=folder,
provider_uid=provider_uid,
provider_message_id=_decode_mime(msg.get("Message-ID")) or None,
sender_email=sender_email or None,
sender_name=sender_name or None,
recipient_emails=_joined_addresses(msg.get("To")) or None,
cc_emails=_joined_addresses(msg.get("Cc")) or None,
subject=_decode_mime(msg.get("Subject"))[:500] or None,
body_text=body_text,
body_html=body_html,
raw_headers="\n".join(f"{k}: {v}" for k, v in msg.items()),
received_at_utc=_received_at(msg),
status=status,
matched_user_id=user_id,
matched_client_id=client_id,
matched_consultant_id=consultant_id,
)
db.add(incoming)
db.flush()
attachments = _save_attachments(msg, tenant_id, branch_id, int(incoming.id))
for attachment in attachments:
db.add(attachment)
incoming.has_attachments = bool(attachments)
incoming.attachment_count = len(attachments)
# Phase 7S.3: immediately try tracking-code mapping after fetch.
# Failure to map should never fail the IMAP import.
try:
apply_email_mapping(db, incoming)
except Exception as map_exc:
incoming.mapping_status = "ERROR"
incoming.mapping_notes = str(map_exc)[:1000]
result.imported += 1
except Exception:
result.failed += 1
if mark_seen:
for msg_seq in ids:
try:
conn.store(msg_seq, "+FLAGS", "\\Seen")
except Exception:
pass
return result
except Exception as exc:
result.error = str(exc)
return result
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
try:
conn.logout()
except Exception:
pass