Repository: celery/kombu

Issue:
[SQS] `fetch_message_attributes` is not used for sync SQS receive_message
Hi team 👋 I've been a long-time supported of Celery and now find myself having to integrate with SQS. So what better package to use than Kombu! Thank you for the dedication over the years and for the excellent implementations.

---

In https://github.com/celery/kombu/pull/2226 support was added for transport_options.fetch_message_attributes to receive all MessageAttributes from a `ReceiveMessage` request.

However, this is only applied in the `async` flow of `AsyncSQSConnection`. 
In `kombu.transport.SQS.Channel._get` or `kombu.transport.SQS.Channel._get_bulk` where `self.sqs.receive_message` is used the `self.sqs object` is a boto3.Client and those fetch_message_attributes are not passed.

The result is that we can send MessageAttributes / MessageAttributesNames with `publish` (by doing product.publish(...message_attributes={...})) but we cannot read back.

How best to implement this I'm not sure but I at least wanted to put it on your radar. I'm happy to take on the work if you point me in the right direction.

For now I'm monkeypatching `_get` and `_get_bulk` with 
```
            resp = self.sqs(queue=queue).receive_message(
                QueueUrl=q_url, MaxNumberOfMessages=max_count,
                MessageAttributeNames=self.fetch_message_attributes,
                WaitTimeSeconds=self.wait_time_seconds)
```

as that returns all MessageAttributes I want to read.


Discussion:
@hfern whould you mind to weigh in please



Patch:
diff --git a/.gitignore b/.gitignore
index e03003a6b..c0a7ec205 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,5 @@ env
 .coverage.*
 control/
 .env
+.hypothesis/*
+junit.xml
diff --git a/AUTHORS b/AUTHORS
index 518ab7993..8111e4acd 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -7,6 +7,7 @@ Adam Gaca <adam.gaca@oke.pl>
 Adam Nelson <adam@varud.com>
 Adam Wentz
 Alan Justino <alan.justino@yahoo.com.br>
+Alex Jones
 Alex Koshelev <daevaorn@gmail.com>
 Alexandre Bourget <alexandre.bourget@savoirfairelinux.com>
 Anastasis Andronidis <anastasis90@yahoo.gr>
diff --git a/kombu/asynchronous/aws/sqs/connection.py b/kombu/asynchronous/aws/sqs/connection.py
index c229ea157..ff5faf618 100644
--- a/kombu/asynchronous/aws/sqs/connection.py
+++ b/kombu/asynchronous/aws/sqs/connection.py
@@ -20,7 +20,15 @@
 class AsyncSQSConnection(AsyncAWSQueryConnection):
     """Async SQS Connection."""
 
-    def __init__(self, sqs_connection, debug=0, region=None, fetch_message_attributes=None, **kwargs):
+    def __init__(
+        self,
+        sqs_connection,
+        debug=0,
+        region=None,
+        message_system_attribute_names=None,
+        message_attribute_names=None,
+        **kwargs
+    ):
         if boto3 is None:
             raise ImportError('boto3 is not installed')
         super().__init__(
@@ -28,9 +36,12 @@ def __init__(self, sqs_connection, debug=0, region=None, fetch_message_attribute
             region_name=region, debug=debug,
             **kwargs
         )
-        self.fetch_message_attributes = (
-            fetch_message_attributes if fetch_message_attributes is not None
-            else ["ApproximateReceiveCount"]
+        self.message_system_attribute_names = (
+            message_system_attribute_names if message_system_attribute_names else ["ApproximateReceiveCount"]
+        )
+        self.message_attribute_names = (
+            [message_attribute_names] if isinstance(message_attribute_names, str)
+            else (message_attribute_names or [])
         )
 
     def _create_query_request(self, operation, params, queue_url, method):
@@ -160,13 +171,17 @@ def receive_message(
     ):
         params = {'MaxNumberOfMessages': number_messages}
         proto_params = {'query': {}, 'json': {}}
-        attrs = attributes if attributes is not None else self.fetch_message_attributes
+        attrs = attributes if attributes is not None else self.message_system_attribute_names
+        msg_attr_names = self.message_attribute_names if self.message_attribute_names else None
 
         if visibility_timeout:
             params['VisibilityTimeout'] = visibility_timeout
         if attrs:
-            proto_params['json'].update({'AttributeNames': list(attrs)})
-            proto_params['query'].update(_query_object_encode({'AttributeName': list(attrs)}))
+            proto_params['json'].update({'MessageSystemAttributeNames': list(attrs)})
+            proto_params['query'].update(_query_object_encode({'MessageSystemAttributeName': list(attrs)}))
+        if msg_attr_names:
+            proto_params['json'].update({'MessageAttributeNames': list(msg_attr_names)})
+            proto_params['query'].update(_query_object_encode({'MessageAttributeNames': list(msg_attr_names)}))
         if wait_time_seconds is not None:
             params['WaitTimeSeconds'] = wait_time_seconds
 
diff --git a/kombu/transport/SQS.py b/kombu/transport/SQS.py
index 5d8aa0f5c..1847b43c2 100644
--- a/kombu/transport/SQS.py
+++ b/kombu/transport/SQS.py
@@ -133,11 +133,15 @@
 from __future__ import annotations
 
 import base64
+import binascii
+import re
 import socket
 import string
 import uuid
 from datetime import datetime
+from json import JSONDecodeError
 from queue import Empty
+from typing import Any
 
 from botocore.client import Config
 from botocore.exceptions import ClientError
@@ -261,6 +265,8 @@ class Channel(virtual.Channel):
     _queue_cache = {}  # SQS queue name => SQS queue URL
     _noack_queues = set()
     QoS = QoS
+    # https://stackoverflow.com/questions/475074/regex-to-parse-or-validate-base64-data
+    B64_REGEX = re.compile(rb'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$')
 
     def __init__(self, *args, **kwargs):
         if boto3 is None:
@@ -476,45 +482,19 @@ def _put(self, queue, message, **kwargs):
         else:
             c.send_message(**kwargs)
 
-    @staticmethod
-    def _optional_b64_decode(byte_string):
-        try:
-            data = base64.b64decode(byte_string)
-            if base64.b64encode(data) == byte_string:
-                return data
-            # else the base64 module found some embedded base64 content
-            # that should be ignored.
-        except Exception:  # pylint: disable=broad-except
-            pass
-        return byte_string
-
     def _message_to_python(self, message, queue_name, q_url):
-        body = self._optional_b64_decode(message['Body'].encode())
-        payload = loads(bytes_to_str(body))
+        raw_msg_body = message['Body']
+        decoded_bytes = self._decode_python_message_body(raw_msg_body)
+        text = bytes_to_str(decoded_bytes)
+
+        payload = self._prepare_json_payload(text)
+
+        # handle no-ack queues immediately
         if queue_name in self._noack_queues:
-            q_url = self._new_queue(queue_name)
-            self.asynsqs(queue=queue_name).delete_message(
-                q_url,
-                message['ReceiptHandle'],
-            )
-        else:
-            try:
-                properties = payload['properties']
-                delivery_info = payload['properties']['delivery_info']
-            except KeyError:
-                # json message not sent by kombu?
-                delivery_info = {}
-                properties = {'delivery_info': delivery_info}
-                payload.update({
-                    'body': bytes_to_str(body),
-                    'properties': properties,
-                })
-            # set delivery tag to SQS receipt handle
-            delivery_info.update({
-                'sqs_message': message, 'sqs_queue': q_url,
-            })
-            properties['delivery_tag'] = message['ReceiptHandle']
-        return payload
+            self._delete_message(queue_name, message)
+            return payload
+
+        return self._envelope_payload(payload, text, message, q_url)
 
     def _messages_to_python(self, messages, queue):
         """Convert a list of SQS Message objects into Payloads.
@@ -535,6 +515,37 @@ def _messages_to_python(self, messages, queue):
         q_url = self._new_queue(queue)
         return [self._message_to_python(m, queue, q_url) for m in messages]
 
+    def _receive_message(
+        self,
+        queue: str,
+        max_number_of_messages: int = 1,
+        wait_time_seconds: int | None = None
+    ):
+        """Unified receive_message wrapper for SQS (boto3.client.SQS) with full attribute support.
+
+        :param queue: The queue as a string
+        :param max_number_of_messages: Int of max number of messages to receive.
+        :param wait_time_seconds: Int of sqs wait time in seconds.
+        :return: SQS client recieve_message
+        """
+        q_url: str = self._new_queue(queue)
+        client = self.sqs(queue=queue)
+
+        message_system_attribute_names = self.get_message_attributes.get(
+            'MessageSystemAttributeNames')
+        message_attribute_names = self.get_message_attributes.get(
+            'MessageAttributeNames')
+
+        params: dict[str, Any] = {
+            'QueueUrl': q_url,
+            'MaxNumberOfMessages': max_number_of_messages,
+            'WaitTimeSeconds': wait_time_seconds or self.wait_time_seconds,
+            'MessageAttributeNames': message_attribute_names,
+            'MessageSystemAttributeNames': message_system_attribute_names
+        }
+
+        return client.receive_message(**params)
+
     def _get_bulk(self, queue,
                   max_if_unlimited=SQS_MAX_MESSAGES, callback=None):
         """Try to retrieve multiple messages off ``queue``.
@@ -567,10 +578,12 @@ def _get_bulk(self, queue,
         # Note: ignoring max_messages for SQS with boto3
         max_count = self._get_message_estimate()
         if max_count:
-            q_url = self._new_queue(queue)
-            resp = self.sqs(queue=queue).receive_message(
-                QueueUrl=q_url, MaxNumberOfMessages=max_count,
-                WaitTimeSeconds=self.wait_time_seconds)
+            resp = self._receive_message(
+                queue=queue,
+                wait_time_seconds=self.wait_time_seconds,
+                max_number_of_messages=max_count
+            )
+
             if resp.get('Messages'):
                 for m in resp['Messages']:
                     m['Body'] = AsyncMessage(body=m['Body']).decode()
@@ -581,10 +594,12 @@ def _get_bulk(self, queue,
 
     def _get(self, queue):
         """Try to retrieve a single message off ``queue``."""
-        q_url = self._new_queue(queue)
-        resp = self.sqs(queue=queue).receive_message(
-            QueueUrl=q_url, MaxNumberOfMessages=1,
-            WaitTimeSeconds=self.wait_time_seconds)
+        resp = self._receive_message(
+            queue=queue,
+            wait_time_seconds=self.wait_time_seconds,
+            max_number_of_messages=1
+        )
+
         if resp.get('Messages'):
             body = AsyncMessage(body=resp['Messages'][0]['Body']).decode()
             resp['Messages'][0]['Body'] = body
@@ -610,8 +625,7 @@ def _get_message_estimate(self, max_if_unlimited=SQS_MAX_MESSAGES):
             max_if_unlimited,
         )
 
-    def _get_bulk_async(self, queue,
-                        max_if_unlimited=SQS_MAX_MESSAGES, callback=None):
+    def _get_bulk_async(self, queue, callback=None):
         maxcount = self._get_message_estimate()
         if maxcount:
             return self._get_async(queue, maxcount, callback=callback)
@@ -800,6 +814,11 @@ def generate_sts_session_token(self, role_arn, token_expiry_seconds):
         return sts_policy['Credentials']
 
     def asynsqs(self, queue=None):
+        message_system_attribute_names = self.get_message_attributes.get(
+            'MessageSystemAttributeNames')
+        message_attribute_names = self.get_message_attributes.get(
+            'MessageAttributeNames')
+
         if queue is not None and self.predefined_queues:
             if queue in self._predefined_queue_async_clients and \
                not hasattr(self, 'sts_expiration'):
@@ -814,7 +833,8 @@ def asynsqs(self, queue=None):
                 AsyncSQSConnection(
                     sqs_connection=self.sqs(queue=queue),
                     region=q.get('region', self.region),
-                    fetch_message_attributes=self.fetch_message_attributes,
+                    message_system_attribute_names=message_system_attribute_names,
+                    message_attribute_names=message_attribute_names
             )
             return c
 
@@ -824,7 +844,8 @@ def asynsqs(self, queue=None):
         c = self._asynsqs = AsyncSQSConnection(
             sqs_connection=self.sqs(queue=queue),
             region=self.region,
-            fetch_message_attributes=self.fetch_message_attributes,
+            message_system_attribute_names=message_system_attribute_names,
+            message_attribute_names=message_attribute_names
         )
         return c
 
@@ -887,7 +908,7 @@ def endpoint_url(self):
             )
 
     @cached_property
-    def wait_time_seconds(self):
+    def wait_time_seconds(self) -> int:
         return self.transport_options.get('wait_time_seconds',
                                           self.default_wait_time_seconds)
 
@@ -897,7 +918,143 @@ def sqs_base64_encoding(self):
 
     @cached_property
     def fetch_message_attributes(self):
-        return self.transport_options.get('fetch_message_attributes')
+        return self.transport_options.get('fetch_message_attributes', None)
+
+    @property
+    def get_message_attributes(self) -> dict[str, Any]:
+        """Get the message attributes to be fetched from SQS.
+
+        Ensures 'ApproximateReceiveCount' is included in system attributes if list is provided.
+        - The number of retries is managed by SQS /
+            (specifically by the ``ApproximateReceiveCount`` message attribute)
+        - See: class QoS(virtual.QoS):
+            (method) def extract_task_name_and_number_of_retries
+
+        :return: A dictionary with SQS message attribute fetch config.
+        """
+        APPROXIMATE_RECEIVE_COUNT = 'ApproximateReceiveCount'
+        fetch = self.fetch_message_attributes
+        message_system_attrs = None
+        message_attrs = None
+
+        if fetch is None or isinstance(fetch, str):
+            return {
+                'MessageAttributeNames': None,
+                'MessageSystemAttributeNames': [APPROXIMATE_RECEIVE_COUNT],
+            }
+
+        if isinstance(fetch, list):
+            message_system_attrs = ['ALL'] if 'ALL'.lower() in [s.lower() for s in fetch] else (
+                list(set(fetch + [APPROXIMATE_RECEIVE_COUNT]))
+            )
+
+        elif isinstance(fetch, dict):
+            system = fetch.get('MessageSystemAttributeNames', [])
+            attrs = fetch.get('MessageAttributeNames', None)
+
+            if isinstance(system, list):
+                message_system_attrs = ['ALL'] if 'ALL'.lower() in [s.lower() for s in system] else (
+                    list(set(system + [APPROXIMATE_RECEIVE_COUNT]))
+                )
+
+            if isinstance(attrs, list) and attrs:
+                message_attrs = ['ALL'] if 'ALL'.lower() in [s.lower() for s in attrs] else (
+                    list(set(attrs))
+                )
+
+        return {
+            'MessageAttributeNames': sorted(message_attrs) if message_attrs else None,
+            'MessageSystemAttributeNames': (
+                sorted(message_system_attrs) if message_system_attrs else [APPROXIMATE_RECEIVE_COUNT]
+            )
+        }
+
+    # —————————————————————————————————————————————————————————————
+    # _message_to_python helper methods (extracted for testing/readability)
+    # —————————————————————————————————————————————————————————————
+
+    def _optional_b64_decode(self, raw: bytes) -> bytes:
+        """Optionally decode a base64 encoded string.
+
+        :param raw: The raw bytes object to decode.
+        :return: Bytes of the optionally decoded raw input.
+        """
+        candidate = raw.strip()
+
+        if self.B64_REGEX.fullmatch(candidate) is None:
+            return raw
+
+        try:
+            decoded = base64.b64decode(candidate, validate=True)
+        except (binascii.Error, ValueError):
+            return raw
+
+        reencoded = base64.b64encode(decoded).rstrip(b'=')
+        if reencoded != candidate.rstrip(b'='):
+            return raw
+
+        try:
+            decoded.decode('utf-8')
+        except UnicodeDecodeError:
+            return raw
+
+        return decoded
+
+    def _decode_python_message_body(self, raw_body):
+        """Decode the message body when needed.
+
+        raw_body: bytes or str
+        returns: bytes (decoded Base64 if it looks like Base64, otherwise raw bytes)
+        """
+        b = raw_body.encode() if isinstance(raw_body, str) else raw_body
+        return self._optional_b64_decode(b)
+
+    def _prepare_json_payload(self, text):
+        """Try to JSON-decode text into a dict; on failure return {}."""
+        try:
+            data = loads(text)
+            return data if isinstance(data, dict) else {}
+        except (JSONDecodeError, TypeError):
+            return {}
+
+    def _delete_message(self, queue_name, message):
+        """Move the message over to the new queue URL and delete it."""
+        new_q = self._new_queue(queue_name)
+        self.asynsqs(queue=queue_name).delete_message(
+            new_q, message['ReceiptHandle']
+        )
+
+    def _envelope_payload(self, payload, raw_text, message, q_url):
+        """Prepare the payload envelope.
+
+        Ensure we have a dict with 'body' and 'properties.delivery_info',
+        then stamp on SQS-specific metadata.
+
+        :param payload: The payload as an object
+        :param raw_text: Text that will be set as the payload body.
+        :param message: A kombu Message.
+        :param q_url: The SQS queue URL.
+
+        :return: Payload object.
+        """
+        # if payload wasn’t already a Kombu JSON dict, wrap it
+        if 'properties' not in payload:
+            payload = {
+                'body': raw_text,
+                'properties': {'delivery_info': {}},
+            }
+
+        props = payload.setdefault('properties', {})
+        di = props.setdefault('delivery_info', {})
+
+        # add SQS metadata
+        di.update({
+            'sqs_message': message,
+            'sqs_queue':   q_url,
+        })
+        props['delivery_tag'] = message['ReceiptHandle']
+
+        return payload
 
 
 class Transport(virtual.Transport):
@@ -940,10 +1097,19 @@ class Transport(virtual.Transport):
         transport = Transport(
             ...,
             transport_options={
-                'fetch_message_attributes': ["All"],
+                'fetch_message_attributes': ["All"],  # Get all of the MessageSystemAttributeNames (formerly AttributeNames)
+            }
+        )
+        # Preferred - A dict specifying system and custom message attributes
+        transport = Transport(
+            ...,
+            transport_options={
+                'fetch_message_attributes': {
+                    'MessageSystemAttributeNames': ["SenderId", "SentTimestamp"],
+                    'MessageAttributeNames': ['S3MessageBodyKey']
+                },
             }
         )
-
     .. _Message Attributes: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html#SQS-ReceiveMessage-request-AttributeNames
 
     """  # noqa: E501


Test patch:
diff --git a/t/unit/asynchronous/aws/sqs/test_connection.py b/t/unit/asynchronous/aws/sqs/test_connection.py
index 27a6eb77e..28258f1f6 100644
--- a/t/unit/asynchronous/aws/sqs/test_connection.py
+++ b/t/unit/asynchronous/aws/sqs/test_connection.py
@@ -4,6 +4,8 @@
 from unittest import mock
 from unittest.mock import MagicMock, Mock
 
+import pytest
+
 from kombu.asynchronous.aws.ext import AWSRequest, boto3
 from kombu.asynchronous.aws.sqs.connection import (AsyncSQSConnection,
                                                    _query_object_encode)
@@ -73,16 +75,39 @@ def assert_requests_equal(self, req1, req2):
         assert req1.params == req2.params
         assert dict(req1.headers) == dict(req2.headers)
 
-    def test_fetch_attributes_on_construction(self):
+    @pytest.mark.parametrize('input,expected', [
+        (
+            None,
+            {'message_system_attribute_names': ["ApproximateReceiveCount"], 'message_attribute_names': []}
+        ),
+        (
+            {'sys_attr_names': None},
+            {'message_system_attribute_names': ["ApproximateReceiveCount"], 'message_attribute_names': []}
+        ),
+        (
+            {'bad_key': None},
+            {'message_system_attribute_names': ["ApproximateReceiveCount"], 'message_attribute_names': []}
+        ),
+        (
+            {'bad_key': 'SQS_Val', 'msg_attr_names': 'Another_SQS_Val'},
+            {
+                'message_system_attribute_names': ["ApproximateReceiveCount"],
+                'message_attribute_names': ['Another_SQS_Val']}
+        ),
+    ])
+    def test_async_connection_sets_default_attributes_on_construction(self, input, expected):
         """Verify default fetch_message_attributes can be set at construction."""
         x = AsyncSQSConnection(
-            self.sqs_client, 'ak', 'sk', http_client=Mock(),
-            fetch_message_attributes=["AttributeOne"],
+            self.sqs_client, 0, 'sk', http_client=Mock(),
+            message_system_attribute_names=input.get('sys_attr_names') if input else None,
+            message_attribute_names=input.get('msg_attr_names') if input else None,
         )
-        assert x.fetch_message_attributes == ["AttributeOne"]
+        assert x.message_system_attribute_names == expected['message_system_attribute_names']
+        assert x.message_attribute_names == expected['message_attribute_names']
 
         # Default value for backwards compatibility
-        assert self.x.fetch_message_attributes == ["ApproximateReceiveCount"]
+        assert self.x.message_system_attribute_names == ["ApproximateReceiveCount"]
+        assert self.x.message_attribute_names == []
 
     def test_create_query_request_get(self):
         # Query Protocol GET call per
@@ -123,7 +148,7 @@ def test_create_query_request(self):
         operation_name = 'ReceiveMessage'
         params = {
             'MaxNumberOfMessages': 10,
-            'AttributeName.1': 'ApproximateReceiveCount',
+            'MessageSystemAttributeName.1': 'ApproximateReceiveCount',
             'WaitTimeSeconds': 20
         }
         queue_url = f'{SQS_URL}123456789012/celery-test'
@@ -152,7 +177,7 @@ def test_create_query_request(self):
         }
         assert prepared.body == (
             'MaxNumberOfMessages=10'
-            '&AttributeName.1=ApproximateReceiveCount'
+            '&MessageSystemAttributeName.1=ApproximateReceiveCount'
             '&WaitTimeSeconds=20'
             '&Action=ReceiveMessage'
         )
@@ -162,7 +187,7 @@ def test_create_json_request(self):
         method = 'POST'
         params = {
             'MaxNumberOfMessages': 10,
-            'AttributeNames': ['ApproximateReceiveCount'],
+            'MessageSystemAttributeNames': ['ApproximateReceiveCount'],
             'WaitTimeSeconds': 20
         }
         queue_url = f'{SQS_URL}123456789012/celery-test'
@@ -200,7 +225,7 @@ def test_create_json_request(self):
         }
         assert json.loads(prepared.body) == {
             'MaxNumberOfMessages': 10,
-            'AttributeNames': ['ApproximateReceiveCount'],
+            'MessageSystemAttributeNames': ['ApproximateReceiveCount'],
             'WaitTimeSeconds': 20,
             'QueueUrl': queue_url,
         }
@@ -220,13 +245,13 @@ def test_make_request__with_query_protocol(self):
             'WaitTimeSeconds': 20
         }
         pparams = {
-            'json': {'AttributeNames': ['ApproximateReceiveCount']},
-            'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+            'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+            'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
         }
         queue_url = f'{SQS_URL}123456789012/celery-test'
         verb = 'POST'
 
-        expect_params = {**params, 'AttributeName.1': 'ApproximateReceiveCount'}
+        expect_params = {**params, 'MessageSystemAttributeName.1': 'ApproximateReceiveCount'}
 
         self.x.make_request(operation, params, queue_url, verb, protocol_params=pparams)
         self.x._create_query_request.assert_called_with(
@@ -248,13 +273,13 @@ def test_make_request__with_json_protocol(self):
             'WaitTimeSeconds': 20
         }
         pparams = {
-            'json': {'AttributeNames': ['ApproximateReceiveCount']},
-            'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+            'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+            'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
         }
 
         queue_url = f'{SQS_URL}123456789012/celery-test'
         verb = 'POST'
-        expect_params = {**params, 'AttributeNames': ['ApproximateReceiveCount']}
+        expect_params = {**params, 'MessageSystemAttributeNames': ['ApproximateReceiveCount']}
 
         self.x.make_request(operation, params, queue_url, verb, protocol_params=pparams)
         self.x._create_json_request.assert_called_with(
@@ -327,8 +352,8 @@ def test_receive_message(self):
             'http://aws.com', callback=self.callback,
             parent=queue,
             protocol_params={
-                'json': {'AttributeNames': ['ApproximateReceiveCount']},
-                'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+                'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+                'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
             },
         )
 
@@ -350,8 +375,8 @@ def test_receive_message__with_visibility_timeout(self):
             'http://aws.com', callback=self.callback,
             parent=queue,
             protocol_params={
-                'json': {'AttributeNames': ['ApproximateReceiveCount']},
-                'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+                'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+                'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
             },
         )
 
@@ -373,8 +398,8 @@ def test_receive_message__with_wait_time_seconds(self):
             'http://aws.com', callback=self.callback,
             parent=queue,
             protocol_params={
-                'json': {'AttributeNames': ['ApproximateReceiveCount']},
-                'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+                'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+                'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
             },
         )
 
@@ -395,30 +420,75 @@ def test_receive_message__with_attributes(self):
             'http://aws.com', callback=self.callback,
             parent=queue,
             protocol_params={
-                'json': {'AttributeNames': ['foo', 'bar']},
-                'query': {'AttributeName.1': 'foo', 'AttributeName.2': 'bar'},
+                'json': {'MessageSystemAttributeNames': ['foo', 'bar']},
+                'query': {'MessageSystemAttributeName.1': 'foo', 'MessageSystemAttributeName.2': 'bar'},
             },
         )
 
-    def test_receive_message__with_fetch_attributes(self):
-        queue = Mock(name='queue')
-        self.x.fetch_message_attributes = ["DifferentAttribute1", "Another2"]
+    @pytest.mark.parametrize(
+        "msg_system_attribute_names, msg_attribute_names, expected_query, expected_json",
+        [
+            (
+                ["DifferentAttribute1", "Another2"],
+                None,
+                {"MessageSystemAttributeName.1": "DifferentAttribute1", "MessageSystemAttributeName.2": "Another2"},
+                {"MessageSystemAttributeNames": ["DifferentAttribute1", "Another2"]},
+            ),
+            (
+                ["DifferentAttribute1", "Another2"],
+                ["SQSExtraAttr"],
+                {
+                    "MessageSystemAttributeName.1": "DifferentAttribute1",
+                    "MessageSystemAttributeName.2": "Another2",
+                    "MessageAttributeNames.1": "SQSExtraAttr",
+                },
+                {
+                    "MessageSystemAttributeNames": ["DifferentAttribute1", "Another2"],
+                    "MessageAttributeNames": ["SQSExtraAttr"],
+                },
+            ),
+            (
+                None,
+                ["SQSExtraAttr"],
+                {
+                    "MessageSystemAttributeName.1": "ApproximateReceiveCount",
+                    "MessageAttributeNames.1": "SQSExtraAttr"
+                },
+                {
+                    "MessageSystemAttributeNames": ["ApproximateReceiveCount"],
+                    "MessageAttributeNames": ["SQSExtraAttr"]
+                },
+            ),
+        ]
+    )
+    def test_receive_message__with_fetch_attributes(
+        self, msg_system_attribute_names, msg_attribute_names, expected_query, expected_json
+    ):
+        queue = Mock(name="queue")
+
+        # only overwrite if provided
+        if msg_system_attribute_names is not None:
+            self.x.message_system_attribute_names = msg_system_attribute_names
+        if msg_attribute_names is not None:
+            self.x.message_attribute_names = msg_attribute_names
+
         self.x.receive_message(
             queue,
             self.x.get_queue_url('queue'),
             4,
             callback=self.callback,
         )
+
         self.x.get_list.assert_called_with(
-            'ReceiveMessage', {
-                'MaxNumberOfMessages': 4,
-            },
-            [('Message', AsyncMessage)],
-            'http://aws.com', callback=self.callback,
+            "ReceiveMessage",
+            {"MaxNumberOfMessages": 4},
+            [("Message", AsyncMessage)],
+            "http://aws.com",
+            callback=self.callback,
             parent=queue,
             protocol_params={
-                'json': {'AttributeNames': ['DifferentAttribute1', 'Another2']},
-                'query': {'AttributeName.1': 'DifferentAttribute1', 'AttributeName.2': 'Another2'},
+                "query": expected_query,
+                "json": expected_json,
             },
         )
 
@@ -629,6 +699,11 @@ def test_query_protocol_encoding(self):
             'AttributeName.2': 'B',
         }
 
+        assert _query_object_encode({'MessageSystemAttributeName': ['A', 'B']}) == {
+            'MessageSystemAttributeName.1': 'A',
+            'MessageSystemAttributeName.2': 'B',
+        }
+
         assert _query_object_encode({'Grandparent': [{'Parent': {'Child': '1', 'Sibling': 2}}]}) == {
             'Grandparent.1.Parent.Child': '1',
             'Grandparent.1.Parent.Sibling': '2',
diff --git a/t/unit/transport/test_SQS.py b/t/unit/transport/test_SQS.py
index 88a6a5f51..4b1d66ef4 100644
--- a/t/unit/transport/test_SQS.py
+++ b/t/unit/transport/test_SQS.py
@@ -118,13 +118,24 @@ def send_message(self, QueueUrl=None, MessageBody=None,
                                    'MessageAttributes': MessageAttributes})
                 break
 
-    def receive_message(self, QueueUrl=None, MaxNumberOfMessages=1,
-                        WaitTimeSeconds=10):
+    def receive_message(
+        self,
+        QueueUrl=None,
+        MaxNumberOfMessages=1,
+        WaitTimeSeconds=10,
+        MessageAttributeNames=None,
+        MessageSystemAttributeNames=None
+    ):
         self._receive_messages_calls += 1
         for q in self._queues.values():
             if q.url == QueueUrl:
                 msgs = q.messages[:MaxNumberOfMessages]
                 q.messages = q.messages[MaxNumberOfMessages:]
+                for msg in msgs:
+                    msg['Attributes'] = {
+                        "SenderId": "AIDAEXAMPLE123ABC",
+                        "SentTimestamp": "1638368280000"
+                    }
                 return {'Messages': msgs} if msgs else {}
 
     def get_queue_attributes(self, QueueUrl=None, AttributeNames=None):
@@ -340,7 +351,7 @@ def test_get_from_sqs(self):
         result = self.channel._get(self.queue_name)
         assert 'body' in result.keys()
 
-        # Now test getting many messages
+        # Now test is getting many messages
         for i in range(3):
             message = f'message: {i}'
             self.producer.publish(message)
@@ -356,13 +367,123 @@ def test_get_bulk_raises_empty(self):
         with pytest.raises(Empty):
             self.channel._get_bulk(self.queue_name)
 
-    def test_optional_b64_decode(self):
-        raw = b'{"id": "4cc7438e-afd4-4f8f-a2f3-f46567e7ca77","task": "celery.task.PingTask",' \
-              b'"args": [],"kwargs": {},"retries": 0,"eta": "2009-11-17T12:30:56.527191"}'
-        b64_enc = base64.b64encode(raw)
-        assert self.channel._optional_b64_decode(b64_enc) == raw
-        assert self.channel._optional_b64_decode(raw) == raw
-        assert self.channel._optional_b64_decode(b"test123") == b"test123"
+    @pytest.mark.parametrize(
+        "raw, expected",
+        [
+            # valid Base64 == decodes
+            (b"VHJ1ZQ==", b"True"),
+            (b"YWJjZA==", b"abcd"),
+            # with surrounding whitespace/newline → still decodes
+            (b"\nVHJ1ZQ==  ", b"True"),
+
+            # “plain text” that happens to be length of 4 == no decode
+            (b"True", b"True"),
+            (b"abcd", b"abcd"),
+
+            # wrong length == no decode
+            (b"abc", b"abc"),
+            (b"abcde", b"abcde"),
+
+            # invalid chars == no decode
+            (b"@@@@", b"@@@@"),
+
+            # other
+            (b'9', b'9'),
+            (b'8.15', b'8.15'),
+            (b'[1, 2, 3]', b'[1, 2, 3]'),
+            (b'1234', b'1234'),
+
+            # json/dict (encoded and raw)
+            (
+                b'{"id": "4cc7438e-afd4-4f8f-a2f3-f46567e7ca77","task": "celery.task.PingTask",'
+                b'"args": [],"kwargs": {},"retries": 0,"eta": "2009-11-17T12:30:56.527191"}',
+                b'{"id": "4cc7438e-afd4-4f8f-a2f3-f46567e7ca77","task": "celery.task.PingTask",'
+                b'"args": [],"kwargs": {},"retries": 0,"eta": "2009-11-17T12:30:56.527191"}'
+            ),
+            (
+                base64.b64encode(
+                    b'{"id": "4cc7438e-afd4-4f8f-a2f3-f46567e7ca77","task": "celery.task.PingTask",'
+                    b'"args": [],"kwargs": {},"retries": 0,"eta": "2009-11-17T12:30:56.527191"}'),
+                b'{"id": "4cc7438e-afd4-4f8f-a2f3-f46567e7ca77","task": "celery.task.PingTask",'
+                b'"args": [],"kwargs": {},"retries": 0,"eta": "2009-11-17T12:30:56.527191"}'
+            )
+
+        ],
+    )
+    def test_optional_b64_decode(self, raw, expected):
+        result = self.channel._optional_b64_decode(raw)
+        assert result == expected
+
+    @pytest.mark.parametrize(
+        "text, expected",
+        [
+            ('{"a":1}', {"a": 1}),
+            ("[1,2,3]", {}),
+            ("not json", {}),
+            ('{"x":"y"}', {"x": "y"}),
+            ("8", {}),
+        ],
+    )
+    def test_prepare_json_payload(self, text, expected):
+        result = self.channel._prepare_json_payload(text)
+        assert result == expected
+
+    @pytest.mark.parametrize(
+        "queue_name, message, new_q_url",
+        [
+            ("q1", {"ReceiptHandle": "rh1"}, "new-url-1"),
+            ("another", {"ReceiptHandle": "handle-2"}, "new-url-2"),
+        ],
+    )
+    def test_delete_message(self, queue_name, message, new_q_url, monkeypatch):
+        monkeypatch.setattr(self.channel, "_new_queue", lambda q: new_q_url)
+        fake_sqs = Mock()
+        monkeypatch.setattr(self.channel, "asynsqs", lambda queue: fake_sqs)
+
+        self.channel._delete_message(queue_name, message)
+
+        fake_sqs.delete_message.assert_called_once_with(
+            new_q_url,
+            message["ReceiptHandle"]
+        )
+
+    @pytest.mark.parametrize(
+        "initial_payload, raw_text, message, q_url, expect_body",
+        [
+            # No 'properties'
+            (
+                {},
+                "raw string",
+                {"ReceiptHandle": "RH"},
+                "http://queue.url",
+                True,
+            ),
+            # Existing 'properties'
+            (
+                {"properties": {"delivery_info": {"foo": "bar"}}},
+                "ignored",
+                {"ReceiptHandle": "TAG"},
+                "https://q.url",
+                False,
+            ),
+        ],
+    )
+    def test_envelope_payload(self, initial_payload, raw_text, message, q_url, expect_body):
+        payload = initial_payload.copy()
+        result = self.channel._envelope_payload(payload, raw_text, message, q_url)
+
+        if expect_body:
+            assert result["body"] == raw_text
+        else:
+            assert "body" not in result
+
+        di = result["properties"]["delivery_info"]
+        assert di["sqs_message"] is message
+        assert di["sqs_queue"] == q_url
+        if not expect_body:
+            assert di.get("foo") == "bar"
+
+        assert result["properties"]["delivery_tag"] == message["ReceiptHandle"]
 
     def test_messages_to_python(self):
         from kombu.asynchronous.aws.sqs.message import Message
@@ -412,6 +533,51 @@ def test_messages_to_python(self):
         for p in json_payloads:
             assert 'properties' in p
 
+    @pytest.mark.parametrize('body', [
+        '',
+        'True',
+        'False',
+        '[]',
+        '{}',
+        '1',
+        '1.0',
+        'This is the body as a string'
+    ])
+    def test_messages_to_python_body_as_a_string(self, body):
+        from kombu.asynchronous.aws.sqs.message import Message
+        q_url = self.channel._new_queue(self.queue_name)
+        msg_attributes = {
+            'S3MessageBodyKey': '(the-test-bucket-name)the-test-key-body',
+            'python_test_attr': 'python_test_attr_value'
+        }
+        attributes = {"SenderId": "AIDAEXAMPLE123ABC", "SentTimestamp": "1638368280000"}
+        self.sqs_conn_mock.send_message(
+            q_url,
+            MessageBody=body,
+            MessageAttributes=msg_attributes
+        )
+
+        received_messages = []
+        for m in self.sqs_conn_mock.receive_message(
+            QueueUrl=q_url,
+            MaxNumberOfMessages=1
+        )['Messages']:
+            m['Body'] = Message(body=m['Body']).decode()
+            received_messages.append(m)
+
+        formatted_messages = self.channel._messages_to_python(
+            received_messages,
+            self.queue_name
+        )
+
+        for msg in formatted_messages:
+            delivery_info = msg['properties']['delivery_info']
+
+            assert msg['body'] == body
+            assert delivery_info['sqs_message']['Body'] == body
+            assert delivery_info['sqs_message']['Attributes'] == attributes
+            assert delivery_info['sqs_message']['MessageAttributes'] == msg_attributes
+
     def test_put_and_get(self):
         message = 'my test message'
         self.producer.publish(message)
@@ -492,14 +658,103 @@ def test_get_async(self):
             self.channel.sqs().get_queue_url(self.queue_name).url
         assert get_list_kwargs['parent'] == self.queue_name
         assert get_list_kwargs['protocol_params'] == {
-            'json': {'AttributeNames': ['ApproximateReceiveCount']},
-            'query': {'AttributeName.1': 'ApproximateReceiveCount'},
+            'json': {'MessageSystemAttributeNames': ['ApproximateReceiveCount']},
+            'query': {'MessageSystemAttributeName.1': 'ApproximateReceiveCount'},
         }
 
-    def test_fetch_message_attributes(self):
-        self.connection.transport_options['fetch_message_attributes'] = ["Attribute1", "Attribute2"]
+    @pytest.mark.parametrize('fetch_attributes,expected', [
+        # as a list for backwards compatibility
+        (
+            None,
+            {'message_system_attribute_names': ['ApproximateReceiveCount'], 'message_attribute_names': []}
+        ),
+        (
+            'incorrect_value',
+            {'message_system_attribute_names': ['ApproximateReceiveCount'], 'message_attribute_names': []}
+        ),
+        (
+            [],
+            {'message_system_attribute_names': ['ApproximateReceiveCount'], 'message_attribute_names': []}
+        ),
+        (
+            ['ALL'],
+            {'message_system_attribute_names': ['ALL'], 'message_attribute_names': []}
+        ),
+        (
+            ['SenderId', 'SentTimestamp'],
+            {
+                'message_system_attribute_names': ['SenderId', 'ApproximateReceiveCount', 'SentTimestamp'],
+                'message_attribute_names': []
+            }
+        ),
+        # As a dict using only System Attributes
+        (
+            {'MessageSystemAttributeNames': ['All']},
+            {
+                'message_system_attribute_names': ['ALL'],
+                'message_attribute_names': []
+            }
+        ),
+        (
+            {'MessageSystemAttributeNames': ['SenderId', 'SentTimestamp']},
+            {
+                'message_system_attribute_names': ['SenderId', 'ApproximateReceiveCount', 'SentTimestamp'],
+                'message_attribute_names': []
+            }
+        ),
+        (
+            {'MessageSystemAttributeNames_BAD_KEY': ['That', 'This']},
+            {
+                'message_system_attribute_names': ['ApproximateReceiveCount'],
+                'message_attribute_names': []
+            }
+        ),
+        # As a dict using only Message Attributes
+        (
+            {'MessageAttributeNames': ['All']},
+            {
+                'message_system_attribute_names': ['ApproximateReceiveCount'],
+                'message_attribute_names': ["ALL"]
+            }
+        ),
+        (
+            {'MessageAttributeNames': ['CustomProp', 'CustomProp2']},
+            {
+                'message_system_attribute_names': ['ApproximateReceiveCount'],
+                'message_attribute_names': ['CustomProp', 'CustomProp2']
+            }
+        ),
+        (
+            {'MessageAttributeNames_BAD_KEY': ['That', 'This']},
+            {
+                'message_system_attribute_names': ['ApproximateReceiveCount'],
+                'message_attribute_names': []
+            }
+        ),
+        # all together now...
+        (
+            {
+                'MessageSystemAttributeNames': ['SenderId', 'SentTimestamp'],
+                'MessageAttributeNames': ['CustomProp', 'CustomProp2']},
+            {
+                'message_system_attribute_names': ['SenderId', 'SentTimestamp', 'ApproximateReceiveCount'],
+                'message_attribute_names': ['CustomProp', 'CustomProp2']
+            }
+        ),
+    ])
+    @pytest.mark.usefixtures('hub')
+    def test_fetch_message_attributes(self, fetch_attributes, expected):
+        self.connection.transport_options['fetch_message_attributes'] = fetch_attributes  # type: ignore
+        async_sqs_conn = self.channel.asynsqs(self.queue_name)
+        assert async_sqs_conn.message_system_attribute_names == sorted(expected['message_system_attribute_names'])
+        assert async_sqs_conn.message_attribute_names == expected['message_attribute_names']
+
+    @pytest.mark.usefixtures('hub')
+    def test_fetch_message_attributes_does_not_exist(self):
+        self.connection.transport_options = {}
         async_sqs_conn = self.channel.asynsqs(self.queue_name)
-        assert async_sqs_conn.fetch_message_attributes == ['Attribute1', 'Attribute2']
+        assert async_sqs_conn.message_system_attribute_names == ['ApproximateReceiveCount']
+        assert async_sqs_conn.message_attribute_names == []
 
     def test_drain_events_with_empty_list(self):
         def mock_can_consume():