معماری بک‌اند

ایمیل تراکنشی جنگو با Resend، و صندوق پستی دامنه روی Zoho Mail

روی یک دامنه، ارسال ایمیل‌های سیستمی با Resend و صندوق پشتیبانی با Zoho Mail: رکوردهای DNS و دو SPF که با هم تداخل ندارند، صف Celery در جنگو، و تنظیمات SMTP رمزنگاری‌شده در دیتابیس به‌جای settings.py.

Resend and Zoho Mail

روی دامنه‌ی سایتم دو جور ایمیل هست: ایمیل‌هایی که خود سیستم می‌فرستد، مثل کد ثبت‌نام و ورود و لینک بازیابی رمز، و مکاتبه‌ی آدم‌ها با پشتیبانی. دسته‌ی اول را Resend می‌فرستد و صندوق پستی دامنه روی Zoho Mail است. در ادامه، جای دامنه‌ی واقعی domain.site نوشته‌ام.

چرا ارسال و صندوق را جدا کردم

ایمیل سیستمی باید سریع به Inbox برسد؛ کد ورودی که دیر برسد یا در Spam بماند، کاربر را پشت در نگه می‌دارد. Resend برای ارسال تراکنشی ساخته شده، از راه API یا SMTP (جنگو از SMTP استفاده می‌کند)، اما صندوق پستی نیست. پشتیبانی یک صندوق واقعی با وب‌میل می‌خواهد، که Zoho Mail با طرح Business Email (نسخه‌ی Forever Free) می‌دهد.

  • noreply@domain.site: ارسال با Resend؛ کدهای تأیید، بازیابی رمز و اطلاعیه‌های خودکار.
  • support@domain.site و info@domain.site: صندوق‌های Zoho Mail برای پشتیبانی.

تنها جایی که این دو باید با هم کنار بیایند DNS است.

رکوردهای DNS

این رکوردها در پنل DNS دامنه ثبت می‌شوند:

نوعناممقداراولویتکاربرد
MX@mx.zoho.com10دریافت ایمیل در Zoho
MX@mx2.zoho.com20دریافت ایمیل در Zoho (ذخیره)
MX@mx3.zoho.com50دریافت ایمیل در Zoho (ذخیره)
TXT@v=spf1 include:zohomail.com ~all-SPF زوهو، روی ریشه‌ی دامنه
TXTsendv=spf1 include:amazonses.com ~all-SPF ارسال Resend، روی زیردامنه‌ی send
TXTzoho._domainkeyv=DKIM1; k=rsa; p=PUBLIC_KEY_FROM_ZOHO-امضای DKIM زوهو (2048 بیتی)
TXT_dmarcv=DMARC1; p=none; rua=mailto:support@domain.site; ruf=mailto:support@domain.site; sp=quarantine; adkim=r; aspf=r-سیاست DMARC با هم‌ترازی منعطف

سه رکورد MX ایمیل ورودی را به Zoho می‌برند؛ mx.zoho.com اصلی است و دوتای دیگر ذخیره. Resend در دریافت ایمیل نقشی ندارد.

چرا دو رکورد SPF با هم تداخل ندارند

هر نام در DNS فقط یک رکورد SPF می‌تواند داشته باشد؛ دو رکورد v=spf1 روی یک نام، بررسی SPF را به خطا می‌کشاند. اینجا لازم نیست include هر دو سرویس در یک رکورد جمع شود: SPF زوهو روی ریشه‌ی دامنه (@) است و SPF مربوط به Resend روی زیردامنه‌ی send. Resend نشانی برگشتی (Return-Path) پیام‌هایش را روی send.domain.site می‌گذارد، پس سرور گیرنده SPF ایمیل‌های Resend را همان‌جا می‌سنجد و SPF ایمیل‌های Zoho را روی خود domain.site. مقدار include:amazonses.com هم همان است که Resend می‌خواهد.

DKIM هم همین‌طور است: کلید زوهو روی zoho._domainkey می‌نشیند و Resend رکورد DKIM خودش را با نام خودش می‌دهد.

DMARC

DMARC با p=none شروع شده است: فقط گزارش، و هنوز رد یا قرنطینه‌ای برای ریشه‌ی دامنه در کار نیست. هر دو نوع گزارش (rua و ruf) به support@domain.site می‌روند، یعنی به همان صندوق Zoho؛ زیردامنه‌ها sp=quarantine دارند. هم‌ترازی منعطف (adkim=r; aspf=r) اینجا مهم است: SPF ایمیل Resend روی send.domain.site قبول می‌شود، اما فرستنده‌ای که خواننده می‌بیند noreply@domain.site است، و در حالت منعطف زیردامنه با دامنه‌ی مادرش هم‌تراز حساب می‌شود.

تأیید دامنه در Resend

  1. در Resend از منوی Domains گزینه‌ی Add Domain را می‌زنم و domain.site را وارد می‌کنم.
  2. Resend رکوردهای لازم را نشان می‌دهد، یعنی SPF روی send و DKIM؛ آن‌ها را در پنل DNS ثبت می‌کنم و Verify را می‌زنم.
  3. در بخش API Keys یک کلید با دسترسی Sending access می‌سازم. این کلید، چیزی شبیه re_xxxxxxxx، در جنگو نقش رمز SMTP را دارد.

سمت جنگو: تنظیمات در دیتابیس، نه در settings.py

قبلاً EMAIL_HOST و بقیه در .env بودند و فقط موقع بالا آمدن پروسه خوانده می‌شدند؛ روشن‌کردن ارسال یا عوض‌کردن رمز یعنی ویرایش فایل و ری‌استارت کانتینر. قاعده‌ی امروز: مقداری که کدِ داخل یک درخواست آن را می‌خواند، جایش در مدل SystemSetting است؛ فقط چیزی که پیش از وجود دیتابیس خوانده می‌شود در .env می‌ماند.

پس تنظیمات SMTP ستون‌های SystemSetting هستند و در پنل ادمین، در «تنظیمات سامانه ← ایمیل و پیامک ← ویرایش»، فقط به دست superuser ویرایش می‌شوند و بدون ری‌استارت اعمال می‌شوند. برای Resend:

ستونمقدار
use_email_serviceبله
email_backenddjango.core.mail.backends.smtp.EmailBackend
email_hostsmtp.resend.com
email_port587
email_use_tlsبله
email_use_sslخیر
email_host_userresend
email_host_password_encryptedکلید API ساخته‌شده در Resend (re_xxxxxxxx)، رمزنگاری‌شده
email_send_fromnoreply@domain.site

همین کلیدها اگر در .env نوشته شوند اثری ندارند؛ کدی نیست که آن‌ها را بخواند.

رمز SMTP، رمزنگاری‌شده

کلید API در یک ستون ساده یعنی هر بکاپ دیتابیس فهرستی از کلیدهای زنده است. ستون email_host_password_encrypted متن رمزشده‌ی Fernet را نگه می‌دارد و کلیدش از محیط می‌آید: FIELD_ENCRYPTION_KEY، یا اگر تعریف نشده باشد، کلیدی که با PBKDF2 از SECRET_KEY ساخته می‌شود. فیلد فرم فقط‌نوشتنی است: خالی نمایش داده می‌شود و ذخیره‌ی خالی مقدار قبلی را نگه می‌دارد. یک نکته: اگر SECRET_KEY بدون FIELD_ENCRYPTION_KEY عوض شود، رمز ذخیره‌شده دیگر باز نمی‌شود و باید دوباره واردش کرد.

اتصال، برای هر ارسال ساخته می‌شود

چون تنظیمات در دیتابیس است، mail_settings_service.py اتصال را برای هر ارسال از روی ردیف می‌سازد:

def _smtp_options(backend, timeout=None):
    if not _speaks_smtp(backend):
        return {}
    row = _setting()
    return {
        "host": system_option("email_host"),
        "port": int(system_option("email_port")),
        "username": system_option("email_host_user"),
        "password": row.get_email_host_password() if row is not None else "",
        "use_tls": system_flag("email_use_tls"),
        "use_ssl": system_flag("email_use_ssl"),
        "timeout": timeout,
    }
def build_mail_connection(timeout=None):
    backend = import_string(_backend_path())
    return backend(alias=MAILER_ALIAS, **_smtp_options(backend, timeout))

get_mail_connection() همین را صدا می‌زند و وقتی ارسال خاموش است یا فرم نیمه‌کاره پر شده، None برمی‌گرداند؛ فرم نیمه‌کاره باید ایمیل را متوقف کند، نه صفحه‌ای را که ایمیل را فرستاده.

تنها تنظیم ایمیل که در settings.py مانده

MAILERS = {
    "default": {
        "BACKEND": "_0_utils.service.email.backends.SystemSettingEmailBackend",
    },
}

از جنگو 6.1، MAILERS جای EMAIL_BACKEND را گرفته است. کد پروژه اتصال را خودش از get_mail_connection() می‌گیرد، اما allauth، بازیابی رمز contrib.auth و mail_admins() از mailer پیش‌فرض جنگو استفاده می‌کنند و بدون این خط، بی‌صدا به localhost:25 با فرستنده‌ی webmaster@localhost می‌رفتند. SystemSettingEmailBackend اتصال را موقع ارسال از همان ردیف می‌سازد، برای پیامی که فرستنده ندارد آدرس «ارسال از طرف» پنل را می‌گذارد و وقتی ارسال خاموش است صفر برمی‌گرداند.

صف Celery: هیچ درخواستی منتظر SMTP نمی‌ماند

اگر ارسال SMTP داخل درخواست ثبت‌نام انجام شود، کاربر منتظر رفت‌وبرگشت‌های شبکه با Resend می‌ماند و کندی آن طرف، کندی ما می‌شود. پس send_mail در email_with_queue_service.py فقط کار را در صف می‌گذارد:

def send_mail(subject, email, context, template):
    if not email_is_enabled():
        ...  # print the code: on screen under DEBUG, behind SHOW_LOG_DEBUG otherwise
    else:
        _send_mail.delay(
            subject=subject, to=email, context=context, template_name=template
        )
@shared_task()
def _send_mail(subject, to, context, template_name):
    connection = get_mail_connection()
    if connection is None:
        return False  # switched off between queueing and running
    html_message = render_to_string(template_name, _mail_context(context))
    message = EmailMultiAlternatives(
        subject=subject,
        body=strip_tags(html_message),
        from_email=mail_from_address(),
        to=[to],
    )
    message.attach_alternative(html_message, "text/html")
    connection.send_messages([message])

به .delay() فقط داده‌ی ساده می‌رود، چون آرگومان‌ها JSON می‌شوند و روی broker می‌نشینند؛ یک بار فرستادن تنظیمات سایت، که نمونه‌های مدل‌اند، به EncodeError و خطای 500 روی POST ثبت‌نام رسید. حالا context سایت داخل worker ساخته می‌شود و ایمیل با تنظیمات لحظه‌ی ارسال رندر می‌شود. worker هم دوباره get_mail_connection() را می‌پرسد و اگر ارسال در این فاصله خاموش شده، کنار می‌کشد.

وقتی ارسال خاموش است، کد فقط در حالت DEBUG روی صفحه چاپ می‌شود؛ کد ورود در لاگ production راه ورودی است برای هر کسی که لاگ را می‌خواند.

ایمیل آزمایشی

در همان صفحه‌ی «ایمیل و پیامک»، دکمه‌ی ایمیل آزمایشی یکی از ایمیل‌های خود سایت (کد ثبت‌نام، کد ورود، بازیابی رمز) را با مقادیر نمونه، مثل کد 123456، یا یک پیام دلخواه می‌فرستد. این مسیر عمداً با فرستنده‌ی اصلی فرق دارد:

  • در صف نمی‌رود، چون نتیجه باید به همان صفحه برگردد؛ و هر فراخوان SMTP با TEST_MAIL_TIMEOUT (15 ثانیه) محدود است.
  • به use_email_service نگاه نمی‌کند، تا بشود اتصال را پیش از روشن‌کردن ارسال امتحان کرد.
  • با build_mail_connection() خطا را بالا می‌دهد، تا رمز اشتباه روی صفحه دیده شود و در None گم نشود.

تست با مقادیر ذخیره‌شده انجام می‌شود؛ پس اول ذخیره، بعد تست.

بررسی تحویل

با این چیدمان سه جا برای نگاه‌کردن دارم: وضعیت دامنه در Resend، رسیدن ایمیل آزمایشی، و گزارش‌های DMARC در صندوق support@. با p=none فعلاً هیچ ایمیلی رد نمی‌شود؛ گزارش‌ها نشان می‌دهند چه کسی به نام دامنه ایمیل می‌فرستد.