web47core 2.0.1 → 2.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +111 -41
- data/app/controllers/status_controller.rb +8 -3
- data/app/helpers/core_form_helper.rb +6 -6
- data/app/helpers/core_helper.rb +1 -1
- data/app/helpers/core_link_helper.rb +47 -8
- data/app/helpers/core_nav_bar_helper.rb +5 -4
- data/app/helpers/core_table_helper.rb +80 -0
- data/app/helpers/model_modal_helper.rb +3 -3
- data/app/views/common/_create_actions.html.haml +12 -0
- data/app/views/common/_update_actions.html.haml +10 -0
- data/app/views/cron/_edit.html.haml +15 -17
- data/app/views/cron/_index.html.haml +74 -67
- data/app/views/delayed_job_metrics/_index.html.haml +27 -0
- data/app/views/delayed_job_metrics/index.html.haml +1 -0
- data/app/views/delayed_job_workers/_index.html.haml +27 -0
- data/app/views/delayed_job_workers/index.html.haml +1 -0
- data/app/views/delayed_jobs/_index.html.haml +47 -52
- data/app/views/delayed_jobs/_show.html.haml +15 -13
- data/app/views/system_configurations/_edit.html.haml +14 -9
- data/app/views/system_configurations/_show.html.haml +18 -12
- data/config/brakeman.ignore +26 -0
- data/config/brakeman.yml +2 -0
- data/config/locales/en.yml +21 -3
- data/lib/app/controllers/concerns/core_delayed_job_metrics_controller.rb +35 -0
- data/lib/app/controllers/concerns/core_delayed_job_workers_controller.rb +35 -0
- data/lib/app/controllers/concerns/core_delayed_jobs_controller.rb +2 -3
- data/lib/app/jobs/cron/command.rb +1 -4
- data/lib/app/jobs/cron/record_delayed_job_metrics.rb +25 -0
- data/lib/app/jobs/cron/restart_orphaned_delayed_jobs.rb +44 -0
- data/lib/app/jobs/cron/server.rb +32 -15
- data/lib/app/jobs/cron/switchboard_sync_configuration.rb +2 -0
- data/lib/app/jobs/cron/switchboard_sync_models.rb +2 -0
- data/lib/app/jobs/cron/trim_collection.rb +1 -1
- data/lib/app/jobs/cron/trim_delayed_job_metrics.rb +29 -0
- data/lib/app/jobs/cron/trim_delayed_job_workers.rb +39 -0
- data/lib/app/jobs/cron/trim_failed_delayed_jobs.rb +2 -0
- data/lib/app/models/api_token.rb +9 -0
- data/lib/app/models/command_job.rb +12 -11
- data/lib/app/models/concerns/api_tokenable.rb +38 -0
- data/lib/app/models/concerns/aws_configuration.rb +65 -0
- data/lib/app/models/concerns/cdn_url.rb +7 -0
- data/lib/app/models/concerns/core_smtp_configuration.rb +65 -0
- data/lib/app/models/concerns/core_system_configuration.rb +18 -201
- data/lib/app/models/concerns/delayed_job_configuration.rb +24 -0
- data/lib/app/models/concerns/email_able.rb +6 -2
- data/lib/app/models/concerns/google_sso_configuration.rb +32 -0
- data/lib/app/models/concerns/search_able.rb +16 -0
- data/lib/app/models/concerns/server_process_able.rb +69 -0
- data/lib/app/models/concerns/slack_configuration.rb +38 -0
- data/lib/app/models/concerns/standard_model.rb +5 -2
- data/lib/app/models/concerns/switchboard_configuration.rb +43 -0
- data/lib/app/models/concerns/twilio_configuration.rb +37 -0
- data/lib/app/models/concerns/zendesk_configuration.rb +92 -0
- data/lib/app/models/{delayed_job.rb → delayed/backend/delayed_job.rb} +41 -0
- data/lib/app/models/delayed/jobs/metric.rb +61 -0
- data/lib/app/models/delayed/jobs/run.rb +40 -0
- data/lib/app/models/delayed/jobs/worker.rb +43 -0
- data/lib/app/models/delayed/plugins/time_keeper.rb +33 -0
- data/lib/app/models/delayed/worker.rb +24 -0
- data/lib/app/models/email_notification.rb +2 -1
- data/lib/app/models/email_template.rb +5 -6
- data/lib/app/models/notification.rb +12 -2
- data/lib/app/models/sms_notification.rb +9 -6
- data/lib/app/models/template.rb +12 -12
- data/lib/web47core/version.rb +1 -1
- data/lib/web47core.rb +33 -9
- metadata +114 -69
@@ -0,0 +1,44 @@
|
|
1
|
+
#
|
2
|
+
# Run in cron
|
3
|
+
#
|
4
|
+
module Cron
|
5
|
+
#
|
6
|
+
# Cycle through all members and tell them to sync with with switchboard
|
7
|
+
#
|
8
|
+
class RestartOrphanedDelayedJobs < Job
|
9
|
+
cron_tab_entry :hourly
|
10
|
+
|
11
|
+
#
|
12
|
+
# Only run when we have background jobs and we have the time keeper plugin installed
|
13
|
+
#
|
14
|
+
def self.valid_environment?
|
15
|
+
Delayed::Worker.plugins.include?(Delayed::Plugins::TimeKeeper) &&
|
16
|
+
SystemConfiguration.delayed_job_restart_orphaned? &&
|
17
|
+
Delayed::Backend::Mongoid::Job.count.positive?
|
18
|
+
rescue StandardError
|
19
|
+
false
|
20
|
+
end
|
21
|
+
|
22
|
+
#
|
23
|
+
# Cycle through delayed jobs, looking for running jobs
|
24
|
+
# skip if we don't have any records or low sample set
|
25
|
+
# skip if the allowed time is less than allowed by the job
|
26
|
+
# look to see if have a worker associated with the delayed job
|
27
|
+
# If we dont have a worker or the worker is dead, restart the job
|
28
|
+
#
|
29
|
+
def execute
|
30
|
+
Delayed::Backend::Mongoid::Job.each do |delayed_job|
|
31
|
+
next unless delayed_job.running?
|
32
|
+
|
33
|
+
metric = Delayed::Jobs::Metric.where(name: delayed_job.display_name).first
|
34
|
+
next if metric.blank? || metric.count < 30 # not enough data to make a call
|
35
|
+
|
36
|
+
run_time = Time.now.utc - delayed_job.locked_at
|
37
|
+
next if run_time < metric.max_allowed_seconds # still within parameters
|
38
|
+
|
39
|
+
worker = delayed_job.worker
|
40
|
+
delayed_job.resubmit if worker.blank? || worker.dead?
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/app/jobs/cron/server.rb
CHANGED
@@ -6,6 +6,7 @@ module Cron
|
|
6
6
|
#
|
7
7
|
class Server
|
8
8
|
include StandardModel
|
9
|
+
include ServerProcessAble
|
9
10
|
#
|
10
11
|
# Constants
|
11
12
|
#
|
@@ -15,20 +16,19 @@ module Cron
|
|
15
16
|
#
|
16
17
|
# Fields
|
17
18
|
#
|
18
|
-
field :host_name, type: String
|
19
|
-
field :pid, type: Integer
|
20
19
|
field :desired_server_count, type: Integer, default: 0
|
21
20
|
field :current_server_count, type: Integer, default: 0
|
22
|
-
field :last_check_in_at, type: Time, default: Time.now.utc
|
23
21
|
field :state, type: String, default: STATE_SECONDARY
|
24
22
|
#
|
25
23
|
# Validations
|
26
24
|
#
|
27
|
-
validates :host_name, presence: true
|
28
|
-
validates :pid, presence: true
|
29
|
-
validates :last_check_in_at, presence: true
|
30
25
|
validates :state, inclusion: { in: ALL_STATES }
|
31
26
|
validate :high_lander
|
27
|
+
#
|
28
|
+
# Callback
|
29
|
+
#
|
30
|
+
before_validation :ensure_last_check_in
|
31
|
+
|
32
32
|
#
|
33
33
|
# Go through the logic once a minute
|
34
34
|
#
|
@@ -89,7 +89,7 @@ module Cron
|
|
89
89
|
# Warm up a server on the next evaluation
|
90
90
|
#
|
91
91
|
def self.warm_up_server
|
92
|
-
return unless
|
92
|
+
return unless auto_scaling_configured?
|
93
93
|
|
94
94
|
primary_server.auto_scale([primary_server.desired_server_count + 1, 10].min)
|
95
95
|
end
|
@@ -148,18 +148,11 @@ module Cron
|
|
148
148
|
!alive?
|
149
149
|
end
|
150
150
|
|
151
|
-
#
|
152
|
-
# Perform a check in for the server
|
153
|
-
#
|
154
|
-
def check_in
|
155
|
-
set({ last_check_in_at: Time.now.utc })
|
156
|
-
end
|
157
|
-
|
158
151
|
#
|
159
152
|
# Auto scale environment
|
160
153
|
#
|
161
154
|
def check_auto_scale
|
162
|
-
return unless
|
155
|
+
return unless auto_scaling_configured?
|
163
156
|
|
164
157
|
if delayed_jobs_count.eql?(0)
|
165
158
|
handle_zero_job_count
|
@@ -181,6 +174,24 @@ module Cron
|
|
181
174
|
@sys_config ||= SystemConfiguration.configuration
|
182
175
|
end
|
183
176
|
|
177
|
+
#
|
178
|
+
# Test if autoscaling is configured, return false if there is an error
|
179
|
+
#
|
180
|
+
def auto_scaling_configured?
|
181
|
+
@auto_scaling_configured ||= sys_config.aws_auto_scaling_configured?
|
182
|
+
rescue StandardError
|
183
|
+
false
|
184
|
+
end
|
185
|
+
|
186
|
+
#
|
187
|
+
# Test if autoscaling is configured, return false if there is an error
|
188
|
+
#
|
189
|
+
def self.auto_scaling_configured?
|
190
|
+
SystemConfiguration.aws_auto_scaling_configured?
|
191
|
+
rescue StandardError
|
192
|
+
false
|
193
|
+
end
|
194
|
+
|
184
195
|
#
|
185
196
|
# Returns the AutoScalingGroup associated with the account
|
186
197
|
#
|
@@ -258,6 +269,8 @@ module Cron
|
|
258
269
|
client.update_auto_scaling_group(auto_scaling_group_name: sys_config.aws_auto_scaling_group_name,
|
259
270
|
min_size: desired_count,
|
260
271
|
desired_capacity: desired_count)
|
272
|
+
rescue StandardError => error
|
273
|
+
App47Logger.log_error "Unable to set auto scaler to #{desired_count}", error
|
261
274
|
end
|
262
275
|
|
263
276
|
#
|
@@ -283,5 +296,9 @@ module Cron
|
|
283
296
|
def inactive_count
|
284
297
|
desired_server_count
|
285
298
|
end
|
299
|
+
|
300
|
+
def ensure_last_check_in
|
301
|
+
self.last_check_in_at ||= Time.now.utc
|
302
|
+
end
|
286
303
|
end
|
287
304
|
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cron
|
4
|
+
#
|
5
|
+
# Clean up Audit Logs that have not been updated in 90 days
|
6
|
+
#
|
7
|
+
class TrimDelayedJobMetrics < TrimCollection
|
8
|
+
#
|
9
|
+
# Fetch each Audit Log and delete it if hasn't been updated in 90 days
|
10
|
+
#
|
11
|
+
def collection
|
12
|
+
Delayed::Jobs::Metric.all
|
13
|
+
end
|
14
|
+
|
15
|
+
#
|
16
|
+
# Return which field to use for comparison when trimming objects
|
17
|
+
#
|
18
|
+
def comparison_field
|
19
|
+
:last_run_at
|
20
|
+
end
|
21
|
+
|
22
|
+
#
|
23
|
+
# Allowed time the amount of time allowed to exists before deleting
|
24
|
+
#
|
25
|
+
def allowed_time
|
26
|
+
12.months.ago.utc
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cron
|
4
|
+
#
|
5
|
+
# Clean up Audit Logs that have not been updated in 90 days
|
6
|
+
#
|
7
|
+
class TrimDelayedJobWorkers < TrimCollection
|
8
|
+
#
|
9
|
+
# Fetch each Audit Log and delete it if hasn't been updated in 90 days
|
10
|
+
#
|
11
|
+
def collection
|
12
|
+
Delayed::Jobs::Worker.all
|
13
|
+
end
|
14
|
+
|
15
|
+
#
|
16
|
+
# Check if the cron job server hasn't reported in a while
|
17
|
+
#
|
18
|
+
def archive?(worker)
|
19
|
+
super && worker.runs.blank?
|
20
|
+
rescue StandardError => error
|
21
|
+
App47Logger.log_warn "Unable to archive item #{worker.inspect}", error
|
22
|
+
false
|
23
|
+
end
|
24
|
+
|
25
|
+
#
|
26
|
+
# Return which field to use for comparison when trimming objects
|
27
|
+
#
|
28
|
+
def comparison_field
|
29
|
+
:last_check_in_at
|
30
|
+
end
|
31
|
+
|
32
|
+
#
|
33
|
+
# Allowed time the amount of time allowed to exists before deleting
|
34
|
+
#
|
35
|
+
def allowed_time
|
36
|
+
2.days.ago.utc
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -110,7 +110,7 @@ class CommandJob
|
|
110
110
|
end
|
111
111
|
|
112
112
|
def failure_or_cancelled?
|
113
|
-
job_state?([STATE_FAIL, STATE_CANCELLED], true)
|
113
|
+
job_state?([STATE_FAIL, STATE_CANCELLED], default_state: true)
|
114
114
|
end
|
115
115
|
|
116
116
|
#
|
@@ -118,11 +118,11 @@ class CommandJob
|
|
118
118
|
# state. If there is a match, then return true, otherwise return false.
|
119
119
|
# If there is an error, return the default.
|
120
120
|
#
|
121
|
-
def job_state?(states,
|
121
|
+
def job_state?(states, default_state: false)
|
122
122
|
states.is_a?(Array) ? states.include?(state) : states.eql?(state)
|
123
123
|
rescue StandardError => error
|
124
|
-
App47Logger.log_warn "Unable to check job failed or cancelled #{
|
125
|
-
|
124
|
+
App47Logger.log_warn "Unable to check job failed or cancelled #{inspect}", error
|
125
|
+
default_state
|
126
126
|
end
|
127
127
|
|
128
128
|
#
|
@@ -323,8 +323,9 @@ class CommandJob
|
|
323
323
|
else
|
324
324
|
logs.create!(dir: dir, command: command, message: output)
|
325
325
|
end
|
326
|
-
|
327
|
-
check_for_text(output, options[:
|
326
|
+
options[:output_limit] ||= -1
|
327
|
+
check_for_text(output, options[:error_texts], output_limit: options[:output_limit])
|
328
|
+
check_for_text(output, options[:required_texts], inclusive_check: false, output_limit: options[:output_limit])
|
328
329
|
output
|
329
330
|
end
|
330
331
|
|
@@ -343,16 +344,17 @@ class CommandJob
|
|
343
344
|
|
344
345
|
#
|
345
346
|
# Check if any occurrences were found (or not found)
|
347
|
+
# For most command jobs, we want to see the full output. -1 accomplishes this
|
346
348
|
#
|
347
|
-
def check_for_text(output, texts = [],
|
349
|
+
def check_for_text(output, texts = [], inclusive_check: true, output_limit: -1)
|
348
350
|
return if texts.blank?
|
349
351
|
|
350
352
|
texts = [texts] if texts.is_a?(String)
|
351
353
|
texts.each do |text|
|
352
|
-
if
|
353
|
-
raise "Error: found text (#{text}) - #{output}" if output.match?(/#{text}/)
|
354
|
+
if inclusive_check
|
355
|
+
raise "Error: found text (#{text}) - #{output[0...output_limit]}" if output.match?(/#{text}/)
|
354
356
|
else
|
355
|
-
raise "Error: missing text (#{text}) - #{output}" unless output.match?(/#{text}/)
|
357
|
+
raise "Error: missing text (#{text}) - #{output[0...output_limit]}" unless output.match?(/#{text}/)
|
356
358
|
end
|
357
359
|
end
|
358
360
|
end
|
@@ -370,5 +372,4 @@ class CommandJob
|
|
370
372
|
def sort_fields
|
371
373
|
%i[created_at]
|
372
374
|
end
|
373
|
-
|
374
375
|
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# API tokenable, support for the api token in user, but also may be applied elsewhere
|
5
|
+
#
|
6
|
+
module ApiTokenable
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def self.included(base)
|
10
|
+
base.class_eval do
|
11
|
+
# store api token
|
12
|
+
field :api_token, type: String
|
13
|
+
# if the api token should be reset
|
14
|
+
field :reset_api_token, type: Boolean, default: true
|
15
|
+
field :last_authenticated_at, type: Time
|
16
|
+
field :last_authenticated_ip, type: String
|
17
|
+
# call back to reset the api token.
|
18
|
+
before_save :assign_api_token, if: :reset_api_token
|
19
|
+
# set the index on api token
|
20
|
+
index({ api_token: 1 }, background: true)
|
21
|
+
|
22
|
+
def cycle_api_token
|
23
|
+
self.set api_token: SecureRandom.urlsafe_base64
|
24
|
+
api_token
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
#
|
30
|
+
# set the api token
|
31
|
+
#
|
32
|
+
def assign_api_token
|
33
|
+
self.reset_api_token = false
|
34
|
+
self.api_token = SecureRandom.urlsafe_base64
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# AWS Configuration
|
5
|
+
#
|
6
|
+
module AwsConfiguration
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def self.included(base)
|
10
|
+
base.class_eval do
|
11
|
+
#
|
12
|
+
# Fields
|
13
|
+
#
|
14
|
+
field :aws_region, type: String
|
15
|
+
field :aws_access_key_id, type: String
|
16
|
+
field :aws_secret_access_key, type: String
|
17
|
+
field :aws_auto_scaling_group_name, type: String
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Make sure the password doesn't get blanked out on an update
|
23
|
+
#
|
24
|
+
def secure_fields
|
25
|
+
super + %i[aws_secret_access_key]
|
26
|
+
end
|
27
|
+
|
28
|
+
#
|
29
|
+
# Determine if AWS is configured
|
30
|
+
#
|
31
|
+
def aws_configured?
|
32
|
+
[aws_region.present?, aws_access_key_id.present?, aws_secret_access_key.present?].all?
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Determine if auto scaling group is configured
|
37
|
+
#
|
38
|
+
def aws_auto_scaling_configured?
|
39
|
+
aws_configured? && aws_auto_scaling_group_name.present?
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# AWS client.
|
44
|
+
#
|
45
|
+
def aws_ec2_client
|
46
|
+
return nil unless aws_configured?
|
47
|
+
|
48
|
+
@aws_ec2_client ||= Aws::EC2::Client.new(region: aws_region,
|
49
|
+
credentials: Aws::Credentials.new(aws_access_key_id,
|
50
|
+
aws_secret_access_key))
|
51
|
+
end
|
52
|
+
|
53
|
+
#
|
54
|
+
# S3 Client
|
55
|
+
#
|
56
|
+
def aws_s3_client
|
57
|
+
return nil unless aws_configured?
|
58
|
+
|
59
|
+
# We want this to remake itself each time because it is possible that the
|
60
|
+
# => user would change the access keys in between actions. Huh?
|
61
|
+
@aws_s3_client ||= Aws::S3::Client.new(region: aws_vault_bucket_region,
|
62
|
+
access_key_id: aws_access_key_id,
|
63
|
+
secret_access_key: aws_secret_access_key)
|
64
|
+
end
|
65
|
+
end
|
@@ -17,6 +17,13 @@
|
|
17
17
|
#
|
18
18
|
module CdnUrl
|
19
19
|
extend ActiveSupport::Concern
|
20
|
+
#
|
21
|
+
# Constants
|
22
|
+
#
|
23
|
+
STYLE_S3_FILE_PATH = ':class/:attachment/:id/:style.:extension' unless defined? STYLE_S3_FILE_PATH
|
24
|
+
STYLE_FILE_PATH = 'public/system/:class/:attachment/:id/:style.:extension' unless defined? STYLE_FILE_PATH
|
25
|
+
STYLE_S3_FILE_URL = ':s3_domain_url' unless defined? STYLE_S3_FILE_URL
|
26
|
+
STYLE_FILE_URL = ':rails_root/public/system/:class/:attachment/:id/:style.:extension' unless defined? STYLE_FILE_URL
|
20
27
|
|
21
28
|
def method_missing(method, *args)
|
22
29
|
if method.to_s.start_with? 'cdn_'
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# SMTP Configuration
|
5
|
+
#
|
6
|
+
module CoreSmtpConfiguration
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def self.included(base)
|
10
|
+
base.class_eval do
|
11
|
+
#
|
12
|
+
# Fields
|
13
|
+
#
|
14
|
+
field :default_email, type: String, default: 'support@app47.com'
|
15
|
+
field :support_email, type: String, default: 'support@app47.com'
|
16
|
+
field :smtp_name, type: String
|
17
|
+
field :smtp_address, type: String
|
18
|
+
field :smtp_domain, type: String
|
19
|
+
field :smtp_port, type: Integer, default: 587
|
20
|
+
field :smtp_user_name, type: String
|
21
|
+
field :smtp_password, type: String
|
22
|
+
field :smtp_enable_starttls_auto, type: Boolean, default: false
|
23
|
+
field :mailgun_api_key, type: String
|
24
|
+
field :email_notification_ttl, type: Integer, default: 180
|
25
|
+
end
|
26
|
+
base.extend SmtpClassMethods
|
27
|
+
end
|
28
|
+
|
29
|
+
#
|
30
|
+
# Class methods for smtp configuration
|
31
|
+
#
|
32
|
+
module SmtpClassMethods
|
33
|
+
def smtp_configuration
|
34
|
+
output = {}
|
35
|
+
config = configuration
|
36
|
+
fields = %w[name address domain port user_name password enable_starttls_auto]
|
37
|
+
fields.each do |field|
|
38
|
+
field_name = "smtp_#{field}".to_sym
|
39
|
+
output[field.to_sym] = config.send(field_name)
|
40
|
+
end
|
41
|
+
output
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# Make sure the password doesn't get blanked out on an update
|
47
|
+
#
|
48
|
+
def secure_fields
|
49
|
+
super + %i[smtp_password mailgun_api_key]
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Determine if SMTP is configured
|
54
|
+
#
|
55
|
+
def smtp_configured?
|
56
|
+
smtp_name.present? && smtp_address.present? && smtp_domain.present?
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Determine if mailgun is configured
|
61
|
+
#
|
62
|
+
def mail_gun_configured?
|
63
|
+
smtp_configured? && mailgun_api_key.present?
|
64
|
+
end
|
65
|
+
end
|