166 lines
7.0 KiB
Python
166 lines
7.0 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 = "아래 견적 건의 협상에 참여해 주세요."
|
|
|
|
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
|
|
|
# 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·primary_color)를
|
|
그대로 쓴다(메일 전용 이중 설정 금지). 메일 전용 키는 인사 문구(email_greeting) 하나뿐.
|
|
헤더는 로고 있으면 로고, 없으면 service_name → 회사명(company_name) → NEGODATA 텍스트 —
|
|
공급사에겐 솔루션명보다 발주사가 보여야 한다. 색은 primary_color → 기본(챗 포털 브랜드 블루) 순.
|
|
레이아웃은 고정이고 값만 갈아끼우므로, 어떤 값을 넣어도 메일이 깨지지 않는다.
|
|
"""
|
|
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=_safe_color(b.get("primary_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 _safe_color(value: str | None) -> str:
|
|
"""브랜드 색은 hex 만 통과 — 이상값이 style 속성을 깨거나 CSS 를 주입하지 못하게 기본색으로 되돌린다."""
|
|
v = (value or "").strip()
|
|
return v if _HEX_COLOR.match(v) else _DEFAULT_EMAIL_COLOR
|
|
|
|
|
|
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>'
|