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. enter image description here

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, which mail.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 type InMemoryUploadedFile from django.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 of Object 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 :)

Вернуться на верх