# backend/app/connectors/mail/normalize.py # provider(mock/gmail/imap/outlook) → 내부 email 모델(phase-9) 정규화 + 멱등 upsert. import base64 import hashlib import html as html_lib import re import uuid from datetime import UTC, datetime from email.header import decode_header, make_header from email.utils import parseaddr from sqlmodel import Session, select from ...models import AccountFolder, ConnectorAccount, Email, ExternalLink from ..base import NormalizedRecord, RawRecord # connector_account id ↔ mail_account id 매핑(시드 정합) _ACCOUNT_KEY = { "ca-mail-work": "work", "ca-mail-personal": "personal", "ca-mail-side": "side", } def mail_account_id_for(email: str) -> str: """이메일 주소 → 안정적 MailAccount id 슬러그(phase-16 멀티계정).""" return "ma-" + hashlib.sha1(email.lower().encode()).hexdigest()[:8] def _name_from_addr(addr: str) -> str: """표시 이름이 없을 때 도메인에서 추정(noreply@e.coupang.com → Coupang).""" if "@" not in (addr or ""): return "" domain = addr.rsplit("@", 1)[1].lower().strip() labels = [x for x in domain.split(".") if x] if not labels: return "" core = labels[-2] if len(labels) >= 2 else labels[0] # 등록가능 라벨(SLD) return core[:1].upper() + core[1:] def _decode_mime_words(s: str) -> str: """RFC2047 인코딩 단어(=?utf-8?b?...?=) 디코딩 — Gmail 헤더의 비ASCII 이름 복원.""" if not s or "=?" not in s: return s try: return str(make_header(decode_header(s))) except Exception: return s def sender_parts(raw: str) -> tuple[str, str]: """보낸사람 원문('이름 <메일>' 또는 '메일') → (표시이름, 메일주소). 인코딩 단어는 디코딩, 표시이름 없으면 도메인 추정, 그것도 없으면 원문을 이름으로.""" name, addr = parseaddr(raw or "") name = _decode_mime_words((name or "").strip().strip('"').strip()) addr = (addr or "").strip() if not addr and "@" in (raw or ""): addr = (raw or "").strip() disp = name or _name_from_addr(addr) or _decode_mime_words((raw or "").strip()) return disp, addr def _outlook_from(frm: dict) -> str: """Outlook 발신자 → '이름 <메일>'(유니코드 그대로 보존). 이름 없으면 주소만.""" name = (frm.get("name") or "").strip().replace("<", "").replace(">", "") addr = (frm.get("address") or "").strip() if name and addr and name.lower() != addr.lower(): if "," in name or '"' in name: # parseaddr 가 콤마로 안 쪼개도록 인용 name = '"' + name.replace('"', "") + '"' return f"{name} <{addr}>" return addr or name def _mail_account_for(account: ConnectorAccount, fallback: str = "work") -> str: """connector 계정 → 내부 mail_account id. 시드 계정은 정적 매핑, real 계정은 이메일 슬러그.""" if account.id in _ACCOUNT_KEY: return _ACCOUNT_KEY[account.id] if getattr(account, "external_account_id", ""): return mail_account_id_for(account.external_account_id) return fallback def _gmail_received(p: dict) -> datetime | None: """Gmail internalDate(epoch ms) → 수신 시각(UTC aware). 정렬·표시 기준.""" ms = p.get("internalDate") if not ms: return None try: return datetime.fromtimestamp(int(ms) / 1000, tz=UTC) except (ValueError, TypeError): return None def _parse_iso_dt(s: str) -> datetime | None: """Outlook receivedDateTime(ISO8601, 'Z') → 수신 시각(UTC aware).""" if not s: return None try: dt = datetime.fromisoformat(s.replace("Z", "+00:00")) return dt if dt.tzinfo else dt.replace(tzinfo=UTC) except ValueError: return None # HTML 주석/조건부 주석(, ) — 멀티라인. # 평문 파트에 섞여 들어오는 MSO/VML 블록을 통째로 제거(DOTALL). _HTML_COMMENT = re.compile(r"(?is)") _DROP_TAGS = re.compile(r"(?is)<\s*(script|style|head)[^>]*>.*?<\s*/\s*\1\s*>") _BR = re.compile(r"(?i)<\s*br\s*/?\s*>") _BLOCK_END = re.compile(r"(?is)") _TAG = re.compile(r"<[^>]+>") # 앵글로 감싼 URL/이메일(, , ) → 태그 오인 말고 언랩(링크 보존). _ANGLE_URL = re.compile(r"<\s*((?:https?|mailto|tel):[^>\s]+)\s*>") _ANGLE_EMAIL = re.compile(r"<\s*([^<>\s@]+@[^<>\s]+)\s*>") # URL/이메일 언랩 후 남는 실제 HTML 태그(,
,

등) 제거. _STRAY_TAG = re.compile(r"]*)?/?>") # &NBSP; 처럼 대소문자가 어긋난 엔티티도 디코딩(html.unescape 는 케이스 민감). _ENTITY_RE = re.compile(r"&[A-Za-z][A-Za-z0-9]*;") def _decode_entity(m: "re.Match") -> str: tok = m.group(0) out = html_lib.unescape(tok) if out != tok: # 정상 케이스(& Á 등)는 그대로 보존 return out low = html_lib.unescape(tok.lower()) # &NBSP; →   → 공백 return low if low != tok.lower() else tok def _unescape_html(s: str) -> str: """명명 엔티티(케이스 보정) + 숫자 엔티티(' 등) 디코딩.""" return html_lib.unescape(_ENTITY_RE.sub(_decode_entity, s)) # 마케팅/평문 메일에 인라인된 수백자짜리 트래킹 URL → scheme://host/… 로 축약(가독성). _URL_RUN = re.compile(r"https?://\S+") def _shorten_url(m: "re.Match") -> str: u = m.group(0) if len(u) <= 90: # 평범한 길이는 그대로 둠 return u host = re.match(r"(https?)://([^/?#\s]+)", u) return f"{host.group(1)}://{host.group(2)}/…" if host else u[:80] + "…" # 평문 파트인데 실제론 HTML 소스인 (잘못 만든) 메일 감지 → 태그 제거 처리. _HTML_HINT = re.compile( r"<\s*(html|body|head|table|div|p|br|a|td|tr|span|img|ul|ol|li|font)\b", re.I ) def _looks_html(text: str) -> bool: return bool(_HTML_HINT.search(text or "")) def _clean_plain_lines(text: str) -> list: """평문 파트 정리: HTML 주석·조건부 주석/VML·잔여 태그 제거, / 언랩, 엔티티 디코딩, CSS 잔재 줄 제거. text/plain 인데 HTML 조각이 섞인 메일에 대응.""" if not text: return [] s = _HTML_COMMENT.sub(" ", text) # 블록 제거 s = _ANGLE_URL.sub(r"\1 ", s) # → https://.. s = _ANGLE_EMAIL.sub(r"\1 ", s) # → a@b.com s = _STRAY_TAG.sub("", s) # 남은 /
등 태그 제거 s = _unescape_html(s) s = _URL_RUN.sub(_shorten_url, s) # 초장문 트래킹 URL 축약 out = [] for ln in s.split("\n"): ln = re.sub(r"[ \t \xa0]+", " ", ln).strip() if ln and not _is_css_noise(ln): out.append(ln) return out[:400] def _is_css_noise(ln: str) -> bool: """마케팅 메일에서 새어 나온 CSS 잔재 줄 감지(본문 가독성 향상).""" low = ln.lower() if "mso-" in low or "{" in ln and ("}" in ln or ":" in ln or ";" in ln): return True stripped = low.lstrip() return stripped.startswith(("@media", "@font-face", "@import", "@keyframes")) def _html_to_lines(html: str) -> list: """HTML 본문 → 읽을 수 있는 평문 줄 목록(블록 단위 줄바꿈 보존·엔티티 디코딩).""" if not html: return [] s = _HTML_COMMENT.sub(" ", html) # 조건부 주석/MSO 블록 먼저 제거(멀티라인) s = _DROP_TAGS.sub(" ", s) s = _BR.sub("\n", s) s = _BLOCK_END.sub("\n", s) s = _TAG.sub("", s) s = _unescape_html(s) s = _URL_RUN.sub(_shorten_url, s) # 초장문 트래킹 URL 축약 out = [] for ln in s.split("\n"): ln = re.sub(r"[ \t \xa0]+", " ", ln).strip() if ln and not _is_css_noise(ln): out.append(ln) # 과도하게 긴 마케팅 메일도 합리적 길이로 컷(렌더 안정). return out[:400] # Gmail labelId → 내부 한국어 라벨 _LABEL_MAP = { "CATEGORY_PERSONAL": "개인", "CATEGORY_PROMOTIONS": "프로모션", "CATEGORY_UPDATES": "업데이트", "CATEGORY_FORUMS": "포럼", "CATEGORY_SOCIAL": "소셜", "IMPORTANT": "중요", } def _gmail_headers(p: dict) -> dict: return {h["name"].lower(): h["value"] for h in p.get("payload", {}).get("headers", [])} def _decode_b64url(data: str) -> str: try: return base64.urlsafe_b64decode(data + "===").decode("utf-8", "ignore") except Exception: return "" def _find_mime_part(part: dict, target: str) -> dict | None: """payload 트리에서 target(mimeType) 파트를 재귀 탐색(데이터 있는 것만). 중첩 multipart 대응.""" if part.get("mimeType", "").startswith(target) and part.get("body", {}).get("data"): return part for sub in part.get("parts", []) or []: found = _find_mime_part(sub, target) if found: return found return None # 원본 HTML 보존 시 제거할 위험 요소(샌드박스 iframe 가 1차 방어, 이건 심층 방어). #