diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index a452dbc..0a50de9 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ImproperlyConfigured from django.core.mail import make_msgid from ..exceptions import AnymailImproperlyInstalled, AnymailRequestsAPIError @@ -20,7 +21,16 @@ class SendGridBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" - self.api_key = get_anymail_setting('SENDGRID_API_KEY', allow_bare=True) + # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD + self.api_key = get_anymail_setting('SENDGRID_API_KEY', default=None, allow_bare=True) + self.username = get_anymail_setting('SENDGRID_USERNAME', default=None, allow_bare=True) + self.password = get_anymail_setting('SENDGRID_PASSWORD', default=None, allow_bare=True) + if self.api_key is None and self.username is None and self.password is None: + raise ImproperlyConfigured( + "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and " + "SENDGRID_PASSWORD in your Django ANYMAIL settings." + ) + # This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending) api_url = get_anymail_setting("SENDGRID_API_URL", "https://api.sendgrid.com/api/") if not api_url.endswith("/"): @@ -53,9 +63,16 @@ class SendGridPayload(RequestsPayload): self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers self.smtpapi = {} # SendGrid x-smtpapi field - auth_headers = {'Authorization': 'Bearer ' + backend.api_key} + http_headers = kwargs.pop('headers', {}) + query_params = kwargs.pop('params', {}) + if backend.api_key is not None: + http_headers['Authorization'] = 'Bearer %s' % backend.api_key + else: + query_params['api_user'] = backend.username + query_params['api_key'] = backend.password super(SendGridPayload, self).__init__(message, defaults, backend, - headers=auth_headers, *args, **kwargs) + params=query_params, headers=http_headers, + *args, **kwargs) def get_api_endpoint(self): return "mail.send.json" diff --git a/anymail/tests/test_sendgrid_backend.py b/anymail/tests/test_sendgrid_backend.py index a49510b..2c83926 100644 --- a/anymail/tests/test_sendgrid_backend.py +++ b/anymail/tests/test_sendgrid_backend.py @@ -48,16 +48,35 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): mail.send_mail('Subject here', 'Here is the message.', 'from@sender.example.com', ['to@example.com'], fail_silently=False) self.assert_esp_called('/api/mail.send.json') - headers = self.get_api_call_headers() - self.assertEqual(headers["Authorization"], "Bearer test_api_key") + http_headers = self.get_api_call_headers() + self.assertEqual(http_headers["Authorization"], "Bearer test_api_key") + + query = self.get_api_call_params(required=False) + if query: + self.assertNotIn('api_user', query) + self.assertNotIn('api_key', query) + data = self.get_api_call_data() self.assertEqual(data['subject'], "Subject here") self.assertEqual(data['text'], "Here is the message.") self.assertEqual(data['from'], "from@sender.example.com") self.assertEqual(data['to'], ["to@example.com"]) # make sure backend assigned a Message-ID for event tracking - headers = json.loads(data['headers']) - self.assertRegex(headers['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain + email_headers = json.loads(data['headers']) + self.assertRegex(email_headers['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain + + @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) + def test_user_pass_auth(self): + """Make sure alternative USERNAME/PASSWORD auth works""" + mail.send_mail('Subject here', 'Here is the message.', + 'from@sender.example.com', ['to@example.com'], fail_silently=False) + self.assert_esp_called('/api/mail.send.json') + query = self.get_api_call_params() + self.assertEqual(query['api_user'], 'sg_username') + self.assertEqual(query['api_key'], 'sg_password') + http_headers = self.get_api_call_headers(required=False) + if http_headers: + self.assertNotIn('Authorization', http_headers) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -534,9 +553,11 @@ class SendGridBackendSendDefaultsTests(SendGridBackendMockAPITestCase): class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): """Test ESP backend without required settings in place""" - def test_missing_api_key(self): + def test_missing_auth(self): with self.assertRaises(ImproperlyConfigured) as cm: mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) errmsg = str(cm.exception) + # Make sure the exception mentions all the auth keys: self.assertRegex(errmsg, r'\bSENDGRID_API_KEY\b') - self.assertRegex(errmsg, r'\bANYMAIL_SENDGRID_API_KEY\b') + self.assertRegex(errmsg, r'\bSENDGRID_USERNAME\b') + self.assertRegex(errmsg, r'\bSENDGRID_PASSWORD\b') diff --git a/anymail/tests/test_sendgrid_integration.py b/anymail/tests/test_sendgrid_integration.py index 3ae721e..3bc46a8 100644 --- a/anymail/tests/test_sendgrid_integration.py +++ b/anymail/tests/test_sendgrid_integration.py @@ -4,6 +4,7 @@ import os import unittest from datetime import datetime, timedelta +from django.core.mail import send_mail from django.test import SimpleTestCase from django.test.utils import override_settings @@ -12,8 +13,13 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path +# For API_KEY auth tests: SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY') +# For USERNAME/PASSWORD auth tests: +SENDGRID_TEST_USERNAME = os.getenv('SENDGRID_TEST_USERNAME') +SENDGRID_TEST_PASSWORD = os.getenv('SENDGRID_TEST_PASSWORD') + @unittest.skipUnless(SENDGRID_TEST_API_KEY, "Set SENDGRID_TEST_API_KEY environment variable " @@ -91,3 +97,31 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): self.assertEqual(err.status_code, 400) # Make sure the exception message includes SendGrid's response: self.assertIn("authorization grant is invalid", str(err)) + + +@unittest.skipUnless(SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD, + "Set SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD" + "environment variables to run SendGrid integration tests") +@override_settings(ANYMAIL_SENDGRID_USERNAME=SENDGRID_TEST_USERNAME, + ANYMAIL_SENDGRID_PASSWORD=SENDGRID_TEST_PASSWORD, + EMAIL_BACKEND="anymail.backends.sendgrid.SendGridBackend") +class SendGridBackendUserPassIntegrationTests(SimpleTestCase, AnymailTestMixin): + """SendGrid username/password API integration tests + + (See notes above for the API-key tests) + """ + + def test_valid_auth(self): + sent_count = send_mail('Anymail SendGrid username/password integration test', + 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) + self.assertEqual(sent_count, 1) + + @override_settings(ANYMAIL_SENDGRID_PASSWORD="Hey, this isn't the password!") + def test_invalid_auth(self): + with self.assertRaises(AnymailAPIError) as cm: + send_mail('Anymail SendGrid username/password integration test', + 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) + err = cm.exception + self.assertEqual(err.status_code, 400) + # Make sure the exception message includes SendGrid's response: + self.assertIn("Bad username / password", str(err)) diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index 9ae65fb..7c703a4 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -31,10 +31,10 @@ their name with an uppercase "G", so Anymail does too.) .. rubric:: SENDGRID_API_KEY -Required. A SendGrid API key with "Mail Send" permission. -(Manage API keys in your `SendGrid API key settings`_. -Anymail does not support SendGrid's earlier username/password -authentication.) +A SendGrid API key with "Mail Send" permission. +(Manage API keys in your `SendGrid API key settings`_.) +Either an API key or both :setting:`SENDGRID_USERNAME ` +and :setting:`SENDGRID_PASSWORD ` are required. .. code-block:: python @@ -50,6 +50,34 @@ nor ``ANYMAIL_SENDGRID_API_KEY`` is set. .. _SendGrid API key settings: https://app.sendgrid.com/settings/api_keys +.. setting:: ANYMAIL_SENDGRID_USERNAME +.. setting:: ANYMAIL_SENDGRID_PASSWORD + +.. rubric:: SENDGRID_USERNAME and SENDGRID_PASSWORD + +SendGrid credentials with the "Mail" permission. You should **not** +use the username/password that you use to log into SendGrid's +dashboard. Create credentials specifically for sending mail in the +`SendGrid credentials settings`_. + + .. code-block:: python + + ANYMAIL = { + ... + "SENDGRID_USERNAME": "", + "SENDGRID_PASSWORD": "", + } + +Either username/password or :setting:`SENDGRID_API_KEY ` +are required (but not both). + +Anymail will also look for ``SENDGRID_USERNAME`` and ``SENDGRID_PASSWORD`` at the +root of the settings file if neither ``ANYMAIL["SENDGRID_USERNAME"]`` +nor ``ANYMAIL_SENDGRID_USERNAME`` is set. + +.. _SendGrid credentials settings: https://app.sendgrid.com/settings/credentials + + .. setting:: ANYMAIL_SENDGRID_API_URL .. rubric:: SENDGRID_API_URL