Skip to content

Commit e42b3de

Browse files
SynromMaximilian Leiwig
and
Maximilian Leiwig
authored
115 multipart html and plain text emails (#179)
* implemented alternative_body * added tests for alternative_body * import root_validator * added documentation in the MailMsg class * Some mail clients only see both bodies with related --------- Co-authored-by: Maximilian Leiwig <[email protected]>
1 parent 97e8c0f commit e42b3de

File tree

4 files changed

+162
-30
lines changed

4 files changed

+162
-30
lines changed

fastapi_mail/msg.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from email.mime.text import MIMEText
88
from email.utils import formatdate, make_msgid
99
from typing import Any, Union
10+
from .schemas import MessageType, MultipartSubtypeEnum
1011

1112
PY3 = sys.version_info[0] == 3
1213

@@ -18,6 +19,7 @@ class MailMsg:
1819
:param: subject: Email subject header
1920
:param: recipients: List of email addresses
2021
:param: body: Plain text message or HTML message
22+
:param: alternative_body: Plain text message or HTML message
2123
:param: template_body: Data to pass into chosen Jinja2 template
2224
:param: subtype: MessageType class. Type of body parameter, either "plain" or "html"
2325
:param: sender: Email sender address
@@ -36,6 +38,7 @@ def __init__(self, entries) -> None:
3638
self.attachments = entries.attachments
3739
self.subject = entries.subject
3840
self.body = entries.body
41+
self.alternative_body = entries.alternative_body
3942
self.template_body = entries.template_body
4043
self.cc = entries.cc
4144
self.bcc = entries.bcc
@@ -88,13 +91,40 @@ async def attach_file(self, message: MIMEMultipart, attachment: Any):
8891

8992
self.message.attach(part)
9093

94+
def attach_alternative(self, message: MIMEMultipart) -> MIMEMultipart:
95+
"""
96+
Attaches an alternative body to a given message
97+
"""
98+
tmpmsg = message
99+
if self.subtype == MessageType.plain:
100+
flipped_subtype = "html"
101+
else:
102+
flipped_subtype = "plain"
103+
tmpmsg.attach(self._mimetext(self.alternative_body, flipped_subtype))
104+
message = MIMEMultipart(MultipartSubtypeEnum.related.value)
105+
message.set_charset(self.charset)
106+
message.attach(tmpmsg)
107+
return message
108+
91109
async def _message(self, sender: str = None) -> Union[EmailMessage, Message]:
92110
"""
93111
Creates the email message
94112
"""
95113

96114
self.message = MIMEMultipart(self.multipart_subtype.value)
97115
self.message.set_charset(self.charset)
116+
117+
if self.template_body:
118+
self.message.attach(self._mimetext(self.template_body, self.subtype.value))
119+
elif self.body:
120+
self.message.attach(self._mimetext(self.body, self.subtype.value))
121+
122+
if (
123+
self.alternative_body is not None
124+
and self.multipart_subtype == MultipartSubtypeEnum.alternative
125+
):
126+
self.message = self.attach_alternative(self.message)
127+
98128
self.message["Date"] = formatdate(time.time(), localtime=True)
99129
self.message["Message-ID"] = self.msgId
100130
self.message["To"] = ", ".join(self.recipients)
@@ -112,12 +142,6 @@ async def _message(self, sender: str = None) -> Union[EmailMessage, Message]:
112142
if self.reply_to:
113143
self.message["Reply-To"] = ", ".join(self.reply_to)
114144

115-
if self.template_body:
116-
self.message.attach(self._mimetext(self.template_body, self.subtype.value))
117-
118-
elif self.body:
119-
self.message.attach(self._mimetext(self.body, self.subtype.value))
120-
121145
if self.attachments:
122146
await self.attach_file(self.message, self.attachments)
123147

fastapi_mail/schemas.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from mimetypes import MimeTypes
55
from typing import Dict, List, Optional, Union
66

7-
from pydantic import BaseModel, EmailStr, validator
7+
from pydantic import BaseModel, EmailStr, validator, root_validator
88
from starlette.datastructures import Headers, UploadFile
99

1010
from fastapi_mail.errors import WrongFile
@@ -38,6 +38,7 @@ class MessageSchema(BaseModel):
3838
attachments: List[Union[UploadFile, Dict, str]] = []
3939
subject: str = ""
4040
body: Optional[Union[str, list]] = None
41+
alternative_body: Optional[str] = None
4142
template_body: Optional[Union[list, dict, str]] = None
4243
cc: List[EmailStr] = []
4344
bcc: List[EmailStr] = []
@@ -94,5 +95,17 @@ def validate_subtype(cls, value, values, config, field):
9495
return MessageType.html
9596
return value
9697

98+
@root_validator
99+
def validate_alternative_body(cls, values):
100+
"""
101+
Validate alternative_body field
102+
"""
103+
if (
104+
values["multipart_subtype"] != MultipartSubtypeEnum.alternative
105+
and values["alternative_body"]
106+
):
107+
values["alternative_body"] = None
108+
return values
109+
97110
class Config:
98111
arbitrary_types_allowed = True

tests/test_connection.py

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import pytest
44

5-
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
5+
from fastapi_mail import (
6+
ConnectionConfig,
7+
FastMail,
8+
MessageSchema,
9+
MessageType,
10+
MultipartSubtypeEnum,
11+
)
612

713
CONTENT = "This file contents some information."
814

@@ -81,10 +87,7 @@ async def test_attachement_message(mail_config):
8187

8288
assert len(outbox) == 1
8389
assert mail._payload[1].get_content_maintype() == "application"
84-
assert (
85-
mail._payload[1].__dict__.get("_headers")[0][1]
86-
== "application/octet-stream"
87-
)
90+
assert mail._payload[1].__dict__.get("_headers")[0][1] == "application/octet-stream"
8891

8992

9093
@pytest.mark.asyncio
@@ -146,20 +149,16 @@ async def test_attachement_message_with_headers(mail_config):
146149

147150
assert len(outbox) == 1
148151
mail = outbox[0]
149-
assert mail._payload[1].get_content_maintype() == msg.attachments[0][1].get(
150-
"mime_type"
151-
)
152-
assert mail._payload[1].get_content_subtype() == msg.attachments[0][1].get(
153-
"mime_subtype"
154-
)
152+
assert mail._payload[1].get_content_maintype() == msg.attachments[0][1].get("mime_type")
153+
assert mail._payload[1].get_content_subtype() == msg.attachments[0][1].get("mime_subtype")
155154

156155
assert mail._payload[1].__dict__.get("_headers")[0][1] == "image/png"
157-
assert mail._payload[1].__dict__.get("_headers")[3][1] == msg.attachments[0][
158-
1
159-
].get("headers").get("Content-ID")
160-
assert mail._payload[1].__dict__.get("_headers")[4][1] == msg.attachments[0][
161-
1
162-
].get("headers").get("Content-Disposition")
156+
assert mail._payload[1].__dict__.get("_headers")[3][1] == msg.attachments[0][1].get(
157+
"headers"
158+
).get("Content-ID")
159+
assert mail._payload[1].__dict__.get("_headers")[4][1] == msg.attachments[0][1].get(
160+
"headers"
161+
).get("Content-Disposition")
163162

164163
assert (
165164
mail._payload[2].__dict__.get("_headers")[3][1] == "attachment; "
@@ -183,9 +182,7 @@ async def test_jinja_message_list(mail_config):
183182
fm = FastMail(conf)
184183

185184
with fm.record_messages() as outbox:
186-
await fm.send_message(
187-
message=msg, template_name="array_iteration_jinja_template.html"
188-
)
185+
await fm.send_message(message=msg, template_name="array_iteration_jinja_template.html")
189186

190187
assert len(outbox) == 1
191188
mail = outbox[0]
@@ -284,10 +281,84 @@ async def test_jinja_message_with_html(mail_config):
284281
)
285282
conf = ConnectionConfig(**mail_config)
286283
fm = FastMail(conf)
287-
await fm.send_message(
288-
message=msg, template_name="array_iteration_jinja_template.html"
289-
)
284+
await fm.send_message(message=msg, template_name="array_iteration_jinja_template.html")
290285

291286
assert msg.template_body == ("\n \n \n Andrej\n \n\n")
292287

293288
assert not msg.body
289+
290+
291+
@pytest.mark.asyncio
292+
async def test_send_msg_with_alternative_body(mail_config):
293+
msg = MessageSchema(
294+
subject="testing",
295+
recipients=["[email protected]"],
296+
body="<p Test data </p>",
297+
subtype=MessageType.html,
298+
alternative_body="Test data",
299+
multipart_subtype=MultipartSubtypeEnum.alternative,
300+
)
301+
302+
sender = f"{mail_config['MAIL_FROM_NAME']} <{mail_config['MAIL_FROM']}>"
303+
conf = ConnectionConfig(**mail_config)
304+
fm = FastMail(conf)
305+
fm.config.SUPPRESS_SEND = 1
306+
with fm.record_messages() as outbox:
307+
await fm.send_message(message=msg)
308+
309+
mail = outbox[0]
310+
assert len(outbox) == 1
311+
body = mail._payload[0]
312+
assert len(body._payload) == 2
313+
assert body._headers[1][1] == 'multipart/alternative; charset="utf-8"'
314+
assert mail["subject"] == "testing"
315+
assert mail["from"] == sender
316+
assert mail["To"] == "[email protected]"
317+
318+
assert body._payload[0]._headers[0][1] == 'text/html; charset="utf-8"'
319+
assert body._payload[1]._headers[0][1] == 'text/plain; charset="utf-8"'
320+
assert msg.alternative_body == "Test data"
321+
assert msg.multipart_subtype == MultipartSubtypeEnum.alternative
322+
323+
324+
@pytest.mark.asyncio
325+
async def test_send_msg_with_alternative_body_and_attachements(mail_config):
326+
directory = os.getcwd()
327+
text_file = directory + "/tests/txt_files/plain.txt"
328+
329+
with open(text_file, "w") as file:
330+
file.write(CONTENT)
331+
332+
subject = "testing"
333+
334+
msg = MessageSchema(
335+
subject=subject,
336+
recipients=[to],
337+
body="html test",
338+
subtype="html",
339+
attachments=[text_file],
340+
alternative_body="plain test",
341+
multipart_subtype="alternative",
342+
)
343+
sender = f"{mail_config['MAIL_FROM_NAME']} <{mail_config['MAIL_FROM']}>"
344+
conf = ConnectionConfig(**mail_config)
345+
fm = FastMail(conf)
346+
fm.config.SUPPRESS_SEND = 1
347+
with fm.record_messages() as outbox:
348+
await fm.send_message(message=msg)
349+
350+
mail = outbox[0]
351+
352+
assert len(outbox) == 1
353+
body = mail._payload[0]
354+
assert len(body._payload) == 2
355+
assert body._headers[1][1] == 'multipart/alternative; charset="utf-8"'
356+
assert mail["subject"] == "testing"
357+
assert mail["from"] == sender
358+
assert mail["To"] == "[email protected]"
359+
360+
assert body._payload[0]._headers[0][1] == 'text/html; charset="utf-8"'
361+
assert body._payload[1]._headers[0][1] == 'text/plain; charset="utf-8"'
362+
363+
assert mail._payload[1].get_content_maintype() == "application"
364+
assert mail._payload[1].__dict__.get("_headers")[0][1] == "application/octet-stream"

tests/test_message.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from fastapi_mail.msg import MailMsg
66
from fastapi_mail.schemas import MessageSchema, MessageType, MultipartSubtypeEnum
7+
from pydantic.error_wrappers import ValidationError
78

89

910
def test_initialize():
@@ -188,3 +189,26 @@ async def test_message_charset():
188189
msg_object = await msg._message("[email protected]")
189190
assert msg_object._charset is not None
190191
assert msg_object._charset == "utf-8"
192+
193+
194+
def test_message_with_alternative_body_but_wrong_multipart_subtype():
195+
message = MessageSchema(
196+
subject="test subject",
197+
recipients=["[email protected]"],
198+
body="test",
199+
subtype=MessageType.plain,
200+
alternative_body="alternative",
201+
)
202+
assert message.alternative_body == None
203+
204+
205+
def test_message_with_alternative_body():
206+
message = MessageSchema(
207+
subject="test subject",
208+
recipients=["[email protected]"],
209+
body="test",
210+
subtype=MessageType.plain,
211+
multipart_subtype=MultipartSubtypeEnum.alternative,
212+
alternative_body="alternative",
213+
)
214+
assert message.alternative_body == "alternative"

0 commit comments

Comments
 (0)