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.
125 lines
4.3 KiB
Python
125 lines
4.3 KiB
Python
# backend/app/connectors/mail/normalize.py
|
|
# provider(mock/gmail/imap) → 내부 email 모델(phase-9) 정규화 + 멱등 upsert.
|
|
import base64
|
|
import uuid
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ...models import 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",
|
|
}
|
|
# 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 _gmail_body(p: dict) -> list:
|
|
"""MIME 파트에서 text/plain 본문을 base64url 디코딩(없으면 snippet)."""
|
|
payload = p.get("payload", {})
|
|
parts = payload.get("parts") or [payload]
|
|
for part in parts:
|
|
if part.get("mimeType", "").startswith("text/"):
|
|
data = part.get("body", {}).get("data")
|
|
if data:
|
|
try:
|
|
text = base64.urlsafe_b64decode(data + "===").decode("utf-8", "ignore")
|
|
return [ln for ln in text.split("\n") if ln.strip()]
|
|
except Exception:
|
|
pass
|
|
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_has_attach(p: dict) -> bool:
|
|
for part in p.get("payload", {}).get("parts", []) or []:
|
|
if part.get("filename"):
|
|
return True
|
|
return False
|
|
|
|
|
|
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", [])
|
|
fields = {
|
|
"account": _ACCOUNT_KEY.get(account.id, "work"),
|
|
"from_key": headers.get("from", ""),
|
|
"to": headers.get("to", "나"),
|
|
"subject": headers.get("subject", "(제목 없음)"),
|
|
"body": _gmail_body(p),
|
|
"labels": _gmail_labels(label_ids),
|
|
"read": "UNREAD" not in label_ids,
|
|
"starred": "STARRED" in label_ids,
|
|
"has_attach": _gmail_has_attach(p),
|
|
"preview": p.get("snippet", ""),
|
|
"date": "방금",
|
|
"ai_json": {}, # phase-9 메일 분석(로컬 LLM)이 후처리로 채움
|
|
}
|
|
else: # mock(mail-data.js shape) / imap
|
|
body = p.get("body", [])
|
|
fields = {
|
|
"account": _ACCOUNT_KEY.get(account.id, 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 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
|