Repository: mozmeao/basket

Issue:
Update additional basket methods to talk to CTMS
Update additional basket methods to talk to CTMS:

* [x] [confirm_user](https://github.com/mozmeao/basket/blob/922d6b50083ed470047d43ac5684883f180142c8/basket/news/tasks.py#L656) - Called when a user confirms a newsletter subscription. Add a parallel update to CTMS.
* [x] [fxa_delete](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L324) - Called when an FxA account is deleted. Add a parallel update to CTMS.
* [x] [fxa_email_changed](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L279) - Called when a user changes their FxA account emailed. Add a parallel update to CTMS.
* [x] [process_donation](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L913) - Called when processing a donation to the Mozilla Foundation. Add a parallel update to CTMS.
* [x] [process_petition_signature](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L1098) - Called when processing a petition signature. Add a parallel update to CTMS.
* [x] [record_common_voice_update](https://github.com/mozmeao/basket/blob/bc8651db0cf67a4eeea26a660826beafad02a94d/basket/news/tasks.py#L731) - Called when processing a Common Voice update, which may include subscribing to the newsletter. Add a parallel update to CTMS.
* [x] [update_custom_unsub](https://github.com/mozmeao/basket/blob/922d6b50083ed470047d43ac5684883f180142c8/basket/news/tasks.py#L702) - Called when a user gives a reason for unsubscribing from all newsletters. Add a call to populate unknown email_ids from CTMS, or to update by basket token.
* [x] [update_user_meta](https://github.com/mozmeao/basket/blob/bc8651db0cf67a4eeea26a660826beafad02a94d/basket/news/tasks.py#L471) - Called from ``/news/user-meta/{token}/``. Add a call to populate unknown email_ids from CTMS, or to update by basket token.
* [x] [amo_check_user_for_deletion](https://github.com/mozmeao/basket/blob/922d6b50083ed470047d43ac5684883f180142c8/basket/news/tasks.py#L1184) - Called when removing the relation between an author and an add-on. If a contact is no longer an author of any add-ons, the AMO data is removed from the contact. Add a call to clear the CTMS AMO data as well.
* [x] [fxa_direct_update_contact](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L306) - Generic function to update a contact by FxA ID. Only called by ``fxa_delete``. Either update this to update CTMS by FxA ID, or update ``fxa_delete``.
* [x] [get_fxa_user_data](https://github.com/mozmeao/basket/blob/57b2b53799b4061055c7873f12b95cfcc03022df/basket/news/tasks.py#L1350) - Get user data and validate the FxA data. The comments suggest this function did more in the past. Add an update to CTMS.
* [x] [upsert_amo_user_data](https://github.com/mozmeao/basket/blob/922d6b50083ed470047d43ac5684883f180142c8/basket/news/tasks.py#L1132) - Calls by the AMO sync background tasks. Add an update to CTMS.

These were updated as part of issue #674:

* [get_user_data](https://github.com/mozmeao/basket/blob/8dfcfa290549720bd2105c3c6d9b141548f08d52/basket/news/utils.py#L309) - Call CTMS to attempt to fill in a missing email_id.
* [upsert_contact](https://github.com/mozmeao/basket/blob/252fe39ac42bfd7674ab435c9d91ab94056ef357/basket/news/tasks.py#L497) - Call CTMS to create new contacts, or update (only if the email_id is known).
* [sfdc_add_update](https://github.com/mozmeao/basket/blob/252fe39ac42bfd7674ab435c9d91ab94056ef357/basket/news/tasks.py#L606) - Run add / update tasks queued during maintenance mode.

These functions will need to be updated again as we transition to CTMS and away from SFDC, so it may be useful to add common functions to centralize changes.


Discussion:

Resolving patch:
diff --git a/basket/news/backends/ctms.py b/basket/news/backends/ctms.py
--- a/basket/news/backends/ctms.py
+++ b/basket/news/backends/ctms.py
@@ -80,7 +80,7 @@ class CTMSError(Exception):
         # Need more information about the future MoFo integrations
         "mofo_email_id": None,
         "mofo_contact_id": None,
-        "mofo_relevant": None,
+        "mofo_relevant": "mofo_relevant",
     },
     "vpn_waitlist": {"geo": "fpn_country", "platform": "fpn_platform"},
 }
diff --git a/basket/news/tasks.py b/basket/news/tasks.py
--- a/basket/news/tasks.py
+++ b/basket/news/tasks.py
@@ -30,6 +30,7 @@
 from basket.news.backends.common import NewsletterException
 from basket.news.backends.ctms import ctms, CTMSNotFoundByAltIDError
 from basket.news.backends.sfdc import sfdc
+from basket.news.backends.sfdc import from_vendor as from_sfdc
 from basket.news.celery import app as celery_app
 from basket.news.models import (
     FailedTask,
@@ -286,18 +287,30 @@ def fxa_email_changed(data):
         # message older than our last update for this UID
         return
 
-    # Update SFDC
+    # Update SFDC / CTMS
     user_data = get_user_data(fxa_id=fxa_id, extra_fields=["id"])
     if user_data:
         sfdc.update(user_data, {"fxa_primary_email": email})
+        ctms.update(user_data, {"fxa_primary_email": email})
     else:
         # FxA record not found, try email
         user_data = get_user_data(email=email, extra_fields=["id"])
         if user_data:
             sfdc.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email})
+            ctms.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email})
         else:
             # No matching record for Email or FxA ID. Create one.
-            sfdc.add({"email": email, "fxa_id": fxa_id, "fxa_primary_email": email})
+            data = {
+                "email": email,
+                "token": generate_token(),
+                "fxa_id": fxa_id,
+                "fxa_primary_email": email,
+            }
+            ctms_data = data.copy()
+            contact = ctms.add(ctms_data)
+            if contact:
+                data["email_id"] = contact["email"]["email_id"]
+            sfdc.add(data)
             statsd.incr("news.tasks.fxa_email_changed.user_not_found")
 
     cache.set(cache_key, ts, 7200)  # 2 hr
@@ -319,6 +332,13 @@ def fxa_direct_update_contact(fxa_id, data):
             # otherwise it's something else and we should potentially retry
             raise
 
+    basket_data = from_sfdc(data)
+    try:
+        ctms.update_by_alt_id("fxa_id", fxa_id, basket_data)
+    except CTMSNotFoundByAltIDError:
+        # No associated record found, skip this update.
+        pass
+
 
 @et_task
 def fxa_delete(data):
@@ -917,8 +937,10 @@ def process_donation_event(data):
 @et_task
 def process_donation(data):
     get_lock(data["email"])
-    # tells the backend to leave the "subscriber" flag alone
-    contact_data = {"_set_subscriber": False}
+    contact_data = {
+        "_set_subscriber": False,  # SFDC, leave "subscriber" flag alone
+        "mofo_relevant": True,  # CTMS, set a MoFo relevant contact
+    }
     # do "or ''" because data can contain None values
     first_name = (data.get("first_name") or "").strip()
     last_name = (data.get("last_name") or "").strip()
@@ -951,11 +973,21 @@ def process_donation(data):
             )
         ):
             sfdc.update(user_data, contact_data)
+            ctms_data = contact_data.copy()
+            del ctms_data["_set_subscriber"]
+            ctms.update(user_data, ctms_data)
     else:
         contact_data["token"] = generate_token()
         contact_data["email"] = data["email"]
         contact_data["record_type"] = settings.DONATE_CONTACT_RECORD_TYPE
 
+        ctms_data = contact_data.copy()
+        del ctms_data["_set_subscriber"]
+        del ctms_data["record_type"]
+        contact = ctms.add(ctms_data)
+        if contact:
+            contact_data["email_id"] = contact["email"]["email_id"]
+
         # returns a dict with the new ID but no other user data, but that's enough here
         user_data = sfdc.add(contact_data)
         if not user_data.get("id"):
@@ -1085,6 +1117,7 @@ def process_donation_receipt(data):
 
 @et_task
 def process_newsletter_subscribe(data):
+    """Process a MoFo newsletter subscription."""
     data = data["form"]
     data["lang"] = get_best_supported_lang(data["lang"])
     upsert_user(SUBSCRIBE, data)
@@ -1107,16 +1140,30 @@ def process_petition_signature(data):
     data = data["form"]
     get_lock(data["email"])
     # tells the backend to leave the "subscriber" flag alone
-    contact_data = {"_set_subscriber": False}
+    contact_data = {
+        "_set_subscriber": False,  # SFDC: leave the "subscriber" flag alone
+        "mofo_relevant": True,  # CTMS: set contact as MoFo relevant
+    }
     contact_data.update({k: data[k] for k in PETITION_CONTACT_FIELDS if data.get(k)})
 
     user_data = get_user_data(email=data["email"], extra_fields=["id"])
     if user_data:
         sfdc.update(user_data, contact_data)
+        ctms_data = contact_data.copy()
+        del ctms_data["_set_subscriber"]
+        ctms.update(user_data, ctms_data)
     else:
         contact_data["token"] = generate_token()
         contact_data["email"] = data["email"]
+        ctms_data = contact_data.copy()
         contact_data["record_type"] = settings.DONATE_CONTACT_RECORD_TYPE
+
+        del ctms_data["_set_subscriber"]
+        ctms_data["mofo_relevant"] = True
+        contact = ctms.add(ctms_data)
+        if contact:
+            contact_data["email_id"] = contact["email"]["email_id"]
+
         sfdc.add(contact_data)
         # fetch again to get ID
         user_data = get_user_data(email=data.get("email"), extra_fields=["id"])
@@ -1206,6 +1253,7 @@ def upsert_amo_user_data(data, user_sync=False):
         amo_data["amo_id"] = None
 
     sfdc.update(user, amo_data)
+    ctms.update(user, amo_data)
     return user
 
 
@@ -1227,6 +1275,7 @@ def amo_check_user_for_deletion(user_id):
     )
     if not addons["records"]:
         sfdc.update({"id": user_id}, {"amo_id": None, "amo_user": False})
+        ctms.update_by_alt_id("sfdc_id", user_id, {"amo_deleted": True})
 
 
 @et_task
@@ -1367,6 +1416,7 @@ def get_fxa_user_data(fxa_id, email):
         # If email doesn't match, update FxA primary email field with the new email.
         if user_data_fxa["email"] != email:
             sfdc.update(user_data_fxa, {"fxa_primary_email": email})
+            ctms.update(user_data_fxa, {"fxa_primary_email": email})
 
     # if we still don't have user data try again with email this time
     if not user_data:


Test patch:
diff --git a/basket/news/tests/test_ctms.py b/basket/news/tests/test_ctms.py
--- a/basket/news/tests/test_ctms.py
+++ b/basket/news/tests/test_ctms.py
@@ -112,6 +112,7 @@
     "lang": "en",
     "last_modified_date": "2021-01-28T21:26:57.511Z",
     "last_name": "Doe",
+    "mofo_relevant": False,
     "newsletters": ["mozilla-welcome"],
     "optin": True,
     "optout": False,
@@ -179,6 +180,7 @@ def test_sample_format(self, mock_nl_slugs):
                 "lang": "en,en-US",
                 "primary_email": "my-fxa-acct@example.com",
             },
+            "mofo": {"mofo_relevant": False},
             "newsletters": [{"name": "mozilla-welcome", "subscribed": True}],
             "vpn_waitlist": {"geo": "fr", "platform": "ios,mac"},
         }
diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py
--- a/basket/news/tests/test_tasks.py
+++ b/basket/news/tests/test_tasks.py
@@ -22,6 +22,7 @@
     amo_sync_addon,
     amo_sync_user,
     et_task,
+    fxa_delete,
     fxa_email_changed,
     fxa_login,
     fxa_verified,
@@ -32,6 +33,7 @@
     process_donation_event,
     process_donation_receipt,
     process_petition_signature,
+    process_newsletter_subscribe,
     record_common_voice_update,
     send_acoustic_tx_message,
     SUBSCRIBE,
@@ -39,6 +41,7 @@
     RetryTask,
     update_custom_unsub,
     update_user_meta,
+    get_fxa_user_data,
 )
 from basket.news.utils import iso_format_unix_timestamp
 
@@ -47,6 +50,7 @@
 @patch("basket.news.tasks.upsert_user")
 @patch("basket.news.tasks.get_user_data")
 @patch("basket.news.tasks.sfdc")
+@patch("basket.news.tasks.ctms")
 class ProcessPetitionSignatureTests(TestCase):
     def _get_sig_data(self):
         return {
@@ -69,11 +73,13 @@ def _get_sig_data(self):
 
     def _get_contact_data(self, data):
         data = data["form"]
-        contact_data = {"_set_subscriber": False}
+        contact_data = {"_set_subscriber": False, "mofo_relevant": True}
         contact_data.update({k: data[k] for k in PETITION_CONTACT_FIELDS if k in data})
         return contact_data
 
-    def test_signature_with_comments_metadata(self, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_with_comments_metadata(
+        self, ctms_mock, sfdc_mock, gud_mock, uu_mock
+    ):
         data = self._get_sig_data()
         contact_data = self._get_contact_data(data)
         user_data = {
@@ -91,11 +97,16 @@ def test_signature_with_comments_metadata(self, sfdc_mock, gud_mock, uu_mock):
         gud_mock.return_value = user_data
         process_petition_signature(data)
         sfdc_mock.update.assert_called_with(gud_mock(), contact_data)
+        del contact_data["_set_subscriber"]
+        ctms_mock.update.assert_called_once_with(gud_mock(), contact_data)
         sfdc_mock.add.assert_not_called()
+        ctms_mock.add.assert_not_called()
         uu_mock.delay.assert_not_called()
         sfdc_mock.campaign_member.create.assert_called_with(campaign_member)
 
-    def test_signature_with_long_comments_metadata(self, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_with_long_comments_metadata(
+        self, ctms_mock, sfdc_mock, gud_mock, uu_mock
+    ):
         data = self._get_sig_data()
         data["form"]["comments"] = "DUDER!" * 100
         data["form"]["metadata"]["location"] = "bowling alley" * 100
@@ -116,11 +127,16 @@ def test_signature_with_long_comments_metadata(self, sfdc_mock, gud_mock, uu_moc
         gud_mock.return_value = user_data
         process_petition_signature(data)
         sfdc_mock.update.assert_called_with(gud_mock(), contact_data)
+        del contact_data["_set_subscriber"]
+        ctms_mock.update.assert_called_once_with(gud_mock(), contact_data)
         sfdc_mock.add.assert_not_called()
+        ctms_mock.add.assert_not_called()
         uu_mock.delay.assert_not_called()
         sfdc_mock.campaign_member.create.assert_called_with(campaign_member)
 
-    def test_signature_without_comments_metadata(self, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_without_comments_metadata(
+        self, ctms_mock, sfdc_mock, gud_mock, uu_mock
+    ):
         data = self._get_sig_data()
         del data["form"]["comments"]
         del data["form"]["metadata"]
@@ -138,11 +154,14 @@ def test_signature_without_comments_metadata(self, sfdc_mock, gud_mock, uu_mock)
         gud_mock.return_value = user_data
         process_petition_signature(data)
         sfdc_mock.update.assert_called_with(gud_mock(), contact_data)
+        del contact_data["_set_subscriber"]
+        ctms_mock.update.assert_called_once_with(gud_mock(), contact_data)
         sfdc_mock.add.assert_not_called()
+        ctms_mock.add.assert_not_called()
         uu_mock.delay.assert_not_called()
         sfdc_mock.campaign_member.create.assert_called_with(campaign_member)
 
-    def test_signature_with_subscription(self, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_with_subscription(self, ctms_mock, sfdc_mock, gud_mock, uu_mock):
         data = self._get_sig_data()
         data["form"]["email_subscription"] = True
         del data["form"]["comments"]
@@ -161,7 +180,10 @@ def test_signature_with_subscription(self, sfdc_mock, gud_mock, uu_mock):
         gud_mock.return_value = user_data
         process_petition_signature(data)
         sfdc_mock.update.assert_called_with(gud_mock(), contact_data)
+        del contact_data["_set_subscriber"]
+        ctms_mock.update.assert_called_once_with(gud_mock(), contact_data)
         sfdc_mock.add.assert_not_called()
+        ctms_mock.add.assert_not_called()
         uu_mock.delay.assert_called_with(
             SUBSCRIBE,
             {
@@ -174,7 +196,9 @@ def test_signature_with_subscription(self, sfdc_mock, gud_mock, uu_mock):
         sfdc_mock.campaign_member.create.assert_called_with(campaign_member)
 
     @patch("basket.news.tasks.generate_token")
-    def test_signature_with_new_user(self, gt_mock, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_with_new_user(
+        self, gt_mock, ctms_mock, sfdc_mock, gud_mock, uu_mock
+    ):
         data = self._get_sig_data()
         del data["form"]["comments"]
         del data["form"]["metadata"]
@@ -193,14 +217,24 @@ def test_signature_with_new_user(self, gt_mock, sfdc_mock, gud_mock, uu_mock):
             "Status": "Signed",
         }
         gud_mock.side_effect = [None, user_data]
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         process_petition_signature(data)
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
+        ctms_data = contact_data.copy()
+        del ctms_data["_set_subscriber"]
+        del ctms_data["record_type"]
+        ctms_mock.add.assert_called_once_with(ctms_data)
+        contact_data["email_id"] = email_id
         sfdc_mock.add.assert_called_with(contact_data)
         uu_mock.delay.assert_not_called()
         sfdc_mock.campaign_member.create.assert_called_with(campaign_member)
 
     @patch("basket.news.tasks.generate_token")
-    def test_signature_with_new_user_retry(self, gt_mock, sfdc_mock, gud_mock, uu_mock):
+    def test_signature_with_new_user_retry(
+        self, gt_mock, ctms_mock, sfdc_mock, gud_mock, uu_mock
+    ):
         data = self._get_sig_data()
         del data["form"]["comments"]
         del data["form"]["metadata"]
@@ -209,10 +243,18 @@ def test_signature_with_new_user_retry(self, gt_mock, sfdc_mock, gud_mock, uu_mo
         contact_data["email"] = data["form"]["email"]
         contact_data["record_type"] = settings.DONATE_CONTACT_RECORD_TYPE
         gud_mock.return_value = None
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         with self.assertRaises(Retry):
             process_petition_signature(data)
 
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
+        ctms_data = contact_data.copy()
+        del ctms_data["_set_subscriber"]
+        del ctms_data["record_type"]
+        ctms_mock.add.assert_called_once_with(ctms_data)
+        contact_data["email_id"] = email_id
         sfdc_mock.add.assert_called_with(contact_data)
         uu_mock.delay.assert_not_called()
         sfdc_mock.campaign_member.create.assert_not_called()
@@ -410,6 +452,7 @@ def test_receipt_thunderbird(self, acoustic_mock):
 @override_settings(TASK_LOCKING_ENABLE=False)
 @patch("basket.news.tasks.get_user_data")
 @patch("basket.news.tasks.sfdc")
+@patch("basket.news.tasks.ctms")
 class ProcessDonationTests(TestCase):
     donate_data = {
         "created": 1479746809.327,
@@ -431,7 +474,7 @@ class ProcessDonationTests(TestCase):
         "campaign_id": "were-you-listening-to-the-dudes-story",
     }
 
-    def test_one_name(self, sfdc_mock, gud_mock):
+    def test_one_name(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -442,15 +485,30 @@ def test_one_name(self, sfdc_mock, gud_mock):
         data["last_name"] = "Donnie"
         process_donation(data)
         sfdc_mock.update.assert_called_with(
-            gud_mock(), {"_set_subscriber": False, "last_name": "Donnie"},
+            gud_mock(),
+            {"_set_subscriber": False, "mofo_relevant": True, "last_name": "Donnie"},
+        )
+        ctms_mock.update.assert_called_with(
+            gud_mock(), {"last_name": "Donnie", "mofo_relevant": True}
         )
 
-    def test_name_splitting(self, sfdc_mock, gud_mock):
+    def test_name_splitting(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = None
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         del data["first_name"]
         data["last_name"] = "Theodore Donald Kerabatsos"
         process_donation(data)
+        ctms_mock.add.assert_called_with(
+            {
+                "token": ANY,
+                "email": "dude@example.com",
+                "first_name": "Theodore Donald",
+                "last_name": "Kerabatsos",
+                "mofo_relevant": True,
+            }
+        )
         sfdc_mock.add.assert_called_with(
             {
                 "_set_subscriber": False,
@@ -459,48 +517,91 @@ def test_name_splitting(self, sfdc_mock, gud_mock):
                 "email": "dude@example.com",
                 "first_name": "Theodore Donald",
                 "last_name": "Kerabatsos",
-            },
+                "email_id": email_id,
+                "mofo_relevant": True,
+            }
         )
 
-    def test_name_empty(self, sfdc_mock, gud_mock):
+    def test_name_empty(self, ctms_mock, sfdc_mock, gud_mock):
         """Should be okay if only last_name is provided and is just spaces.
 
         https://github.com/mozmeao/basket/issues/45
         """
         data = self.donate_data.copy()
         gud_mock.return_value = None
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         del data["first_name"]
         data["last_name"] = "  "
         process_donation(data)
+        ctms_mock.add.assert_called_with(
+            {"token": ANY, "email": "dude@example.com", "mofo_relevant": True}
+        )
         sfdc_mock.add.assert_called_with(
             {
                 "_set_subscriber": False,
                 "token": ANY,
-                "record_type": ANY,
                 "email": "dude@example.com",
-            },
+                "record_type": ANY,
+                "email_id": email_id,
+                "mofo_relevant": True,
+            }
         )
 
-    def test_name_none(self, sfdc_mock, gud_mock):
+    def test_name_none(self, ctms_mock, sfdc_mock, gud_mock):
         """Should be okay if only last_name is provided and is None.
 
         https://sentry.prod.mozaws.net/operations/basket-prod/issues/683973/
         """
         data = self.donate_data.copy()
         gud_mock.return_value = None
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         del data["first_name"]
         data["last_name"] = None
         process_donation(data)
+        ctms_mock.add.assert_called_with(
+            {"token": ANY, "email": "dude@example.com", "mofo_relevant": True}
+        )
         sfdc_mock.add.assert_called_with(
             {
                 "_set_subscriber": False,
                 "token": ANY,
+                "email": "dude@example.com",
                 "record_type": ANY,
+                "email_id": email_id,
+                "mofo_relevant": True,
+            }
+        )
+
+    def test_ctms_add_fails(self, ctms_mock, sfdc_mock, gud_mock):
+        """If ctms.add fails, sfdc.add is still called without an email_id."""
+        data = self.donate_data.copy()
+        gud_mock.return_value = None
+        ctms_mock.add.return_value = None
+        process_donation(data)
+        ctms_mock.add.assert_called_with(
+            {
+                "token": ANY,
                 "email": "dude@example.com",
-            },
+                "first_name": "Jeffery",
+                "last_name": "Lebowski",
+                "mofo_relevant": True,
+            }
+        )
+        sfdc_mock.add.assert_called_with(
+            {
+                "_set_subscriber": False,
+                "token": ANY,
+                "email": "dude@example.com",
+                "first_name": "Jeffery",
+                "last_name": "Lebowski",
+                "record_type": ANY,
+                "mofo_relevant": True,
+            }
         )
 
-    def test_only_update_contact_if_modified(self, sfdc_mock, gud_mock):
+    def test_only_update_contact_if_modified(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -514,10 +615,16 @@ def test_only_update_contact_if_modified(self, sfdc_mock, gud_mock):
                 "_set_subscriber": False,
                 "first_name": "Jeffery",
                 "last_name": "Lebowski",
+                "mofo_relevant": True,
             },
         )
+        ctms_mock.update.assert_called_with(
+            gud_mock(),
+            {"first_name": "Jeffery", "last_name": "Lebowski", "mofo_relevant": True},
+        )
 
         sfdc_mock.reset_mock()
+        ctms_mock.reset_mock()
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -526,8 +633,9 @@ def test_only_update_contact_if_modified(self, sfdc_mock, gud_mock):
         }
         process_donation(data)
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
 
-    def test_donation_data(self, sfdc_mock, gud_mock):
+    def test_donation_data(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -559,7 +667,7 @@ def test_donation_data(self, sfdc_mock, gud_mock):
             },
         )
 
-    def test_donation_data_optional_null(self, sfdc_mock, gud_mock):
+    def test_donation_data_optional_null(self, ctms_mock, sfdc_mock, gud_mock):
         """Having a `None` in an optional field used to throw a TypeError.
 
         https://github.com/mozmeao/basket/issues/366
@@ -596,7 +704,7 @@ def test_donation_data_optional_null(self, sfdc_mock, gud_mock):
             },
         )
 
-    def test_donation_silent_failure_on_dupe(self, sfdc_mock, gud_mock):
+    def test_donation_silent_failure_on_dupe(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -615,7 +723,7 @@ def test_donation_silent_failure_on_dupe(self, sfdc_mock, gud_mock):
         sfdc_mock.opportunity.create.side_effect = exc
         process_donation(data)
 
-    def test_donation_normal_failure_not_dupe(self, sfdc_mock, gud_mock):
+    def test_donation_normal_failure_not_dupe(self, ctms_mock, sfdc_mock, gud_mock):
         data = self.donate_data.copy()
         gud_mock.return_value = {
             "id": "1234",
@@ -635,6 +743,36 @@ def test_donation_normal_failure_not_dupe(self, sfdc_mock, gud_mock):
             process_donation(data)
 
 
+@patch("basket.news.tasks.upsert_user")
+@patch("basket.news.tasks.get_best_supported_lang")
+class ProcessNewsletterSubscribeTests(TestCase):
+    def test_process(self, mock_gbsl, mock_upsert):
+        """
+        process_newsletter_subscribe calls upsert_user
+
+        Note: The input data is a guess and may not reflect real queue items
+        """
+        data = {
+            "form": {
+                "email": "test@example.com",
+                "newsletters": ["mozilla-foundation"],
+                "lang": "fr",
+            },
+            "other": "stuff",
+        }
+        mock_gbsl.return_value = "fr"
+        process_newsletter_subscribe(data)
+        mock_gbsl.assert_called_once_with("fr")
+        mock_upsert.assert_called_once_with(
+            SUBSCRIBE,
+            {
+                "email": "test@example.com",
+                "newsletters": ["mozilla-foundation"],
+                "lang": "fr",
+            },
+        )
+
+
 @override_settings(TASK_LOCKING_ENABLE=True)
 class TaskDuplicationLockingTests(TestCase):
     def setUp(self):
@@ -1064,11 +1202,12 @@ def test_fxa_login_task_with_utm_data_no_subscribe(self, afa_mock, upsert_mock):
         upsert_mock.delay.assert_not_called()
 
 
+@patch("basket.news.tasks.ctms", spec_set=["update", "add"])
 @patch("basket.news.tasks.sfdc")
 @patch("basket.news.tasks.get_user_data")
 @patch("basket.news.tasks.cache")
 class FxAEmailChangedTests(TestCase):
-    def test_timestamps_older_message(self, cache_mock, gud_mock, sfdc_mock):
+    def test_timestamps_older_message(self, cache_mock, gud_mock, sfdc_mock, ctms_mock):
         data = {
             "ts": 1234.567,
             "uid": "the-fxa-id-for-el-dudarino",
@@ -1078,9 +1217,10 @@ def test_timestamps_older_message(self, cache_mock, gud_mock, sfdc_mock):
         # ts higher in cache, should no-op
         gud_mock.return_value = {"id": "1234"}
         fxa_email_changed(data)
-        sfdc_mock.upsert_row.assert_not_called()
+        sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
 
-    def test_timestamps_newer_message(self, cache_mock, gud_mock, sfdc_mock):
+    def test_timestamps_newer_message(self, cache_mock, gud_mock, sfdc_mock, ctms_mock):
         data = {
             "ts": 1234.567,
             "uid": "the-fxa-id-for-el-dudarino",
@@ -1091,8 +1231,11 @@ def test_timestamps_newer_message(self, cache_mock, gud_mock, sfdc_mock):
         # ts higher in message, do the things
         fxa_email_changed(data)
         sfdc_mock.update.assert_called_with(ANY, {"fxa_primary_email": data["email"]})
+        ctms_mock.update.assert_called_once_with(
+            ANY, {"fxa_primary_email": data["email"]}
+        )
 
-    def test_timestamps_nothin_cached(self, cache_mock, gud_mock, sfdc_mock):
+    def test_timestamps_nothin_cached(self, cache_mock, gud_mock, sfdc_mock, ctms_mock):
         data = {
             "ts": 1234.567,
             "uid": "the-fxa-id-for-el-dudarino",
@@ -1102,8 +1245,9 @@ def test_timestamps_nothin_cached(self, cache_mock, gud_mock, sfdc_mock):
         gud_mock.return_value = {"id": "1234"}
         fxa_email_changed(data)
         sfdc_mock.update.assert_called_with(ANY, {"fxa_primary_email": data["email"]})
+        ctms_mock.update.assert_called_with(ANY, {"fxa_primary_email": data["email"]})
 
-    def test_fxa_id_not_found(self, cache_mock, gud_mock, sfdc_mock):
+    def test_fxa_id_not_found(self, cache_mock, gud_mock, sfdc_mock, ctms_mock):
         data = {
             "ts": 1234.567,
             "uid": "the-fxa-id-for-el-dudarino",
@@ -1121,8 +1265,11 @@ def test_fxa_id_not_found(self, cache_mock, gud_mock, sfdc_mock):
         sfdc_mock.update.assert_called_with(
             {"id": "1234"}, {"fxa_id": data["uid"], "fxa_primary_email": data["email"]},
         )
+        ctms_mock.update.assert_called_with(
+            {"id": "1234"}, {"fxa_id": data["uid"], "fxa_primary_email": data["email"]},
+        )
 
-    def test_fxa_id_nor_email_found(self, cache_mock, gud_mock, sfdc_mock):
+    def test_fxa_id_nor_email_found(self, cache_mock, gud_mock, sfdc_mock, ctms_mock):
         data = {
             "ts": 1234.567,
             "uid": "the-fxa-id-for-el-dudarino",
@@ -1130,6 +1277,8 @@ def test_fxa_id_nor_email_found(self, cache_mock, gud_mock, sfdc_mock):
         }
         cache_mock.get.return_value = 0
         gud_mock.return_value = None
+        email_id = str(uuid4())
+        ctms_mock.add.return_value = {"email": {"email_id": email_id}}
         fxa_email_changed(data)
         gud_mock.assert_has_calls(
             [
@@ -1138,9 +1287,57 @@ def test_fxa_id_nor_email_found(self, cache_mock, gud_mock, sfdc_mock):
             ],
         )
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
+        ctms_mock.add.assert_called_with(
+            {
+                "email": data["email"],
+                "token": ANY,
+                "fxa_id": data["uid"],
+                "fxa_primary_email": data["email"],
+            },
+        )
+        sfdc_mock.add.assert_called_with(
+            {
+                "email_id": email_id,
+                "token": ANY,
+                "email": data["email"],
+                "fxa_id": data["uid"],
+                "fxa_primary_email": data["email"],
+            },
+        )
+
+    def test_fxa_id_nor_email_found_ctms_add_fails(
+        self, cache_mock, gud_mock, sfdc_mock, ctms_mock
+    ):
+        data = {
+            "ts": 1234.567,
+            "uid": "the-fxa-id-for-el-dudarino",
+            "email": "the-dudes-new-email@example.com",
+        }
+        cache_mock.get.return_value = 0
+        gud_mock.return_value = None
+        ctms_mock.add.return_value = None
+        fxa_email_changed(data)
+        gud_mock.assert_has_calls(
+            [
+                call(fxa_id=data["uid"], extra_fields=["id"]),
+                call(email=data["email"], extra_fields=["id"]),
+            ],
+        )
+        sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
+        ctms_mock.add.assert_called_with(
+            {
+                "email": data["email"],
+                "token": ANY,
+                "fxa_id": data["uid"],
+                "fxa_primary_email": data["email"],
+            },
+        )
         sfdc_mock.add.assert_called_with(
             {
                 "email": data["email"],
+                "token": ANY,
                 "fxa_id": data["uid"],
                 "fxa_primary_email": data["email"],
             },
@@ -1218,6 +1415,7 @@ def test_existing_user(self, gud_mock, sfdc_mock, ctms_mock):
         ctms_mock.update.assert_called_with(gud_mock(), update_data)
 
 
+@patch("basket.news.tasks.ctms", spec_set=["update_by_alt_id"])
 @patch("basket.news.tasks.sfdc")
 @patch("basket.news.tasks.upsert_amo_user_data")
 class AMOSyncAddonTests(TestCase):
@@ -1281,7 +1479,7 @@ def setUp(self):
             {"id": "A4321", "amo_id": 11263, "email": "the-dude@example.com"},
         ]
 
-    def test_update_addon(self, uaud_mock, sfdc_mock):
+    def test_update_addon(self, uaud_mock, sfdc_mock, ctms_mock):
         uaud_mock.side_effect = self.users_data
         sfdc_mock.addon.get_by_custom_id.return_value = {"Id": "B5678"}
         amo_sync_addon(self.amo_data)
@@ -1319,8 +1517,9 @@ def test_update_addon(self, uaud_mock, sfdc_mock):
                 ),
             ],
         )
+        ctms_mock.update_by_alt_id.assert_not_called()
 
-    def test_null_values(self, uaud_mock, sfdc_mock):
+    def test_null_values(self, uaud_mock, sfdc_mock, ctms_mock):
         uaud_mock.side_effect = self.users_data
         sfdc_mock.addon.get_by_custom_id.return_value = {"Id": "B5678"}
         self.amo_data["current_version"] = None
@@ -1360,8 +1559,9 @@ def test_null_values(self, uaud_mock, sfdc_mock):
                 ),
             ],
         )
+        ctms_mock.update_by_alt_id.assert_not_called()
 
-    def test_deleted_addon(self, uaud_mock, sfdc_mock):
+    def test_deleted_addon(self, uaud_mock, sfdc_mock, ctms_mock):
         self.amo_data["status"] = "deleted"
         sfdc_mock.addon.get_by_custom_id.return_value = {
             "Id": "A9876",
@@ -1399,13 +1599,17 @@ def test_deleted_addon(self, uaud_mock, sfdc_mock):
             ],
         )
         # it should update the 2nd user that has no returned records
-        sfdc_mock.update.called_once_with(
+        sfdc_mock.update.assert_called_once_with(
             {"id": "A4322"}, {"amo_id": None, "amo_user": False},
         )
+        ctms_mock.update_by_alt_id.assert_called_once_with(
+            "sfdc_id", "A4322", {"amo_deleted": True}
+        )
         sfdc_mock.dev_addon.delete.has_calls([call(1234), call(1235)])
         sfdc_mock.addon.delete.assert_called_with("A9876")
 
 
+@patch("basket.news.tasks.ctms")
 @patch("basket.news.tasks.sfdc")
 @patch("basket.news.tasks.get_user_data")
 class AMOSyncUserTests(TestCase):
@@ -1421,7 +1625,7 @@ def setUp(self):
         }
         self.user_data = {"id": "A1234", "amo_id": 1234, "fxa_id": "fxa_id_of_dude"}
 
-    def test_existing_user_with_amo_id(self, gud_mock, sfdc_mock):
+    def test_existing_user_with_amo_id(self, gud_mock, sfdc_mock, ctms_mock):
         gud_mock.return_value = self.user_data
         amo_sync_user(self.amo_data)
         # does not include email or amo_id
@@ -1436,8 +1640,19 @@ def test_existing_user_with_amo_id(self, gud_mock, sfdc_mock):
                 "amo_deleted": False,
             },
         )
+        ctms_mock.update.assert_called_with(
+            self.user_data,
+            {
+                "amo_id": 1234,
+                "amo_display_name": "His Dudeness",
+                "amo_homepage": "https://elduder.io",
+                "amo_last_login": "2019-08-06T10:39:44Z",
+                "amo_location": "California, USA, Earth",
+                "amo_deleted": False,
+            },
+        )
 
-    def test_existing_user_no_amo_id(self, gud_mock, sfdc_mock):
+    def test_existing_user_no_amo_id(self, gud_mock, sfdc_mock, ctms_mock):
         gud_mock.side_effect = [None, self.user_data]
         amo_sync_user(self.amo_data)
         # does not include email
@@ -1452,14 +1667,26 @@ def test_existing_user_no_amo_id(self, gud_mock, sfdc_mock):
                 "amo_deleted": False,
             },
         )
+        ctms_mock.update.assert_called_with(
+            self.user_data,
+            {
+                "amo_id": 1234,
+                "amo_display_name": "His Dudeness",
+                "amo_homepage": "https://elduder.io",
+                "amo_last_login": "2019-08-06T10:39:44Z",
+                "amo_location": "California, USA, Earth",
+                "amo_deleted": False,
+            },
+        )
 
-    def test_new_user(self, gud_mock, sfdc_mock):
+    def test_new_user(self, gud_mock, sfdc_mock, ctms_mock):
         gud_mock.return_value = None
         user = amo_sync_user(self.amo_data)
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
         assert user is None
 
-    def test_deleted_user_matching_fxa_id(self, gud_mock, sfdc_mock):
+    def test_deleted_user_matching_fxa_id(self, gud_mock, sfdc_mock, ctms_mock):
         self.amo_data["deleted"] = True
         gud_mock.return_value = self.user_data
         amo_sync_user(self.amo_data)
@@ -1474,8 +1701,19 @@ def test_deleted_user_matching_fxa_id(self, gud_mock, sfdc_mock):
                 "amo_id": None,
             },
         )
+        ctms_mock.update.assert_called_with(
+            self.user_data,
+            {
+                "amo_display_name": "His Dudeness",
+                "amo_homepage": "https://elduder.io",
+                "amo_last_login": "2019-08-06T10:39:44Z",
+                "amo_location": "California, USA, Earth",
+                "amo_deleted": True,
+                "amo_id": None,
+            },
+        )
 
-    def test_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock):
+    def test_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock, ctms_mock):
         self.amo_data["deleted"] = True
         self.amo_data["fxa_id"] = None
         gud_mock.return_value = self.user_data
@@ -1491,8 +1729,19 @@ def test_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock):
                 "amo_id": None,
             },
         )
+        ctms_mock.update.assert_called_with(
+            self.user_data,
+            {
+                "amo_display_name": "His Dudeness",
+                "amo_homepage": "https://elduder.io",
+                "amo_last_login": "2019-08-06T10:39:44Z",
+                "amo_location": "California, USA, Earth",
+                "amo_deleted": True,
+                "amo_id": None,
+            },
+        )
 
-    def test_not_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock):
+    def test_not_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock, ctms_mock):
         self.amo_data["fxa_id"] = None
         gud_mock.return_value = self.user_data
         amo_sync_user(self.amo_data)
@@ -1507,12 +1756,24 @@ def test_not_deleted_user_fxa_id_is_None(self, gud_mock, sfdc_mock):
                 "amo_deleted": False,
             },
         )
+        ctms_mock.update.assert_called_with(
+            self.user_data,
+            {
+                "amo_display_name": "His Dudeness",
+                "amo_homepage": "https://elduder.io",
+                "amo_last_login": "2019-08-06T10:39:44Z",
+                "amo_location": "California, USA, Earth",
+                "amo_id": None,
+                "amo_deleted": False,
+            },
+        )
 
-    def test_ignore_user_no_id_or_fxa_id(self, gud_mock, sfdc_mock):
+    def test_ignore_user_no_id_or_fxa_id(self, gud_mock, sfdc_mock, ctms_mock):
         self.amo_data["fxa_id"] = None
         self.amo_data["id"] = None
         amo_sync_user(self.amo_data)
         sfdc_mock.update.assert_not_called()
+        ctms_mock.update.assert_not_called()
 
 
 @override_settings(COMMON_VOICE_BATCH_PROCESSING=True, COMMON_VOICE_BATCH_CHUNK_SIZE=5)
@@ -1689,3 +1950,118 @@ def test_no_ctms_record(self, mock_sfdc, mock_ctms):
         mock_ctms.update_by_alt_id.assert_called_once_with(
             "token", self.token, self.data
         )
+
+
+@patch("basket.news.tasks.ctms", spec_set=["update"])
+@patch("basket.news.tasks.sfdc", spec_set=["update"])
+@patch("basket.news.tasks.get_user_data")
+class TestGetFxaUserData(TestCase):
+    def test_found_by_fxa_id_email_match(self, mock_gud, mock_sfdc, mock_ctms):
+        """A user can be found by FxA ID."""
+        user_data = {
+            "id": "1234",
+            "token": "the-token",
+            "fxa_id": "123",
+            "email": "test@example.com",
+        }
+        mock_gud.return_value = user_data
+
+        fxa_user_data = get_fxa_user_data("123", "test@example.com")
+        assert user_data == fxa_user_data
+
+        mock_gud.assert_called_once_with(fxa_id="123", extra_fields=["id"])
+        mock_sfdc.update.assert_not_called()
+        mock_ctms.update.assert_not_called()
+
+    def test_found_by_fxa_id_email_mismatch(self, mock_gud, mock_sfdc, mock_ctms):
+        """If the FxA user has a different FxA email, set fxa_primary_email."""
+        user_data = {
+            "id": "1234",
+            "token": "the-token",
+            "fxa_id": "123",
+            "email": "test@example.com",
+        }
+        mock_gud.return_value = user_data
+
+        fxa_user_data = get_fxa_user_data("123", "fxa@example.com")
+        assert user_data == fxa_user_data
+
+        mock_gud.assert_called_once_with(fxa_id="123", extra_fields=["id"])
+        mock_sfdc.update.assert_called_once_with(
+            user_data, {"fxa_primary_email": "fxa@example.com"}
+        )
+        mock_ctms.update.assert_called_once_with(
+            user_data, {"fxa_primary_email": "fxa@example.com"}
+        )
+
+    def test_miss_by_fxa_id(self, mock_gud, mock_sfdc, mock_ctms):
+        """If the FxA user has a different FxA email, set fxa_primary_email."""
+        user_data = {
+            "id": "1234",
+            "token": "the-token",
+            "email": "test@example.com",
+        }
+        mock_gud.side_effect = [None, user_data]
+
+        fxa_user_data = get_fxa_user_data("123", "test@example.com")
+        assert user_data == fxa_user_data
+
+        assert mock_gud.call_count == 2
+        mock_gud.assert_any_call(fxa_id="123", extra_fields=["id"])
+        mock_gud.assert_called_with(email="test@example.com", extra_fields=["id"])
+        mock_sfdc.update.asser
+        mock_ctms.update.assert_not_called()
+
+
+@patch("basket.news.tasks.ctms", spec_set=["update_by_alt_id"])
+@patch("basket.news.tasks.sfdc", content=Mock(spec_set=["update"]))
+class TestFxaDelete(TestCase):
+    def test_delete(self, mock_sfdc, mock_ctms):
+        fxa_delete({"uid": "123"})
+        mock_sfdc.contact.update.assert_called_once_with(
+            "FxA_Id__c/123", {"FxA_Account_Deleted__c": True}
+        )
+        mock_ctms.update_by_alt_id.assert_called_once_with(
+            "fxa_id", "123", {"fxa_deleted": True, "newsletters": []}
+        )
+
+    def test_delete_does_not_exist_success(self, mock_sfdc, mock_ctms):
+        """If the record doesn't exist, the exception is caught."""
+        err_content = {
+            "errorCode": "REQUIRED_FIELD_MISSING",
+            "fields": ["Field1", "Field2"],
+            "message": "Required fields are missing: [Field1, Field2]",
+        }
+
+        exc = sfapi.SalesforceMalformedRequest("url", 400, "contact", [err_content])
+        mock_sfdc.contact.update.side_effect = exc
+        fxa_delete({"uid": "123"})
+        mock_sfdc.contact.update.assert_called_once_with(
+            "FxA_Id__c/123", {"FxA_Account_Deleted__c": True}
+        )
+        mock_ctms.update_by_alt_id.assert_not_called()
+
+    def test_delete_other_exception_raised(self, mock_sfdc, mock_ctms):
+        """If updating raises a different error, re-raise"""
+        err_content = {
+            "errorCode": "SOMETHING_ELSE",
+            "message": "Something else went wrong",
+        }
+
+        exc = sfapi.SalesforceMalformedRequest("url", 400, "contact", [err_content])
+        mock_sfdc.contact.update.side_effect = exc
+        self.assertRaises(Retry, fxa_delete, {"uid": "123"})
+        mock_ctms.update_by_alt_id.assert_not_called()
+
+    def test_delete_ctms_not_found_succeeds(self, mock_sfdc, mock_ctms):
+        """If the CTMS record is not found by FxA ID, the exception is caught."""
+        mock_ctms.update_by_alt_id.side_effect = CTMSNotFoundByAltIDError(
+            "fxa_id", "123"
+        )
+        fxa_delete({"uid": "123"})
+        mock_sfdc.contact.update.assert_called_once_with(
+            "FxA_Id__c/123", {"FxA_Account_Deleted__c": True}
+        )
+        mock_ctms.update_by_alt_id.assert_called_once_with(
+            "fxa_id", "123", {"fxa_deleted": True, "newsletters": []}
+        )