Rails وRack
ملف تهيئة، ومهمة لا يمكنها الإرسال مرتين، ووحدة تحكم لـ webhook، واختبارات لا تصل إلى الشبكة أبدًا.
الإعداد
ليس للـ gem تكامل خاص به مع Rails: لا Railtie، ولا طريقة تسليم لـ ActionMailer. تستدعي API من مهمة أو من كائن خدمة، عبر العميل المشترك أو عميل تبنيه، بالطريقة نفسها كما من أي برنامج Ruby.
api_key = Rails.application.credentials.dig(:openemail, :api_key) OpenEmail.init(api_key:, timeout: 15) if api_keyاحفظ المفتاح عبر bin/rails credentials:edit، تحت openemail وapi_key. وبعد OpenEmail.init، تستخدم OpenEmail.emails وOpenEmail.webhooks وكل مساحة أسماء أخرى ذلك العميل في كل طلب ومهمة وجلسة console في العملية.
ودون بيانات الاعتماد، يبني العميل المشترك نفسه من OPENEMAIL_API_KEY عند أول استدعاء له بدلًا من ذلك، فالنشر الذي يحفظ المفتاح في البيئة لا يحتاج إلى ملف تهيئة إطلاقًا. ويرفع OpenEmail.init الخطأ ArgumentError حين لا يجد أي اعتماد في أي مكان، وملف التهيئة يعمل أيضًا مع أوامر مثل assets:precompile حيث قد لا يكون المفتاح مضبوطًا، ولهذا جاء الاستدعاء أعلاه مشروطًا.
الإرسال من مهمة
أرسل من مهمة بدل الطلب، كي لا يعطّل إرسال بطيء أو فاشل أي صفحة أبدًا. اشتقّ idempotency_key: من السجل الذي يخصه الإرسال. فالمهمة التي تعمل مجددًا، لأن الطابور أعاد محاولتها أو لأن عاملًا توقف بعد أن أجابت API، ترسل حينها بالمفتاح نفسه، فتعيد API تشغيل الرسالة التي أرسلتها بالفعل بدل إرسال رسالة ثانية.
class SendInvoiceJob < ApplicationJob queue_as :default retry_on OpenEmail::NetworkError, wait: 30.seconds, attempts: 5 discard_on OpenEmail::ValidationError def perform(invoice) sent = OpenEmail.emails.send( from: "Acme Billing <[email protected]>", to: invoice.customer_email, subject: "Invoice #{invoice.number}", html: "<p>Your invoice #{invoice.number} is attached.</p>", attachments: [{filename: "#{invoice.number}.pdf", content: invoice.pdf.download, contentType: "application/pdf"}], idempotency_key: "invoice:#{invoice.id}:email" ) invoice.update!(email_id: sent[:id], email_status: sent[:status]) endendيعيد retry_on OpenEmail::NetworkError الإرسال الذي لم يتلقَّ ردًا إلى الطابور، ويجعل المفتاح نفسه التشغيل التالي إعادة تشغيل إن كان الأول قد وصل فعلًا إلى API. ويتخلّص discard_on OpenEmail::ValidationError من رسالة رفضتها API كما كُتبت، لأن إرسالها مجددًا لا يمكن أن ينجح. وحين يصل NetworkError إلى المهمة، يكون الـ gem قد أعاد محاولة الإرسال بنفسه بالفعل، مرتين افتراضيًا، بالمفتاح نفسه.
اجعل المفتاح ثابتًا للسجل وللغرض، كما هو حال invoice:42:email. فالمفتاح المبني من طابع زمني أو من SecureRandom يكون جديدًا في كل تشغيل، وعندئذ سترسل المهمة المعادة مرتين. وإعادة استخدام مفتاح بمتن مختلف، مثل فاتورة تغيّرت بين تشغيلين، تُرفض بالخطأ 422 idempotency_key_reuse بدل أن تُرسَل.
يعيد invoice.pdf.download البايتات من Active Storage في صورة String ثنائي، يأخذه content الخاص بالمرفق كما هو. والوقت من Active Support، مثل scheduledAt: 1.day.from_now، يُرسَل كلحظة UTC مثل أي Time، بينما Date.tomorrow هو Date في Ruby، يُرسَل كتاريخ مجرد تقرؤه API على أنه منتصف الليل UTC، فمرّر وقتًا حين تهمّ الساعة.
مهمة Sidekiq العادية تعمل بالطريقة نفسها. اشتقّ المفتاح من وسائط المهمة ودع إعادات المحاولة الخاصة بـ Sidekiq تعيد تشغيل الإرسال.
class WelcomeEmailJob include Sidekiq::Job def perform(user_id, email) OpenEmail.emails.send( from: "Acme <[email protected]>", to: email, subject: "Welcome to Acme", text: "Glad you are here.", idempotency_key: "welcome:#{user_id}" ) endendمن كائن خدمة
كائن الخدمة الذي يأخذ عميله كوسيط، مع العميل المشترك قيمةً افتراضية، يُبقي الإرسال في مكان واحد ويتيح للاختبار أن يعطيه عميلًا خاصًا به.
class InvoiceEmail def initialize(client: OpenEmail.client) @client = client end def deliver(number:, to:) @client.emails.send( from: "Acme Billing <[email protected]>", to:, subject: "Invoice #{number}", text: "Your invoice #{number} is attached.", idempotency_key: "invoice:#{number}:email" ) endendيرسل InvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") عبر العميل المشترك، وInvoiceEmail.new(client: test_client) عبر أي عميل آخر.
استقبال webhooks
تحقق من كل تسليم قبل أن تتصرف بناءً عليه. يحلّل Rails متن JSON إلى params، لكن التوقيع يغطي البايتات الخام، فمرّر request.raw_post إلى أداة التحقق، مع request.headers التي تقرأ منها ترويسة التوقيع.
class OpenEmailWebhooksController < ActionController::API def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendRails.application.routes.draw do post "/webhooks/openemail", to: "open_email_webhooks#create"endوحدة التحكم من نوع ActionController::API ليس فيها حماية من التزوير يلزم تجاوزها. أما تحت ActionController::Base، أو ApplicationController مبنية عليها، فأضف skip_forgery_protection، وإلا رفض Rails طلب POST قبل أن يعمل الإجراء الخاص بك، لأن التسليم لا يحمل رمز أصالة.
class OpenEmailWebhooksController < ApplicationController skip_forgery_protection def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: Rails.application.credentials.dig(:openemail, :webhook_secret) ) HandleOpenEmailEventJob.perform_later(event) head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endendسلّم الحدث إلى مهمة وأجب فورًا: فالتسليم الذي لا يتلقى ردًا خلال 5 ثوانٍ يُعدّ فاشلًا ويُرسَل مجددًا لاحقًا. وid الحدث هو نفسه في كل إعادة محاولة وإعادة تشغيل له، فاحفظ المعرّفات التي عالجتها وتخطَّ ما رأيته من قبل.
يجيب head :bad_request على التسليم المزوَّر أو القديم. أما غياب السر فإخفاق مختلف: يرفع verify_webhook_signature له ArgumentError، ولا تلتقطه وحدة التحكم، فيجيب التطبيق ذو الإعداد الخاطئ بـ 500 ويُعاد التسليم بعد أن تصلحه، بدل أن يُرفض كل حدث على أنه مزوَّر.
يعمل الفحص نفسه في أي تطبيق Rack، مع بيئة Rack نفسها بوصفها headers:، حيث تصل ترويسة التوقيع على أنها HTTP_X_OPENEMAIL_SIGNATURE. ونقطة نهاية Rack مثل هذه تعمل وحدها عبر run OpenEmailWebhook.new في config.ru، أو داخل Rails عبر mount OpenEmailWebhook.new => "/webhooks/openemail" في المسارات.
class OpenEmailWebhook def call(env) request = Rack::Request.new(env) event = OpenEmail.verify_webhook_signature( payload: request.body.read, headers: env, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET") ) HandleOpenEmailEventJob.perform_later(event) [204, {}, []] rescue OpenEmail::WebhookSignatureError [400, {"content-type" => "text/plain"}, ["bad signature"]] endendالمعاملات والرفع
يمكن أن يكون متن الطلب أي كائن يستجيب لـ to_hash، فتمر ActionController::Parameters المسموح بها مباشرة. والملف القادم من نموذج، أي ActionDispatch::Http::UploadedFile، يذهب مباشرة إلى files.upload الذي يأخذ منه اسم الملف ونوعه.
class UploadsController < ApplicationController def create file = OpenEmail.files.upload(params.require(:file)) render json: {id: file[:id]} endendخيوط التنفيذ وعمليات fork
يمكن مشاركة عميل واحد بأمان بين خيوط التنفيذ، فتستطيع خيوط Puma وعمّال Sidekiq كلها الإرسال عبر OpenEmail.emails في آن واحد. والعميل يُجمَّد بمجرد بنائه، ومجموعة الاتصالات خلفه تأخذ قفلًا، وكذلك يفعل OpenEmail.init وOpenEmail.client.
بعد عملية fork، كما في وضع cluster في Puma مع preload_app! أو في Unicorn أو Resque، تنسى العملية الابنة الاتصالات التي ورثتها وتفتح اتصالاتها الخاصة، فالعميل المبني في ملف تهيئة قبل fork آمن في كل عامل. ولا يوجد ما يلزم إعادة توصيله في on_worker_boot.
تحتفظ كل عملية بما يصل إلى 8 اتصالات خاملة لكل مضيف، لمدة ثانيتين لكل منها. مرّر OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) الخاص بك بوصفه adapter: لتغيير ذلك.
الاختبار
أعطِ العميل محوّلًا في اختباراتك فلا يغادر شيء الجهاز. يستبدل OpenEmail.init في setup العميل المشترك للعملية كلها، ويتخلص منه OpenEmail.reset_client في teardown، فيبني الاستدعاء التالي من البيئة مجددًا.
require "test_helper" class SendInvoiceJobTest < ActiveJob::TestCase FakeOpenEmail = Struct.new(:requests) do def call(request) requests << request OpenEmail::HttpResponse.new( status: 200, headers: {"content-type" => "application/json"}, body: JSON.generate({id: "msg_test", status: "sent"}) ) end end setup do @api = FakeOpenEmail.new([]) OpenEmail.init(api_key: "oe_test_fake", adapter: @api, max_retries: 0, disable_update_notice: true) end teardown do OpenEmail.reset_client end test "a job that runs twice sends under one key" do invoice = invoices(:september) 2.times { SendInvoiceJob.perform_now(invoice) } keys = @api.requests.map { |request| request.headers["Idempotency-Key"] } assert_equal ["invoice:#{invoice.id}:email"] * 2, keys assert_equal "/emails", URI(@api.requests.first.url).path endendكل طلب مسجَّل هو OpenEmail::HttpRequest يحتوي على method وurl وheaders وbody وtimeout، وbody الخاص به هو JSON الذي كان سيُرسَل، فيعرض JSON.parse(request.body) الرسالة نفسها. أعد حالة خطأ مع غلاف أخطاء API لتختبر كيف تتعامل شيفرتك مع الرفض.
العميل المشترك يخص العملية كلها، فالاختبارات التي تعمل في خيوط تنفيذ في الوقت نفسه ينبغي أن تعطي الشيفرة قيد الاختبار عميلًا خاصًا بها، كما يقبل كائن الخدمة أعلاه.