- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- 네이버부스트캠프
- 코딩테스트 python
- 오라클
- 인공지능기초다지기
- DB
- 부스트코스
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- 파이썬
- 빅분기
- Oracle
- 기초다지기
- PY4E
- Django
- SQL
- AI 플랫폼을 활용한 데이터 분석
- boostcourse
- r
- 데이터베이스
- 코딩테스트
- 프로그래머스
- Ai
- 빅데이터분석기사
- python
- unsub
- 이기적
- boostcoures
- 정보처리기사
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- Today
- Total
매일공부
HTML 이메일 렌더링 트러블슈팅: Quill 에디터, 표 스타일, 그리고 naver.com만 반송되던 버그 본문
이메일 캠페인 도구 개발기 시리즈 (6/9). 전체 개요
메일 클라이언트마다 CSS/HTML 해석이 제각각이라, "화면에서는 예쁘게 보이는 HTML"이 "실제 수신함에서도 똑같이 보인다"를 보장하지 않는다. 이 프로젝트에서 겪은 문제들을 순서대로 정리한다.
1. Quill 에디터의 글자 크기/정렬이 실제 메일함에서 안 먹히던 문제
캠페인 본문 입력에 Quill(WYSIWYG 에디터)을 붙였는데, Quill은 기본적으로 글자 크기 (ql-size-*)와 정렬(ql-align-*)을 CSS class로 저장한다. 문제는 발송되는 메일 HTML에는 그 class를 정의하는 스타일시트가 포함되지 않는다는 것 — 이메일은 보통 <style> 블록이나 외부 CSS를 신뢰하지 않고 인라인 style 속성만 확실하게 렌더링한다.
해결: Quill의 attributors/style/size, attributors/style/align을 등록해서 class 대신 인라인 style 속성으로 저장되도록 변경. 글자 크기 선택지도 8pt~48pt 15단계로 세분화했다.
2. 표에 테두리가 안 보이던 문제 (그리고 그 수정이 만든 회귀)
에디터에 붙여넣은 <table>이 테두리 스타일 없이 저장되어 메일 클라이언트 기본값(테두리 없음)으로 보였다. models.py에 inline_table_styles()를 만들어 발송 직전/미리보기 렌더링 시 <table>/<td>/<th>에 테두리·패딩을 인라인으로 자동 삽입하게 했다.
그런데 1차 수정 후에도 실제 발송 메일에서는 표 폭이 창 크기에 따라 흔들리고 줄 간격도 좁았다. 원인은 Outlook/일부 웹메일이 <table>의 CSS width/ border-collapse를 무시하거나 잘라내는 것 — 그래서 CSS뿐 아니라 cellpadding/cellspacing/border/width HTML 속성 자체를 강제하고 table-layout:fixed로 컬럼 폭을 고정해야 했다.
그래도 테두리가 안 보이는 문제가 재현돼서 다시 파보니, 앞선 수정(글자 정렬 인라인화)이 만든 회귀였다. <td>에 이미 style="text-align: center;"가 붙어 있었는데, inline_table_styles()가 "style이 이미 있으면 건드리지 않는다"는 조건으로 짜여 있어서 테두리 추가를 통째로 건너뛰고 있었던 것. 기존 style을 스킵하지 않고 이어붙이는 _merge_tag_style()로 다시 짜서 해결했다.
교훈: 인라인 스타일을 다루는 여러 후처리 함수를 순서대로 적용할 때는 "이미 style이 있으면 스킵"이라는 방어 코드가 오히려 다른 후처리를 무력화할 수 있다. 병합(merge) 전략을 처음부터 고려해야 한다.
3. naver.com 수신자만 조용히 반송되던 버그 (가장 까다로웠던 문제)
발신 관련 문제는 이번이 처음이 아니었다 — Flask 삽질기에서 인증 계정과 발신 주소를 분리하는 문제(로그인은 개인 계정, 발신은 대표 메일링)를 다뤘는데, 이번엔 그 발신 주소 자체가 헤더 안에서 잘못 인코딩되는 문제였다. 같은 "메일 발신" 영역에서 계층이 다른 두 번째 문제를 만난 셈이다.
캠페인 발송 후 앱 화면과 발송 이력 DB(campaign_send_logs)에는 24건 전부 status=success로 기록됐는데, 실제로는 naver.com 수신자 9건이 반송됐다.
왜 앱은 success로 기록했나
server.sendmail()은 SMTP 릴레이가 메시지를 250 OK로 접수한 시점까지만 확인한다. 릴레이가 이후 naver.com에 실제 전달을 시도하는 건 비동기 과정이라, 그 단계에서 거부당한 사실을 앱이 알 방법이 없다(반송 메일 파싱/수신 기능이 없으므로).
왜 naver.com만 실패했나
models.py의 build_message()에서 From 헤더를 문자열 포맷으로 합치고 있었다:
msg['From'] = f"{smtp['from_name']} <{smtp['from_email']}>"
from_name이 한글("(주)그린텍")이라 파이썬이 헤더를 인코딩할 때, 표시 이름뿐 아니라 <software@example.com> 주소 부분까지 통째로 하나의 RFC 2047 encoded-word (base64)로 묶어버렸다:
From: =?utf-8?b?KOyjvCnqt7jrprDthY0gPHNvZnR3YXJlQGV4YW1wbGUuY29tPg==?=
디코딩하면 (주)그린텍 <software@example.com> 전체가 그 안에 들어있다 — 즉 헤더에 평문 <주소>가 전혀 없어서, RFC 5322 기준으로는 "유효한 메일박스 주소가 없는 헤더"가 된다. Gmail/다음 등 대부분의 서버는 관대하게 디코딩 후 재해석해서 통과시키지만, naver는 엄격하게 검사해서 553 5.7.2 The sender address is not a valid RFC-5322 address로 거부했다.
수정
email.utils.formataddr()을 써서 표시 이름만 인코딩하고 주소는 평문으로 유지:
from email.utils import formataddr
msg['From'] = formataddr((smtp['from_name'], smtp['from_email']))
결과:
From: =?utf-8?b?KOyjvCnqt7jrprDthY0=?= <software@example.com>
주소가 평문으로 남아 있어 어떤 수신 서버든 정상 파싱 가능해졌다.
교훈: 다국어(특히 non-ASCII) 표시 이름이 들어가는 이메일 헤더는 절대 직접 f-string으로 조립하지 말고 email.utils.formataddr / email.header.Header 같은 표준 라이브러리 유틸을 쓸 것. 관대한 수신 서버 (Gmail)에서 테스트가 통과했다고 안전한 게 아니다 — 엄격한 서버(naver) 기준으로 검증해야 진짜 RFC 준수 여부를 알 수 있다.
근본적으로 못 막는 부분: 비동기 반송은 자동 감지가 안 된다
이 앱은 반송 메일을 자동으로 파싱하는 기능이 없다(1회성 캠페인 발송 도구라 IMAP 폴링 등 추가 구현은 보류). 대신 관리자가 반송 메일함을 직접 확인한 뒤 기록할 수 있도록 campaign_send_logs.manual_failure_note 컬럼을 추가해서, 발송 이력 표에 "반송 확인" 메모를 남길 수 있게만 했다. 완전 자동화 대신 "사람이 확인한 사실을 기록할 자리"를 만들어주는 선에서 범위를 제한한 것.
'IT > 작업기록' 카테고리의 다른 글
| 발송 이력과 장애 격리: "부가 기능의 실패가 핵심 기능을 끌고 내려가지 않게" (0) | 2026.08.31 |
|---|---|
| 서로 다른 두 고객 데이터소스를 하나의 구독자 목록으로 통합하기 (0) | 2026.08.31 |
| 캠페인 상태 머신: 테스트 없이 실제 메일이 나가지 않도록 만들기 (0) | 2026.08.31 |
| Flask 첫 프로젝트에서 마주친 삽질들 (0) | 2026.08.31 |
| 이메일 캠페인 발송 시스템 DB 설계 노트 (0) | 2026.08.31 |