o2o-site-AEO/solution/backend/services/mail_service.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

140 lines
4.7 KiB
Python

"""메일 한 통을 보낸다 — Azure Communication Services 우선, SMTP 폴백.
★ 설정이 없으면 보내지 않고 False 를 돌려준다(teams_webhook 과 같은 규약). 값이 없어도 서버는 뜬다.
★ 이 파일은 "메일 한 통 보내기" 만 안다 — 무엇을 언제 보낼지는 부르는 쪽이 정한다.
★ ACS 를 1순위로 두는 이유: 회사가 이미 공용 리소스를 쓰고 있고(negodata), 발신 도메인의
SPF·DKIM 을 그쪽이 관리한다. SMTP 는 그 리소스를 못 쓰는 환경의 폴백이다.
"""
import os
import re
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr, parseaddr
from common.logger import LOG
ACS_ENDPOINT_ENV = "ACS_EMAIL_ENDPOINT"
ACS_KEY_ENV = "ACS_EMAIL_ACCESSKEY"
ACS_SENDER_ENV = "ACS_EMAIL_SENDER"
HOST_ENV = "SMTP_HOST"
PORT_ENV = "SMTP_PORT"
USER_ENV = "SMTP_USER"
PASSWORD_ENV = "SMTP_PASSWORD"
FROM_ENV = "SMTP_FROM"
FROM_NAME_ENV = "SMTP_FROM_NAME"
TLS_ENV = "SMTP_TLS"
TIMEOUT_SEC = 15.0
DEFAULT_FROM_NAME = "Web4AI"
def is_configured() -> bool:
return _acs_configured() or _smtp_configured()
def _acs_configured() -> bool:
return bool(_env(ACS_ENDPOINT_ENV) and _env(ACS_KEY_ENV) and _env(ACS_SENDER_ENV))
def _smtp_configured() -> bool:
return bool(_env(HOST_ENV) and _sender())
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _sender() -> str:
return _env(FROM_ENV) or _env(USER_ENV)
def _port() -> int:
try:
return int(_env(PORT_ENV) or "587")
except ValueError:
return 587
def _mode() -> str:
"""starttls | ssl | plain. 기본은 starttls(587)이고 465 는 ssl 로 떨어진다."""
value = _env(TLS_ENV).lower()
if value in {"ssl", "starttls", "plain"}:
return value
return "ssl" if _port() == 465 else "starttls"
def is_valid_address(value: str) -> bool:
address = parseaddr(value or "")[1]
return bool(re.fullmatch(r"[^@\s]+@[^@\s.]+(\.[^@\s.]+)+", address))
def _send_acs(*, to: str, subject: str, text: str, reply_to: str | None) -> bool:
try:
from azure.communication.email import EmailClient
client = EmailClient.from_connection_string(
f"endpoint={_env(ACS_ENDPOINT_ENV)};accesskey={_env(ACS_KEY_ENV)}"
)
message = {
"senderAddress": _env(ACS_SENDER_ENV),
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text},
}
if reply_to:
message["replyTo"] = [{"address": reply_to}]
client.begin_send(message).result()
return True
except Exception as error: # noqa: BLE001 — 폴백(SMTP)이 남아 있다
LOG.e(f"[mail] ACS 발송 실패: {type(error).__name__}")
return False
def send(*, to: str, subject: str, text: str, reply_to: str | None = None) -> bool:
"""보냈으면 True. 설정이 없거나 실패하면 False — 예외를 밖으로 던지지 않는다.
★ 손님이 누른 버튼 하나가 메일 서버 장애로 500 이 되면 안 된다. 부르는 쪽이 False 를 보고
"전달하지 못했다"를 손님 화면의 말로 바꾼다."""
if not is_configured():
LOG.w("[mail] SMTP 미설정 — 보내지 않는다")
return False
if not is_valid_address(to):
LOG.w("[mail] 받는 주소가 형식에 맞지 않는다")
return False
if _acs_configured():
if _send_acs(to=to, subject=subject, text=text, reply_to=reply_to):
return True
if not _smtp_configured():
return False
if not _smtp_configured():
return False
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((_env(FROM_NAME_ENV) or DEFAULT_FROM_NAME, _sender()))
message["To"] = to
if reply_to and is_valid_address(reply_to):
message["Reply-To"] = reply_to
message.set_content(text)
host, port, mode = _env(HOST_ENV), _port(), _mode()
user, password = _env(USER_ENV), _env(PASSWORD_ENV)
try:
if mode == "ssl":
client = smtplib.SMTP_SSL(host, port, timeout=TIMEOUT_SEC, context=ssl.create_default_context())
else:
client = smtplib.SMTP(host, port, timeout=TIMEOUT_SEC)
with client:
if mode == "starttls":
client.starttls(context=ssl.create_default_context())
if user and password:
client.login(user, password)
client.send_message(message)
return True
except Exception as error: # noqa: BLE001 — 발송 실패를 호출측 실패로 만들지 않는다
LOG.e(f"[mail] 발송 실패: {type(error).__name__}")
return False