Multiple different attachments using Gmail API in Django
How do we account for sending multiple (or no) attachments (via request.FILES
) in Django using the Gmail API, so we can store the legacy message ID (ie "FBf…MiD") for future retrieval to reply in the same thread/thread_ID? I am switching from the SMTP (to be fully deprecated by Google) of Django's email.send(), which appears to be significantly different in how it handles file types.
I am specifically struggling to find out how to attach multiple files of different types to the Gmail API. (unsure if I am supposed to serialize the EmailMultiAlternatives, or how to do so)
view.py
class MakeQuoteWithItems(…, CreateView):
def post(self, request, *args, **kwargs):
# init a Django Model
quote = QuoteClass(request)
# with a generated PDF, and send the associated email
quote.make_and_email_quote()
Current working way (SMTP)
from django.core.mail import EmailMultiAlternatives
def emailContactsTheOptionsQuote(quote, attachments=None):
from_email = …
subject = …
email_context = …
html_content = render_to_string("email/tempalte.html", email_context, quote.request)
to_emails = … (string, [] or whatever format needed)
…
#
# Current working way sending through SMTP
#
# Django settings somehow gets the email out
# EMAIL_USE_TLS = False
# EMAIL_HOST = os.getenv("EMAIL_PROVIDER_SMTP_NOTIFIER_HOST")
# EMAIL_HOST_USER = os.getenv("EMAIL_PROVIDER_SMTP_NOTIFIER_EMAIL")
# EMAIL_HOST_PASSWORD = os.getenv("EMAIL_PROVIDER_SMTP_NOTIFIER_PASSWORD")
# EMAIL_PORT = 587
#
mail = EmailMultiAlternatives(
subject,
strip_tags(html_content),
to=to_emails,
bcc=bcc_emails,
from_email=from_email,
)
mail.attach_alternative(html_content, "text/html")
mail.attach(
quote.pdf_canvas._filename, open(quote.canvas._filename, "rb").read(), "application/pdf"
)
if quote.attachments:
for attachment in quote.attachments:
mail.attach(attachment._name, attachment.read(), attachment.content_type)
mail.send(fail_silently=False)
# But can't get a hold of the mail going out to get it's ID to reply to later
Proposed way (Gmail API)
def emailContactsTheOptionsQuote(quote, attachments=None):
from_email = …
subject = …
# Instead -
gmail_email = create_and_send_gmail_message(
from_email,
to_emails,
subject,
html_content,
text_content,
options_quote.request,
attachmentFiles=email_attachments,
)
# Some Django Model to query to get the email.message_id and email.thread_id
GmailEmailLog.objects.create(
gmail_email=gmail_email,
message_id=gmail_email.message_id,
# Thread ids are the same for the first message in a thread, but remain
# the same for all subsequent messages sent within the thread
thread_id=gmail_email.thread_id,
quote_id=quote.id
…
)
return gmail_email
helper_functions.py
def create_and_send_gmail_message(
sender,
to,
subject,
msgHtml,
msgPlain,
request,
attachmentFiles=None,
cc=None,
bcc=None,
):
if attachmentFiles:
message_body = create_body_message_with_attachments(sender,to,subject,msgHtml,msgPlain,attachmentFiles,cc,bcc,)
else:
# This is not an issue
message_body = create_messag_body_html(sender,to,subject,msgHtml,msgPlain,cc,bcc,)
result = SendMessageInternal(message_body, request)
return result
def SendMessageInternal(incoming_message_body, request):
credentials = get_credentials(request)
service = discovery.build("gmail", "v1", credentials=credentials)
user_id = settings.EMAIL_GMAIL_USERID
try:
msg = (
service.users()
.messages()
.send(
userId=user_id,
body=incoming_message_body,
)
.execute()
)
print(f"Message Id: {msg['id']}")
return msg
except errors.HttpError as error:
print(f"An error occurred: {error}")
return f"{error=}"
return "OK"
This is where the magic/headaches happens, and have issues. I am not sure why I feel like I'm reinventing the wheel, and can't find any packages from the server side that make it easier. I can get the 'attachments' to 'attach' to the email and show up in the UI, but the files are not one clickable, and sometimes empty given their type.
def create_body_message_with_attachments(
sender,
to,
subject,
msgHtml,
msgPlain,
attachmentFiles,
cc=None,
bcc=None,
):
"""Create a message for an email.
Args:
sender: Email address of the sender.
to: Email address of the receiver.
subject: The subject of the email message.
msgHtml: Html message to be sent
msgPlain: Alternative plain text message for older email clients
attachmentFile: The path to the file to be attached.
Returns:
An object containing a base64url encoded email object.
"""
# allow either one recipient as string, or multiple as list
if isinstance(to, list):
to = ", ".join(to)
if cc:
if isinstance(cc, list):
cc = ", ".join(cc)
if bcc:
if isinstance(bcc, list):
bcc = ", ".join(bcc)
message = MIMEMultipart("mixed")
message["to"] = to
message["from"] = sender
message["subject"] = subject
…
# allow either one attachment as string, or multiple as list
if not isinstance(attachmentFiles, list):
attachmentFiles = [attachmentFiles]
# attachmentFiles
# [
# <InMemoryUploadedFile: Screenshot.jpg (image/jpg)>,
# <InMemoryUploadedFile: Screenshot2.png (image/png)>,
# <_io.BufferedReader name='/path/to/quote.pdf'>
# ]
messageA = MIMEMultipart("alternative")
messageR = MIMEMultipart("related")
messageR.attach(MIMEText(msgHtml, "html"))
messageA.attach(MIMEText(msgPlain, "plain"))
messageA.attach(messageR)
message.attach(messageA)
for attachment in attachmentFiles:
# Trying to separate the filename from the file content for different types
# File Name
if hasattr(attachment, "temporary_file_path"):
filename = os.path.basename(attachment.temporary_file_path())
elif hasattr(attachment, "name"):
filename = os.path.basename(attachment.name)
else:
filename = os.path.basename(attachment)
# File Contents
# Content Data
if isinstance(attachment, str) and os.path.exists(attachment):
content_type, _ = mimetypes.guess_type(attachment) or (content_type, None)
with open(attachment, "rb") as f:
file_data = f.read()
# Handle BufferedReader (BytesIO)
elif isinstance(attachment, io.BytesIO):
file_data = attachment.getvalue() # Ensure correct byte data is read
# Handle Django InMemoryUploadedFile
elif isinstance(attachment, InMemoryUploadedFile):
content_type = attachment.content_type or content_type
file_data = attachment.read()
# Type
# I have tried different ways to get the MIME type,
# but am unsure of the most pythonic way to do so, many opions out there
mim = magic.Magic(mime=True)
try:
c_t = mim.from_file(filename)
except OSError as e:
# Magic needs to differentiate?! between files and streams
c_t = mim.from_buffer(attachment.read(2048))
# Magic often returns 'application/x-empty'
print(f"file: {attachment} with {content_type=} and {c_t=}")
main_type, sub_type = content_type.split("/", 1)
if main_type == "text":
# Unsure if I have to open and close for each type
# fp = open(attachment, "rb")
msg_attachment = MIMEText(file_data, _subtype=sub_type)
# fp.close()
elif main_type == "image":
# Unsure if I have to open and close for each type
# fp = open(attachment, "rb")
msg_attachment = MIMEImage(file_data, _subtype=sub_type)
# fp.close()
elif main_type == "audio":
msg_attachment = MIMEAudio(file_data, _subtype=sub_type)
elif main_type == "application" and sub_type == "pdf":
msg_attachment = MIMEApplication(attachment.read(), _subtype=sub_type)
else:
msg_attachment = MIMEBase(main_type, sub_type)
msg_attachment.set_payload(attachment.read())
encoders.encode_base64(msg_attachment)
msg_attachment.add_header(
'Content-Disposition', 'attachment', filename=f'{filename}'
)
message.attach(msg_attachment)
raw = base64.urlsafe_b64encode(message.as_bytes())
raw = raw.decode()
body = {"raw": raw}
return body
Notes(that help differentiate other SO questions)
- Not using SMTP, as that has already begun sundowning/deprecating, and we are trying to use the google account setting of "less secure apps"
- Using
django.core.mail
's EmailMultiAlternatives to compose and send, whichmail.send(fail_silently=True)
does not return the message, or give an ID on the gmail server, so finding the exact email is not functional/deterministic given the smilarities - In Django, when the upload files come through the
request.FILES
, they are a of typeInMemoryUploadedFile
fromdjango.core.files.uploadedfile
¿with the potential to be different MIME Types?, but the internal attachment of a generated PDF is of type_io.BufferedReader
. This is presumably causing my headaches - using Python 3.11.7 in dev and Python 3.12.3 in prod, and aware that in 3.13 that
mimetypes.guess_type()
will be deprecated per this - Sending the EmailMultiAlternatives directly to the Gmail API
send().execute()
I get an error ofObject of type EmailMultiAlternatives is not JSON serializable
- The MIME information from the received email is:
.
--===============2268240127970561189==
Content-Type: image/png
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="Screenshot 2025-03-12 at 1.54.01PM.png"
--===============2268240127970561189==
Content-Type: image/png
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="Screenshot 2025-03-12 at 4.44.11PM.png"
--===============2268240127970561189==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="Q-DFD-12345-031525-0XEH-1153.pdf"
--===============2268240127970561189==--
Thanks in advance :)