أعتقد أنني سأشارك الإعداد الذي توصلت إليه لاستخدام AWS SES للإيميلات الصادرة والارتدادية والواردة. هناك بالتأكيد بعض التفاصيل الدقيقة في خدمة SES، وقد استغرق الأمر قدرًا كبيرًا من التجربة والخطأ لفهم كيفية عملها بالضبط. هذا أقرب إلى تفريغ ذهني منه إلى خطوات متسلسلة يجب اتباعها حرفيًا. من المفترض أن يكون هذا غير ضروري، لكن استخدمه على مسؤوليتك الخاصة. وبكل تأكيد اقرأ دائمًا وافهم أي كود يكتبه آخرون قبل تنفيذه.
الخلفية:
أعمل على نشر Discourse في AWS والاستفادة من جميع خدماتهم قدر الإمكان لضمان الموثوقية والتكرار. بصفتي مطورًا، أنا أكثر راحة مع سطر الأوامر والكود، وأردت استخدام أتمتة IaC. يتم نشر بيئتي بأكملها باستخدام Terraform، لكنني حاولت التنقل عبر وحدة تحكم الويب ومواءمة الأمور بأفضل ما أستطيع. IAM ووثائق الصلاحيات خارج نطاق هذا الموضوع، لكنني أعتقد أنني حددت الأماكن التي تكون فيها ضرورية.
تشغيل نسخة من Postfix يبدو مبالغة لتطبيق واحد. استخدام صندوق بريد POP3 يبدو عتيقًا جدًا من التسعينيات. لذا غصت في متاهة AWS.
لقد وجدت بعض المنشورات المفيدة جدًا التي ساعدتني في رحلتي
في البداية، توقعت أن يتولى نقطة نهاية webhook الخاصة بـ AWS معالجة الرسائل الواردة، لكن بعد مراجعة الكود أدركت أنها لن تفعل ذلك. استندت كود مستقبل lambda الخاص بي إلى المثال الرائع من @dltj. اخترت استخدام SNS لتوصيل الرسائل بدلاً من S3.
المتطلبات المسبقة
حساب AWS
معرفة عملية بأنواع DNS وسجلات البريد الإلكتروني
نطاق (أو نطاق فرعي) يمكنك إجراء تغييرات عليه
ملاحظات
يجب إنشاء كل ما هو موثق في نفس منطقة AWS
النص الغامق والمائل مثل هذا هو قيم محددة لتنفيذك
النص المائل هي أسماء متغيرات، قيم ثابتة، أو عناصر واجهة المستخدم
الخطوات
إنشاء هوية نطاق لخدمة البريد الإلكتروني البسيطة (SES)، your.domain، في إحدى مناطق AWS التي تدعم استلام البريد الإلكتروني
إنشاء موضوع لخدمة الإشعارات البسيطة (SNS)، feedback-sns-topic، لإشعارات الملاحظات
أ. أضف ARN لموضوع feedback-sns-topic إلى إعداد aws_sns_topic_arn_allowlist الخاص بك.
تكوين هوية النطاق your.domain
أ. تفعيل إعادة توجيه ملاحظات البريد الإلكتروني
ب. تكوين إشعارات الملاحظات للارتداد والشكاوى (وليس التسليم) لاستخدام موضوع SNS feedback-sns-topic
إنشاء اشتراك في موضوع SNS feedback-sns-topic
أ. البروتوكول هو HTTPS (لست لا تزال تستخدم HTTP، أليس كذلك؟)
ب. اضبط نقطة النهاية على https://your.domain/webhooks/aws (انظر منشور VERP)
ج. تأكد من أن توصيل الرسالة الخاممعطل
إنشاء موضوع SNS آخر، incoming-sns-topic، للبريد الإلكتروني الوارد
إنشاء مجموعة قواعد استلام البريد الإلكتروني لـ SES، inbound-mail-set، إذا لم تكن هناك مجموعة قواعد نشطة بالفعل. إذا كانت موجودة، استخدمها لأنه يمكن أن تكون هناك مجموعة قواعد نشطة واحدة فقط
إنشاء قاعدة استلام في مجموعة قواعد الاستلام inbound-mail-set
أ. اضبط شرط المستلم على your.domain
ب. أضف إجراء للنشر في موضوع SNS incoming-sns-topic، بتشفير Base64
إنشاء مفتاح API في مثيل Discourse الخاص بالمستخدم system، ومنح إجراء receive email على مورد email
إنشاء سر في Secret Manager، email-handler-secret، بالمفاتيح التالية وقيمها المقابلة:
api_username - system، ما لم تستخدم شيئًا مختلفًا في الخطوة 9
إنشاء طبقة Lambda، lambda-receiver-layer، لتشغيل python3.10 تحتوي على مكتبات requests وaws-lambda-powertools
إنشاء دالة lambda، email-receiver-lambda، لتشغيل python3.10 مع كود المستقبل:
# Copyright (c) 2023 Derek J. Lambert
#
# يُمنح بهذا الإذن، مجانًا، لأي شخص يحصل على نسخة
# من هذا البرنامج والوثائق المرتبطة به ("البرنامج")، التعامل
# مع البرنامج دون قيود، بما في ذلك دون تقييد الحقوق
# في الاستخدام، والنسخ، والتعديل، والدمج، والنشر، والتوزيع، والترخيص دون تقييد، و/أو بيع
# نسخ من البرنامج، والسماح للأشخاص الذين يُزودون بالبرنامج
# بفعل ذلك، بشرط ما يلي:
#
# يجب تضمين إشعار حقوق النشر المذكور أعلاه وإشعار الإذن هذا في جميع
# النسخ أو الأجزاء الجوهرية من البرنامج.
#
# يُقدم البرنامج "كما هو"، دون أي ضمان من أي نوع، صريح أو
# ضمني، بما في ذلك على سبيل المثال لا الحصر ضمانات الصلاحية للبيع،
# والملاءمة لغرض معين وعدم التعدي. في أي حال من الأحوال يكون
# المؤلفون أو أصحاب الحقوق مسؤولين عن أي مطالبة، أو أضرار، أو أي
# مسؤولية أخرى، سواء في عقد، أو تعدي، أو غير ذلك، تنشأ من،
# أو في اتصال مع البرنامج أو استخدامه أو التعاملات الأخرى فيه.
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' غير مستخدم، لكن في حال كان هناك خطأ في التكوين
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)
تكوين دالة lambda email-receiver-lambda:
أ. أضف الطبقة lambda-receiver-layer
ب. أضف طبقة محددة حسب المنطقة لـ AWS Parameter Store
ج. أضف متغير البيئة SECRET_NAME بالقيمة email-handler-secret
د. إذا كنت ترغب في تسجيل تفاصيل إضافية، أضف متغير البيئة POWERTOOLS_LOGGER_LOG_EVENT بالقيمة true
منح دالة lambda email-receiver-lambda صلاحية IAM secretsmanager:GetSecretValue للسر email-handler-secret
إنشاء اشتراك في موضوع SNS incoming-sns-topic
أ. البروتوكول هو AWS Lambda
ب. اضبط نقطة النهاية على ARN الخاص بـ email-receiver-lambda
ستكون صلاحيات IAM مطلوبة لاشتراك SNS في موضوع incoming-sns-topic لاستدعاء email-receiver-lambda، لكنني أعتقد أن هذا سيتم تلقائيًا عند التكوين عبر وحدة التحكم
لأغراض تصحيح الأخطاء، أو لإزعاج الذات بشكل عام، يمكنك إضافة اشتراك بريد إلكتروني إلى أي من موضوعات SNS لمراقبة الإشعارات.
سجلت هذا في جلستين أو ثلاث، لكنني أعتقد أنه كل شيء. يمكنني محاولة الإجابة على الأسئلة العامة عندما يسمح الوقت بذلك.
اكتشفت مؤخرًا أن Lambda Powertools تدعم مكتبة Pydantic الرائعة، وقمت بتحديث البرنامج النصي لاستخدامها. في طبقة Lambda، lambda-receiver-layer، ستحتاج aws-lambda-powertools إلى تضمين الملحق parser (أي aws-lambda-powertools[parser])
# 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)
هذه هي الخطوات الإضافية حتى لا تحصل على هذا الخطأ.
في دليل 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 من المنشور الأصلي.
إذا كان أي شخص في عام 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عند الانتهاء من كل شيء، يجب أن ترى رسائل البريد الإلكتروني المستلمة في سجلات المسؤول الخاصة بك.
لقد قمت للتو بإعداد هذا، وأعتقد أن تسليم الرسائل الخام يجب أن يكون معطلًا، وليس مفعّلًا.
عند تفعيل تسليم الرسائل الخام، لا تتضمن إشعارات SNS المرتدة (bounce) بيانات الوصف (metadata) الخاصة بـ SNS التي يحتاجها Discourse للتحقق من الرسالة. احتوت سجلات الوصول الخاصة بي على إدخالات مثل هذه: