Learn Ruby on Rails Book

Handling idempotency when sending emails using Sidekiq

Now that we have covered unit testing and background jobs with Sidekiq, let's add a new feature to send reminder emails to users with outstanding tasks.

In this chapter we will discuss concepts like idempotency, mail delivery windows, setting user preferences, and more.

Features

These are the basic requirements of the feature :

  • Send reminder emails to users with pending tasks.
  • Users should have an option to select their timezone while scheduling mail.
  • User can set preferred hour i.e delivery time to receive mail with all pending tasks, if any.

Mail delivery feature

Technical design

To implement this feature, we need to introduce the following changes:

On the backend

  • For scheduling periodic Sidekiq jobs, we can use the sidekiq-cron gem, which helps us to schedule the worker, which will be sending the mail.

  • In the development environment, instead of sending an email to the user's email address, we can preview the email in our browser using the letter_opener gem.

  • Mail will be sent to each user at 10 AM by default, or at their preferred time, which we will store in a new table called preference. This preferences table consist notification_delivery_hour,receive_email and user columns.

  • Create user_notifications table with last_notification_sent_date and user columns.

  • In user_notification model, add validations to ensure that the last_notification_sent_date attribute is present, has a valid date format, and the date is not in the past.

  • We will be creating a default preference for the user whenever a new user is created.

  • We will be saving the default preference value in a constant DEFAULT_NOTIFICATION_DELIVERY_HOUR, which will be loaded automatically from config/initializers/constants.rb, by Rails, after it loads the framework plus any gems and plugins in our application.

  • Add PreferencesController which will add the ability for the user to update their preference for when to receive the email.

  • Mail sending can often fail due to network errors, among others. When that happens, Sidekiq will retry based on rules we set in the initializer.

On the frontend

  • Define preference related axios connectors.

  • We will add a MyPreferences component which will handle the preferences of the user.

  • Delegate the job of taking in the preferred time for mail delivery to a PreferenceForm component within the MyPreferences component.

  • Add a function fetchPreferenceDetails in the MyPreferences container component which will fetch preference details like notification_delivery_hour and receive_email.

  • Similarly, add a function updatePreference in the MyPreferences container component which will update notification_delivery_hour.

Sidekiq worker

To send each batch of reminder emails, we will schedule a TodoNotificationsWorker to run every hour and delegate the job of finding the recipients to a TodoNotificationsService.

This service will ultimately invoke separate UserNotificationWorkers for each recipient it finds.

Invoking separate workers helps to avoid having a single point of failure in the batch bring the whole delivery system down.

We will also create a scheduler file to store cron values that will help schedule our TodoNotificationsWorker at the desired time interval for each environment.

The parent Sidekiq worker will then invoke TodoNotificationsService, which will find all the users with pending tasks but no entries in a new table called user_notifications.

What is idempotency

We need our service to be idempotent, ie: duplicate mail shouldn't be sent every time we retry.

From a RESTful service standpoint, an operation or service call is idempotent when clients can perform a duplicate request harmlessly. In other words, making multiple identical requests has the same effect as making a single request.

Note that while idempotent operations produce the same result on the server, the response itself may not be the same; e.g. a resource's state may change between requests.

To ensure this, we will use the user_notifications table from earlier to associate each user with a last_notification_sent_date.

If a user has an entry for today in user_notifications, it means there's no need to try again.

Action Mailer

Action Mailer allows you to send emails from your application using mailer classes and views. They inherit from ActionMailer::Base and live in app/mailers.

To make sure that the email has truly been sent to the user, we will make use of Action Mailer's after_action callback to safely set the last_notification_sent_date value only after the mail has been sent successfully.

Mailers have methods called "actions" and they use views to structure their content.

A controller generates content like say HTML, which will be sent back to the client, whereas a Mailer creates a message to be delivered via email.

We will be creating the TaskMailer mailer and task_mailer view.

We are now ready to start coding. Let us dive in.

Setting up base

Open the Gemfile.rb and add the following lines:

1# For periodic sidekiq jobs
2gem "sidekiq-cron"
3
4# For opening mails in development env
5gem "letter_opener", :group => :development

Install the gems:

1bundle install

In order for the letter_opener gem to open the mails automatically in our browser, we need to set the following in config/environments/development.rb:

1config.action_mailer.delivery_method = :letter_opener
2config.action_mailer.perform_deliveries = true

Now any email delivery will pop up in our browser instead of being sent via the internet. The messages will be stored in ./tmp/letter_opener.

When you start redis with sidekiq-cron, you may see warnings such as shown below:

1Redis#exists(key)` will return an Integer in redis-rb 4.3, if you want to keep the old behavior, use `exists?` instead.

To turn off this warning, open config/application.rb file and add the following lines:

1# previous code if any
2  class Application < Rails::Application
3    # previous code if any
4    Redis.exists_returns_integer = false
5    # previous code if any
6  end
7# previous code if any

Creating models

1bundle exec rails g model user_notification
2bundle exec rails g model preference

Go to db/migrate folder and paste the following lines of code for the CreateUserNotifications migration:

1class CreateUserNotifications < ActiveRecord::Migration[6.1]
2  def change
3    create_table :user_notifications do |t|
4      t.date :last_notification_sent_date, null: false
5      t.references :user, foreign_key: true
6      t.index [:user_id, :last_notification_sent_date],
7        name: :index_user_preferences_on_user_id_and_notification_sent_date,
8        unique: true
9      t.timestamps
10    end
11  end
12end

Also, paste the following lines of code for the CreatePreferences migration:

1class CreatePreferences < ActiveRecord::Migration[6.1]
2  def change
3    create_table :preferences do |t|
4      t.integer :notification_delivery_hour
5      t.boolean :receive_email, default: true, null: false
6      t.references :user, foreign_key: true
7      t.timestamps
8    end
9  end
10end

Run the migrations:

1bundle exec rails db:migrate

Moving messages to i18n en.locales

Let's move the error messages to en.yml:

1en:
2  # previous code if any
3  successfully_updated: "%{entity} was successfully updated!"
4  preference:
5    notification_delivery_hour:
6      range: "value should be between 0 and 23"
7  date:
8    invalid: "must be a valid date"
9    cant_be_in_past: "can't be in the past"

Adding validations

Now let's setup UserNotification model and add validations for last_notification_sent_date.

Open app/models/user_notification.rb and add the following lines to it:

1class UserNotification < ApplicationRecord
2  belongs_to :user
3
4  validates :last_notification_sent_date, presence: true
5  validate :last_notification_sent_date_is_valid_date
6  validate :last_notification_sent_date_cannot_be_in_the_past
7
8  private
9
10  def last_notification_sent_date_is_valid_date
11    if last_notification_sent_date.present?
12      Date.parse(last_notification_sent_date.to_s)
13    end
14  rescue ArgumentError
15    errors.add(:last_notification_sent_date, t('date.invalid'))
16  end
17
18  def last_notification_sent_date_cannot_be_in_the_past
19    if last_notification_sent_date.present? &&
20        last_notification_sent_date < Date.today
21      errors.add(:last_notification_sent_date, t('date.cant_be_in_past'))
22    end
23  end
24end

Now let's setup our Preference model along with validations in app/models/preference.rb:

1class Preference < ApplicationRecord
2  belongs_to :user
3
4  validates :notification_delivery_hour, presence: true,
5    numericality: { only_integer: true },
6    inclusion: {
7      in: 0..23,
8      message: I18n.t('preference.notification_delivery_hour.range')
9    }
10end

Currently, we are only providing the ability to set the preference for when to receive an email notification.

Thus in the above code, we have added the validations for the notification_delivery_hour attribute which makes sure that it's present, and it only accepts integers ranging from 0-23.

Let's create constants.rb file:

1touch config/initializers/constants.rb

Paste the following lines to constants.rb:

1module Constants
2  DEFAULT_NOTIFICATION_DELIVERY_HOUR = 10
3end

Now let's set the associations correctly in our user model too:

1class User < ApplicationRecord
2  # previous code if any
3  has_many :user_notifications, dependent: :destroy, foreign_key: :user_id
4  has_one  :preference, dependent: :destroy, foreign_key: :user_id
5
6  # previous code if any
7  before_save :to_lowercase
8  before_create :build_default_preference
9
10  private
11
12  def to_lowercase
13    email.downcase!
14  end
15
16  def build_default_preference
17    self.build_preference(notification_delivery_hour: Constants::DEFAULT_NOTIFICATION_DELIVERY_HOUR)
18  end
19end

The private method, build_default_preference, will create a default preference for the user whenever a new user is created.

Creating the controller

Create a new controller for the preference model by running the following command:

1bundle exec rails generate controller Preferences

Open app/controllers/preferences_controller.rb and add the following lines to it:

1class PreferencesController < ApplicationController
2  before_action :authenticate_user_using_x_auth_token
3  before_action :load_preference, only: %i[update]
4
5  def show
6    preference = Preference.find_by(user_id: params[:id])
7    render status: :ok, json: {preference: preference}
8  end
9
10  def update
11    if @preference.update(preference_params)
12      render status: :ok, json: {
13        notice: t('successfully_updated', entity: 'Preference')
14      }
15    else
16      errors = @preference.errors.full_messages.to_sentence
17      render status: :unprocessable_entity, json: { errors: errors }
18    end
19  end
20
21  private
22
23  def preference_params
24    params.require(:preference).permit(:notification_delivery_hour, :receive_email)
25  end
26
27  def load_preference
28    @preference = Preference.find(params[:id])
29  rescue ActiveRecord::RecordNotFound => e
30    render json: { errors: e }, status: :not_found
31  end
32
33end

In the above code, you can see that we have used errors, full_messages, and to_sentence helpers.

The errors helper method provides us all the errors that might've occurred while performing any ActiveRecord operation on a particular model.

errors is an instance of ActiveRecord::Errors. Thus we can make use of the public instance methods which come as part of this class to format the errors in our desired manner.

There is a method called full_messages, which will provide us the full error messages in an array.

We used the to_sentence helper to convert the array of errors returned from errors.full_messages to a comma-separated sentence where the last element is joined by the connector word, which by default is and.

Now, let's create the route for the preference. Open the routes.rb and append the following line:

1resources :preferences, only: %i[show update]

Building the UI for user preference

Since we have already added APIs for handling user preference, let's now build the views for it.

Let's create a new file to define all the preference related APIs:

1touch app/javascript/src/apis/preferences.js

Open the apis/preferences.js and paste the following lines:

1import axios from "axios";
2
3const create = payload => axios.post("/preferences/", payload);
4
5const show = userId => axios.get(`/preferences/${userId}`);
6
7const update = ({ id, payload }) => axios.put(`/preferences/${id}`, payload);
8
9const preferencesApi = {
10  create,
11  show,
12  update,
13};
14
15export default preferencesApi;

Now, let's create the preference component:

1mkdir -p app/javascript/src/components/MyPreferences
2touch app/javascript/src/components/MyPreferences/index.jsx

Paste the following lines into index.jsx:

1import React, { useState, useEffect } from "react";
2import preferencesApi from "apis/preferences";
3import Container from "components/Container";
4import PageLoader from "components/PageLoader";
5import { getFromLocalStorage } from "helpers/storage";
6import PreferenceForm from "./Form";
7
8const MyPreferences = () => {
9  const [notificationDeliveryHour, setNotificationDeliveryHour] = useState("");
10  const [receiveEmail, setReceiveEmail] = useState(true);
11  const [loading, setLoading] = useState(false);
12  const [pageLoading, setPageLoading] = useState(true);
13  const [preferenceId, setPreferenceId] = useState("");
14  const userId = getFromLocalStorage("authUserId");
15
16  const updatePreference = async () => {
17    try {
18      setLoading(true);
19      await preferencesApi.update({
20        id: preferenceId,
21        payload: {
22          notification_delivery_hour: notificationDeliveryHour,
23          receive_email: receiveEmail,
24        },
25      });
26    } catch (error) {
27      logger.error(error);
28    } finally {
29      setLoading(false);
30    }
31  };
32
33  const fetchPreferenceDetails = async () => {
34    try {
35      const { data } = await preferencesApi.show(userId);
36
37      setNotificationDeliveryHour(data.preference.notification_delivery_hour);
38      setReceiveEmail(data.preference.receive_email);
39      setPreferenceId(data.preference.id);
40    } catch (error) {
41      logger.error(error);
42    } finally {
43      setPageLoading(false);
44    }
45  };
46
47  useEffect(() => {
48    fetchPreferenceDetails();
49  }, []);
50
51  if (pageLoading || !userId || !preferenceId) {
52    return (
53      <div className="w-screen h-screen">
54        <PageLoader />
55      </div>
56    );
57  }
58
59  return (
60    <Container>
61      <PreferenceForm
62        notificationDeliveryHour={notificationDeliveryHour}
63        setNotificationDeliveryHour={setNotificationDeliveryHour}
64        receiveEmail={receiveEmail}
65        setReceiveEmail={setReceiveEmail}
66        loading={loading}
67        updatePreference={updatePreference}
68      />
69    </Container>
70  );
71};
72
73export default MyPreferences;

Here, fetchPreferenceDetails function uses the show axios API call from preference related axios APIs. Similarly, the updatePreference function will be using the update axios API.

As you can see we have used a PreferenceForm component which is not yet created. Let's create it:

1mkdir -p app/javascript/src/components/MyPreferences/Form
2touch app/javascript/src/components/MyPreferences/Form/index.jsx

Paste the following lines in the MyPreferences/Form/index.jsx:

1import React from "react";
2import Button from "components/Button";
3import Input from "components/Input";
4import Select from "react-select";
5
6const defaultTimezone = "UTC";
7
8const PreferenceForm = ({
9  notificationDeliveryHour,
10  setNotificationDeliveryHour,
11  receiveEmail,
12  setReceiveEmail,
13  loading,
14  updatePreference,
15}) => {
16  const onHandleDeliveryHourChange = event => {
17    const regex = /^[0-9\b]*$/;
18    const deliveryHour = event.target.value;
19    if (!regex.test(deliveryHour)) return null;
20
21    setNotificationDeliveryHour(deliveryHour);
22  };
23
24  const handleSubmit = () => {
25    event.preventDefault();
26    updatePreference();
27  };
28
29  return (
30    <form className="max-w-lg mx-auto" onSubmit={handleSubmit}>
31      <div className="flex justify-between text-bb-gray-600 mt-10">
32        <h1 className="pb-3 mt-5 text-2xl leading-5 font-bold">
33          Delivery Hour For Mail
34        </h1>
35      </div>
36
37      <div className="flex justify-between text-gray-700 mb-5">
38        Set your preferred hour to receive mail with your pending tasks, if any
39      </div>
40
41      <div>
42        <p className="leading-5 font-medium text-bb-gray-700 text-sm my-3">
43          Delivery Timezone
44        </p>
45        <div className="w-full">
46          <Select
47            options={defaultTimezone}
48            defaultValue={defaultTimezone}
49            placeholder={defaultTimezone}
50            isDisabled={true}
51          />
52        </div>
53      </div>
54
55      <div className="w-2/6">
56        <Input
57          type="number"
58          label="Delivery Time (Hours)"
59          placeholder="Enter hour"
60          min={0}
61          max={23}
62          value={notificationDeliveryHour}
63          onChange={onHandleDeliveryHourChange}
64        />
65      </div>
66
67      <div className="flex items-center w-2/6 mt-5 ">
68        <input
69          type="checkbox"
70          id="receiveEmail"
71          checked={receiveEmail}
72          className="w-4 h-4 text-bb-purple border-gray-300 rounded form-checkbox focus:ring-bb-purple cursor-pointer"
73          onChange={e => setReceiveEmail(e.target.checked)}
74        />
75        <label className="ml-2">Receive Mail</label>
76      </div>
77
78      <div className="w-2/6">
79        <Button
80          type="submit"
81          buttonText="Update Preferences"
82          loading={loading}
83        />
84      </div>
85    </form>
86  );
87};
88
89export default PreferenceForm;

We need to provide an Input component from where the user can set their preferred time for mail delivery.

We have to constraint the values that this component can accept, when it's type is set to be number.

Thus let's update our reusable Input component to provide this functionality:

1import React from "react";
2import PropTypes from "prop-types";
3
4const Input = ({
5  type = "text",
6  label,
7  value,
8  onChange,
9  placeholder,
10  min,
11  max,
12  required = true,
13}) => {
14  return (
15    <div className="mt-6">
16      {label && (
17        <label className="block text-sm font-medium leading-5 text-bb-gray-700">
18          {label}
19        </label>
20      )}
21      <div className="mt-1 rounded-md shadow-sm">
22        <input
23          type={type}
24          required={required}
25          value={value}
26          onChange={onChange}
27          placeholder={placeholder}
28          min={min}
29          max={max}
30          className="block w-full px-3 py-2 placeholder-gray-400 transition duration-150
31          ease-in-out border border-gray-300 rounded-md appearance-none focus:outline-none
32          focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5"
33        />
34      </div>
35    </div>
36  );
37};
38
39Input.propTypes = {
40  type: PropTypes.string,
41  label: PropTypes.string,
42  value: PropTypes.node,
43  placeholder: PropTypes.string,
44  onChange: PropTypes.func,
45  required: PropTypes.bool,
46  min: PropTypes.number,
47  max: PropTypes.number,
48};
49
50export default Input;

Finally, our Preference component is ready to use. Let's add a route for this component in App.jsx and let's also create a link to it in our navbar:

1//  previous code if any
2import MyPreferences from "components/MyPreferences";
3
4const App = () => {
5  //  previous code if any
6  return (
7    <Router>
8      <ToastContainer />
9      <Switch>
10        {/* previous code if any */}
11        <Route exact path="/login" component={Login} />
12        <Route exact path="/my/preferences" component={MyPreferences} />
13        {/* previous code if any */}
14      </Switch>
15    </Router>
16  );
17};
18
19export default App;

Now open the Navbar component from components/NavBar/index.jsx and add the following line in order to create a Preferences item next to Create item in navbar:

1// previous code if any
2const NavBar = () => {
3  // previous code if any
4  <NavItem name="Todos" path="/" />
5  <NavItem
6      name="Create"
7      iconClass="ri-add-fill"
8      path="/tasks/create"
9  />
10  <NavItem name="Preferences" path="/my/preferences" />

Implement task mailer

Let's create the task mailer by running the following Rails command:

1bundle exec rails g mailer TaskMailer

Open the application_mailer.rb from app/mailers/application_mailer.rb and, provide the default email from which the mail will be send:

1class ApplicationMailer < ActionMailer::Base
2  default from: 'no-reply@granite.com'
3  layout 'mailer'
4end

Mailers have methods called "actions" and they use views to structure their content. A controller generates content like say HTML, which will be sent back to the client, whereas a Mailer creates a message to be delivered via email.

Now, let's edit the task_mailer.rb which we have created.

Paste the following lines in task_mailer.rb:

1class TaskMailer < ApplicationMailer
2  after_action :create_user_notification
3
4  def pending_tasks_email(receiver_id)
5    @receiver = User.find(receiver_id)
6    @tasks = @receiver.tasks.pending
7    mail(to: @receiver.email, subject: 'Pending Tasks')
8  end
9
10  private
11
12  def create_user_notification
13    @receiver.user_notifications.create(last_notification_sent_date: Date.today)
14  end
15end

As you can see in the above code we have used after_action which is an Action Mailer Callback. Using an after_action callback enables you to perform operations after successfully sending the mail.

Once we ensure that mail is successfully sent, we will create an entry in user_notifications with today's date, for the current user.

Our TaskMailer is ready. Let's create a mailer view, which will contain the content and styling of the email.

Open app/views and you should be able to see the task_mailer directory. Let's create a pending_tasks_email.html.erb file into it:

1touch app/views/task_mailer/pending_tasks_email.html.erb

Paste the following lines into it:

1<html>
2  <head>
3    <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
4  </head>
5  <body>
6    <h1>Hi! <%= @receiver.name %></h1>
7    <p>Here is a list of your pending tasks for today.<br /></p>
8    <ul>
9      <% @tasks.each do |task| %>
10      <li><%= task.title %></li>
11      <% end %>
12    </ul>
13  </body>
14</html>

Why use secrets.yml

Rails supports the secrets.yml file to store your application's secrets. It is used to store secrets such as access keys for external APIs or our Redis URL etc.

This will provide reusability and also will help provide us easier access.

Let's create this file:

1touch config/secrets.yml

Open the file and paste the following lines:

1default: &default
2  redis_url: <%= ENV['REDISTOGO_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/1' %>

Creating schedule.yml

We will create a scheduler file containing the cron syntax for different environments and workers.

Cron is a standard Unix utility that is used to schedule commands for automatic execution at specific intervals.

Here we are using cron to send an email notification to the users every day at the default time, i.e., at 10 AM or preferred time, if set by the user.

Let's create the scheduler file:

1touch config/schedule.yml

Open the file and paste the following lines into it:

1default: &default
2  todo_notifications_worker:
3    cron: "0 * * * *"
4    class: "TodoNotificationsWorker"
5    queue: "default"
6
7development:
8  todo_notifications_worker:
9    cron: "* * * * *"
10    class: "TodoNotificationsWorker"
11    queue: "default"
12
13test:
14  <<: *default
15
16staging:
17  <<: *default
18
19production:
20  <<: *default

The default and development are two important keys in the YAML file. Each of those keys invoke a worker.

In the above code, you can see that the cron value for default and development are different. The reason is that in development we don't need to wait. We need the cron to get triggered as soon as possible.

The cron value for development executes a cron job every minute, whereas for default the same is executed every hour.

Here in our YAML file, the default key is the main key because its value will be used in other environments like test, staging, and production.

We create an alias for the default key using an anchor marked with the ampersand & and then reference that alias using the asterisk *. This is purely a "YAML" way of inheriting existing keys.

Adding logic to schedule cron jobs

We have already written down when and what needs to be enqueued, in our schedule.yml file.

Thus in the Sidekiq initializer, we have to add logic that will read the content from our scheduler file and will enqueue based on the cron defined for the specific environment.

Also, we have to update the REDIS_URL with the URL we have specified in our secrets file.

Open sidekiq.rb from config/initializers.

Add the following lines to this file:

1# frozen_string_literal: true
2
3if Rails.env.test? || Rails.env.heroku?
4  require 'sidekiq/testing'
5  Sidekiq::Testing.inline!
6end
7
8Sidekiq::Extensions.enable_delay!
9
10Sidekiq.configure_server do |config|
11  config.redis = { url:  Rails.application.secrets.redis_url, size: 9 }
12  unless Rails.env.test? || Rails.env.production?
13    schedule_file = "config/schedule.yml"
14
15    if File.exists?(schedule_file)
16      Sidekiq::Cron::Job.load_from_hash! YAML.load_file(schedule_file)[Rails.env]
17    end
18  end
19end
20
21Sidekiq.configure_client do |config|
22  config.redis = { url: Rails.application.secrets.redis_url, size: 1 }
23end

We have currently not enabled mail sending in production environment.

If possible, we will be handling this case in the upcoming chapters.

Adding TodoNotifications functionality

For Sidekiq workers, there already exists a generator for generating a worker file.

Let's create the TodoNotifications worker:

1rails generate sidekiq:worker TodoNotificationsWorker

The sole purpose of the TodoNotificationsWorker is process a TodoNotificationsService, which will take care of other core functionalities.

Open app/workers/todo_notifications_worker.rb and paste the following:

1# frozen_string_literal: true
2
3class TodoNotificationsWorker
4  include Sidekiq::Worker
5
6  def perform
7    todo_notification_service = TodoNotificationService.new
8    todo_notification_service.process
9  end
10end

Let's create our TodoNotificationService to handle the heavy work.

Rails do not provide any inbuilt generators to create services.

Currently, we can only manually create the service as well as its test file.

This service will take care of finding users with pending tasks, whose delivery time falls with the current execution time range, and invoke workers which will send the mail to each user.

Let's create the service:

1mkdir -p app/services
2touch app/services/todo_notification_service.rb

Open the todo_notification_service.rb file and paste the following:

1# frozen_string_literal: true
2
3class TodoNotificationService
4  attr_reader :users_to_notify
5
6  def initialize
7    @users_to_notify = get_users_to_notify
8  end
9
10  def process
11    notify_users
12  end
13
14  private
15
16  def get_users_to_notify
17    users = get_users_with_pending_tasks
18    users_with_pending_notifications(users)
19  end
20
21  def notify_users
22    users_to_notify.each do |user|
23      UserNotificationsWorker.perform_async(user.id)
24    end
25  end
26
27  def get_users_with_pending_tasks
28    User.includes(:tasks, :preference).where(
29      tasks: { progress: 'pending' },
30      preferences: {
31        receive_email: true,
32        notification_delivery_hour: hours_till_now
33      }
34    )
35  end
36
37  def users_with_pending_notifications(users)
38    no_mail_sent = "user_notifications.last_notification_sent_date <>"
39    first_time_mailing = "user_notifications.id is NULL"
40    delivery_condition = "#{no_mail_sent} ? OR #{first_time_mailing}"
41
42    users.left_outer_joins(:user_notifications)
43      .where(delivery_condition, Date.today).distinct
44  end
45
46  def hours_till_now
47    current_hour = Time.now.utc.hour
48    (0..current_hour)
49  end
50end

Let's also create a separate worker, named UserNotificationsWorker.

As per our technical design, we need to ensure that mail delivery for one single user, doesn't break the whole delivery system.

The first line of defense against this case, is to invoke a separate worker, which is UserNotificationsWorker, for each of the user.

This way failure of one worker won't affect the other workers, given that Sidekiq runs them in separate threads.

Let's create UserNotificationsWorker:

1rails generate sidekiq:worker UserNotificationsWorker

Open app/workers/user_notifications_worker.rb and paste the following:

1class UserNotificationsWorker
2  include Sidekiq::Worker
3
4  def perform(user_id)
5    TaskMailer.delay.pending_tasks_email(user_id)
6  end
7end

Phew! That was a lot of content with loads of good stuff.

That completes our feature and mail delivery should be working in a development environment.

We will be writing tests for the same, in the next chapter.

Now let's commit these changes:

1git add -A
2git commit -m "Added ability to send email to each user with pending tasks"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next