تكوين AWS SES للبريد الإلكتروني الصادر، المرتد، والوارد

أردت مشاركة الإعداد الذي توصلت إليه لاستخدام AWS SES للبريد الإلكتروني الصادر، والبريد المرتد، والبريد الوارد ***. هناك بالتأكيد بعض الفروق الدقيقة في خدمة SES، وقد استغرق الأمر قدرًا كبيرًا من التجربة والخطأ لفهم كيفية عملها بالضبط. هذا أشبه بتفريغ للأفكار منه دليل خطوة بخطوة. يجب أن يكون غير ضروري، ولكن استخدمه على مسؤوليتك الخاصة. وبكل الوسائل، اقرأ دائمًا وفهم أي رمز كتبه الآخرون تقوم بتطبيقه.

الخلفية:

أعمل على نشر Discourse في AWS والاستفادة من جميع خدماتهم قدر الإمكان لضمان الموثوقية والتكرار. كمطور، أنا أكثر ارتياحًا مع سطر الأوامر والتعليمات البرمجية، وأردت استخدام أتمتة البنية التحتية كرمز (IaC). يتم نشر بيئتي بالكامل باستخدام Terraform، ولكنني حاولت النقر عبر وحدة التحكم على الويب وترتيب الأمور بأفضل ما يمكنني. تعتبر مستندات IAM والسياسات خارج نطاق هذا، ولكنني أعتقد أنني حددت الأماكن التي تكون فيها الأشياء مطلوبة.

يبدو تشغيل مثيل Postfix مبالغًا فيه لتطبيق واحد. استخدام صندوق بريد POP3 هو شيء من التسعينيات. لذلك تعمقت في متاهة AWS.

لقد وجدت بعض المنشورات المفيدة للغاية التي ساعدتني في مهمتي:

ساعدت حاوية mail-receiver أيضًا في فهم كيفية معالجة Discourse للرسائل:

في البداية، توقعت أن يتعامل نقطة نهاية الويب هوك (webhook) الخاصة بـ AWS مع الرسائل الواردة، ولكن بعد مراجعة التعليمات البرمجية أدركت أنها لن تفعل ذلك. لقد بنيت تعليمات برمجية المستلم lambda الخاصة بي على المثال الممتاز بواسطة @dltj. اخترت استخدام SNS لتسليم الرسائل بدلاً من S3.

المتطلبات الأساسية

  • حساب AWS
  • معرفة عملية بنظام أسماء النطاقات (DNS) وأنواع السجلات المتعلقة بالبريد الإلكتروني
  • نطاق (أو نطاق فرعي) يمكنك إجراء تغييرات فيه

ملاحظات

  • يجب إنشاء كل ما هو موثق في نفس منطقة AWS
  • النص الغامق المائل مثل هذا هي قيم خاصة بتطبيقك
  • النص المائل هو أسماء المتغيرات أو القيم الثابتة أو عناصر واجهة المستخدم

خطوات

  1. قم بإنشاء هوية نطاق خدمة البريد الإلكتروني البسيط (SES)، your.domain، في إحدى مناطق AWS التي تدعم استقبال البريد الإلكتروني

  2. تحقق من هوية النطاق

  3. قم بإنشاء موضوع خدمة الإشعارات البسيط (SNS)، feedback-sns-topic، لإشعارات الملاحظات

  4. قم بتكوين هوية النطاق your.domain
    أ. تمكين إعادة توجيه ملاحظات البريد الإلكتروني
    ب. تكوين إشعارات الملاحظات المرتدة والشكاوى (وليس التسليم) لاستخدام موضوع SNS feedback-sns-topic

  5. قم بإنشاء اشتراك في موضوع SNS feedback-sns-topic
    أ. البروتوكول هو HTTPS (ألا تزال تستخدم HTTP؟)
    ب. قم بتعيين نقطة النهاية إلى https://your.domain/webhooks/aws (انظر منشور VERP)
    ج. حدد تمكين تسليم الرسائل الخام

  6. قم بإنشاء موضوع SNS آخر، incoming-sns-topic، للبريد الإلكتروني الوارد

  7. قم بإنشاء مجموعة قواعد استقبال البريد الإلكتروني من SES، inbound-mail-set، إذا لم تكن هناك مجموعة قواعد نشطة موجودة. إذا كان الأمر كذلك، فاستخدمها لأنه لا يمكن أن يكون هناك سوى مجموعة قواعد نشطة واحدة

  8. قم بإنشاء قاعدة استقبال في مجموعة قواعد الاستقبال inbound-mail-set
    أ. قم بتعيين شرط المستلم إلى your.domain
    ب. أضف إجراء للنشر إلى موضوع SNS incoming-sns-topic، مع ترميز Base64

  9. قم بإنشاء مفتاح API في مثيل Discourse الخاص بك للمستخدم system، مع منح إجراء receive email على المورد email

  10. قم بإنشاء سر في Secret Manager، email-handler-secret، مع المفاتيح التالية وقيمها المقابلة:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - من الخطوة 9
    • api_username - system، ما لم تستخدم شيئًا مختلفًا في الخطوة 9
  11. قم بإنشاء طبقة Lambda، lambda-receiver-layer، لبيئة التشغيل python3.10 تحتوي على مكتبات requests و aws-lambda-powertools

  12. قم بإنشاء دالة Lambda، email-receiver-lambda، لبيئة التشغيل python3.10 مع تعليمات برمجية المستلم:

# Copyright (c) 2023 Derek J. Lambert
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import json
import os
from typing import TypedDict

import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities import parameters
from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.sns_event import SNSEvent, SNSEventRecord
from aws_lambda_powertools.utilities.typing import LambdaContext


class Secret(TypedDict):
    api_endpoint: str
    api_username: str
    api_key: str


service = os.getenv('AWS_LAMBDA_FUNCTION_NAME')
logger  = Logger(log_uncaught_exceptions=True, service=service)

try:
    SECRET_NAME = os.environ['SECRET_NAME']
except KeyError as e:
    raise RuntimeError(f'Missing {e} environment variable')

AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)
EXTENSION_ENDPOINT = f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}'


def get_secret() -> Secret:
    return parameters.get_secret(SECRET_NAME, transform='json')


def handle_record(record: SNSEventRecord):
    sns         = record.sns
    sns_message = json.loads(sns.message)

    try:
        message_type    = sns_message['notificationType']
        message_mail    = sns_message['mail']
        message_content = sns_message['content']
        message_receipt = sns_message['receipt']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from message')

    try:
        receipt_action = message_receipt['action']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from receipt')

    try:
        action_encoding = receipt_action['encoding']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from action')

    try:
        mail_source      = message_mail['source']
        mail_destination = ','.join(message_mail['destination'])
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from mail')

    logger.info(f'Processing SNS {message_type} {sns.get_type} record with MessageId {sns.message_id} from {mail_source} to {mail_destination}')

    # 'email' is deprecated, but just in case something is configured incorrectly
    body_key = 'email_encoded' if action_encoding == 'BASE64' else 'email'

    request_body = {
        body_key: message_content
    }

    secret  = get_secret()
    headers = {
        'Api-Username': secret['api_username'],
        'Api-Key':      secret['api_key'],
    }

    response = requests.post(url=secret['api_endpoint'], headers=headers, json=request_body)

    logger.info(response.text)
    response.raise_for_status()


@event_source(data_class=SNSEvent)
@logger.inject_lambda_context
def lambda_handler(event: SNSEvent, context: LambdaContext):
    for record in event.records:
        handle_record(record)
  1. قم بتكوين دالة Lambda email-receiver-lambda:
    أ. أضف الطبقة lambda-receiver-layer
    ب. أضف طبقة خاصة بالمنطقة لـ AWS Parameter Store
    ج. أضف متغير البيئة SECRET_NAME بالقيمة email-handler-secret
    د. إذا كنت ترغب في تسجيل تفاصيل إضافية، أضف متغير البيئة POWERTOOLS_LOGGER_LOG_EVENT بالقيمة true

  2. امنح دالة Lambda email-receiver-lambda إذن IAM secretsmanager:GetSecretValue للسر email-handler-secret

  3. قم بإنشاء اشتراك في موضوع SNS incoming-sns-topic
    أ. البروتوكول هو AWS Lambda
    ب. قم بتعيين نقطة النهاية إلى ARN الخاص بـ email-receiver-lambda

  4. ستكون هناك حاجة إلى أذونات IAM لاشتراك SNS في موضوع incoming-sns-topic لاستدعاء email-receiver-lambda، ولكن أعتقد أن هذا سيتم تلقائيًا عند تكوينه عبر وحدة التحكم

لأغراض التصحيح، أو الإزعاج الذاتي العام، يمكنك إضافة اشتراك بريد إلكتروني إلى أي من موضوعي SNS لمراقبة الإشعارات.

لقد قمت بتدوين هذا في جلستين، ولكن أعتقد أنه كل شيء. يمكنني محاولة الإجابة على الأسئلة العامة حسب الوقت المتاح.

9 إعجابات

تحديثات للمنشور الأصلي

الإصدار 2

# Copyright (c) 2023 Derek J. Lambert
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
from enum import Enum
from typing import Literal, Optional

import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.logging import utils
from aws_lambda_powertools.utilities.parser import BaseModel, event_parser
from aws_lambda_powertools.utilities.parser.models import SnsModel, SesMessage, SnsRecordModel, SesMail, SesReceipt, SesMailCommonHeaders
from aws_lambda_powertools.utilities.typing import LambdaContext


class Secret(BaseModel):
    api_endpoint: str
    api_username: str
    api_key:      str


class SnsSesActionEncoding(str, Enum):
    BASE64 = 'BASE64'
    UTF8   = 'UTF8'


class SnsSesReceiptAction(BaseModel):
    type:     Literal['SNS']
    encoding: SnsSesActionEncoding
    topicArn: str


class SnsSesReceipt(SesReceipt):
    action: SnsSesReceiptAction


class SnsSesMailCommonHeaders(SesMailCommonHeaders):
    returnPath: Optional[str]


class SnsSesMail(SesMail):
    commonHeaders: SnsSesMailCommonHeaders


class SnsSesMessage(SesMessage):
    notificationType: str  # TODO: Are there other values besides 'Received'?
    content:          str
    mail:             SnsSesMail
    receipt:          SnsSesReceipt


try:
    SECRET_NAME       = os.environ['SECRET_NAME']
    AWS_SESSION_TOKEN = os.environ['AWS_SESSION_TOKEN']
except KeyError as e:
    raise RuntimeError(f'Missing {e} environment variable')

AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)

logger = Logger(service=os.getenv('AWS_LAMBDA_FUNCTION_NAME'), log_uncaught_exceptions=True, use_rfc3339=True)

utils.copy_config_to_registered_loggers(source_logger=logger)


def get_secret() -> Secret:
    # AWS Parameters and Secrets Lambda Extension
    # https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html

    response = requests.get(
        url=f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}',
        headers={
            'X-Aws-Parameters-Secrets-Token': AWS_SESSION_TOKEN
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    return Secret.parse_raw(response.json()['SecretString'])


def handle_record(record: SnsRecordModel):
    sns_record       = record.Sns
    sns_ses_message  = SnsSesMessage.parse_raw(record.Sns.Message)
    mail_destination = ','.join(sns_ses_message.mail.destination)

    logger.info(f'Processing SNS {sns_ses_message.notificationType} notification record with MessageId {sns_record.MessageId} from {sns_ses_message.mail.source} to {mail_destination}')

    # 'email' is deprecated, but just in case something is configured incorrectly
    body_key = 'email_encoded' if sns_ses_message.receipt.action.encoding is SnsSesActionEncoding.BASE64 else 'email'
    secret   = get_secret()

    response = requests.post(
        url=secret.api_endpoint,
        headers={
            'Api-Username': secret.api_username,
            'Api-Key':      secret.api_key,
        },
        json={
            body_key: sns_ses_message.content
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    logger.info(f'Endpoint response: {response.text}')


@event_parser(model=SnsModel)
@logger.inject_lambda_context
def lambda_handler(event: SnsModel, context: LambdaContext):
    for record in event.Records:
        handle_record(record)
إعجاب واحد (1)

شكراً لهذا الدليل @dlambert :smiley:

لقد كنت أقوم بعمل رائع، حتى وصلت إلى الخطوة 11:

أين / كيف أقوم بإنشاء هذه؟ :thinking:

هل تمكنت من تشغيله؟

لقد علقت أيضًا عند الخطوة 11. لا أعرف ماذا أفعل بعد ذلك. هل يمكن لأحد المساعدة؟

إعجاب واحد (1)

لا، آسف، لقد استسلمت وقمت بتعطيل جميع وظائف الرد عبر البريد الإلكتروني، واستخدمنا SES للبريد الصادر البسيط فقط :cry:

لقد حاولت اتباع جميع الخطوات للإعداد ولكن في النهاية، أحصل على هذا الخطأ في Cloudwatch هل يمكن لأي شخص المساعدة في هذا؟

[ERROR] HTTPError: 403 Client Error: Forbidden for url: https://forum.siteurl.com/admin/email/handle_mail
Traceback (most recent call last):
  File "/opt/python/aws_lambda_powertools/middleware_factory/factory.py", line 135, in wrapper
    response = middleware()
  File "/opt/python/aws_lambda_powertools/utilities/data_classes/event_source.py", line 39, in event_source
    return handler(data_class(event), context)
  File "/opt/python/aws_lambda_powertools/logging/logger.py", line 453, in decorate
    return lambda_handler(event, context, *args, **kwargs)
  File "/var/task/lambda_function.py", line 107, in lambda_handler
    handle_record(record)
  File "/var/task/lambda_function.py", line 100, in handle_record
    response.raise_for_status()
  File "/opt/python/requests/models.py", line 1021, in raise_for_status
    raise HTTPError(http_error_msg, response=self)

حسنًا، كان ذلك بسبب تعطيل Cloudflare مما حل المشكلة. ربما لاحقًا سأكتب هنا كيف جعلت الأمر يعمل باتباع جميع الخطوات. :slight_smile:

إعجاب واحد (1)

هذا ما فعلته.

قمت بتثبيت Python 3.10 على جهاز الكمبيوتر الخاص بي، بعد الخطوة 10.

ثم قمت بتشغيل هذه الأوامر.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

pip install requests aws-lambda-powertools -t ./python

touch ./python/__init__.py

حيث كانت لدي مشاكل مع urllib3

هذه هي الخطوات الإضافية حتى لا تحصل على هذا الخطأ.

في دليل lambda-receiver-layer الخاص بك، قم بإنشاء هذا الملف requirements.txt

أضف السطر التالي في هذا الملف requirements.txt:

urllib3<2

ثم قم بتشغيل الأمر التالي

pip install -r requirements.txt -t layer

الآن سيتم إنشاء مجلد آخر داخل دليل lambda-receiver-layer يسمى layer

انسخ كل محتويات layer إلى مجلد python

الآن، انقر بزر الماوس الأيمن على مجلد Python وانقر على “Compress to ZIP” (الضغط إلى ZIP) وأعد تسمية هذا الملف المضغوط إلى lambda-receiver-layer

الآن، عد إلى AWS Management Console، وانتقل إلى خدمة Lambda، وانتقل إلى “Layers” (الطبقات). انقر على “Create Layer” (إنشاء طبقة)، ضع هذا في الاسم lambda-receiver-layer وقم بتحميل الأرشيف المضغوط الذي أنشأته. في وقت التشغيل أضف Python 3.10 ثم انقر على إنشاء.

الآن اتبع مرة أخرى من الخطوة 12 من المنشور الأصلي.

أنا عالق في الخطوة 11، أين ألصق كود بايثون؟

أحتاج إلى مساعدة عاجلة لإصلاح مشاكل ارتداد SMTP الخاصة بي في عدة حالات، لقد نشرت وظيفة في Marketplace Fix AWS SNS Bounce

لقد علقت عند النقطة 14، هل يمكن لأي شخص توضيح ما يجب علي فعله؟

إذا كان أي شخص في عام 2025 يتساءل عما إذا كان الإصدار 2 لا يزال يعمل، يمكنني التأكيد على أنه يعمل.\n\nبعض المشاكل التي قد تواجهها:\n- تأكد من أنك تقوم بتكوين مجموعات القواعد في Configuration > Email receiving في وحدة التحكم، وليس مجموعات القواعد في Mail Manager > Rule sets. تكلف أشياء Mail Manager الكثير من المال، خاصة مع نقاط النهاية الواردة هذه.\n- تحتاج إلى سجل MX في نظام أسماء النطاقات الخاص بك لاستقبال رسائل البريد الإلكتروني للرد لإرسالها إلى AWS SES. إذا كان لديك بالفعل سجل MX لنطاقك الجذر لأشياء البريد الإلكتروني العامة (أي، استخدام رسائل البريد الإلكتروني الخاصة بـ Google Workspace لأشياء العمل العامة لعنوان مثل contact@example.com)، فستحتاج إلى استخدام نطاق فرعي لردودك. في حالتي، قمت بإنشاء سجل MX على reply.example.com لإرسال الردود إلى inbound-smtp.<REGION>.amazonaws.com. انظر هذه الوثائق لمزيد من التفاصيل.\n- يمكنك استخدام CloudWatch لمعرفة كيفية عمل الأشياء. إذا رأيت خطأ حيث لا يتم تحميل مكتبة/وحدة معينة، فمن المحتمل أنك قمت بتكوين طبقة Lambda الخاصة بك بشكل غير صحيح أو لم تقم بتوصيلها بالوظيفة. تحقق من أن ملف ZIP الذي تقوم بتحميله يحتوي على بنية الدليل الصحيحة التي تبدو مثل python/lib/python3.10/site-packages/؛ انظر هذه الوثائق. أوصي بالبحث عن بعض الدروس التعليمية عبر الإنترنت حول إنشاء طبقة Lambda.\n\nلا يزال الكود يعمل مع ARM64 - تحتاج فقط إلى تكوين طبقة Lambda الخاصة بك بالبنية الصحيحة عن طريق تنزيل مكتبات Python المستندة إلى ARM.\n\nعند الانتهاء من كل شيء، يجب أن ترى رسائل البريد الإلكتروني المستلمة في سجلات المسؤول الخاصة بك.

إعجاب واحد (1)