anymail / django-anymail

Django email backends and webhooks for Amazon SES, Brevo (Sendinblue), MailerSend, Mailgun, Mailjet, Postmark, Postal, Resend, SendGrid, SparkPost, Unisender Go and more
https://anymail.dev
BSD 3-Clause "New" or "Revised" License
1.65k stars 125 forks source link

boto3 sesv2 client does not have method 'send_email'. Check AmazonSESV2SendEmailPayload #368

Closed andraantariksa closed 4 months ago

andraantariksa commented 4 months ago
boto3                                   1.34.19
django-anymail                          10.3
Django                                  4.2.11
Python                                  3.12.2

We're using a custom email backend. Where AnymailBackend is amazon_ses EmailBackend.

class SESBackend(AnymailBackend):

    def build_message_payload(self, message: Any, defaults: Any) -> Any:
        message.esp_extra = {
            'ConfigurationSetName': settings.SES_CONFIGURATION_SET_NAME,
        }

        if 'X-Mailgun-Tag' in message.extra_headers:
            extra_headers = message.extra_headers['X-Mailgun-Tag']

            if isinstance(extra_headers, list):
                tags = [
                    {'Name': 'Tag%s' % (count + 1), 'Value': tag.replace(':', '-').replace('#', '')}
                    for count, tag in enumerate(extra_headers)
                ]
            else:
                tags = [{'Name': 'Tag1', 'Value': extra_headers.replace(':', '-').replace('#', '')}]

            message.esp_extra['EmailTags'] = tags

            del message.extra_headers['X-Mailgun-Tag']

        custom_tag_dict = message.extra_headers.get('Custom-Tag')
        if custom_tag_dict and isinstance(custom_tag_dict, dict):
            tags = [
                {'Name': key, 'Value': value}
                for key, value in custom_tag_dict.items()
            ]
            message.esp_extra['EmailTags'] = tags
            message.extra_headers.pop('Custom-Tag')

        if 'X-Mailgun-Variables' in message.extra_headers:
            del message.extra_headers['X-Mailgun-Variables']

        return super().build_message_payload(message, defaults)
NotImplementedError: boto3 sesv2 client does not have method 'send_email'. Check AmazonSESV2SendEmailPayload.api_name.
  File "post_office/mail.py", line 357, in send
    email.dispatch(
  File "post_office/models.py", line 175, in dispatch
    self.email_message().send()
  File "django/core/mail/message.py", line 298, in send
    return self.get_connection(fail_silently).send_messages([self])
  File "anymail/backends/base.py", line 117, in send_messages
    sent = self._send(message)
  File "anymail/backends/amazon_ses.py", line 78, in _send
    return super()._send(message)
  File "anymail/backends/base.py", line 147, in _send
    response = self.post_to_esp(payload, message)
  File "anymail/backends/amazon_ses.py", line 105, in post_to_esp
    raise NotImplementedError(

Related issue with #308


I have tried to reproduce the issue by recreating the email backend from shell, but the client does have the require function

>>> from django.core import mail
>>> c = mail.get_connection('<REDACTED>.SESBackend')
>>> c.open()
True
>>> c.client.send_email
<bound method ClientCreator._create_api_method.<locals>._api_call of <botocore.client.SESV2 object at 0x7f441b9d6810>>
medmunds commented 4 months ago

Hmm, I'm not sure what's happening here. Everything looks OK to me in the code you've shown. (And it's a clever way to convert Mailgun special headers from your existing code into SES params.)

Your shell example shows the SESv2 client was created successfully and does have a send_email function as expected. (<bound method ... of <botocore.client.SESV2 object ...>> is the correct value for c.client.send_email. In the shell, you could call it using c.client.send_email(params...).)

Some ideas to check:

To get more info, you might try wrapping the post_to_esp method to capture more info—in particular, what's actually in self.client at the time:

class SESBackend(AnymailBackend):
    def post_to_esp(self, payload, message):
        try:
            getattr(self.client, payload.api_name)
        except AttributeError:
            # Include repr(self.client) in the error message:
            raise NotImplementedError(
                f"{self.client!r} does not have attr {payload.api_name!r}."
            ) from None
        return super().post_to_esp(payload, message)

    def build_message_payload(self, message: Any, defaults: Any) -> Any:
        ...

If self.client ends up being None—or anything other than a botocore.client.SESV2 object—that might suggest some other places to look.


Btw (unrelated to your issue), you can supply the SES ConfigurationSetName directly in your settings.py ANYMAIL dict if you prefer, rather than using esp_extra (docs):

ANYMAIL = {
    ...,
    "AMAZON_SES_CONFIGURATION_SET_NAME": "<name>",
}
medmunds commented 4 months ago

@andraantariksa any more info on this? As I said earlier, I don't see any explanation for the error you saw in the code you included, and your Django shell test seems to show it's working correctly.

andraantariksa commented 4 months ago

Sorry, I still can't reproduce the issue :frowning_face: .

medmunds commented 4 months ago

I'm guessing maybe you were running with an older version of django-anymail in the environment where the error occurred, and had some problem with your boto3 credentials. That would make this a duplicate of #308. (Or perhaps you were running with an older version of boto3 that didn't support SES v2, which could cause a similar error.)

If you see the problem again and can provide more info, please add it here and I'll reopen the issue.