You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

514 lines
21 KiB
Python

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

# 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 주석/조건부 주석(<!--[if mso]>..<![endif]-->, <!--[if false]><!-->) — 멀티라인.
# 평문 파트에 섞여 들어오는 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)</\s*(p|div|tr|li|h[1-6]|blockquote|table|ul|ol)\s*>")
_TAG = re.compile(r"<[^>]+>")
# 앵글로 감싼 URL/이메일(<https://..>, <tel:..>, <a@b.com>) → 태그 오인 말고 언랩(링크 보존).
_ANGLE_URL = re.compile(r"<\s*((?:https?|mailto|tel):[^>\s]+)\s*>")
_ANGLE_EMAIL = re.compile(r"<\s*([^<>\s@]+@[^<>\s]+)\s*>")
# URL/이메일 언랩 후 남는 실제 HTML 태그(<v:roundrect ..>, <div>, </p> 등) 제거.
_STRAY_TAG = re.compile(r"</?\s*[a-zA-Z!][\w:.-]*(?:\s[^>]*)?/?>")
# &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: # 정상 케이스(&amp; &Aacute; 등)는 그대로 보존
return out
low = html_lib.unescape(tok.lower()) # &NBSP; → &nbsp; → 공백
return low if low != tok.lower() else tok
def _unescape_html(s: str) -> str:
"""명명 엔티티(케이스 보정) + 숫자 엔티티(&#39; 등) 디코딩."""
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·잔여 태그 제거, <url>/<email> 언랩,
엔티티 디코딩, CSS 잔재 줄 제거. text/plain 인데 HTML 조각이 섞인 메일에 대응."""
if not text:
return []
s = _HTML_COMMENT.sub(" ", text) # <!--[if mso]>..<![endif]--> 블록 제거
s = _ANGLE_URL.sub(r"\1 ", s) # <https://..> → https://..
s = _ANGLE_EMAIL.sub(r"\1 ", s) # <a@b.com> → a@b.com
s = _STRAY_TAG.sub("", s) # 남은 <v:..>/<div> 등 태그 제거
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차 방어, 이건 심층 방어).
# <script>/<iframe>/<object>/<embed> 블록 + on* 이벤트 핸들러 + javascript: 스킴.
_SCRIPTISH = re.compile(
r"(?is)<\s*(script|iframe|object|embed|noscript)\b.*?<\s*/\s*\1\s*>"
)
_SELF_SCRIPTISH = re.compile(r"(?is)<\s*(script|iframe|object|embed)\b[^>]*/?>")
_ON_ATTR = re.compile(r"(?is)\son[a-z]+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)")
_JS_URL = re.compile(r"(?i)(href|src|action)\s*=\s*([\"']?)\s*javascript:[^\"'>\s]*")
# 본문 HTML 저장 상한(병적으로 큰 마케팅 메일 방지).
_MAX_HTML = 600_000
def _sanitize_html(html: str) -> str:
"""능동 콘텐츠(script/iframe/on*/javascript:)만 제거. 서식·이미지·스타일은 보존."""
if not html:
return ""
s = _HTML_COMMENT.sub(" ", html) # 조건부/MSO 주석 제거(렌더 불필요·잡음)
s = _SCRIPTISH.sub("", s)
s = _SELF_SCRIPTISH.sub("", s)
s = _ON_ATTR.sub("", s)
s = _JS_URL.sub(r'\1=\2#', s)
return s[:_MAX_HTML]
def _gmail_html(p: dict) -> str:
"""Gmail payload 에서 text/html 파트 원본을 추출(능동 콘텐츠 제거). 없으면 빈 문자열."""
html_part = _find_mime_part(p.get("payload", {}), "text/html")
if html_part:
return _sanitize_html(_decode_b64url(html_part["body"]["data"]))
return ""
def _part_header(part: dict, name: str) -> str:
low = name.lower()
for h in part.get("headers", []) or []:
if (h.get("name") or "").lower() == low:
return h.get("value", "")
return ""
def _gmail_body(p: dict) -> list:
"""본문 추출: text/plain 우선, 없으면 text/html(태그 제거), 그것도 없으면 snippet.
중첩 multipart(mixed>alternative>...) 를 재귀 탐색한다."""
payload = p.get("payload", {})
plain = _find_mime_part(payload, "text/plain")
if plain:
text = _decode_b64url(plain["body"]["data"])
if _looks_html(text): # 잘못 만든 메일: 평문 파트에 HTML 소스가 들어있음
lines = _html_to_lines(text)
else: # 평문 정리(주석/조건부 주석·VML·잔여 태그·<url> 언랩·CSS 잔재 제거)
lines = _clean_plain_lines(text)
if lines:
return lines[:400]
html_part = _find_mime_part(payload, "text/html")
if html_part:
lines = _html_to_lines(_decode_b64url(html_part["body"]["data"]))
if lines:
return lines
snip = p.get("snippet", "")
return [snip] if snip else []
def _gmail_labels(label_ids: list) -> list:
return [_LABEL_MAP[lid] for lid in label_ids if lid in _LABEL_MAP]
def _gmail_folder(label_ids: list) -> str:
"""Gmail labelIds → 시스템 폴더(스팸/휴지통/보낸/받은편지함). 카테고리는 라벨이라 폴더 아님."""
if "SPAM" in label_ids:
return "spam"
if "TRASH" in label_ids:
return "trash"
if "INBOX" in label_ids:
return "inbox"
if "SENT" in label_ids:
return "sent"
return "inbox"
def _gmail_attachments(part: dict, out: list | None = None) -> list:
"""payload 트리를 재귀 탐색해 첨부 메타데이터 수집(일반 첨부 + 인라인 cid 이미지).
인라인 이미지는 filename 이 없어도 Content-ID 가 있으면 수집(본문 HTML 에서 cid: 로 참조)."""
out = [] if out is None else out
fn = part.get("filename") or ""
body = part.get("body") or {}
cid = _part_header(part, "Content-ID").strip("<>").strip()
disp = _part_header(part, "Content-Disposition").lower()
inline = bool(cid) or "inline" in disp
if body.get("attachmentId") and (fn or cid):
out.append(
{
"id": body["attachmentId"],
"name": fn or (cid or "image"),
"mime": part.get("mimeType", "application/octet-stream"),
"size": int(body.get("size") or 0),
"content_id": cid,
"inline": inline,
}
)
for sub in part.get("parts", []) or []:
_gmail_attachments(sub, out)
return out
def _gmail_has_attach(part: dict) -> bool:
"""첨부 존재 여부(파일명 있는 파트). 다운로드 불가(attachmentId 없음)여도 표시는 함."""
if part.get("filename"):
return True
return any(_gmail_has_attach(sub) for sub in part.get("parts", []) or [])
def normalize_email(
account: ConnectorAccount, raw: RawRecord, provider: str = "mock"
) -> NormalizedRecord:
p = raw.payload
if provider == "gmail":
headers = _gmail_headers(p)
label_ids = p.get("labelIds", [])
attachments = _gmail_attachments(p.get("payload", {}))
fields = {
"account": _mail_account_for(account),
"from_key": headers.get("from", ""),
"to": headers.get("to", ""),
"cc": headers.get("cc", ""),
"thread_id": p.get("threadId", ""),
"subject": headers.get("subject", "(제목 없음)"),
"body": _gmail_body(p),
"body_html": _gmail_html(p),
# 카테고리(중요/프로모션…) + 사용자 라벨(커넥터가 id→이름 해석해 주입).
"labels": _gmail_labels(label_ids) + list(p.get("_userLabels", [])),
"folder": _gmail_folder(label_ids),
"read": "UNREAD" not in label_ids,
"starred": "STARRED" in label_ids,
"has_attach": _gmail_has_attach(p.get("payload", {})),
"attachments": attachments,
"preview": p.get("snippet", ""),
# 표시용 time/date 는 응답 시 received_at 으로 산출(상대시각 갱신·계정 일관).
"received_at": _gmail_received(p),
# ai_json 은 의도적으로 미설정 — 재동기화 시 worker 가 채운 분석 결과 보존.
}
elif provider == "outlook":
frm = (p.get("from") or {}).get("emailAddress", {})
body_obj = p.get("body") or {}
is_html = (body_obj.get("contentType") or "").lower() == "html"
content = body_obj.get("content") or ""
body_html = _sanitize_html(content) if is_html else ""
if is_html:
body_lines = _html_to_lines(content)
else: # 평문: HTML 소스면 태그 제거, 아니면 주석/잔여 태그/CSS 잔재 정리.
if _looks_html(content):
body_lines = _html_to_lines(content)
else:
body_lines = _clean_plain_lines(content)
if not body_lines and p.get("bodyPreview"):
body_lines = [p["bodyPreview"]]
to_list = p.get("toRecipients") or []
to = ", ".join(
(r.get("emailAddress", {}) or {}).get("address", "") for r in to_list
) or ""
cc = ", ".join(
(r.get("emailAddress", {}) or {}).get("address", "")
for r in (p.get("ccRecipients") or [])
)
# 인라인 이미지(cid)도 수집 → 본문 cid: 치환에 필요(프론트는 inline 칩 숨김).
attachments = [
{
"id": a.get("id", ""),
"name": a.get("name", "첨부파일"),
"mime": a.get("contentType", "application/octet-stream"),
"size": int(a.get("size") or 0),
"content_id": (a.get("contentId") or "").strip("<>").strip(),
"inline": bool(a.get("isInline")),
}
for a in (p.get("attachments") or [])
]
fields = {
"account": _mail_account_for(account),
"from_key": _outlook_from(frm),
"to": to,
"cc": cc,
"thread_id": p.get("conversationId", ""),
"subject": p.get("subject") or "(제목 없음)",
"body": body_lines,
"body_html": body_html,
"labels": p.get("categories") or [],
# 폴더는 커넥터가 parentFolderId → 슬러그/표시명으로 해석해 주입(없으면 받은편지함).
"folder": p.get("_folder", "inbox"),
"folder_name": p.get("_folder_name", ""),
# 집중 받은편지함 분류(focused/other) — 받은편지함=집중, 기타는 별도 폴더로 분리.
"inference": p.get("inferenceClassification", ""),
"read": bool(p.get("isRead", False)),
"starred": (p.get("flag") or {}).get("flagStatus") == "flagged",
"has_attach": bool(p.get("hasAttachments", False)),
"attachments": attachments,
"preview": p.get("bodyPreview", ""),
# 표시용 time/date 는 응답 시 received_at 으로 산출(상대시각 갱신·계정 일관).
"received_at": _parse_iso_dt(p.get("receivedDateTime", "")),
# ai_json 미설정 — 재동기화 시 분석 결과 보존(gmail 과 동일).
}
else: # mock(mail-data.js shape) / imap
body = p.get("body", [])
fields = {
"account": _mail_account_for(account, p.get("account", "work")),
"from_key": p.get("from", p.get("from_key", "")),
"to": p.get("to", ""),
"subject": p.get("subject", ""),
"body": body if isinstance(body, list) else [body],
"labels": p.get("labels", []),
"read": p.get("read", False),
"starred": p.get("starred", False),
"has_attach": p.get("hasAttach", p.get("has_attach", False)),
"preview": p.get("preview", ""),
"date": p.get("date", "방금"),
"ai_json": p.get("ai", {}) or {},
}
return NormalizedRecord(
entity_type="email",
external_id=raw.external_id,
fields=fields,
etag=raw.etag,
external_updated_at=raw.external_updated_at,
)
def folder_dict(slug, name, icon, sort, kind="system", **extra) -> dict:
"""AccountFolder 항목 dict 빌더(커넥터 _sync_folders 공용)."""
return {"slug": slug, "name": name, "kind": kind, "icon": icon, "sort_order": sort, **extra}
def replace_account_folders(
session: Session, mail_account: str, folders: list[dict]
) -> None:
"""계정의 AccountFolder 목록을 sync 시 통째로 갱신(추가/수정/삭제)."""
existing = {
f.slug: f
for f in session.exec(
select(AccountFolder).where(AccountFolder.mail_account == mail_account)
).all()
}
seen: set[str] = set()
for i, fd in enumerate(folders):
slug = fd["slug"]
seen.add(slug)
row = existing.get(slug) or AccountFolder(
id=f"{mail_account}:{slug}", mail_account=mail_account, slug=slug
)
row.name = fd["name"]
row.kind = fd.get("kind", "system")
row.icon = fd.get("icon", "folder")
row.provider_id = fd.get("provider_id", "")
row.unread = int(fd.get("unread", 0))
row.total = int(fd.get("total", 0))
row.sort_order = int(fd.get("sort_order", i))
session.add(row)
for slug, row in existing.items():
if slug not in seen:
session.delete(row)
session.commit()
def upsert_email(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
link = session.exec(
select(ExternalLink).where(
ExternalLink.external_id == norm.external_id,
ExternalLink.entity_type == "email",
)
).first()
if link: # 이미 존재 → update(멱등)
e = session.get(Email, link.entity_id)
if e:
for k, v in norm.fields.items():
if v is not None and hasattr(e, k):
setattr(e, k, v)
session.add(e)
return e.id, False
eid = "m-" + uuid.uuid4().hex[:8]
e = Email(id=eid, **{k: v for k, v in norm.fields.items() if v is not None})
session.add(e)
return eid, True