نشرة هذا الموقع تعمل على خادم كتبته بنفسي: 1,258 سطرًا من TypeScript وSvelte وSQL. تأكيد مزدوج، إلغاء اشتراك بنقرة واحدة، واجهة سطر أوامر للإرسال، ونقطة فحص للصحة. لا حساب على منصة، ولا فاتورة شهرية، وقائمة المشتركين ملف واحد أنسخه بـ rsync.
النشرة البريدية هي ثلاث نقاط نهاية، وجدولان، وترويسة رسالة تخفيها عنك أغلب المنصات.
هذه الترويسة هي ما جعل البناء قصيرًا. في فبراير 2024 جعلت Google وYahoo إلغاء الاشتراك بنقرة واحدة شرطًا على المُرسِلين بالحجم، عبر تطبيق RFC 8058 — ترويسات List-Unsubscribe وList-Unsubscribe-Post في كل رسالة. أصعب جزء في التسويق بالبريد صار ترويسة قياسية. وكل ما تضيفه المنصة المدفوعة حولها فهو إمّا هذا أو دفتر العناوين.
لماذا تستضيف النشرة بنفسك أصلًا؟
الجرد الصادق: القائمة صغيرة، والحجم رسالة واحدة لكل مقال، وخدمات الموقع الأخرى تعمل عندي أصلًا. استئجار منصة لهذا كان يعني تسليم العلاقة الوحيدة مع القرّاء التي أملكها فعلًا لطرف ثالث، مع صيغة تصدير اسمها مهذب ومواصفاته غامضة. كتبتها بنفسي: عصرٌ للأساس وعصرٌ للحواف.
| هذا الخادم | منصة مستضافة | |
|---|---|---|
| أسطر الكود التي أستطيع قراءتها | 1,258، كلها | 0 |
| ملكية القائمة | ملف SQLite واحد | زر تصدير |
| إلغاء الاشتراك | ترويسة RFC 8058 بتوقيع محلي | إعداد في لوحة التحكم |
| سقف التسليم | ~5/10 على mail-tester حتى يتوافق DKIM | سمعتها هي |
| الوقت حتى أول إرسال | عطلة نهاية أسبوع | أمسية |
الجدول هو الصفقة كلها على شاشة واحدة. المنصة تتفوق بسمعة التسليم وبأنك لا تفكر؛ والملف يتفوق بالملكية وبإمكانية تدقيقه في محرر نصوص.
كيف يعمل التأكيد المزدوج بلا جلسة؟
ثلاثة ملفات تحمل التدفق كله. في db.ts جدولان — subscribers مع قيد CHECK يحبس status على pending أو confirmed أو unsubscribed، وsends لأثر التدقيق. ونقطة الاشتراك تأخذ POST واحدًا:
curl -X POST https://mrsaynothing.dev/letters/api/subscribe
-H 'content-type: application/json'
-d '{"email":"[email protected]"}' قبل أن تنظر نقطة النهاية إلى بريدك، تنظر إلى حقل في النموذج اسمه website. البشر لا يرونه أبدًا فيبقى فارغًا؛ والبوتات تملأ كل شيء، فملؤه يشتري لها ok مزيفًا ولا شيء غيره. وبعد الفخ، يدخل العنوان في دلو رموز — 10 طلبات في الساعة لكل IP، وإعادة إرسال واحدة لكل عنوان كل 10 دقائق — والردود متطابقة عمدًا كان العنوان مشتركًا أم لا، حتى لا تصلح نقطة النهاية لعدّ أي أحد.
رابط التأكيد يحمل رمزًا موقّعًا بدل جلسة:
import { createHmac, timingSafeEqual } from 'node:crypto';
// payload = base64url(email|action), MAC = HMAC-SHA256(payload)
export function sign(email: string, action: 'confirm' | 'unsub'): string {
const payload = Buffer.from(`${email}|${action}`).toString('base64url');
const mac = createHmac('sha256', config.hmacSecret).update(payload).digest('base64url');
return `${payload}.${mac}`;
} التحقق هو timingSafeEqual مقابل MAC يُحسب من جديد. لا جدول للرموز ولا صلاحية تنتهي — التوقيع هو السلطة الوحيدة، فارابط التأكيد يعمل حتى والقاعدة في منتصف إعادة التشغيل. ورموز إلغاء الاشتراك لا تنتهي أبدًا، عن قصد: آلية الموافقة يجب أن تظل تعمل إلى الأبد، وإبطالها يعني تدوير مفتاح التوقيع، وهو يسكن خارج المستودع ويُوصل للقراءة فقط وقت التشغيل.
رابط تأكيد يعمل والقاعدة نائمة: شيء واحد أقل يمكن أن يتعفّن.
عُرف غريب لدى المستقبِلات هو من حدّد إعدادات الأمان: إلغاء الاشتراك بنقرة واحدة في Gmail يطلق POST عبر نطاق مختلف مباشرة إلى نقطة النهاية، فكان لا بد من تعطيل فحص نفس الأصل المعتاد على هذا المسار — MAC هو من يصادق هناك، وترويسة الأصل كانت تعيق معيارًا فقط.
ما الذي انكسر أثناء البناء؟
السجل، بترتيب الحدوث:
- فشل بناء الصورة بخطأ
ERR_PNPM_IGNORED_BUILDS. كانpnpm install --frozen-lockfileيمر محليًا ويموت داخل الحاوية، لأن سكربت postinstall الخاص بـ esbuild لم يكن معتمدًا أصلًا. الإصلاح: تضمين ملفpnpm-workspace.yamlالصغير الذي يعلن سكربتات البناء المسموحة داخل الصورة. نصف يوم للعثور، ملف واحد للإصلاح. - الحماية من CSRF حجبت Gmail. طلب Gmail ذو النقرة الواحدة لا يحمل بوضوح أي بيانات نفس الأصل. المسار يثق الآن بالرمز، والرمز لا يُزوَّر.
- التسليم له سقف على الطريق المجاني. بدون DKIM متوافق، يمنح mail-tester الإعداد حوالي 5/10، وقد تُرسِل الفلاتر الصارمة الرسالة. هذه هي التكلفة الصادقة لتجاوز relay مدفوع؛ والإصلاح معروف ومنتظر أن تبرره القائمة يومًا.
ما الذي لا يفعله بعد؟
لا تتبّعًا للفتح — وهذه مبدأ، لأن بكسل التتبع هو سبب انعدام ثقة الناس بالنشرات. قائمة واحدة، بلا شرائح، ولا جدولة بعد واجهة سطر أوامر تُستدعى يدويًا أو بمؤقّت، إضافة إلى سقف التسليم أعلاه. الإرسال أمر واحد: cli.mjs send --subject ... --file post.md، مع علم --dry يصيّر النص وHTML كاملين للمراجعة قبل أن يتحرك أي شيء. أما قصة النسخ الاحتياطي فهي قصة SQLite: انسخ الملف، استعد الملف، ووضع WAL يبقي النسخة متسقة والخادم يعمل. وشغله تحت إشراف systemd هو باقي شغل التشغيل.
215 سطرًا منها هي واجهة سطر الأوامر. والـ 1,043 الباقية كلها «موافقة».
أي جزء من حزبتك يديره مورد مستأجر وأنت تفضّل امتلاكه في ملف تستطيع قراءته؟ وأي اشتراك ما زلت تدفع ثمنه لأن ترحيل البيانات يبدو أسوأ من الفاتورة؟
اشترك في نشرة [mrsaynothing.dev/newsletter](/en/newsletter) — إنها تعمل على هذا.FAQ
هل وصول الرسائل جيد مع نشرة بريدية مستضافة ذاتيًا؟
ترسل عبر SMTP عادي، فتقع تحت السقف نفسه الذي يواجهه أي مُرسِل صغير: بدون DKIM متوافق، يمنح mail-tester الإعداد حوالي 5/10، وقد تُرسِل بعض الفلاتر الصارمة الرسالة إلى البريد المزعج. يكفي تمامًا لقائمة صغيرة؛ والحل المعروف هو relay مدفوع إذا كبرت القائمة.
لماذا node:sqlite بدلًا من Postgres؟
الحالة كلها جدولان: subscribers و sends. تشتغل SQLite بوضع WAL دون أي خدمة خلفية، والنسخ الاحتياطي هو نسخ ملف واحد. كانت Postgres أكبر تبعية في أصغر مهمة.
كيف يعمل إلغاء الاشتراك بنقرة واحدة؟
كل رسالة تحمل الترويستين List-Unsubscribe و List-Unsubscribe-Post وفق RFC 8058. تطلب Gmail وYahoo إلغاء الاشتراك بنقرة واحدة من المُرسِلين بالحجم منذ فبراير 2024 — الترويسة هي الميزة كلها.
— mrsaynothing
— mrsaynothing
بناء وأعطال وما نُشر فعلًا.
ناقش هذا المقال على dev.to dev.to ↗
احصل على سجل البناء التالي بالبريد
رسالة واحدة لكل مقال. بالأدلة والأخطاء.
ما هذا؟مولّد git undo: الأمر الدقيق لفوضك
أعجبتك هذه الكتابات؟ بناء مثل هذا هو عملي. اشترك في النشرة