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

Rails وRack

ملف تهيئة، ومهمة لا يمكنها الإرسال مرتين، ووحدة تحكم لـ webhook، واختبارات لا تصل إلى الشبكة أبدًا.

الإعداد

ليس للـ gem تكامل خاص به مع Rails: لا Railtie، ولا طريقة تسليم لـ ActionMailer. تستدعي API من مهمة أو من كائن خدمة، عبر العميل المشترك أو عميل تبنيه، بالطريقة نفسها كما من أي برنامج Ruby.

config/initializers/openemail.rb
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 تشغيل الرسالة التي أرسلتها بالفعل بدل إرسال رسالة ثانية.

app/jobs/send_invoice_job.rb
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 تعيد تشغيل الإرسال.

app/sidekiq/welcome_email_job.rb
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

من كائن خدمة

كائن الخدمة الذي يأخذ عميله كوسيط، مع العميل المشترك قيمةً افتراضية، يُبقي الإرسال في مكان واحد ويتيح للاختبار أن يعطيه عميلًا خاصًا به.

app/services/invoice_email.rb
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 التي تقرأ منها ترويسة التوقيع.

app/controllers/open_email_webhooks_controller.rb
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  endend
config/routes.rb
Rails.application.routes.draw do  post "/webhooks/openemail", to: "open_email_webhooks#create"end

وحدة التحكم من نوع ActionController::API ليس فيها حماية من التزوير يلزم تجاوزها. أما تحت ActionController::Base، أو ApplicationController مبنية عليها، فأضف skip_forgery_protection، وإلا رفض Rails طلب POST قبل أن يعمل الإجراء الخاص بك، لأن التسليم لا يحمل رمز أصالة.

app/controllers/open_email_webhooks_controller.rb
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" في المسارات.

app/webhooks/open_email_webhook.rb
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 الذي يأخذ منه اسم الملف ونوعه.

app/controllers/uploads_controller.rb
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، فيبني الاستدعاء التالي من البيئة مجددًا.

test/jobs/send_invoice_job_test.rb
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 لتختبر كيف تتعامل شيفرتك مع الرفض.

العميل المشترك يخص العملية كلها، فالاختبارات التي تعمل في خيوط تنفيذ في الوقت نفسه ينبغي أن تعطي الشيفرة قيد الاختبار عميلًا خاصًا بها، كما يقبل كائن الخدمة أعلاه.