Rails और Rack
एक initializer, ऐसा जॉब जो दो बार नहीं भेज सकता, एक वेबहुक controller, और ऐसे टेस्ट जो कभी नेटवर्क तक नहीं पहुँचते।
सेटअप
gem का अपना कोई Rails इंटीग्रेशन नहीं है: न Railtie, और न ActionMailer का कोई delivery method। आप API को किसी जॉब या service object से, साझा क्लाइंट या अपने बनाए क्लाइंट के ज़रिए, ठीक वैसे ही कॉल करते हैं जैसे किसी भी 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 और हर दूसरा namespace उस प्रोसेस की हर रिक्वेस्ट, जॉब और console सेशन में उसी क्लाइंट का इस्तेमाल करते हैं।
क्रेडेंशियल के बिना, साझा क्लाइंट इसकी जगह अपनी पहली कॉल पर ख़ुद को OPENEMAIL_API_KEY से बना लेता है, इसलिए जो deploy कुंजी को एनवायरनमेंट में रखता है उसे किसी initializer की ज़रूरत ही नहीं। OpenEmail.init को जब कहीं भी क्रेडेंशियल नहीं मिलता तो वह ArgumentError raise करता है, और initializer assets:precompile जैसे commands के लिए भी चलता है, जहाँ कुंजी सेट न हो, इसीलिए ऊपर की कॉल सुरक्षित रखी गई है।
जॉब से भेजना
रिक्वेस्ट के बजाय किसी जॉब से भेजें, ताकि धीमा या विफल send कभी किसी पेज को न रोके। idempotency_key: उस रिकॉर्ड से निकालें जिसके बारे में send है। जो जॉब दोबारा चलता है, चाहे queue ने उस पर पुनः प्रयास किया हो या API के जवाब देने के बाद कोई worker बंद हो गया हो, वह फिर उसी कुंजी से भेजता है, और API दूसरा संदेश भेजने के बजाय पहले भेजा गया संदेश replay करता है।
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]) endendretry_on OpenEmail::NetworkError उस send को जिसे कोई जवाब नहीं मिला वापस queue को सौंप देता है, और अगर पहला वाला API तक पहुँच गया था तो वही कुंजी अगली बार को replay बना देती है। discard_on OpenEmail::ValidationError उस संदेश को छोड़ देता है जिसे API ने लिखे रूप में ही अस्वीकार कर दिया, क्योंकि उसे दोबारा भेजना सफल नहीं हो सकता। जब तक कोई NetworkError जॉब तक पहुँचता है, gem ख़ुद उसी कुंजी से send को दोबारा आज़मा चुका होता है, डिफ़ॉल्ट रूप से दो बार।
कुंजी को रिकॉर्ड और उद्देश्य के लिए स्थिर रखें, जैसे invoice:42:email है। timestamp या SecureRandom से बनी कुंजी हर बार नई होती है, और तब दोबारा आज़माया गया जॉब दो बार भेज देता। किसी कुंजी को अलग बॉडी के साथ दोबारा इस्तेमाल करने पर, जैसे कोई इनवॉइस जो दो रनों के बीच बदल गया हो, भेजे जाने के बजाय 422 idempotency_key_reuse के साथ अस्वीकार किया जाता है।
invoice.pdf.download Active Storage से बाइट्स को binary String के रूप में लौटाता है, जिसे अटैचमेंट का content जैसा है वैसा ही ले लेता है। Active Support का समय, जैसे scheduledAt: 1.day.from_now, किसी भी Time की तरह UTC क्षण के रूप में भेजा जाता है, जबकि Date.tomorrow एक Ruby Date है, जो सादी तारीख़ के रूप में भेजी जाती है जिसे API UTC की आधी रात पढ़ता है, इसलिए जब घंटा मायने रखता हो तो समय पास करें।
एक सादा Sidekiq जॉब भी इसी तरह काम करता है। कुंजी को जॉब के arguments से निकालें और Sidekiq के अपने पुनः प्रयासों को send replay करने दें।
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किसी service object से
जो service object अपना क्लाइंट argument के रूप में लेता है, डिफ़ॉल्ट के तौर पर साझा क्लाइंट के साथ, वह send को एक जगह रखता है और टेस्ट को उसे अपना क्लाइंट देने देता है।
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" ) endendInvoiceEmail.new.deliver(number: "INV-2026-0042", to: "[email protected]") साझा क्लाइंट से भेजता है, और InvoiceEmail.new(client: test_client) किसी दूसरे से।
वेबहुक प्राप्त करना
हर डिलीवरी पर कार्रवाई करने से पहले उसे सत्यापित करें। 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"endActionController::API controller में ऐसी कोई forgery protection नहीं होती जिसे पार करना पड़े। ActionController::Base के तहत, या उस पर बने ApplicationController में, skip_forgery_protection जोड़ें, वरना आपकी action चलने से पहले ही Rails POST को अस्वीकार कर देता है, क्योंकि डिलीवरी में कोई authenticity token नहीं होता।
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 उसके हर पुनः प्रयास और replay पर एक ही रहता है, इसलिए जिन ids को आप संभाल चुके हैं उन्हें सहेजें और देखे हुए को छोड़ दें।
head :bad_request जाली या पुरानी डिलीवरी का जवाब देता है। ग़ायब secret एक अलग विफलता है: उसके लिए verify_webhook_signature ArgumentError raise करता है, जिसे controller rescue नहीं करता, इसलिए ग़लत कॉन्फ़िगर किया गया ऐप 500 जवाब देता है और ठीक करने के बाद डिलीवरी फिर आज़माई जाती है, बजाय इसके कि हर इवेंट जाली मानकर लौटा दिया जाए।
यही जाँच किसी भी Rack ऐप में काम करती है, Rack env को ही headers: के रूप में देकर, जहाँ सिग्नेचर हेडर HTTP_X_OPENEMAIL_SIGNATURE के रूप में आता है। इस जैसा Rack endpoint config.ru में run OpenEmailWebhook.new के साथ अपने आप चलता है, या Rails के अंदर routes में 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"]] endendParams और अपलोड
रिक्वेस्ट बॉडी कोई भी ऑब्जेक्ट हो सकती है जो to_hash का जवाब दे, इसलिए permitted 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 के workers सभी एक साथ OpenEmail.emails के ज़रिए भेज सकते हैं। बनने के बाद क्लाइंट frozen हो जाता है, उसके पीछे का कनेक्शन पूल lock लेता है, और OpenEmail.init तथा OpenEmail.client भी ऐसा ही करते हैं।
fork के बाद, जैसे preload_app! वाले Puma के cluster मोड, Unicorn या Resque में, चाइल्ड प्रोसेस विरासत में मिले कनेक्शन भूल जाता है और अपने खोलता है, इसलिए fork से पहले किसी initializer में बना क्लाइंट हर worker में सुरक्षित है। on_worker_boot में दोबारा जोड़ने को कुछ नहीं है।
हर प्रोसेस हर होस्ट के लिए 8 तक निष्क्रिय कनेक्शन रखता है, हर एक को 2 सेकंड तक। इसे बदलने के लिए अपना OpenEmail::NetHttpAdapter.new(max_idle:, keep_alive_timeout:) adapter: के रूप में पास करें।
टेस्टिंग
अपने टेस्ट में क्लाइंट को एक एडैप्टर दें और कुछ भी मशीन से बाहर नहीं जाता। setup में OpenEmail.init पूरे प्रोसेस के लिए साझा क्लाइंट को बदल देता है, और teardown में OpenEmail.reset_client उसे हटा देता है, ताकि अगली कॉल फिर से एनवायरनमेंट से बने।
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हर दर्ज रिक्वेस्ट method, url, headers, body और timeout वाला एक OpenEmail::HttpRequest है, और उसका body वह JSON है जो भेजा जाता, इसलिए JSON.parse(request.body) ख़ुद संदेश दिखाता है। यह टेस्ट करने के लिए कि आपका कोड अस्वीकार को कैसे संभालता है, API के error envelope के साथ कोई error status लौटाएँ।
साझा क्लाइंट पूरे प्रोसेस का है, इसलिए जो टेस्ट एक ही समय में थ्रेड्स में चलते हैं उन्हें टेस्ट हो रहे कोड को अपना क्लाइंट देना चाहिए, जैसा ऊपर का service object स्वीकार करता है।