Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
- { tox: django52-py313-postal, python: "3.13" }
- { tox: django52-py313-postmark, python: "3.13" }
- { tox: django52-py313-resend, python: "3.13" }
- { tox: django52-py313-sendgrid, python: "3.13" }
# - { tox: django52-py313-sendgrid, python: "3.13" }
- { tox: django52-py313-sparkpost, python: "3.13" }
- { tox: django52-py313-unisender_go, python: "3.13" }

Expand Down Expand Up @@ -96,7 +96,7 @@ jobs:
ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }}
ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }}
ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }}
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ vars.ANYMAIL_TEST_SENDGRID_DOMAIN }}
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}
ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }}
ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }}
Expand Down
16 changes: 14 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,22 @@ Release history
^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long

vNext
-----
v13.0.1
-------

*Unreleased changes*

Breaking changes (external)
~~~~~~~~~~~~~~~~~~~~~~~~~~~

* **SendGrid:** Anymail no longer officially supports SendGrid, because we are
unable to test it. Although it will *probably* keep working, you'll get
warnings about this change in status. See `#432`_ for details, and the
`docs <https://anymail.dev/en/stable/esps/sendgrid/>`__ if you want
to suppress the warnings. (Since this breaking change is due to external
causes and impacts SendGrid users on all versions of Anymail, it is being
handled as a minor patch rather than a semver major version change.)

Fixes
~~~~~

Expand Down Expand Up @@ -1777,6 +1788,7 @@ Features
.. _#148: https://github.com/anymail/django-anymail/issues/148
.. _#153: https://github.com/anymail/django-anymail/issues/153
.. _#304: https://github.com/anymail/django-anymail/issues/304
.. _#432: https://github.com/anymail/django-anymail/issues/432

.. _@ailionx: https://github.com/ailionx
.. _@alee: https://github.com/alee
Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Anymail currently supports these ESPs:
* **Postal** (self-hosted ESP)
* **Postmark** (ActiveCampaign transactional email)
* **Resend**
* **SendGrid** (Twilio transactional email)
* **SendGrid** (Twilio transactional email; no longer tested)
* **SparkPost** (Bird transactional email)
* **Unisender Go**

Expand Down Expand Up @@ -94,7 +94,7 @@ Anymail 1-2-3
.. This quickstart section is also included in docs/quickstart.rst

Here's how to send a message.
This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid
This example uses Mailgun, but you can substitute Amazon SES or Mailjet or Postmark
or SparkPost or any other supported ESP where you see "mailgun":

1. Install Anymail from PyPI:
Expand Down Expand Up @@ -122,7 +122,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
"MAILGUN_API_KEY": "<your Mailgun key>",
"MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed
}
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or...
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or amazon_ses.EmailBackend, or...
DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings
SERVER_EMAIL = "your-server@example.com" # ditto (default from-email for Django errors)

Expand Down
7 changes: 7 additions & 0 deletions anymail/backends/sendgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from ..exceptions import (
AnymailConfigurationError,
AnymailNotSupportedWarning,
AnymailSerializationError,
AnymailWarning,
)
Expand All @@ -24,6 +25,12 @@ def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name

warnings.warn(
"django-anymail has dropped official support for SendGrid."
" See https://github.com/anymail/django-anymail/issues/432.",
AnymailNotSupportedWarning,
)

# Warn if v2-only username or password settings found
username = get_anymail_setting(
"username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True
Expand Down
12 changes: 12 additions & 0 deletions anymail/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ def check_deprecated_settings(app_configs, **kwargs):
)
)

if (
getattr(settings, "EMAIL_BACKEND", "")
== "anymail.backends.sendgrid.EmailBackend"
):
errors.append(
checks.Warning(
"django-anymail has dropped official support for SendGrid.",
hint="See https://github.com/anymail/django-anymail/issues/432.",
id="anymail.W003",
)
)

return errors


Expand Down
4 changes: 4 additions & 0 deletions anymail/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
"""Warning for deprecated Anymail features"""


class AnymailNotSupportedWarning(AnymailWarning):
"""Warning for ESP integrations that are no longer being tested"""


# Helpers


Expand Down
18 changes: 18 additions & 0 deletions anymail/webhooks/sendgrid.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import warnings
from datetime import datetime, timezone
from email.parser import BytesParser
from email.policy import default as default_policy

from ..exceptions import AnymailNotSupportedWarning
from ..inbound import AnymailInboundMessage
from ..signals import (
AnymailInboundEvent,
Expand All @@ -21,6 +23,14 @@ class SendGridTrackingWebhookView(AnymailBaseWebhookView):
esp_name = "SendGrid"
signal = tracking

def __init__(self, **kwargs):
super().__init__(**kwargs)
warnings.warn(
"django-anymail has dropped official support for SendGrid."
" See https://github.com/anymail/django-anymail/issues/432.",
AnymailNotSupportedWarning,
)

def parse_events(self, request):
esp_events = json.loads(request.body.decode("utf-8"))
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
Expand Down Expand Up @@ -136,6 +146,14 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
esp_name = "SendGrid"
signal = inbound

def __init__(self, **kwargs):
super().__init__(**kwargs)
warnings.warn(
"django-anymail has dropped official support for SendGrid."
" See https://github.com/anymail/django-anymail/issues/432.",
AnymailNotSupportedWarning,
)

def parse_events(self, request):
return [self.esp_to_anymail_event(request)]

Expand Down
1 change: 1 addition & 0 deletions docs/esps/esp-feature-matrix.csv
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Full,Full,**Unsupported**,Full,Full
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No
:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes [#caveats]_,Yes [#caveats]_
Expand Down
12 changes: 10 additions & 2 deletions docs/esps/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ The table below summarizes the Anymail features supported for each ESP.
:widths: auto
:class: sticky-left

.. [#support-status]
"Full" support indicates the Anymail project has an account with the ESP and
regularly runs live integration tests against their API. "Limited" indicates
Anymail has access to the ESP for testing and debugging issues, but doesn't
run integration tests regularly. "Unsupported" means Anymail does not have
testing access to the ESP. (The ESP's detail page will provide more details
on "Limited" and "Unsupported".)

.. [#caveats]
Some restrictions apply---see the ESP detail page
(usually under "Limitations and Quirks").
Expand All @@ -56,8 +64,8 @@ The table below summarizes the Anymail features supported for each ESP.
The ESP supports tracking, but Anymail can't enable/disable it
for individual messages. See the ESP detail page for more information.

Trying to choose an ESP? Please **don't** start with this table. It's far more
important to consider things like an ESP's deliverability stats, latency, uptime,
Trying to choose an ESP? Please **don't** start with the feature checklist. It's far
more important to consider things like an ESP's deliverability stats, latency, uptime,
and support for developers. The *number* of extra features an ESP offers is almost
meaningless. (And even specific features don't matter if you don't plan to use them.)

Expand Down
32 changes: 29 additions & 3 deletions docs/esps/sendgrid.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@
SendGrid
========

Anymail integrates with the Twilio `SendGrid`_ email service, using their `Web API v3`_.
Anymail integrates with the Twilio `SendGrid`_ email service, using their `Web API`_.

.. important::
.. warning:: **Unsupported since June 2025**

Anymail's SendGrid integration hasn't been tested against the live SendGrid API
since June 2025. As a result, SendGrid is no longer officially supported in
django-anymail. See `issue #432`_ for background and recommendations.

Although it will *probably* keep working, future bugs will likely be discovered
in production, by users like you, and we won't be able to verify proposed fixes.

To alert users to the change in support status, django-anymail 13.0.1 and later
will issue warnings when SendGrid features are used. If you are comfortable using
code that is no longer fully tested, you can disable these warnings by adding this
to your settings.py:

.. code-block:: python

SILENCED_SYSTEM_CHECKS = ["anymail.W003"]

import warnings
warnings.filterwarnings(
"ignore",
message="django-anymail has dropped official support for SendGrid",
)


.. note::

**Troubleshooting:**
If your SendGrid messages aren't being delivered as expected, be sure to look for
Expand All @@ -15,7 +40,8 @@ Anymail integrates with the Twilio `SendGrid`_ email service, using their `Web A
to succeed, and reports these errors as drop events.

.. _SendGrid: https://sendgrid.com/
.. _Web API v3: https://www.twilio.com/docs/sendgrid/api-reference
.. _Web API: https://www.twilio.com/docs/sendgrid/api-reference
.. _issue #432: https://github.com/anymail/django-anymail/issues/432
.. _activity feed: https://app.sendgrid.com/email_activity?events=drops


Expand Down
14 changes: 14 additions & 0 deletions tests/test_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ def test_anymail_webhook_authorization(self):
],
)

@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
def test_sendgrid_unsupported(self):
errors = check_deprecated_settings(None)
self.assertEqual(
errors,
[
checks.Warning(
"django-anymail has dropped official support for SendGrid.",
hint="See https://github.com/anymail/django-anymail/issues/432.",
id="anymail.W003",
)
],
)


class InsecureSettingsTests(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True})
Expand Down
17 changes: 16 additions & 1 deletion tests/test_sendgrid_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from unittest.mock import patch

from django.core import mail
from django.test import SimpleTestCase, override_settings, tag
from django.test import SimpleTestCase, ignore_warnings, override_settings, tag
from django.utils.timezone import (
get_fixed_timezone,
override as override_current_timezone,
Expand All @@ -16,6 +16,7 @@
from anymail.exceptions import (
AnymailAPIError,
AnymailConfigurationError,
AnymailNotSupportedWarning,
AnymailSerializationError,
AnymailUnsupportedFeature,
AnymailWarning,
Expand All @@ -39,6 +40,7 @@
EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend",
ANYMAIL={"SENDGRID_API_KEY": "test_api_key"},
)
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase):
# SendGrid v3 success responses are empty:
DEFAULT_RAW_RESPONSE = b""
Expand All @@ -63,9 +65,17 @@ def setUp(self):


@tag("sendgrid")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
"""Test backend support for Django standard email features"""

def test_not_supported_warning(self):
with self.assertWarns(
AnymailNotSupportedWarning,
msg="django-anymail has dropped official support for SendGrid.",
):
mail.get_connection()

def test_send_mail(self):
"""Test basic API for simple send"""
mail.send_mail(
Expand Down Expand Up @@ -461,6 +471,7 @@ def test_api_error_includes_details(self):


@tag("sendgrid")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
"""Test backend support for Anymail added features"""

Expand Down Expand Up @@ -1298,6 +1309,7 @@ def test_json_serialization_errors(self):


@tag("sendgrid")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase):
"""
Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid
Expand All @@ -1310,6 +1322,7 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase):


@tag("sendgrid")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendSessionSharingTestCase(
SessionSharingTestCases, SendGridBackendMockAPITestCase
):
Expand All @@ -1320,6 +1333,7 @@ class SendGridBackendSessionSharingTestCase(

@tag("sendgrid")
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""

Expand All @@ -1330,6 +1344,7 @@ def test_missing_auth(self):

@tag("sendgrid")
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendGridBackendDisallowsV2Tests(AnymailTestMixin, SimpleTestCase):
"""Using v2-API-only features should cause errors with v3 backend"""

Expand Down
11 changes: 10 additions & 1 deletion tests/test_sendgrid_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from textwrap import dedent
from unittest.mock import ANY

from django.test import tag
from django.test import ignore_warnings, tag

from anymail.exceptions import AnymailNotSupportedWarning
from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent
from anymail.webhooks.sendgrid import SendGridInboundWebhookView
Expand All @@ -20,7 +21,15 @@


@tag("sendgrid")
@ignore_warnings(category=AnymailNotSupportedWarning)
class SendgridInboundTestCase(WebhookTestCase):
def test_not_supported_warning(self):
with self.assertWarns(
AnymailNotSupportedWarning,
msg="django-anymail has dropped official support for SendGrid.",
):
self.client.post("/anymail/sendgrid/inbound/", data={"email": ""})

def test_inbound_basics(self):
raw_event = {
"headers": dedent(
Expand Down
Loading
Loading