Skip to content

Commit 4e5fd3d

Browse files
committed
fix: 이메일 전송 관련 이슈 수정
1 parent 8762013 commit 4e5fd3d

9 files changed

Lines changed: 185 additions & 13 deletions

File tree

app/admin_api/serializers/notification.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from json import loads as json_loads
12
from typing import Any
23

34
from core.const.serializer import COMMON_ADMIN_FIELDS
@@ -23,6 +24,23 @@
2324
)
2425
from rest_framework import serializers
2526

27+
28+
def _validate_template_data(value: str, template_class: type[NotificationTemplateBase]) -> str:
29+
# 에디터가 컴파일된 HTML을 통째로 넣는 실수를 발송 시점이 아니라 저장 시점에 잡는다.
30+
try:
31+
parsed = json_loads(value)
32+
except ValueError as e:
33+
raise serializers.ValidationError("올바른 JSON이 아닙니다.") from e
34+
35+
if not isinstance(parsed, dict):
36+
raise serializers.ValidationError('JSON object여야 합니다. (예: {"title": "제목", "body": "<html>...</html>"})')
37+
38+
if missing := sorted(k for k in template_class.required_data_keys if not str(parsed.get(k) or "").strip()):
39+
raise serializers.ValidationError(f"다음 key가 비어 있습니다: {missing}")
40+
41+
return value
42+
43+
2644
# ---- SentTo nested ----------------------------------------------------------
2745

2846

@@ -70,6 +88,9 @@ class Meta:
7088
"sent_to_status_summary",
7189
)
7290

91+
def validate_template_data(self, value: str) -> str:
92+
return _validate_template_data(value, self.Meta.model.template_class) if value else value
93+
7394
def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase:
7495
# template이 명시되지 않은 templateless 경로면 transient (unsaved) template_class 인스턴스로 폴백.
7596
# Kakao는 template이 required + template_data/sent_from이 read-only라 or 우측이 실행되지 않음.
@@ -155,6 +176,9 @@ class Meta:
155176
def get_template_variables(self, obj: NotificationTemplateBase) -> list[str]:
156177
return sorted(obj.template_variables)
157178

179+
def validate_data(self, value: str) -> str:
180+
return _validate_template_data(value, self.Meta.model)
181+
158182
def render(self, context: dict[str, Any]) -> str:
159183
return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM)
160184

app/admin_api/test/notification_test.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,40 @@ def test_template_create(api_client):
6464
assert EmailNotificationTemplate.objects.filter(code="new-tpl").exists()
6565

6666

67+
@pytest.mark.django_db
68+
@pytest.mark.parametrize(
69+
"data",
70+
[
71+
"<!DOCTYPE html><html><body>Hello</body></html>", # 에디터 컴파일 결과를 그대로 넣은 경우
72+
'["not", "an", "object"]',
73+
'{"body":"b"}', # title 누락
74+
'{"title":" ","body":"b"}',
75+
],
76+
)
77+
def test_template_create_rejects_invalid_data(api_client, data):
78+
response = api_client.post(
79+
reverse("v1:admin-notification-email-template-list"),
80+
data={"code": "bad-tpl", "title": "잘못됨", "sent_from": "from@example.com", "data": data},
81+
format="json",
82+
)
83+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
84+
assert not EmailNotificationTemplate.objects.filter(code="bad-tpl").exists()
85+
86+
87+
@pytest.mark.django_db
88+
def test_create_history_templateless_rejects_invalid_template_data(api_client):
89+
response = api_client.post(
90+
reverse("v1:admin-notification-email-history-list"),
91+
data={
92+
"template_data": "<html>plain</html>",
93+
"sent_from": "from@example.com",
94+
"sent_to_list": [{"recipient": "to@example.com"}],
95+
},
96+
format="json",
97+
)
98+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
99+
100+
67101
@pytest.mark.django_db
68102
def test_template_partial_update(api_client, email_template):
69103
response = api_client.patch(

app/core/external_apis/smtp_email.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from email.policy import default as default_email_policy
12
from logging import getLogger
23
from typing import TypedDict, cast
34

@@ -6,6 +7,15 @@
67

78
logger = getLogger(__name__)
89

10+
# 기본값 78이면 긴 한글 제목이 RFC 2047 상한(75자)을 넘는 encoded-word로 접혀 일부 클라이언트에서 깨진다.
11+
_EMAIL_POLICY = default_email_policy.clone(max_line_length=76)
12+
13+
14+
class _SafeHeaderEmailMessage(EmailMessage):
15+
# backend가 message()를 인자 없이 호출하므로 기본 policy 자체를 갈아끼운다.
16+
def message(self, *, policy=_EMAIL_POLICY): # type: ignore[no-untyped-def]
17+
return super().message(policy=policy)
18+
919

1020
class EmailPayload(TypedDict):
1121
title: str
@@ -21,7 +31,7 @@ def send_message(self, *, data: SendParameters) -> None:
2131
if not payload.get("title"):
2232
raise ValueError("title is required in payload.")
2333

24-
message = EmailMessage(
34+
message = _SafeHeaderEmailMessage(
2535
subject=payload["title"],
2636
body=payload.get("body", ""),
2737
from_email=data["sent_from"],

app/core/test/smtp_email_test.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import re
2+
from email.header import decode_header, make_header
3+
4+
import pytest
5+
from core.external_apis.__interface__ import SendParameters
6+
from core.external_apis.smtp_email import email_client
7+
from django.core import mail
8+
9+
_MAX_ENCODED_WORD_LENGTH = 75 # RFC 2047, 구분자 포함
10+
11+
_LONG_KOREAN_SUBJECT = (
12+
"[파이콘 한국] 행사 일주일 전 꼭 확인해 주세요. / [PyCon Korea] One week to go — please check before you come"
13+
)
14+
15+
16+
def _params(**overrides) -> SendParameters:
17+
return SendParameters(
18+
payload=overrides.pop("payload", {"title": "제목", "body": "<p>본문</p>"}),
19+
send_to=overrides.pop("send_to", "to@example.com"),
20+
sent_from=overrides.pop("sent_from", "from@example.com"),
21+
template_code=overrides.pop("template_code", ""),
22+
)
23+
24+
25+
def _sent_subject_header() -> str:
26+
head = mail.outbox[0].message().as_bytes().split(b"\r\n\r\n")[0].decode()
27+
return re.search(r"^Subject:(.*?)(?=^\S+:)", head + "\nX:", re.S | re.M).group(1)
28+
29+
30+
@pytest.mark.parametrize(
31+
"subject", [_LONG_KOREAN_SUBJECT, "파이콘 한국 티켓 결제가 완료되었습니다!", "ASCII only subject"]
32+
)
33+
def test_send_message_subject_encoded_words_are_rfc2047_compliant(subject):
34+
email_client.send_message(data=_params(payload={"title": subject, "body": "<p>본문</p>"}))
35+
36+
header = _sent_subject_header()
37+
encoded_words = [word for word in header.split() if word.startswith("=?")]
38+
assert all(len(word) <= _MAX_ENCODED_WORD_LENGTH for word in encoded_words)
39+
assert len({word.split("?")[2] for word in encoded_words}) <= 1 # base64/quoted-printable 혼용 금지
40+
assert str(make_header(decode_header(header.strip()))) == subject
41+
42+
43+
def test_send_message_sends_html_body():
44+
email_client.send_message(data=_params(payload={"title": "제목", "body": "<p>본문</p>"}))
45+
46+
message = mail.outbox[0]
47+
assert message.content_subtype == "html"
48+
assert message.body == "<p>본문</p>"
49+
assert message.to == ["to@example.com"]
50+
51+
52+
def test_send_message_requires_title():
53+
with pytest.raises(ValueError, match="title"):
54+
email_client.send_message(data=_params(payload={"body": "<p>본문</p>"}))
55+
56+
57+
def test_send_message_requires_sent_from():
58+
with pytest.raises(ValueError, match="sent_from"):
59+
email_client.send_message(data=_params(sent_from=""))

app/notification/models/base.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class NotificationTemplateBase(BaseAbstractModel):
5353
variable_start: ClassVar[str] = "{{"
5454
variable_end: ClassVar[str] = "}}"
5555
html_template_name: ClassVar[str]
56+
required_data_keys: ClassVar[tuple[str, ...]] = ()
5657

5758
choices_meta_schema: ClassVar[dict] = {
5859
"code": {"label": "코드", "type": "string", "filter": "search"},
@@ -241,11 +242,20 @@ class Meta:
241242
def __str__(self) -> str:
242243
return f"{self.recipient} ({self.get_status_display()})"
243244

244-
def _parsed_template_data(self) -> Any:
245+
def _parsed_template_data(self) -> dict[str, Any]:
246+
# render 결과가 그대로 채널 payload가 되므로 JSON object가 아니면 여기서 fail-fast.
247+
# (평문/HTML이 저장된 경우 Django template context나 채널 client에서 TypeError로 뒤늦게 터진다.)
245248
try:
246-
return json_loads(self.history.template_data)
249+
parsed = json_loads(self.history.template_data)
247250
except ValueError:
248-
return self.history.template_data
251+
parsed = None
252+
253+
if not isinstance(parsed, dict):
254+
raise ValueError(
255+
f"Notification (template_code={self.history.template_code or '-'}) has invalid template_data: "
256+
f"expected a JSON object, got {type(parsed).__name__ if parsed is not None else 'non-JSON text'}.",
257+
)
258+
return parsed
249259

250260
def _required_template_variables(self, payload: Any) -> set[str]:
251261
template_class = self.history.template_class

app/notification/models/email.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@
1212

1313
class EmailNotificationTemplate(NotificationTemplateBase):
1414
html_template_name: ClassVar[str] = "email_preview.html"
15+
required_data_keys: ClassVar[tuple[str, ...]] = ("title", "body")
1516

1617

1718
class EmailNotificationHistorySentTo(NotificationHistorySentToBase):
1819
history = models.ForeignKey("EmailNotificationHistory", on_delete=models.PROTECT, related_name="sent_to_list")
1920

2021
@property
2122
def payload(self) -> dict[str, Any]:
23+
# body는 HTML이라 context를 escape, title은 메일 제목(plain text)이라 그대로 둔다.
2224
rendered = self.render()
23-
rendered["body"] = self.render_as_html()
25+
rendered["body"] = self.render(autoescape=True).get("body", "")
2426
return rendered
2527

2628

app/notification/models/nhn_cloud_sms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
class NHNCloudSMSNotificationTemplate(NotificationTemplateBase):
1414
html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html"
15+
required_data_keys: ClassVar[tuple[str, ...]] = ("body",) # title은 MMS 전용이라 선택
1516

1617

1718
class NHNCloudSMSNotificationHistorySentTo(NotificationHistorySentToBase):

app/notification/test/history_send_test.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,27 +127,41 @@ def test_history_send_parameters_uses_rendered_payload(system_user):
127127

128128

129129
@pytest.mark.django_db
130-
def test_email_payload_body_is_html_rendered(system_user):
131-
# 이메일 발송 시 payload["body"]는 HTML 템플릿으로 렌더링된 결과여야 함.
130+
def test_email_payload_body_is_template_body_without_preview_chrome(system_user):
131+
# 어드민 미리보기 껍데기(email_preview.html)의 아바타/날짜/"…에게" UI가 실제 메일에 섞이면 안 된다.
132132
tpl = EmailNotificationTemplate.objects.create(
133133
code="html-body",
134134
title="t",
135135
sent_from="a@b.c",
136-
data='{"title":"안녕 {{ name }}","body":"본문 {{ name }}"}',
136+
data='{"title":"안녕 {{ name }}","body":"<p>본문 {{ name }}</p>"}',
137137
created_by=system_user,
138138
updated_by=system_user,
139139
)
140140
history = _create_history(tpl, context={"name": "길동"})
141141
sent_to = history.sent_to_list.get()
142142
payload = sent_to.payload
143143

144-
# title은 plain text
145144
assert payload["title"] == "안녕 길동"
146-
assert not payload["title"].strip().startswith("<")
145+
assert payload["body"] == "<p>본문 길동</p>"
146+
assert "email-main" not in payload["body"]
147+
assert "에게" not in payload["body"]
147148

148-
# body는 HTML 렌더링 결과
149-
assert payload["body"].strip().startswith("<")
150-
assert "길동" in payload["body"]
149+
150+
@pytest.mark.django_db
151+
def test_email_payload_escapes_context_in_body(system_user):
152+
tpl = EmailNotificationTemplate.objects.create(
153+
code="escape-body",
154+
title="t",
155+
sent_from="a@b.c",
156+
data='{"title":"안녕 {{ name }}","body":"<p>{{ name }}</p>"}',
157+
created_by=system_user,
158+
updated_by=system_user,
159+
)
160+
history = _create_history(tpl, context={"name": "<script>alert(1)</script>"})
161+
payload = history.sent_to_list.get().payload
162+
163+
assert payload["body"] == "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>"
164+
assert payload["title"] == "안녕 <script>alert(1)</script>" # 제목은 plain text
151165

152166

153167
@pytest.mark.django_db

app/notification/test/template_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ def test_render_as_html_kakao_preview_renders_buttons():
130130
assert "가기" in html
131131

132132

133+
# ---- template_data 형식 검증 --------------------------------------------------
134+
135+
136+
def test_render_raises_on_non_json_template_data():
137+
# 에디터가 컴파일된 HTML을 data에 통째로 저장한 경우 — Django template context TypeError 대신
138+
# 발송 실패 사유로 읽히는 ValueError로 fail-fast.
139+
tpl = EmailNotificationTemplate(data="<!DOCTYPE html><html><body>Hello {{ name }}</body></html>")
140+
sent_to = tpl.build_preview_sent_to({"name": "길동"})
141+
with pytest.raises(ValueError, match="JSON object"):
142+
sent_to.render_as_html()
143+
144+
145+
def test_render_raises_on_json_non_object_template_data():
146+
tpl = EmailNotificationTemplate(data='["body"]')
147+
with pytest.raises(ValueError, match="JSON object"):
148+
tpl.build_preview_sent_to({}).render()
149+
150+
133151
# ---- JSON-unsafe context (per-string substitution 검증) -----------------------
134152

135153

0 commit comments

Comments
 (0)