1

I'm trying to get the AWS SES Lambda forwarder Python sample code to forward emails to an outside account unmodified. In (A) below, the message body will not forward properly unless the email is multipart. Most errors are tied to:

body = MIMEText(str(mailobject.get_payload(decode=True), 'UTF-8'))

Which should pass the email body to body as a string when MIMEmultipart is false if I'm understanding the python documentation correctly. Depending on how that line of code is done, it will send the body as shown below, or the compiler will return an error as shown in (B) or 'str' object has no attribute 'policy'",.

What an email looks like when I get it. The html formatting shows up as text.

<html>
        <body>
                <p>You have chosen to subscribe to the topic:
                <br /><b>arn:aws:sns:I INTENTIONALLY REMOVED THIS</b></p>
                <p>To confirm this subscription, click or visit the link below (If this was in error no action is necessary):
                <br /><a href="https://sns.us-east-1.amazonaws.com/confirmation.html?TopicArn=arn:aws:sns:us-east-1:I INTENTIONALLY REMOVED THIS</a></p>
                <p><small>Please do not reply directly to this email. If you wish to remove yourself from receiving all future SNS subscription confirmation requests please send an email to <a href="mailto:sns-opt-out@amazon.com">sns-opt-out</a></small></p>
        </body>
</html>

(A) The code I'm using:

import os
import boto3
import email
import re
from botocore.exceptions import ClientError
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from datetime import datetime

region = os.environ['Region']

def get_time():
    
    # datetime object containing current date and time
    now = datetime.now()
    
    # dd/mm/YY H:M:S
    return now.strftime("%d/%m/%Y %H:%M:%S")

def get_message_from_s3(message_id):

    incoming_email_bucket = os.environ['MailS3Bucket']
    incoming_email_prefix = os.environ['MailS3Prefix']

    if incoming_email_prefix:
        object_path = (incoming_email_prefix + "/" + message_id)
    else:
        object_path = message_id

    object_http_path = (f"http://s3.console.aws.amazon.com/s3/object/{incoming_email_bucket}/{object_path}?region={region}")

    # Create a new S3 client.
    client_s3 = boto3.client("s3")

    # Get the email object from the S3 bucket.
    object_s3 = client_s3.get_object(Bucket=incoming_email_bucket,
        Key=object_path)
    # Read the content of the message.
    file = object_s3['Body'].read()

    file_dict = {
        "file": file,
        "path": object_http_path
    }

    return file_dict

def create_message(file_dict):

    stringMsg = file_dict['file'].decode('utf-8')

    # Create a MIME container.
    msg = MIMEMultipart('alternative')

    sender = os.environ['MailSender']
    recipient = os.environ['MailRecipient']

    # Parse the email body.
    mailobject = email.message_from_string(file_dict['file'].decode('utf-8'))
    #print(mailobject.as_string())

    # Get original sender for reply-to
    from_original = mailobject['Return-Path']
    from_original = from_original.replace('<', '');
    from_original = from_original.replace('>', '');
    print(from_original)

    # Create a new subject line.
    subject = mailobject['Subject']

    body = ""

    if mailobject.is_multipart():

        print('IS multipart')
        index = stringMsg.find('Content-Type: multipart/')
        stringBody = stringMsg[index:]
        #print(stringBody)
        stringData = 'Subject: ' + subject + '\nTo: ' + sender + '\nreply-to: ' + from_original + '\n' + stringBody

        message = {
            "Source": sender,
            "Destinations": recipient,
            "Data": stringData
        }
        return message

        for part in mailobject.walk():
            ctype = part.get_content_type()
            cdispo = str(part.get('Content-Disposition'))

            # case for each common content type
            if ctype == 'text/plain' and 'attachment' not in cdispo:
                bodyPart = MIMEText(part.get_payload(decode=True), 'plain', part.get_content_charset())
                msg.attach(bodyPart)

            if ctype == 'text/html' and 'attachment' not in cdispo:
                mt = MIMEText(part.get_payload(decode=True), 'html', part.get_content_charset())
                email.encoders.encode_quopri(mt)
                del mt['Content-Transfer-Encoding']
                mt.add_header('Content-Transfer-Encoding', 'quoted-printable')
                msg.attach(mt)

            if 'attachment' in cdispo and 'image' in ctype:
                mi = MIMEImage(part.get_payload(decode=True), ctype.replace('image/', ''))
                del mi['Content-Type']
                del mi['Content-Disposition']
                mi.add_header('Content-Type', ctype)
                mi.add_header('Content-Disposition', cdispo)
                msg.attach(mi)

            if 'attachment' in cdispo and 'application' in ctype:
                ma = MIMEApplication(part.get_payload(decode=True), ctype.replace('application/', ''))
                del ma['Content-Type']
                del ma['Content-Disposition']
                ma.add_header('Content-Type', ctype)
                ma.add_header('Content-Disposition', cdispo)
                msg.attach(ma)


    # not multipart - i.e. plain text, no attachments, keeping fingers crossed
    else:
        
        body = MIMEText(str(mailobject.get_payload(decode=True), 'UTF-8'))
        msg.attach(body)

    # The file name to use for the attached message. Uses regex to remove all
    # non-alphanumeric characters, and appends a file extension.
    filename = re.sub('[^0-9a-zA-Z]+', '_', subject)

    # Add subject, from and to lines.
    msg['Subject'] = subject
    msg['From'] = sender
    msg['To'] = recipient
    msg['reply-to'] = mailobject['Return-Path']
    
    # Create a new MIME object.
    att = MIMEApplication(file_dict["file"], filename)
    att.add_header("Content-Disposition", 'attachment', filename=filename)

    # Attach the file object to the message.
    msg.attach(att)
    message = {
        "Source": sender,
        "Destinations": recipient,
        "Data": msg.as_string()
    }
    return message
    
def send_email(message):
    aws_region = os.environ['Region']

# Create a new SES client.
    client_ses = boto3.client('ses', region)

    # Send the email.
    try:
        #Provide the contents of the email.
        response = client_ses.send_raw_email(
            Source=message['Source'],
            Destinations=[
                message['Destinations']
            ],
            RawMessage={
                'Data':message['Data']
            }
        )

    # Display an error if something goes wrong.
    except ClientError as e:
        output = e.response['Error']['Message']
    else:
        output = "Email sent! Message ID: " + response['MessageId']

    return output

def lambda_handler(event, context):
    # Get the unique ID of the message. This corresponds to the name of the file
    # in S3.
    message_id = event['Records'][0]['ses']['mail']['messageId']
    print(f"Received message ID {message_id}")

    # Retrieve the file from the S3 bucket.
    file_dict = get_message_from_s3(message_id)

    # Create the message.
    message = create_message(file_dict)
    #this alone didn't fix it:   message = create_message(str(file_dict))
    # Send the email and print the result.
    result = send_email(message)
    print(result)

(B). The code example from this post fails at the 'body' assignment and what I've been trying to get to work in (A)...

body = MIMEText(mailobject.get_payload(decode=True), 'UTF-8')
    msg.attach(body)
Response:
{
  "errorMessage": "'bytes' object has no attribute 'encode'",
  "errorType": "AttributeError",
  "stackTrace": [
    "  File \"/var/task/lambda_function.py\", line 190, in lambda_handler\n    message = create_message(file_dict)\n",
    "  File \"/var/task/lambda_function.py\", line 128, in create_message\n    body = MIMEText(mailobject.get_payload(decode=True), 'UTF-8')\n",
    "  File \"/var/lang/lib/python3.8/email/mime/text.py\", line 34, in __init__\n    _text.encode('us-ascii')\n"
  ]
}

To replicate this problem, get a new SES instance going, follow the tutorial for the original AWS SES Lambda forwarder Python sample code and verify it works. From there, replace the AWS sample code with my code in (A) or one of the other linked examples.

Also, I found this Lambda email test event code and was able to get it working enough to test this code from the AWS Lambda dashboard. To make it work I created two new test events (multipart/non-multipart) in the dashboard and pasted in that code changing (a) the email addresses as applicable, (b) the the lambda function ARN to my function's ARN, and (c) the message-id for an applicable email stored on my active s3 instance. This has saved me the trouble of manually creating and sending test emails for debugging and hopefully will do the same for somebody else that finds this post and is having similar problems. That test code will result in an actual email being forwarded.

ZenLand
  • 11
  • 2

1 Answers1

0

For those getting this error:

'bytes' object has no attribute 'encode'

In this line:

body = MIMEText(mailobject.get_payload(decode=True), 'UTF-8')

I could make it work. I am not an expert on this so the code might need some improvement. Also the email body includes html tags. But at least it got delivered.

If decoding the email still fails the error message will appear in your CloudWatch log. Also you will receive an email with the error message.

payload = mailobject.get_payload(decode=True)
try:
    decodedPayload = payload.decode()
    body = MIMEText(decodedPayload, 'UTF-8')
    msg.attach(body)
except Exception as error:
    errorMsg = "An error occured when decoding the email payload:\n" + str(error)
    print(errorMsg)
    body = errorMsg + "\nPlease download it manually from the S3 bucket."
    msg.attach(MIMEText(body, 'plain'))

It is up to you which information you want to add to the error email like the subject or the from address.

Jan
  • 150
  • 3
  • 11