o2o-negosium-original/negodata/backend/services/email.py

158 lines
6.7 KiB
Python

"""협상 초청 메일 발송.
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
2순위(폴백): SMTP (aiosmtplib).
둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0).
설정 출처: config.<APP_ENV>.toml 의 [MailConfig] (config/config_models.py:MailConfig).
HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
"""
from __future__ import annotations
import re
from datetime import datetime
from email.message import EmailMessage
from html import escape
from pathlib import Path
from string import Template
from zoneinfo import ZoneInfo
from common.logger import LOG
from config.server_configs import mail_config
_KST = ZoneInfo("Asia/Seoul")
# 회사 CI(settings.branding) 미설정 시 기본값 — 설정 화면 placeholder 와 같은 값이어야 한다.
# 색 기본값은 공급사 협상 포털(frontend --brand-600)과 동일 — 메일에서 포털로 이어지는 화면이 한 브랜드로 보이게.
_DEFAULT_EMAIL_COLOR = "#3182f6"
_DEFAULT_EMAIL_HEADER = "NEGODATA"
_DEFAULT_EMAIL_GREETING = "아래 견적 건의 협상에 참여해 주세요."
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
class EmailUnavailable(RuntimeError):
"""ACS·SMTP 모두 미설정이라 발송 채널이 없음."""
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
"""Azure Communication Services Email — accesskey 인증."""
from azure.communication.email.aio import EmailClient
conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}"
message = {
"senderAddress": mail_config.azure_acs_sender,
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text, "html": html},
}
async with EmailClient.from_connection_string(conn) as client:
poller = await client.begin_send(message)
await poller.result()
LOG.i(f"email sent via ACS → {to} ({subject})")
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
import aiosmtplib
msg = EmailMessage()
msg["From"] = mail_config.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=mail_config.smtp_host,
port=mail_config.smtp_port,
username=mail_config.smtp_user or None,
password=mail_config.smtp_password or None,
start_tls=mail_config.smtp_starttls,
)
LOG.i(f"email sent via SMTP → {to} ({subject})")
async def send_email(to: str, subject: str, html: str, text: str) -> None:
"""ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable."""
if mail_config.acs_configured:
await _send_acs(to, subject, html, text)
return
if mail_config.smtp_host:
await _send_smtp(to, subject, html, text)
return
# endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내.
if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender:
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
def _fmt_deadline(end_time: datetime | None) -> str:
"""협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열."""
if not end_time:
return ""
dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M")
def build_invite_email(
*,
supplier_name: str,
quotation_name: str,
qt_number: str,
end_time: datetime | None,
chat_url: str,
company_name: str = "",
branding: dict | None = None,
) -> tuple[str, str, str]:
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
공급사는 링크로 협상 화면에 진입해 입찰한다.
branding = companies.settings.branding — 헤더는 회사 CI(logo_url·service_name)를
그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐.
헤더는 로고 있으면 로고, 없으면 service_name → 회사명(company_name) → NEGODATA 텍스트 —
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 회사별 커스텀 없이 솔루션 기본색 고정.
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
"""
b = branding or {}
sp = supplier_name or "협력사"
deadline = _fmt_deadline(end_time) or "미정"
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
header_name = (b.get("service_name") or "").strip() or (company_name or "").strip() or _DEFAULT_EMAIL_HEADER
greeting = (b.get("email_greeting") or "").strip() or _DEFAULT_EMAIL_GREETING
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
html = _INVITE_HTML.substitute(
supplier_name=escape(sp),
quotation_name=escape(quotation_name),
qt_number=escape(qt_number),
deadline=escape(deadline),
chat_url=escape(chat_url),
brand_color=_DEFAULT_EMAIL_COLOR,
header_content=_header_content(header_name, (b.get("logo_url") or "").strip()),
email_greeting=escape(greeting),
)
text = (
f"{sp} 담당자님, {greeting}\n\n"
f" - 견적명: {quotation_name}\n"
f" - 견적번호: {qt_number}\n"
f" - 협상 마감: {deadline}\n\n"
f"협상 참여 링크: {chat_url}\n\n"
f"— negotium B2B 구매협상 솔루션\n"
)
return subject, html, text
def _header_content(name: str, logo_url: str) -> str:
"""헤더(흰 배경) 내용물 — 회사 CI 로고가 있으면 이미지(회사명은 alt 로), 없으면 회사명 텍스트. escape 는 여기서 끝낸다."""
if logo_url:
return (
f'<img src="{escape(logo_url)}" alt="{escape(name)}" height="32" '
f'style="display:block;height:32px;max-width:220px;border:0;margin:0 auto;" />'
)
return f'<span style="color:#191f28;font-size:18px;font-weight:800;letter-spacing:-0.4px;">{escape(name)}</span>'