الأدلة التقنية

بيئة تطوير محلية كاملة بملف Docker Compose واحد

نبني بيئة تطوير فيها البريد وHTTPS والأسماء المحلية، ونكتب اختباراً آلياً لبريد استعادة كلمة المرور ثم نكسرها.

بيئة تطوير محلية كاملة بملف Docker Compose واحد

في الجزء الأول شرحنا لماذا يتحدث تطبيقك على جهازك مع بدائل محلية للخدمات الخارجية، وفي الجزء الثاني قارنا الأدوات واخترنا منها Mailpit لالتقاط البريد، وmkcert للشهادات، وCaddy أمام التطبيق، وCoreDNS للأسماء. وفي هذا الجزء نجمعها في ملف Docker Compose واحد حول تطبيق صغير فيه ميزة حقيقية واحدة، هي «نسيت كلمة المرور»، ثم نكتب اختباراً آلياً يفتح المتصفح ويطلب رابط الاستعادة ويقرأ البريد من Mailpit ويفتح الرابط، ثم نكسر البيئة عمداً لنرى الأخطاء التي سوف تراها أنت يوماً ما.

والمواضيع في السلسلة:

وسوف نناقش في هذا المقال ما يلي:

  • شكل البيئة: شبكة داخلية لا تصل إلى الإنترنت، وCoreDNS يجيب عن كل اسم تحت lab.test، وCaddy بشهادة من mkcert.
  • التطبيق وحارس البريد الذي يمنعه من العمل بسيرفر SMTP حقيقي في وضع التطوير.
  • جعل متصفح واحد داخل Container يثق بجهة الإصدار المحلية، دون أن نلمس مخزن الثقة على جهازك.
  • اختبار آلي ب Playwright وAPI Mailpit يتأكد أن بريد الاستعادة يصل وأن رابطه يعمل.
  • ميزة Chaos في Mailpit: كيف يتصرف التطبيق عندما يرفض سيرفر البريد الرسالة.
  • أربعة كسور: سيرفر SMTP حقيقي، والمحتوى المختلط Mixed Content، وشهادة لاسم آخر، وCookie آمن على HTTP.
لا نشغل في هذه التجربة الأمر mkcert -install على جهازك، ولا نعدل ملف hosts، ولا نغير أي إعداد في النظام، فجهة الإصدار تنشأ في مجلد التجربة، والثقة بها تكون داخل Container الاختبار وحده، والشبكة كلها داخلية فلا يخرج منها بريد حقيقي حتى لو أخطأنا في الإعداد. وإذا قررت أن تثق بجهة mkcert على جهازك فعلاً فاقرأ قبلها قسم المخاطر في الجزء الأول.

شكل البيئة

البيئة خمسة Containers على شبكة Docker واحدة من نوع internal، وهذا النوع لا يملك طريقاً إلى خارج جهازك، وقد شرحنا أنواع شبكات Docker في مقال شبكات Docker. والصورة التالية تبين البيئة:

شبكة داخلية باسم lab-ldev-net بعنوان 10.231.77.0/24 وفيها خمسة Containers: lab-ldev-tests وفيه Chromium يثق بجهة mkcert، وlab-ldev-dns وهو CoreDNS على 10.231.77.53 يجيب بأن كل اسم تحت lab.test يشير إلى 10.231.77.10، وlab-ldev-caddy على 10.231.77.10 بشهادة *.lab.test من mkcert ويوجه app.lab.test إلى التطبيق وmail.lab.test إلى Mailpit، وlab-ldev-app وهو تطبيق Node.js يرسل البريد عبر SMTP إلى lab-ldev-mailpit على المنفذ 1025، وخارج الشبكة سحابة الإنترنت وعليها علامة X حمراء لأن الشبكة لا تصل إليها
كل الأسماء تحت lab.test تصل إلى Caddy، وCaddy يوزعها، ولا يوجد أي طريق إلى الإنترنت

وترتيب الملفات كما يلي:

lab/
├── compose.yaml
├── Corefile          # CoreDNS: *.lab.test → Caddy
├── Caddyfile         # sites, mkcert certificate
├── static/hello.js   # served over plain HTTP, for the mixed content test
├── app/              # Node.js app: server.mjs, package.json, Dockerfile
└── tests/            # Playwright tests: reset.test.mjs, open.mjs, Dockerfile

الخطوة 1: جهة إصدار وشهادة من mkcert

نصدر الشهادة في Container مؤقت كما فعلنا في قسم mkcert في الجزء الثاني، ونحفظ جهة الإصدار في المجلد ca والشهادة في المجلد certs، وكلاهما خارج مجلد المشروع حتى لا يرفع المفتاح إلى Git بالخطأ:

mkdir -p ~/lab-ldev/ca ~/lab-ldev/certs
docker run --rm --name lab-ldev-mkcert -e CAROOT=/ca \
  -v ~/lab-ldev/ca:/ca -v ~/lab-ldev/certs:/certs -w /certs alpine:3.22 sh -c '
  wget -q -O /usr/local/bin/mkcert https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64
  chmod +x /usr/local/bin/mkcert
  mkcert -cert-file lab.pem -key-file lab-key.pem "lab.test" "*.lab.test"'

export LAB_CA=~/lab-ldev/ca LAB_CERTS=~/lab-ldev/certs

والنتيجة أربعة ملفات: rootCA.pem وrootCA-key.pem لجهة الإصدار، وlab.pem وlab-key.pem للشهادة التي تغطي lab.test وكل اسم بمستوى واحد تحته، وتنتهي في 12 يناير 2029. ولاحظ في ملف Compose بعد قليل أن Container الاختبار يأخذ rootCA.pem وحده، أما المفتاح rootCA-key.pem فلا يدخل أي Container.

الخطوة 2: الأسماء مع CoreDNS

ملف Corefile يجيب عن كل اسم تحت lab.test بعنوان Caddy الثابت، ويرسل أي اسم آخر إلى DNS الداخلي في Docker الذي يعرف أسماء ال Containers:

# Every name under lab.test (and lab.test itself) points to Caddy; everything else goes to Docker's own DNS
lab.test:53 {
    template IN A {
        answer "{{ .Name }} 60 IN A 10.231.77.10"
    }
    template IN AAAA {
        rcode NOERROR
    }
    log
}
.:53 {
    forward . 127.0.0.11
    log
}

في الإعداد أعلاه لاحظ التالي:

  • إضافة template تبني الإجابة من اسم السؤال نفسه، فـ app.lab.test وanything.lab.test يحصلان على العنوان نفسه، وهذا ما لا يستطيعه ملف hosts كما رأينا في الجزء الثاني.
  • السؤال عن AAAA يحصل على إجابة فارغة بالرمز NOERROR، والسبب أن المكتبات تسأل عن IPv4 وIPv6 معاً، والإجابة الفارغة السريعة أفضل من سؤال ينتظر حتى تنتهي مهلته.
  • العنوان 127.0.0.11 هو DNS المدمج في Docker داخل كل Container، فيبقى اسم مثل mailpit يعمل، أما الأسماء الخارجية فلن يجد لها إجابة لأن الشبكة داخلية.

الخطوة 3: Caddy بشهادة mkcert

{
	# the certificates come from mkcert; Caddy must not try ACME or touch any trust store
	auto_https disable_certs
	skip_install_trust
}

(lab_tls) {
	tls /certs/lab.pem /certs/lab-key.pem
}

app.lab.test {
	import lab_tls
	reverse_proxy app:3000
}

mail.lab.test {
	import lab_tls
	reverse_proxy mailpit:8025
}

# a name the wildcard does not cover: *.lab.test matches one label only
api.v2.lab.test {
	import lab_tls
	respond "v2 API"
}

# plain HTTP on purpose, for the Secure cookie and mixed content tests
http://plain.lab.test {
	reverse_proxy app:3000
}

http://static.lab.test {
	root * /srv/static
	file_server
}

في الإعداد أعلاه لاحظ التالي:

  • الخيار auto_https disable_certs يوقف إصدار الشهادات التلقائي ويبقي التحويل من HTTP إلى HTTPS، والخيار skip_install_trust يمنع Caddy من محاولة إضافة جهته الداخلية إلى أي مخزن ثقة كما شرحنا في الجزء الثاني.
  • الموقع api.v2.lab.test والموقعان على HTTP موجودة لتجارب الكسر فقط، وتطبيقك الحقيقي لا يحتاجها.
  • تفاصيل Caddy نفسه وطريقة تثبيته على سيرفر في دليل Caddy.

الخطوة 4: التطبيق وحارس البريد

التطبيق في الملف app/server.mjs بلغة Node.js مع مكتبة nodemailer 10.1.0، ويقرأ إعداد SMTP من متغيرات البيئة كما يفعل في الإنتاج، والجزء المهم منه هو السطور الأولى:

const {
    APP_ENV = 'development',
    APP_URL = 'https://app.lab.test',
    SMTP_HOST = 'mailpit',
    SMTP_PORT = '1025',
    MAIL_FROM = '[email protected]',
    DEV_SMTP_ALLOW = 'mailpit,localhost,127.0.0.1',
} = process.env;

// Guard 1: outside production the app refuses to start with an SMTP server that is not a mail catcher
if (APP_ENV !== 'production' && !DEV_SMTP_ALLOW.split(',').includes(SMTP_HOST)) {
    console.error(`refusing to start: APP_ENV=${APP_ENV} but SMTP_HOST=${SMTP_HOST} is not in DEV_SMTP_ALLOW (${DEV_SMTP_ALLOW})`);
    process.exit(1);
}

const mailer = nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT), secure: false, connectionTimeout: 5000 });

وبقية الملف أربعة مسارات: الصفحة الرئيسية التي تضع Cookie الجلسة بالخواص Secure; HttpOnly; SameSite=Lax، وصفحة /forgot التي تستقبل البريد وتنشئ رمزاً عشوائياً وترسل رسالة فيها الرابط https://app.lab.test/reset/<token>، وصفحة الرابط نفسها، وصفحة /mixed لتجربة المحتوى المختلط، وتجد الملف كاملاً في مجلد التجربة. ولاحظ أن الحارس طبقة أولى فقط، فالطبقة الثانية هي الشبكة الداخلية، وسوف نكسر الأولى ونرى الثانية تعمل.

الخطوة 5: ملف Compose

# Local dev stack: app + Mailpit + Caddy (mkcert certificates) + CoreDNS for *.lab.test
# Everything sits on one internal network: no container here can reach the internet.
name: lab-ldev

networks:
  lab:
    name: lab-ldev-net
    internal: true
    ipam:
      config:
        - subnet: 10.231.77.0/24

services:
  dns:
    image: coredns/coredns:1.14.7
    container_name: lab-ldev-dns
    command: ["-conf", "/etc/coredns/Corefile"]
    volumes:
      - ./Corefile:/etc/coredns/Corefile:ro
    networks:
      lab: { ipv4_address: 10.231.77.53 }

  mailpit:
    image: axllent/mailpit:v1.31.5
    container_name: lab-ldev-mailpit
    environment:
      MP_ENABLE_CHAOS: "true"          # lets the tests make the SMTP server fail on purpose
      MP_SMTP_RELAY_ALL: "false"       # never release mail to the real world
    networks: [lab]

  app:
    build: ./app
    image: lab-ldev-app:1
    container_name: lab-ldev-app
    environment:
      APP_ENV: development
      APP_URL: https://app.lab.test
      SMTP_HOST: ${SMTP_HOST:-mailpit}
      SMTP_PORT: ${SMTP_PORT:-1025}
      DEV_SMTP_ALLOW: ${DEV_SMTP_ALLOW:-mailpit,localhost,127.0.0.1}
    dns: [10.231.77.53]
    networks: [lab]

  caddy:
    image: caddy:2.11.7-alpine
    container_name: lab-ldev-caddy
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./static:/srv/static:ro
      - ${LAB_CERTS:?set LAB_CERTS}:/certs:ro
    networks:
      lab: { ipv4_address: 10.231.77.10 }

  tests:
    build: ./tests
    image: lab-ldev-pw:1
    container_name: lab-ldev-tests
    profiles: [tests]
    environment:
      NODE_EXTRA_CA_CERTS: /ca/rootCA.pem   # Node trusts the lab CA inside this container only
    volumes:
      - ${LAB_CA:?set LAB_CA}/rootCA.pem:/ca/rootCA.pem:ro
      - ./tests:/tests/src:ro
    dns: [10.231.77.53]
    networks: [lab]
    command: ["sleep", "infinity"]

في الإعداد أعلاه لاحظ التالي:

  • internal: true هو الحماية الأهم في الملف، فلا يوجد أي Container هنا يستطيع أن يصل إلى سيرفر بريد حقيقي، ولهذا أيضاً لا توجد منافذ منشورة على جهازك، فكل الطلبات تأتي من Container الاختبار داخل الشبكة نفسها.
  • التطبيق وContainer الاختبار يستخدمان CoreDNS عبر dns، أما Caddy فلا يحتاجه لأنه يتصل بالخدمات بأسمائها في Docker.
  • ال Container الخاص بالاختبار مبني على mcr.microsoft.com/playwright:v1.64.0-noble ومعه Playwright 1.64.0 وحزمة libnss3-tools التي فيها الأداة certutil، وهو في ملف تعريف Profile اسمه tests فلا يعمل إلا عندما تطلبه.
  • القيمة MP_SMTP_RELAY_ALL بـ false هي القيمة الافتراضية أصلاً، وكتبناها لتبقى واضحة لمن يقرأ الملف بعدك.

الآن نشغل البيئة:

docker compose -p lab-ldev --profile tests up -d --build
docker ps --filter name=lab-ldev --format '{{.Names}} {{.Status}}'

والمخرج سوف يكون كما يلي:

lab-ldev-caddy Up 44 seconds
lab-ldev-tests Up 21 seconds
lab-ldev-app Up 38 seconds
lab-ldev-dns Up 39 seconds
lab-ldev-mailpit Up 42 seconds (healthy)

ونتأكد من الأسماء من داخل Container الاختبار:

docker exec lab-ldev-tests sh -c '
  dig +short app.lab.test; dig +short whatever.lab.test; dig +short api.v2.lab.test
  getent hosts mail.lab.test
  dig example.com | grep status'

والمخرج سوف يكون كما يلي:

10.231.77.10
10.231.77.10
10.231.77.10
10.231.77.10    mail.lab.test
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 30630

ولاحظ السطر الأخير، فالاسم الخارجي example.com لا يجد إجابة، وهذه أول علامة أن الشبكة لا تصل إلى الإنترنت.

الخطوة 6: متصفح واحد يثق بجهة الإصدار

قبل أن نضيف جهة الإصدار نفتح التطبيق بالأمر curl ثم بالمتصفح، والملف tests/open.mjs يفتح أي رابط في Chromium دون الخيار ignoreHTTPSErrors ويطبع النتيجة وال Cookies ورسائل ال Console:

docker exec lab-ldev-tests curl -sS https://app.lab.test/ -o /dev/null
docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/

والمخرج سوف يكون كما يلي:

curl: (60) SSL certificate problem: unable to get local issuer certificate
https://app.lab.test/ → page.goto: net::ERR_CERT_AUTHORITY_INVALID at https://app.lab.test/

وهذا هو السلوك الصحيح، فلا أحد يثق بجهة mkcert بعد. ومتصفح Chromium على Linux يقرأ الجهات الإضافية من قاعدة NSS في المجلد ~/.pki/nssdb للمستخدم، وهنا المستخدم هو root داخل ال Container، فنضيف الجهة إلى هذه القاعدة وحدها:

docker exec lab-ldev-tests sh -c '
  mkdir -p $HOME/.pki/nssdb
  certutil -d sql:$HOME/.pki/nssdb -N --empty-password
  certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n lab-ldev-ca -i /ca/rootCA.pem
  certutil -d sql:$HOME/.pki/nssdb -L'

والمخرج سوف يكون كما يلي:

Certificate Nickname                                         Trust Attributes
                                                             SSL,S/MIME,JAR/XPI

lab-ldev-ca                                                  C,,

أما Node.js فيثق بالجهة عبر المتغير NODE_EXTRA_CA_CERTS الذي وضعناه في ملف Compose، وcurl عبر الخيار --cacert، وكل هذا داخل Container يختفي مع docker compose down، والشهادة التي يقدمها Caddy الآن كما يلي:

subject=O = mkcert development certificate, OU = root@74748a9e7516
issuer=O = mkcert development CA, OU = root@74748a9e7516, CN = mkcert root@74748a9e7516
notBefore=Oct 12 10:12:05 2026 GMT
notAfter=Jan 12 10:12:05 2029 GMT
X509v3 Subject Alternative Name:
    DNS:lab.test, DNS:*.lab.test

الخطوة 7: اختبار آلي لبريد استعادة كلمة المرور

الآن الجزء الذي بنينا البيئة من أجله، فالاختبار التالي يحذف كل الرسائل من Mailpit، ثم يفتح صفحة /forgot ويكتب بريداً فريداً ويضغط الزر، ثم يسأل ال API في Mailpit حتى تصل رسالة لهذا العنوان، ثم يستخرج الرابط من نص الرسالة ويفتحه ويتأكد من الصفحة. والصورة التالية تبين الخطوات:

ست خطوات مرقمة بين ثلاثة أطراف: الاختبار بمتصفح Chromium، والتطبيق خلف Caddy، وMailpit. الخطوة 1 حذف الرسائل عبر DELETE /api/v1/messages، والخطوة 2 المتصفح يرسل البريد إلى /forgot، والخطوة 3 التطبيق يرسل الرسالة عبر SMTP إلى mailpit:1025، والخطوة 4 الاختبار يسأل /api/v1/search كل 200 ملي ثانية حتى تصل الرسالة، والخطوة 5 قراءة نص الرسالة واستخراج الرابط، والخطوة 6 فتح الرابط والتأكد من عنوان الصفحة والبريد
الاختبار لا ينتظر وقتاً ثابتاً، وإنما يسأل Mailpit حتى تصل الرسالة أو تنتهي المهلة

والملف tests/reset.test.mjs كما يلي:

// End-to-end test: "forgot password" must deliver a working reset link, and we read the email from Mailpit's API.
//   node --test /tests/src/reset.test.mjs
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';

const APP = 'https://app.lab.test';
const MAILPIT = 'https://mail.lab.test/api/v1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let browser, page;

// Poll Mailpit until a message for this address arrives (email is asynchronous; never use a fixed sleep)
async function waitForMail(to, timeoutMs = 5000) {
    const q = encodeURIComponent(`to:"${to}"`);
    for (const end = Date.now() + timeoutMs; Date.now() < end; await sleep(200)) {
        const r = await (await fetch(`${MAILPIT}/search?query=${q}`)).json();
        if (r.messages_count > 0) return (await fetch(`${MAILPIT}/message/${r.messages[0].ID}`)).json();
    }
    throw new Error(`no email for ${to} within ${timeoutMs} ms`);
}

before(async () => {
    await fetch(`${MAILPIT}/messages`, { method: 'DELETE' });            // start from an empty inbox
    browser = await chromium.launch();
    page = await browser.newPage();                                       // no ignoreHTTPSErrors: the lab CA must really be trusted
});
after(async () => { await browser?.close(); });

test('password reset email arrives and its link works', async () => {
    const to = `sara+${Date.now()}@example.com`;
    await page.goto(`${APP}/forgot`);
    await page.fill('input[name=email]', to);
    await page.click('button');
    await page.waitForSelector('#status');

    const msg = await waitForMail(to);
    assert.equal(msg.Subject, 'Reset your password');
    assert.equal(msg.From.Address, '[email protected]');
    const link = msg.Text.match(/https:\/\/app\.lab\.test\/reset\/[0-9a-f]{32}/)?.[0];
    assert.ok(link, 'reset link in the email body');

    await page.goto(link);
    assert.equal(await page.textContent('h1'), 'Choose a new password');
    assert.equal(await page.textContent('#who'), to);
});
docker exec -w /tests lab-ldev-tests node --test src/reset.test.mjs

والمخرج سوف يكون كما يلي:

✔ password reset email arrives and its link works (30886.73043ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0

في الاختبار أعلاه لاحظ التالي:

  • العنوان sara+<timestamp>@example.com فريد في كل تشغيل، فلا يجد الاختبار رسالة من تشغيل سابق، وexample.com نطاق محجوز للأمثلة فلا يملك صندوقاً حقيقياً حتى لو خرجت الرسالة. ولاحظ أن Mailpit يحول الجزء بعد علامة + إلى وسم Tag على الرسالة، فظهر الرقم كوسم في الواجهة.
  • الدالة waitForMail تسأل كل 200 ملي ثانية حتى 5 ثوان، وهذا هو الفرق بين اختبار ثابت واختبار متقلب Flaky، فالانتظار الثابت إما طويل يبطئ كل التشغيلات أو قصير يفشل على جهاز مشغول، وقد شرحنا هذه المشكلة في قسم الاختبارات المتقلبة.
  • الاختبار يفتح الرابط بالمتصفح نفسه، فهو يختبر أن الرابط في البريد يشير إلى النطاق الصحيح وأن الرمز يعمل، وهذا خطأ شائع عندما يبنى الرابط من localhost بدل عنوان التطبيق العام.
  • الزمن الكلي قرابة 31 ثانية لأن الجهاز كانت تعمل عليه أعمال أخرى ثقيلة أثناء التجربة، وأغلبه تشغيل Chromium، وليس انتظار البريد.

والصورة التالية من واجهة Mailpit داخل المتصفح نفسه في Container الاختبار:

واجهة Mailpit وفيها على اليسار رسالتان بعنوان Reset your password من no-reply@lab.test، إحداهما إلى lina@example.com والأخرى إلى عنوان sara مع رقم يظهر كوسم، وعلى اليمين الرسالة المفتوحة بالمرسل والمستلم والعنوان والتاريخ والحجم 648 بايت، وتحتها تبويبات Text وHeaders وRaw وLink Check، ونص الرسالة مع رابط الاستعادة على app.lab.test
الرسالة كما التقطها Mailpit، مع تبويب Link Check الذي يفحص الروابط في الرسالة

الخطوة 8: عندما يرفض سيرفر البريد الرسالة

الاختبار السابق يختبر الطريق السعيد Happy Path فقط، أما في الإنتاج فسيرفر البريد قد يرد برفض مؤقت مثل 451، وميزة Chaos في Mailpit تجعله يفعل ذلك عند الطلب. فقد فعلناها في ملف Compose بالمتغير MP_ENABLE_CHAOS، والآن نطلب منه أن يرفض كل مستلم بالرمز 451، ثم نطلب رابط استعادة، ثم نعيد الإعداد:

docker exec lab-ldev-tests sh -c 'C="--cacert /ca/rootCA.pem -s"
  curl $C -X PUT -H "Content-Type: application/json" \
    -d "{\"Recipient\":{\"ErrorCode\":451,\"Probability\":100}}" https://mail.lab.test/api/v1/chaos; echo
  curl $C -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
  curl $C -X PUT -H "Content-Type: application/json" -d "{}" https://mail.lab.test/api/v1/chaos; echo
  curl $C -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot'
docker logs lab-ldev-app 2>&1 | tail -2

والمخرج سوف يكون كما يلي:

{"Sender":{"ErrorCode":451,"Probability":0},"Recipient":{"ErrorCode":451,"Probability":100},"Authentication":{"ErrorCode":535,"Probability":0}}
HTTP 503
{"Sender":{"ErrorCode":451,"Probability":0},"Recipient":{"ErrorCode":451,"Probability":0},"Authentication":{"ErrorCode":535,"Probability":0}}
HTTP 202
reset mail for [email protected] failed: Can't send mail - all recipients were rejected: 451 Chaos recipient error
reset mail for [email protected] accepted by mailpit: 250 2.0.0 Ok: queued as 1nPp2ImToreousghgjnJjP

تطبيقنا الصغير يرد ب 503 ويطلب من المستخدم المحاولة لاحقاً، وهذا مقبول في تجربة، أما في تطبيق حقيقي فالرد 451 مؤقت ويعني «حاول لاحقاً»، والتصرف الصحيح أن تضع الرسالة في طابور Queue يعيد المحاولة ويرد على المستخدم بنجاح، كما شرحنا في قسم الطابور وإعادة المحاولة. وهنا تظهر فائدة Chaos، فهو يختبر هذا الكود دون أن تنتظر يوماً يفشل فيه مزود البريد فعلاً.

نكسرها: التطبيق يشير إلى سيرفر SMTP حقيقي

لنفرض أن أحد الفريق نسخ ملف .env من بيئة staging إلى جهازه، فأصبح التطبيق في وضع التطوير يشير إلى سيرفر بريد خارجي، وسوف نستخدم الاسم smtp.example.com لأنه في نطاق محجوز للأمثلة:

SMTP_HOST=smtp.example.com SMTP_PORT=587 docker compose -p lab-ldev up -d app
docker logs lab-ldev-app

والمخرج سوف يكون كما يلي:

refusing to start: APP_ENV=development but SMTP_HOST=smtp.example.com is not in DEV_SMTP_ALLOW (mailpit,localhost,127.0.0.1)

الطبقة الأولى عملت، والتطبيق رفض أن يبدأ. والآن نفترض أسوأ حالة، وهي أن أحداً أضاف السيرفر الخارجي إلى قائمة السماح ليتجاوز الحارس، مرة بالاسم ومرة بعنوان IP من نطاق التوثيق 192.0.2.0/24:

SMTP_HOST=smtp.example.com SMTP_PORT=587 DEV_SMTP_ALLOW=smtp.example.com docker compose -p lab-ldev up -d app
docker exec lab-ldev-tests curl -s --cacert /ca/rootCA.pem -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
docker logs lab-ldev-app 2>&1 | tail -1

SMTP_HOST=192.0.2.25 SMTP_PORT=587 DEV_SMTP_ALLOW=192.0.2.25 docker compose -p lab-ldev up -d app
docker exec lab-ldev-tests curl -s --cacert /ca/rootCA.pem -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
docker logs lab-ldev-app 2>&1 | tail -1

والمخرج سوف يكون كما يلي:

HTTP 503
reset mail for [email protected] failed: DNS lookup for smtp.example.com timed out
HTTP 503
reset mail for [email protected] failed: connect ENETUNREACH 192.0.2.25:587 - Local (0.0.0.0:0)

في المخرج أعلاه لاحظ التالي:

  • بالاسم فشل التطبيق قبل أن يحاول الاتصال، لأن الشبكة الداخلية لا تستطيع أن تسأل DNS خارجياً، وبالعنوان المباشر رد النظام بالخطأ ENETUNREACH لأن الشبكة لا تملك أي طريق إلى خارج الجهاز.
  • الطبقتان مستقلتان، فالحارس في الكود يحمي حتى لو شغل أحد التطبيق خارج Docker، والشبكة الداخلية تحمي حتى لو كان في الكود خطأ أو مكتبة ترسل بريداً من مكان لا تعرفه.

ونعيد التطبيق إلى إعداده الصحيح:

docker compose -p lab-ldev up -d app

نكسرها: المحتوى المختلط Mixed Content

الصفحة https://app.lab.test/mixed تحمل ملف JavaScript من http://static.lab.test، وهذا يحدث كثيراً عندما يبني التطبيق روابط ملفاته من إعداد قديم أو من ترويسة لم يضبطها ال Reverse Proxy، كما شرحنا في مقال تطبيقك خلف Reverse Proxy، وعلى HTTP المحلي لا تراه أبداً:

docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/mixed
docker exec lab-ldev-tests curl -s http://static.lab.test/hello.js

والمخرج سوف يكون كما يلي:

https://app.lab.test/mixed → 200 #out="script not loaded"
   cookies: []
   error: Mixed Content: The page at 'https://app.lab.test/mixed' was loaded over HTTPS, but requested an insecure script 'http://static.lab.test/hello.js'. This request has been blocked; the content must be served over HTTPS.
document.getElementById('out').textContent = 'script loaded over http';

الملف موجود ويعمل بالأمر curl، لكن المتصفح رفض تحميله داخل صفحة HTTPS كما يشرح توثيق MDN، وبالتالي بقيت الصفحة على النص الأول، وهذا بالضبط ما يراه المستخدم في الإنتاج: صفحة تعمل نصف عمل دون أي خطأ في سجلات السيرفر.

نكسرها: شهادة لاسم آخر

أضاف الفريق إصداراً ثانياً من ال API على api.v2.lab.test، واستخدم الشهادة نفسها:

docker exec lab-ldev-tests curl -sS --cacert /ca/rootCA.pem https://api.v2.lab.test/
docker exec -w /tests lab-ldev-tests node src/open.mjs https://api.v2.lab.test/

والمخرج سوف يكون كما يلي:

curl: (60) SSL: no alternative certificate subject name matches target host name 'api.v2.lab.test'
https://api.v2.lab.test/ → page.goto: net::ERR_CERT_COMMON_NAME_INVALID at https://api.v2.lab.test/

الجهة موثوقة هنا، والمشكلة في الاسم، فالشهادة الشاملة *.lab.test تغطي مستوى واحداً، وmkcert نفسه ذكرنا بذلك عند إصدارها. والحل إما شهادة جديدة فيها *.v2.lab.test، أو اسم بمستوى واحد مثل api-v2.lab.test، ونفس القاعدة تنطبق على شهادات Let's Encrypt الشاملة في الإنتاج.

التطبيق يضع Cookie الجلسة sid بالخاصية Secure، والآن نفتح الصفحة نفسها مرة عبر HTTPS ومرة عبر الاسم plain.lab.test الذي يخدم التطبيق نفسه على HTTP:

docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/ http://plain.lab.test/

والمخرج سوف يكون كما يلي:

https://app.lab.test/ → 200
   cookies: ["sid secure=true"]
http://plain.lab.test/ → 200
   cookies: []

على HTTP رفض المتصفح ال Cookie بصمت، فلا يوجد خطأ في الصفحة ولا في السيرفر، والذي يراه المستخدم أنه يسجل الدخول ثم يعود إلى صفحة الدخول. وهذه المشكلة لا تظهر على localhost لأن المتصفح يستثنيه كما رأينا في تجربة الجزء الأول، وتظهر أول مرة عندما يفتح أحد التطبيق باسم آخر، وتفاصيل خواص ال Cookies في مقال الجلسات وJWT. ولهذا السبب بالذات تستحق بيئة التطوير HTTPS على اسم حقيقي، فهي تكشف هذا الخطأ على جهازك قبل أن يكتشفه المستخدم.

إزالة التجربة

نحذف ما أنشأناه بالأسماء فقط، ثم نحذف جهة الإصدار نفسها لأن مفتاحها لا يلزم بعد التجربة:

docker compose -p lab-ldev --profile tests down -v
docker image rm lab-ldev-app:1 lab-ldev-pw:1
rm -rf ~/lab-ldev/ca ~/lab-ldev/certs

كيف تستخدم هذا الإعداد كل يوم؟

  • ضع ملف Compose والملفات الثلاثة Corefile وCaddyfile وtests/ في مستودع المشروع، وأبق جهة الإصدار خارجه، ولكل مطور جهته.
  • إذا أردت أن تفتح التطبيق من متصفح جهازك نفسه فأمامك طريقان: أن تنشر منفذ Caddy على 127.0.0.1 وتستخدم أسماء *.localhost، أو أن توجه نطاق .test إلى CoreDNS وتثق بجهة mkcert على جهازك، والثاني قرار يحتاج تفكيراً كما شرحنا في قسم المخاطر.
  • شغل الاختبار نفسه في خط CI بالملف نفسه، فهو لا يحتاج إنترنت ولا حسابات، وانظر مقال البيئات وGitOps لما بعد جهازك.
  • عندما تحتاج قاعدة بيانات أو تخزين S3 أضفها كخدمة أخرى على الشبكة نفسها، وللاختبارات المعزولة لكل تشغيل استخدم Testcontainers كما في الجزء الثاني.

أخطاء شائعة

  • ignoreHTTPSErrors: true في الاختبارات: يجعل الاختبار ينجح مع أي شهادة، فلا يكشف شهادة لاسم خاطئ ولا محتوى مختلطاً، والصحيح أن تثق بالجهة داخل Container الاختبار.
  • انتظار ثابت بعد إرسال البريد: اسأل API Mailpit حتى تصل الرسالة بدل sleep(3000).
  • البحث عن الرسالة بالعنوان فقط: رسائل التشغيلات السابقة تبقى، فاحذفها في بداية الاختبار أو استخدم عنواناً فريداً، أو الاثنين معاً كما فعلنا.
  • شبكة Compose عادية بدل internal: عندها يصل أي خطأ في إعداد SMTP إلى الإنترنت مباشرة.
  • رفع rootCA-key.pem إلى المستودع: كل من يقرأ المستودع يستطيع إصدار شهادة يقبلها جهاز كل من وثق بهذه الجهة.
  • إنشاء رابط البريد من ترويسة الطلب: ابن الرابط من إعداد ثابت مثل APP_URL، والسبب أن ترويسة Host يتحكم فيها من يرسل الطلب.

الخلاصة

  • ملف Compose واحد بشبكة internal يجمع التطبيق وMailpit وCaddy وCoreDNS، ولا يخرج منه أي بريد حتى لو أخطأ الإعداد.
  • سيرفر CoreDNS بإضافة template يجيب عن كل اسم تحت lab.test، وCaddy يقدم شهادة mkcert، والثقة بالجهة داخل Container الاختبار وحده عبر NSS وNODE_EXTRA_CA_CERTS.
  • الاختبار الآلي يطلب رابط الاستعادة ويقرأ الرسالة من API Mailpit ويفتح الرابط، ونجح بعد أن وثقنا بالجهة، وقبلها فشل المتصفح بالخطأ ERR_CERT_AUTHORITY_INVALID.
  • ميزة Chaos جعلت Mailpit يرد ب 451، فظهر كيف يتصرف التطبيق عندما يرفض سيرفر البريد الرسالة.
  • عندما وجهنا التطبيق إلى سيرفر حقيقي رفض الحارس أن يبدأ، وعندما تجاوزناه فشل الاتصال بالخطأين DNS lookup timed out وENETUNREACH.
  • المحتوى المختلط، والشهادة الشاملة على اسم بمستويين، وال Cookie الآمن على HTTP، ثلاثة أخطاء ظهرت هنا على جهازك بدل أن تظهر في الإنتاج.
دليلبيئة التطوير المحلية: كيف تحاكي البريد وDNS وHTTPS على جهازكلماذا تستبدل البريد وDNS وHTTPS ببدائل محلية أثناء التطوير، وكيف يعامل المتصفح localhost، وأي النطاقات تتجنب.دليلأدوات بيئة التطوير المحلية: ماذا تستخدم لكل خدمة؟مقارنة بالقياس بين أدوات التقاط البريد والشهادات المحلية والأسماء ومحاكاة ال API والسحابة، وأيها تختار لمشروعك.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، والتجربة على Docker Engine 29.8.0 وDocker Compose v5.5.1 مع CoreDNS 1.14.7 وCaddy 2.11.7 وMailpit v1.31.5 وNode.js 24.21.0 وnodemailer 10.1.0 وmkcert v1.4.4، والاختبار ب Playwright 1.64.0 (Chromium 156.0.8078.4) وcertutil من NSS 3.98 داخل Ubuntu 24.04، والمعلومات من توثيق Mailpit وCaddy وCoreDNS وMDN كما قرأناه في 12 أكتوبر 2026.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.