"""표준 라이브러리 기반 비동기 SMTP 발송 서비스.""" import asyncio import logging import smtplib from email.message import EmailMessage from email.utils import formataddr from config.config_system import ( EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME, EMAIL_NOTIFICATION_ENABLED, EMAIL_RETRY_COUNT, EMAIL_RETRY_DELAY_SECONDS, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USE_TLS, SMTP_USERNAME, ) logger = logging.getLogger(__name__) def _send_sync(to_email: str, subject: str, html: str) -> None: message = EmailMessage() message["From"] = formataddr((EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS)) message["To"] = to_email message["Subject"] = subject message.set_content("HTML 이메일을 지원하는 메일 앱에서 확인해 주세요.") message.add_alternative(html, subtype="html") with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp: if SMTP_USE_TLS: smtp.starttls() smtp.login(SMTP_USERNAME, SMTP_PASSWORD) smtp.send_message(message) async def send_email(to_email: str, subject: str, html: str) -> bool: """SMTP 발송을 워커 스레드에서 실행하고 설정된 횟수만큼 재시도한다.""" if not EMAIL_NOTIFICATION_ENABLED: logger.info("이메일 발송 비활성화: %s", to_email) return True if not SMTP_USERNAME or not SMTP_PASSWORD or not EMAIL_FROM_ADDRESS: logger.error("SMTP 환경 변수가 설정되지 않았습니다.") return False for attempt in range(1, EMAIL_RETRY_COUNT + 1): try: await asyncio.to_thread(_send_sync, to_email, subject, html) logger.info("이메일 발송 성공: %s", to_email) return True except (OSError, smtplib.SMTPException): logger.exception("이메일 발송 실패 (%s/%s)", attempt, EMAIL_RETRY_COUNT) if attempt < EMAIL_RETRY_COUNT: await asyncio.sleep(EMAIL_RETRY_DELAY_SECONDS * (2 ** (attempt - 1))) return False def send_email_background(to_email: str, subject: str, html: str) -> None: """HTTP 응답을 차단하지 않고 이메일 발송을 예약한다.""" task = asyncio.create_task(send_email(to_email, subject, html)) task.add_done_callback(lambda completed: completed.exception())