-
Notifications
You must be signed in to change notification settings - Fork 134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for Mailtrap #406
base: main
Are you sure you want to change the base?
Conversation
anymail/backends/mailtrap.py
Outdated
) | ||
|
||
# Not the best status reporting. Mailtrap only says that the order of message-ids will be in this order (but JSON is unordered?) | ||
recipient_status_order = [*message.to, *message.cc, *message.bcc] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mailtrap's status reporting is pretty poor, and I'm not even sure if this will work consistently, given that it is depending on ORDERED JSON responses 🤦
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the response:
message_ids
array[string]
An array of message IDs, one per recipient, in order of To, Cc, Bcc
JSON arrays are reliably ordered, just like Python lists. But, it's not safe to use the original message's to/cc/bcc here, as they are formatted addresses that might include display names (and as some Anymail features can alter recipient lists before sending).
Take a look at any of the backends/payloads that use an all_recipients
list for an example of how to handle this (e.g., MailgunPayload.set_recipients() and MailgunBackend.parse_recipient_status()); your payload might need to keep the to/cc/bcc recipients separate to ensure they end up in the correct order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do. Do you think there would be any value in sorting the recipients by email address before sending?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would not try to sort recipients. I don't think it would help anything, and there might be cases where senders are deliberately putting addresses in a specific order.
8c44826
to
1ef637c
Compare
Thanks for this! It looks pretty good at first glance; I'll try to take a closer look later this week.
Missing inbound support is not unusual; it's fine to just ignore it. Reply-to is a little surprising. Some ESPs require handling this as an extra header. (I haven't had a chance to look at Mailtrap's docs.)
What's going wrong? (In the contributing docs, I notice the "test a representative combination of Python and Django versions" command is outdated—the current version should be |
Aha! I'm no email expert, so TIL! I will add the reply-to-via-headers support momentarily. |
Switching to a dev container, using pyenv to install 3.12 and 3.8, and using the updated tox command got further. I got a complaint that python 3.8 was missing, but creating a symlink /usr/bin/python3.8 to the pyenv installed version fixed that. I think I'm good to go for writing tests. Thanks. |
18307bc
to
ab47bbc
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cahna this is looking really solid! I have a bunch of suggestions, some responses to your questions, and some questions of my own.
I haven't been through the webhook you just added yet; will take a look at that soon.
[GitHub sometimes hides review comments: please search the page for "Hidden Conversations" to be sure you see them all.]
pyproject.toml
Outdated
@@ -63,6 +64,7 @@ dependencies = [ | |||
"django>=4.0", | |||
"requests>=2.4.3", | |||
"urllib3>=1.25.0", # requests dependency: fixes RFC 7578 header encoding | |||
"typing_extensions>=4.12", # for older Python compatibility |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little concerned about adding a new dependency for all users. I wonder if some combination of from __future__ import annotations
and if typing.TYPE_CHECKING
could let typing_extensions be an optional dependency?
(On the other hand, Anymail is moving toward typing at some point in #394, so maybe this is fine?)
Since Anymail already requires Python 3.8, it looks like typing_extensions is only needed for TypedDict
(3.9+) and NotRequired
(3.11+), so at the very least we could probably make this a version-dependent dependency: "typing_extensions>=4.12; python_version < 3.11"
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm happy to implement whatever you request. I have started to rely heavily on Python type checking for my own development, but if you want me to remove all type hints once the work part is done, I'm happy to do that, too.
I confirm your assessment that it has only been added for TypedDict
and NotRequired
support in versions <3.11.
at the very least we could probably make this a version-dependent dependency: "typing_extensions>=4.12; python_version < 3.11"
I will do this for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what the best practice is for type hints in libs that support multiple python versions, but my intuition tells me there has to be a better way than this(?):
if sys.version_info < (3, 11):
from typing_extensions import Any, Dict, List, Literal, NotRequired, TypedDict
else:
from typing import Any, Dict, List, Literal, NotRequired, TypedDict
I suppose if #394 is coming, then maybe it is worth making typing_extensions
a requirement? That way all of the type imports can come from typing_extensions
instead of typing
:
from typing_extensions import Any, Dict, List, Literal, NotRequired, TypedDict
I will do some more research on this.
anymail/backends/mailtrap.py
Outdated
) | ||
|
||
# Not the best status reporting. Mailtrap only says that the order of message-ids will be in this order (but JSON is unordered?) | ||
recipient_status_order = [*message.to, *message.cc, *message.bcc] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the response:
message_ids
array[string]
An array of message IDs, one per recipient, in order of To, Cc, Bcc
JSON arrays are reliably ordered, just like Python lists. But, it's not safe to use the original message's to/cc/bcc here, as they are formatted addresses that might include display names (and as some Anymail features can alter recipient lists before sending).
Take a look at any of the backends/payloads that use an all_recipients
list for an example of how to handle this (e.g., MailgunPayload.set_recipients() and MailgunBackend.parse_recipient_status()); your payload might need to keep the to/cc/bcc recipients separate to ensure they end up in the correct order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, the webhooks look really good, too. Handful of minor comments.
Thanks again for your work on this.
Thanks for your feedback @medmunds! Taking a look now. FYI: I only have Sundays available to work on this for the foreseeable future. |
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Co-authored-by: Mike Edmunds <medmunds@gmail.com>
Searching for "hidden" within the page showed nothing, so I think I saw everything. LMK if I missed something. |
I will start working on integration tests locally with my own free account. @medmunds, would you be able to setup a free mailtrap account, put the API key and test inbox ID into the repo's github secrets, and expose them to the build as environment variables? FYI: the free mailtrap tier allows 100 test emails/month. |
parsed_response = self.deserialize_json_response(response, payload, message) | ||
|
||
# TODO: how to handle fail_silently? | ||
if not self.fail_silently and ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While trying to duplicate test coverage from the other backend tests, I found that the fail_silently test(s) don't work, and this doesn't seem to be the correct way to do this. How should I handle that? Should I?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fail_silently should be handled for you in AnymailBaseBackend.send_messages(). The important part is that any errors raised by this function need to be one of the Anymail errors, not generic Python errors like KeyError.
It's helpful to distinguish a few different cases:
- If the API uses HTTP status codes to indicate an error, that's already been reported in AnymailRequestsBackend.raise_for_status() (so parse_recipients_status() won't be called). It looks to me like that's the case for all Mailtrap error responses.
- (If the API indicates errors by returning HTTP 2xx but with an "errors" field in the response, that needs to be reported as an AnymailRequestsAPIError with the message extracted from the response. This doesn't seem to apply to Mailtrap.)
- If the API indicated success, but the response isn't parseable (e.g., missing keys), that should be reported as an AnymailRequestsAPIError with a message describing the invalid response format. I'll add a separate review comment with a suggestion for that.
@medmunds I got started on backend unit tests, but I have left several |
Done: variables |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still looking great! And nice start on the docs.
Handful of feedback on the main implementation.
I glanced through the backend tests and tried to respond to your questions. This is the part of adding an ESP where we typically discover all of the little details they haven't documented, so some tests may need adjustment based on that. (The "test_all_options" integration test can be helpful for discovering those.)
(Apologies for not getting feedback on your earlier round sooner—I'm starting to get caught up in the end of the year rush.)
@@ -44,6 +44,7 @@ jobs: | |||
- { tox: django41-py310-mailersend, python: "3.12" } | |||
- { tox: django41-py310-mailgun, python: "3.12" } | |||
- { tox: django41-py310-mailjet, python: "3.12" } | |||
- { tox: django41-py310-mailtrap, python: "3.12" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'll also need to add vars.ANYMAIL_TEST_MAILTRAP_DOMAIN
, vars. ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID
and secrets. ANYMAIL_TEST_MAILTRAP_API_TOKEN
in the big env:
section below. (Around line 91; GitHub won't let me add a review comment down there.)
self.recipients_to: List[str] = [] | ||
self.recipients_cc: List[str] = [] | ||
self.recipients_bcc: List[str] = [] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might instead use something like:
self.recipients_to: List[str] = [] | |
self.recipients_cc: List[str] = [] | |
self.recipients_bcc: List[str] = [] | |
self.recipients: Dict[Literal["to", "cc", "bcc"], List[EmailAddress]] = {"to": [], "cc": [], "bcc": []} |
just to simplify the code later in set_recipients() to: self.recipients[recipient_type] = emails
. (And then grab the addr_spec
s in parse_recipients_status().) But what you have now will work, so feel free to ignore this suggestion.
def set_track_clicks(self, *args, **kwargs): | ||
"""Do nothing. Mailtrap supports this, but it is not configured in the send request.""" | ||
pass | ||
|
||
def set_track_opens(self, *args, **kwargs): | ||
"""Do nothing. Mailtrap supports this, but it is not configured in the send request.""" | ||
pass | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is (somewhat confusingly) not the correct way to handle this. Anymail's track_clicks
and track_opens
are meant to override the account defaults for message tracking: e.g., to disable link rewriting on password reset emails, or all tracking for mail to a particular user.
If Mailtrap doesn't support per-message tracking overrides, then these options are indeed "unsupported" and should raise errors if used. (You've correctly labeled them No [#nocontrol]_
in the big feature matrix in the docs.)
def set_track_clicks(self, *args, **kwargs): | |
"""Do nothing. Mailtrap supports this, but it is not configured in the send request.""" | |
pass | |
def set_track_opens(self, *args, **kwargs): | |
"""Do nothing. Mailtrap supports this, but it is not configured in the send request.""" | |
pass |
parsed_response = self.deserialize_json_response(response, payload, message) | ||
|
||
# TODO: how to handle fail_silently? | ||
if not self.fail_silently and ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fail_silently should be handled for you in AnymailBaseBackend.send_messages(). The important part is that any errors raised by this function need to be one of the Anymail errors, not generic Python errors like KeyError.
It's helpful to distinguish a few different cases:
- If the API uses HTTP status codes to indicate an error, that's already been reported in AnymailRequestsBackend.raise_for_status() (so parse_recipients_status() won't be called). It looks to me like that's the case for all Mailtrap error responses.
- (If the API indicates errors by returning HTTP 2xx but with an "errors" field in the response, that needs to be reported as an AnymailRequestsAPIError with the message extracted from the response. This doesn't seem to apply to Mailtrap.)
- If the API indicated success, but the response isn't parseable (e.g., missing keys), that should be reported as an AnymailRequestsAPIError with a message describing the invalid response format. I'll add a separate review comment with a suggestion for that.
# TODO: how to handle fail_silently? | ||
if not self.fail_silently and ( | ||
not parsed_response.get("success") | ||
or ("errors" in parsed_response and parsed_response["errors"]) | ||
or ("message_ids" not in parsed_response) | ||
): | ||
raise AnymailRequestsAPIError( | ||
email_message=message, payload=payload, response=response, backend=self | ||
) | ||
else: | ||
# message-ids will be in this order | ||
recipient_status_order = [ | ||
*payload.recipients_to, | ||
*payload.recipients_cc, | ||
*payload.recipients_bcc, | ||
] | ||
recipient_status = { | ||
email: AnymailRecipientStatus( | ||
message_id=message_id, | ||
status="sent", | ||
) | ||
for email, message_id in zip( | ||
recipient_status_order, parsed_response["message_ids"] | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's an approach for reporting invalid responses. It's also probably worth checking the number of message_ids, since zip() won't raise an error for that.
# TODO: how to handle fail_silently? | |
if not self.fail_silently and ( | |
not parsed_response.get("success") | |
or ("errors" in parsed_response and parsed_response["errors"]) | |
or ("message_ids" not in parsed_response) | |
): | |
raise AnymailRequestsAPIError( | |
email_message=message, payload=payload, response=response, backend=self | |
) | |
else: | |
# message-ids will be in this order | |
recipient_status_order = [ | |
*payload.recipients_to, | |
*payload.recipients_cc, | |
*payload.recipients_bcc, | |
] | |
recipient_status = { | |
email: AnymailRecipientStatus( | |
message_id=message_id, | |
status="sent", | |
) | |
for email, message_id in zip( | |
recipient_status_order, parsed_response["message_ids"] | |
) | |
} | |
try: | |
message_ids = parsed_response["message_ids"] | |
except KeyError as err: | |
raise AnymailRequestsAPIError( | |
"Invalid response format: missing message_ids", | |
email_message=message, payload=payload, response=response, backend=self | |
) from err | |
# message-ids will be in this order | |
recipient_status_order = [ | |
*payload.recipients_to, | |
*payload.recipients_cc, | |
*payload.recipients_bcc, | |
] | |
if len(recipient_status_order) != len(message_ids): | |
raise AnymailRequestsAPIError( | |
"Invalid response format: wrong number of message_ids", | |
email_message=message, payload=payload, response=response, backend=self | |
) | |
recipient_status = { | |
email: AnymailRecipientStatus( | |
message_id=message_id, | |
status="sent", | |
) | |
for email, message_id in zip(recipient_status_order, message_ids) | |
} |
API error responses should already be covered by raise_for_status() in the superclass. (But if you wanted to add a check like if parsed_response.get("errors") or not parsed_response.get("success"): raise AnymailRequestsAPIError("Unexpected errors in {response.status_code} response", ...)
, that wouldn't hurt anything.)
@@ -0,0 +1,299 @@ | |||
# FILE: tests/test_mailtrap_backend.py |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
??? (is this some editor convention?)
@unittest.skip("TODO: is this test correct/necessary?") | ||
def test_send_with_recipients_refused(self): | ||
"""Test sending an email with all recipients refused""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably unnecessary, since Mailtrap doesn't seem to provide per-recipient send status.
Mailtrap's error documentation isn't all that descriptive, so we might need to test this. If you try to send a message with two recipients, both on your suppression list (or previous bounces), what happens? (Send API success and later webhook reject? Or send API error?)
Similarly, if you send with one valid recipient and one suppressed recipient, what happens?
(I can do some investigation here if that would be helpful.)
mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) | ||
errmsg = str(cm.exception) | ||
self.assertRegex(errmsg, r"\bMAILTRAP_API_TOKEN\b") | ||
self.assertRegex(errmsg, r"\bANYMAIL_MAILTRAP_API_TOKEN\b") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably also need to test various combinations of settings, both valid and error cases (api_url, testing_enabled, test_inbox_id), to make sure the right api is called or a helpful error is raised.
Hi, and thanks for creating such a great library! I would love to have Mailtrap support, and I figured it would be helpful if I started on that. This is very much a work-in-progress, as I'm still trying to understand how to handle all of the features that Anymail provides, and whether those features are supported by Mailtrap. I have already caught at least one category of features that Mailtrap does not support: inbound emails
and reply-to.If anyone would be willing to provide some feedback on what I have, and any guidance on how to continue, I would appreciate it, and I will be sure to add tests and documentation.
I am also having trouble getting local builds and tests to work, or I would have started on adding tests. If someone could point me in the direction of minimal steps to get tests working on Ubuntu, I would appreciate it. (The contributing docs don't seem to work for me).Cheers
TODO