Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email rate limiter #1933

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,6 @@ gem "administrate"
gem "psych", "~> 4"

gem "postmark-rails"

# rails-settings-cached for storing global settings
gem "rails-settings-cached", "~> 2.9", ">= 2.9.5"
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-settings-cached (2.9.5)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.1.5)
actionpack (= 7.1.5)
activesupport (= 7.1.5)
Expand Down Expand Up @@ -720,6 +723,7 @@ DEPENDENCIES
rack-mini-profiler (>= 2.3.3)
rails (~> 7.1.5)
rails-controller-testing (~> 1.0, >= 1.0.5)
rails-settings-cached (~> 2.9, >= 2.9.5)
ransack (~> 4.1)
react-rails (= 2.6.2)
rolify (~> 6.0)
Expand Down
69 changes: 69 additions & 0 deletions app/controllers/admin/settings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Admin
class SettingsController < Admin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end

# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end

def edit
# Load current settings values
@number_of_email = Setting.number_of_email || 5
@interval_length = Setting.interval_length || 5
@interval_unit = Setting.interval_unit || TIME_INTERVAL.first
end

def update
# Update settings values directly
Setting.number_of_email = params[:number_of_email].to_i
Setting.interval_length = params[:interval_length].to_i
Setting.interval_unit = params[:interval_unit].to_sym

if Setting.number_of_email && Setting.interval_length && Setting.interval_unit
redirect_to edit_admin_setting_path, notice: "Settings updated successfully."
else
flash[:alert] = "Failed to update settings."
render :edit
end
end

# The result of this lookup will be available as `requested_resource`

# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end

# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end

# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ def success
if @invoice.paid?
PaymentMailer.with(
invoice_id: @invoice.id,
subject: "Payment details by #{@invoice.client.name}").payment.deliver_later
subject: "Payment details by #{@invoice.client.name}",
current_user_id: current_user.id
).payment.deliver_later

@invoice.send_to_client_email(
invoice_id: @invoice.id,
subject: "Payment Confirmation of Invoice #{@invoice.invoice_number} by #{@invoice.client.name}"
subject: "Payment Confirmation of Invoice #{@invoice.invoice_number} by #{@invoice.client.name}",
current_user_id: current_user.id
)
render json: { invoice: @invoice, notice: I18n.t("invoices.payments.success.success") }, status: :ok
else
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/internal_api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ def send_invoice
invoice.send_to_email(
subject: invoice_email_params[:subject],
message: invoice_email_params[:message],
recipients: invoice_email_params[:recipients]
recipients: invoice_email_params[:recipients],
current_user_id: current_user.id
)

render json: { message: "Invoice will be sent!" }, status: :accepted
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/internal_api/v1/payments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def create
if @invoice.paid?
@invoice.send_to_client_email(
invoice_id: @invoice.id,
subject: "Payment Confirmation of Invoice #{@invoice.invoice_number} for #{@invoice.company.name}"
subject: "Payment Confirmation of Invoice #{@invoice.invoice_number} for #{@invoice.company.name}",
current_user_id: current_user.id
)
end

Expand Down
57 changes: 57 additions & 0 deletions app/dashboards/setting_dashboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "administrate/base_dashboard"

class SettingDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
id: Field::Number,
interval_length: Field::Number,
interval_unit: Field::Select.with_options(collection: ::Setting::TIME_INTERVAL),
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze

# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[].freeze

# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[].freeze

# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
interval_length
interval_unit
].freeze

# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze

# Overwrite this method to customize how settings are displayed
# across all pages of the admin dashboard.
#
# def display_resource(setting)
# "Setting ##{setting.id}"
# end
end
50 changes: 50 additions & 0 deletions app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,54 @@ class ApplicationMailer < ActionMailer::Base
append_view_path Rails.root.join("app", "views", "mailers")
default from: ENV["DEFAULT_MAILER_SENDER"]
layout "mailer"

def current_user
@current_user ||= User.find_by(id: params[:current_user_id])
end

def email_rate_limiter
@email_rate_limiter ||= current_user.email_rate_limiter
end

def email_within_rate_limit
return if email_rate_limiter.nil? || current_user.nil?
# When the user's last email interval reset time is greater then send email
return true unless rate_within_time_limit

rate_within_time_limit && email_sent_within_limit
end

def raise_email_limit_crossed_error
raise "Email Limit crossed" unless email_within_rate_limit
end

def update_email_rate_limiter
if rate_within_time_limit
if email_rate_limiter.current_interval_started_at.nil?
email_rate_limiter.current_interval_started_at = Time.current
end
# Update the count of emails sent in the rate limiter, as this email was sent within the current rate limit interval.
email_rate_limiter.number_of_emails_sent = @email_rate_limiter.number_of_emails_sent + 1
email_rate_limiter.save
else
# Reset the email rate limiter and set the new time interval started at time.
email_rate_limiter.update(number_of_emails_sent: 1, current_interval_started_at: Time.current)
end
end

def rate_within_time_limit
# Returns true when the current interval start timestamp is earlier than the user's last email interval reset time.
# Condition: (current time - global interval length) < user's email reset timestamp
# Example:
# X = (current time - global interval length) = (6:25 PM - 5.minutes) = 6:20 PM
# Y = user's email reset timestamp = 6:21 PM
# Result: 6:20 PM (X) < 6:21 PM (Y) = True

email_rate_limiter.current_interval_started_at &&
(Setting.current_inteval_start_timestamp < email_rate_limiter.current_interval_started_at)
end

def email_sent_within_limit
email_rate_limiter.number_of_emails_sent < Setting.number_of_email
end
end
2 changes: 2 additions & 0 deletions app/mailers/client_payment_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class ClientPaymentMailer < ApplicationMailer
include EmailRateLimiterAction

def payment
@invoice = Invoice.find(params[:invoice_id])
subject = params[:subject]
Expand Down
10 changes: 10 additions & 0 deletions app/mailers/concerns/email_rate_limiter_action.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

module EmailRateLimiterAction
extend ActiveSupport::Concern

included do
before_action :raise_email_limit_crossed_error
after_action :update_email_rate_limiter
end
end
1 change: 1 addition & 0 deletions app/mailers/invoice_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

class InvoiceMailer < ApplicationMailer
include ::EmailRateLimiterAction
after_action :update_status, only: [:invoice]

def invoice
Expand Down
2 changes: 2 additions & 0 deletions app/mailers/payment_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class PaymentMailer < ApplicationMailer
include EmailRateLimiterAction

def payment
@invoice = Invoice.find(params[:invoice_id])
recipients = recipients_with_role
Expand Down
4 changes: 2 additions & 2 deletions app/models/concerns/client_payment_sendable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module ClientPaymentSendable
extend ActiveSupport::Concern

def send_to_client_email(invoice_id:, subject:)
ClientPaymentMailer.with(invoice_id:, subject:).payment.deliver_later
def send_to_client_email(invoice_id:, subject:, current_user_id:)
ClientPaymentMailer.with(invoice_id:, subject:, current_user_id:).payment.deliver_later
end
end
4 changes: 2 additions & 2 deletions app/models/concerns/invoice_sendable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module InvoiceSendable
extend ActiveSupport::Concern

def send_to_email(subject:, recipients:, message:)
InvoiceMailer.with(invoice_id: self.id, subject:, recipients:, message:).invoice.deliver_later
def send_to_email(subject:, recipients:, message:, current_user_id:)
InvoiceMailer.with(invoice_id: self.id, subject:, recipients:, message:, current_user_id:).invoice.deliver_later
end
end
21 changes: 9 additions & 12 deletions app/models/device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,15 @@
#
# Table name: devices
#
# id :bigint not null, primary key
# device_type :string default("laptop")
# insurance_bought_date :date
# insurance_expiry_date :date
# is_insured :boolean default(FALSE)
# name :string
# serial_number :string
# specifications :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# company_id :bigint not null
# user_id :bigint not null
# id :bigint not null, primary key
# device_type :string default("laptop")
# name :string
# serial_number :string
# specifications :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# company_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
Expand Down
24 changes: 24 additions & 0 deletions app/models/email_rate_limiter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: email_rate_limiters
#
# id :bigint not null, primary key
# current_interval_started_at :datetime
# number_of_emails_sent :integer default(0)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_email_rate_limiters_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class EmailRateLimiter < ApplicationRecord
belongs_to :user
end
27 changes: 27 additions & 0 deletions app/models/setting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

# RailsSettings Model
class Setting < RailsSettings::Base
cache_prefix { "v1" }
TIME_INTERVAL = %i[min hr]

# Define your fields
field :number_of_email, type: :integer, default: "5"
field :interval_length, type: :integer, default: "5"
field :interval_unit,
default: :min,
validates: { presence: true, inclusion: { in: TIME_INTERVAL } },
option_values: TIME_INTERVAL

def self.current_inteval_start_timestamp
current_time = Time.current

interval = if interval_unit == :min
interval_length.minutes
else
interval_length.hours
end

current_time - interval
end
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def initialize(msg = "Spam User Login")
has_many :custom_leaves, through: :custom_leave_users, source: :custom_leave
has_many :carryovers
has_many :notification_preferences, dependent: :destroy
has_one :email_rate_limiter, dependent: :destroy

rolify strict: true

Expand Down
Loading
Loading