تخطَّ إلى المستندات
Python

أطر العمل

Django وFlask وFastAPI، ونقطة نهاية لخطافات الويب، ومهام خلفية لا ترسل مرتين أبدًا.

Django

احفظ المفتاح في الإعدادات، مقروءًا من بيئة التشغيل، وابنِ OpenEmail واحدًا في وحدة خاصة به تستوردها العروض (views). ويمكن مشاركة العميل بين خيوط التنفيذ بأمان، فتخدم نسخة واحدة كل الطلبات وتحتفظ بمجمّع اتصالات واحد للعملية. وسمِّ الوحدة بأي اسم عدا openemail.py، الذي قد يحجب الحزمة.

settings.py
import os OPENEMAIL_API_KEY = os.environ['OPENEMAIL_API_KEY']OPENEMAIL_TIMEOUT = 10.0OPENEMAIL_SENDER = 'Acme <[email protected]>'
mailer.py
from django.conf import settingsfrom openemail import OpenEmail mailer = OpenEmail(settings.OPENEMAIL_API_KEY, timeout=settings.OPENEMAIL_TIMEOUT)
views.py
from django.conf import settingsfrom django.http import HttpRequest, JsonResponsefrom django.views.decorators.http import require_POSTfrom openemail import OpenEmailApiError from acme.mailer import mailer  @require_POSTdef invite(request: HttpRequest) -> JsonResponse:    try:        sent = mailer.emails.send({            'from': settings.OPENEMAIL_SENDER,            'to': request.POST['email'],            'subject': 'You are invited to Acme',            'text': 'Accept the invitation to join the workspace.',        })    except OpenEmailApiError as error:        if error.is_validation:            return JsonResponse({'error': error.message, 'field': error.param}, status=422)         raise     return JsonResponse({'id': sent['id']}, status=202)

يعمل العميل الجاهز openemail هنا أيضًا: استدعِ init(settings.OPENEMAIL_API_KEY) مرة واحدة، من الدالة ready() في إعدادات تطبيقك (app config)، واستورد openemail حيثما ترسل.

Flask

يبني مصنع التطبيق (app factory) العميل مع التطبيق ويحفظه في app.extensions، وتصل إليه العروض عبر current_app. والمصنع الذي يقبل عميلًا أيضًا يتيح للاختبار أن يمرّر عميلًا مبنيًا على httpx.MockTransport.

app.py
import os from flask import Flask, current_app, requestfrom openemail import OpenEmail  def create_app(mailer: OpenEmail | None = None) -> Flask:    app = Flask(__name__)    app.extensions['openemail'] = mailer or OpenEmail(os.environ['OPENEMAIL_API_KEY'], timeout=10)     @app.post('/invites')    def invite() -> tuple[dict[str, str], int]:        client: OpenEmail = current_app.extensions['openemail']        sent = client.emails.send({            'from': 'Acme <[email protected]>',            'to': request.form['email'],            'subject': 'You are invited to Acme',            'text': 'Accept the invitation to join the workspace.',        })         return {'id': sent['id']}, 202     return app

FastAPI

ابنِ AsyncOpenEmail واحدًا في lifespan، فينتمي إلى حلقة الأحداث التي تخدم الطلبات ويُغلق حين يتوقف الخادم، ومرّره إلى المسارات عبر تبعية (dependency).

main.py
from collections.abc import AsyncIteratorfrom contextlib import asynccontextmanagerfrom typing import Annotated from fastapi import Body, Depends, FastAPI, Requestfrom openemail import AsyncOpenEmail  @asynccontextmanagerasync def lifespan(app: FastAPI) -> AsyncIterator[None]:    async with AsyncOpenEmail(timeout=10) as mailer:        app.state.openemail = mailer        yield  app = FastAPI(lifespan=lifespan)  def get_openemail(request: Request) -> AsyncOpenEmail:    mailer: AsyncOpenEmail = request.app.state.openemail    return mailer  Mailer = Annotated[AsyncOpenEmail, Depends(get_openemail)]  @app.post('/invites', status_code=202)async def invite(email: Annotated[str, Body(embed=True)], mailer: Mailer) -> dict[str, str]:    sent = await mailer.emails.send({        'from': 'Acme <[email protected]>',        'to': email,        'subject': 'You are invited to Acme',        'text': 'Accept the invitation to join the workspace.',    })     return {'id': sent['id'], 'status': sent['status']}

يستبدل الاختبار العميل عبر app.dependency_overrides[get_openemail]، فلا يصل أي مسار إلى API.

نقاط نهاية خطافات الويب

تحقّق من كل تسليم قبل أن تتصرف بناءً عليه، بالمتن الخام وترويسات الطلب: await request.body() وrequest.headers في FastAPI، وrequest.body وrequest.headers في Django، وrequest.get_data() وrequest.headers في Flask. والبحث عن الترويسة لا يميّز حالة الأحرف، فيعمل كائن الترويسات الخاص بكل إطار عمل كما هو.

webhooks.py
import os from fastapi import FastAPI, HTTPException, Request, Responsefrom openemail import WEBHOOK_EVENTS, WebhookVerificationError, verify_webhook_signature from acme.jobs import handle_bounce app = FastAPI()  @app.post('/webhooks/openemail', status_code=204)async def openemail_webhook(request: Request) -> Response:    try:        event = verify_webhook_signature(            payload=await request.body(),            headers=request.headers,            secret=os.environ['OPENEMAIL_WEBHOOK_SECRET'],        )    except WebhookVerificationError as error:        raise HTTPException(status_code=400, detail='bad signature') from error     if event['type'] == WEBHOOK_EVENTS.EMAIL_BOUNCED:        handle_bounce.delay(event['id'], event['data'])     return Response(status_code=204)
webhook_views.py
import os from django.http import HttpRequest, HttpResponsefrom django.views.decorators.csrf import csrf_exemptfrom django.views.decorators.http import require_POSTfrom openemail import WebhookVerificationError, verify_webhook_signature from acme.models import ReceivedEvent  @csrf_exempt@require_POSTdef openemail_webhook(request: HttpRequest) -> HttpResponse:    try:        event = verify_webhook_signature(            payload=request.body,            headers=request.headers,            secret=os.environ['OPENEMAIL_WEBHOOK_SECRET'],        )    except WebhookVerificationError:        return HttpResponse('bad signature', status=400)     ReceivedEvent.objects.get_or_create(        id=event['id'],        defaults={'type': event['type'], 'data': event['data']},    )     return HttpResponse(status=204)

يرفض Django طلب POST لا يحمل رمز CSRF، والتسليم لا يحمل أي رمز منه، لذا يكون العرض csrf_exempt: فالتوقيع هو ما يثبت أن الطلب جاء من OpenEmail. واقرأ request.headers بدلًا من request.META، التي تُعاد تسمية مفاتيحها إلى صيغة HTTP_X_OPENEMAIL_SIGNATURE.

أجب بـ 2xx بسرعة وأنجز العمل بعد ذلك. فالتسليم الذي لا يتلقى ردًا، أو يتلقى 408 أو 425 أو 429 أو 5xx، يُعاد مجددًا، حتى 8 مرات في نحو 27 ساعة ونصف، وإعادة الإرسال ترسل الحدث مرة أخرى بالـ id نفسه، فاحتفظ بالمعرّفات التي عالجتها وتخطَّ أي تكرار.

المهام الخلفية

يعيد طابور المهام تشغيل المهمة حين تفشل، وقد تفشل المهمة بعد أن تكون رسالتها قد خرجت: ضاعت الاستجابة، أو توقف العامل قبل أن ينتهي. مرّر idempotency_key= مشتقًّا مما جعل الإرسال ضروريًا. عندها يحمل كل تشغيل للمهمة المفتاح نفسه، فيعيد التكرار تشغيل الرسالة الأصلية بدلًا من إرسال رسالة ثانية.

tasks.py
from celery import Task, shared_taskfrom openemail import OpenEmail, OpenEmailApiError, OpenEmailNetworkError mailer = OpenEmail()  @shared_task(bind=True, acks_late=True, max_retries=5)def send_receipt(self: Task, order_id: str, email: str) -> str:    try:        sent = mailer.emails.send(            {                'from': 'Acme Billing <[email protected]>',                'to': email,                'subject': f'Receipt for order {order_id}',                'text': f'Thank you for order {order_id}.',            },            idempotency_key=f'receipt:{order_id}',        )    except OpenEmailNetworkError as error:        raise self.retry(exc=error, countdown=30)    except OpenEmailApiError as error:        if error.is_server_error:            raise self.retry(exc=error, countdown=30)         raise     return sent['id']

مع acks_late=True، لا يؤكّد Celery استلام المهمة إلا بعد تشغيلها، فالمهمة التي قطعها عامل توقف يمكن تسليمها مرة أخرى، وهذا آمن هنا لأن المفتاح يحوّل ذلك التشغيل الثاني إلى إعادة تشغيل. ويعيد Retry في RQ تشغيل المهمة الفاشلة، بالأثر نفسه.

jobs.py
from redis import Redisfrom rq import Queue, Retry from openemail import OpenEmail mailer = OpenEmail()  def send_receipt(order_id: str, email: str) -> str:    sent = mailer.emails.send(        {            'from': 'Acme Billing <[email protected]>',            'to': email,            'subject': f'Receipt for order {order_id}',            'text': f'Thank you for order {order_id}.',        },        idempotency_key=f'receipt:{order_id}',    )     return sent['id']  queue = Queue(connection=Redis())queue.enqueue(send_receipt, 'AC-4192', '[email protected]', retry=Retry(max=5, interval=[10, 60, 300]))

اشتقّ المفتاح مما جعل الإرسال ضروريًا، لا من الساعة أبدًا. وهو من 1 إلى 255 من الأحرف أو الأرقام أو الشرطات السفلية أو النقاط أو النقطتين الرأسيتين أو الشرطات، فابنِه من معرّف لا من عنوان بريد إلكتروني. وابنِ جسم الطلب كذلك من وسائط المهمة وحدها: فالتكرار بالمفتاح نفسه وجسم مختلف يُرفض بـ 422 idempotency_key_reuse بدلًا من إعادة تشغيله.

ابنِ العميل على مستوى الوحدة. فهو لا يفتح أي اتصال حتى أول طلب له، لذا تفتح كل عملية عاملة متفرّعة من العملية الأم اتصالاتها الخاصة.