Repository: anymail/django-anymail (language: python)

Issue:
Postmark inbound test email can not be parsed
It seems like the inbound test email from postmark can not be parsed by anymail

After pressing the check button in postmark:

![Screenshot from 2023-04-03 15-34-29](https://user-images.githubusercontent.com/5213399/229525953-edb31c1e-1598-4bf3-ad35-63a5e601197e.png)


- Anymail version: 91.
- ESP: Postmark
- Your ANYMAIL settings (change secrets to "redacted"): According documentation, Work in progress during development..
- Versions of Django, requests, python: python 3.10.6 with Django 4.1.7
- Exact error message and/or stack trace
```
[03/Apr/2023 13:07:04] "POST /redacted/inbound/ HTTP/1.1" 500 104741
Internal Server Error: /redacted/inbound/
Traceback (most recent call last):
  File "/opt/venv/lib/python3.10/site-packages/django/core/handlers/exception.py", line 56, in inner
    response = get_response(request)
  File "/opt/venv/lib/python3.10/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/opt/venv/lib/python3.10/site-packages/django/views/generic/base.py", line 103, in view
    return self.dispatch(request, *args, **kwargs)
  File "/opt/venv/lib/python3.10/site-packages/django/utils/decorators.py", line 46, in _wrapper
    return bound_method(*args, **kwargs)
  File "/opt/venv/lib/python3.10/site-packages/django/views/decorators/csrf.py", line 55, in wrapped_view
    return view_func(*args, **kwargs)
  File "/opt/venv/lib/python3.10/site-packages/anymail/webhooks/base.py", line 67, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/opt/venv/lib/python3.10/site-packages/django/views/generic/base.py", line 142, in dispatch
    return handler(request, *args, **kwargs)
  File "/opt/venv/lib/python3.10/site-packages/anymail/webhooks/base.py", line 81, in post
    events = self.parse_events(request)
  File "/opt/venv/lib/python3.10/site-packages/anymail/webhooks/postmark.py", line 26, in parse_events
    return [self.esp_to_anymail_event(esp_event)]
  File "/opt/venv/lib/python3.10/site-packages/anymail/webhooks/postmark.py", line 169, in esp_to_anymail_event
    attachments = [
  File "/opt/venv/lib/python3.10/site-packages/anymail/webhooks/postmark.py", line 172, in <listcomp>
    content=attachment["Content"],
KeyError: 'Content'
```

Some debugging

```
(Pdb) esp_event.get("Attachments", [])
[{'Name': 'test.txt', 'ContentType': 'text/plain', 'Data': 'VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu', 'ContentLength': 45}]
```

I checked the [developer guide by postmark](https://postmarkapp.com/developer/user-guide/inbound/parse-an-email#code-examples) and it seems like anymail is doing the right thing.
The documentation also mentions `Content`, but in my case I'm seeing a `Data` key.

I'll follow up to figure out what's causing this issue. It might be an issue at Postmark, but given that the view crashes as a result of it I'll report it here first.


Discussion / hints:
Got confirmation that this is an issue on the Postmark side and that they are working on it.
Thanks for following up with Postmark on this. I reported the same issue to them about a year ago.

Postmark's actual inbound payloads use a `Content` key in attachments (matching their documentation, and Anymail's implementation). But their (hardcoded) sample payload incorrectly uses a `Data` key.

I'm thinking Anymail should maybe allow either key, just in case it takes a while for them to release a fix.

That puts things in a different perspective. I just had contact with them and they will keep me informed about any fix. 
They do not expect it very soon, because there is a workaround available. 
You can just send a real email to get a correct POST to your endpoint. 

I will continue testing with real data tomorrow. This will allow me to understand how the current implementation works and finish the implementation for my client.

Normally I would not be in favor of workarounds like this, but given that it might take "another year" I think it is not a bad idea to implement.

I'll try to come back to this by the end of this week whether I can pick this up. 

Update 2023/04/11: I'm trying to pick this up, but I expect that it might take some time as I'm quite busy during the upcoming weeks.

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 81d04531..19ab016a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -30,6 +30,12 @@ vNext
 
 *Unreleased changes*
 
+Fixes
+~~~~~
+
+* **Postmark:** Workaround for handling inbound test webhooks.
+  (`More info <https://github.com/anymail/django-anymail/issues/304>`__)
+
 Other
 ~~~~~
 
diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py
index 4b13aed2..652947d7 100644
--- a/anymail/webhooks/postmark.py
+++ b/anymail/webhooks/postmark.py
@@ -1,8 +1,9 @@
 import json
+import warnings
 
 from django.utils.dateparse import parse_datetime
 
-from ..exceptions import AnymailConfigurationError
+from ..exceptions import AnymailConfigurationError, AnymailWarning
 from ..inbound import AnymailInboundMessage
 from ..signals import (
     AnymailInboundEvent,
@@ -169,7 +170,13 @@ def esp_to_anymail_event(self, esp_event):
         attachments = [
             AnymailInboundMessage.construct_attachment(
                 content_type=attachment["ContentType"],
-                content=attachment["Content"],
+                content=(
+                    attachment.get("Content")
+                    # WORKAROUND:
+                    # The test webhooks are not like their real webhooks
+                    # This allows the test webhooks to be parsed.
+                    or attachment["Data"]
+                ),
                 base64=True,
                 filename=attachment.get("Name", "") or None,
                 content_id=attachment.get("ContentID", "") or None,
@@ -177,6 +184,18 @@ def esp_to_anymail_event(self, esp_event):
             for attachment in esp_event.get("Attachments", [])
         ]
 
+        # Warning to the user regarding the workaround of above.
+        for attachment in esp_event.get("Attachments", []):
+            if "Data" in attachment:
+                warnings.warn(
+                    "Received a test webhook attachment. "
+                    "It is recommended to test with real inbound events. "
+                    "See https://github.com/anymail/django-anymail/issues/304 "
+                    "for more information.",
+                    AnymailWarning,
+                )
+                break
+
         message = AnymailInboundMessage.construct(
             from_email=self._address(esp_event.get("FromFull")),
             to=", ".join([self._address(to) for to in esp_event.get("ToFull", [])]),


Test patch:
diff --git a/tests/test_postmark_inbound.py b/tests/test_postmark_inbound.py
index 18fa3957..7dd8d4b5 100644
--- a/tests/test_postmark_inbound.py
+++ b/tests/test_postmark_inbound.py
@@ -4,7 +4,7 @@
 
 from django.test import tag
 
-from anymail.exceptions import AnymailConfigurationError
+from anymail.exceptions import AnymailConfigurationError, AnymailWarning
 from anymail.inbound import AnymailInboundMessage
 from anymail.signals import AnymailInboundEvent
 from anymail.webhooks.postmark import PostmarkInboundWebhookView
@@ -165,14 +165,27 @@ def test_attachments(self):
                     "ContentType": 'message/rfc822; charset="us-ascii"',
                     "ContentLength": len(email_content),
                 },
+                # This is an attachement like send by the test webhook
+                # A workaround is implemented to handle it.
+                # Once Postmark solves the bug on their side this workaround
+                # can be reverted.
+                {
+                    "Name": "test.txt",
+                    "ContentType": "text/plain",
+                    "Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
+                    "ContentLength": 45,
+                },
             ]
         }
 
-        response = self.client.post(
-            "/anymail/postmark/inbound/",
-            content_type="application/json",
-            data=json.dumps(raw_event),
-        )
+        with self.assertWarnsRegex(
+            AnymailWarning, r"Received a test webhook attachment. "
+        ):
+            response = self.client.post(
+                "/anymail/postmark/inbound/",
+                content_type="application/json",
+                data=json.dumps(raw_event),
+            )
         self.assertEqual(response.status_code, 200)
         kwargs = self.assert_handler_called_once_with(
             self.inbound_handler,
@@ -183,7 +196,7 @@ def test_attachments(self):
         event = kwargs["event"]
         message = event.message
         attachments = message.attachments  # AnymailInboundMessage convenience accessor
-        self.assertEqual(len(attachments), 2)
+        self.assertEqual(len(attachments), 3)
         self.assertEqual(attachments[0].get_filename(), "test.txt")
         self.assertEqual(attachments[0].get_content_type(), "text/plain")
         self.assertEqual(attachments[0].get_content_text(), "test attachment")
@@ -192,6 +205,14 @@ def test_attachments(self):
             attachments[1].get_content_bytes(), email_content
         )
 
+        # Attachment of test webhook
+        self.assertEqual(attachments[2].get_filename(), "test.txt")
+        self.assertEqual(attachments[2].get_content_type(), "text/plain")
+        self.assertEqual(
+            attachments[2].get_content_text(),
+            "This is attachment contents, base-64 encoded.",
+        )
+
         inlines = message.inline_attachments
         self.assertEqual(len(inlines), 1)
         inline = inlines["abc123"]