|
|
# 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: # 정상 케이스(& Á 등)는 그대로 보존
|
|
|
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·잔여 태그 제거, <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
|