gov_fake_notify 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +3 -0
  5. data/.rubocop_todo.yml +15 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +6 -0
  8. data/Gemfile +9 -0
  9. data/Gemfile.lock +87 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +271 -0
  12. data/Rakefile +8 -0
  13. data/bin/console +15 -0
  14. data/bin/setup +8 -0
  15. data/exe/gov_fake_notify +5 -0
  16. data/gov_fake_notify.gemspec +40 -0
  17. data/lib/gov_fake_notify/attachment_store.rb +48 -0
  18. data/lib/gov_fake_notify/cli/root.rb +53 -0
  19. data/lib/gov_fake_notify/cli.rb +3 -0
  20. data/lib/gov_fake_notify/commands/create_template_command.rb +35 -0
  21. data/lib/gov_fake_notify/commands/fetch_all_messages_status_command.rb +45 -0
  22. data/lib/gov_fake_notify/commands/fetch_file_command.rb +41 -0
  23. data/lib/gov_fake_notify/commands/fetch_message_status_command.rb +44 -0
  24. data/lib/gov_fake_notify/commands/fetch_templates_command.rb +43 -0
  25. data/lib/gov_fake_notify/commands/send_email_command.rb +153 -0
  26. data/lib/gov_fake_notify/config.rb +42 -0
  27. data/lib/gov_fake_notify/control_app.rb +31 -0
  28. data/lib/gov_fake_notify/current_service.rb +22 -0
  29. data/lib/gov_fake_notify/files_app.rb +41 -0
  30. data/lib/gov_fake_notify/iodine.rb +9 -0
  31. data/lib/gov_fake_notify/notifications_app.rb +58 -0
  32. data/lib/gov_fake_notify/root_app.rb +23 -0
  33. data/lib/gov_fake_notify/store.rb +27 -0
  34. data/lib/gov_fake_notify/templates_app.rb +32 -0
  35. data/lib/gov_fake_notify/version.rb +5 -0
  36. data/lib/gov_fake_notify.rb +72 -0
  37. data/lib/views/files/confirm.html.erb +118 -0
  38. data/lib/views/files/download.html.erb +119 -0
  39. data/lib/views/govuk/file.html.erb +3 -0
  40. data/lib/views/govuk/horizontal_line.html.erb +1 -0
  41. data/lib/views/govuk/paragraph.html.erb +1 -0
  42. data/lib/views/govuk/show.html.erb +19 -0
  43. data/lib/views/layouts/govuk.html.erb +64 -0
  44. metadata +219 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'gov_fake_notify/store'
7
+
8
+ module GovFakeNotify
9
+ # A service used to fetch all message statuses
10
+ class FetchAllMessagesStatusCommand
11
+
12
+ attr_reader :errors
13
+
14
+ def self.call(params, **kwargs)
15
+ new(params, **kwargs).call
16
+ end
17
+
18
+ def initialize(params, store: Store.instance)
19
+ @params = params
20
+ @store = store
21
+ @errors = []
22
+ @messages = []
23
+ end
24
+
25
+ def call
26
+ message_keys = store.transaction { store.roots.select { |k| k =~ /^message-/ } }
27
+ @messages = store.transaction { message_keys.map { |key| store.fetch(key) } }
28
+
29
+ self
30
+ end
31
+
32
+ def success?
33
+ errors.empty?
34
+ end
35
+
36
+ def to_json
37
+ # We do not support links yet
38
+ JSON.pretty_generate(notifications: messages, links: [])
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :params, :store, :messages
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'gov_fake_notify/store'
7
+ require 'gov_fake_notify/attachment_store'
8
+
9
+ module GovFakeNotify
10
+ # A service used to fetch an attached file
11
+ class FetchFileCommand
12
+
13
+ attr_reader :filename, :errors
14
+
15
+ def self.call(id, **kwargs)
16
+ new(id, **kwargs).call
17
+ end
18
+
19
+ def initialize(id, attachment_store: AttachmentStore.instance)
20
+ @id = id
21
+ @attachment_store = attachment_store
22
+ @errors = []
23
+ end
24
+
25
+ def call
26
+ file_data = attachment_store.fetch(id)
27
+ errors << 'File not found' and return if file_data.nil?
28
+
29
+ @filename = file_data['file']
30
+ self
31
+ end
32
+
33
+ def success?
34
+ errors.empty?
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :id, :attachment_store
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'gov_fake_notify/store'
7
+
8
+ module GovFakeNotify
9
+ # A service used to fetch a message status
10
+ class FetchMessageStatusCommand
11
+
12
+ attr_reader :errors
13
+
14
+ def self.call(id, **kwargs)
15
+ new(id, **kwargs).call
16
+ end
17
+
18
+ def initialize(id, store: Store.instance)
19
+ @id = id
20
+ @store = store
21
+ @errors = []
22
+ @message = nil
23
+ end
24
+
25
+ def call
26
+ @message = store.transaction { store.fetch("message-#{id}") }
27
+ errors << 'Message not found' and return if message.nil?
28
+
29
+ self
30
+ end
31
+
32
+ def success?
33
+ errors.empty?
34
+ end
35
+
36
+ def to_json
37
+ JSON.pretty_generate(message)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :id, :store, :message
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'gov_fake_notify/store'
7
+
8
+ module GovFakeNotify
9
+ # A service used to fetch all templates
10
+ class FetchTemplatesCommand
11
+ attr_reader :errors
12
+
13
+ def self.call(params, **kwargs)
14
+ new(params, **kwargs).call
15
+ end
16
+
17
+ def initialize(params, store: Store.instance)
18
+ @params = params
19
+ @store = store
20
+ @errors = []
21
+ @results = []
22
+ end
23
+
24
+ def call
25
+ @results = store.transaction { store.roots.select { |k| k =~ /^template-/ } }.map do |key|
26
+ store.transaction { store.fetch(key).slice('id', 'name', 'subject') }
27
+ end
28
+ self
29
+ end
30
+
31
+ def success?
32
+ errors.empty?
33
+ end
34
+
35
+ def to_json
36
+ JSON.pretty_generate(templates: results)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :params, :store, :results
42
+ end
43
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'erb'
5
+ require 'tilt'
6
+ require 'gov_fake_notify/store'
7
+ require 'gov_fake_notify/attachment_store'
8
+
9
+ module GovFakeNotify
10
+ # A service used when the sending of an email is requested
11
+ class SendEmailCommand # rubocop:disable Metrics/ClassLength
12
+ def self.call(params, **kwargs)
13
+ # do nothing yet
14
+ new(params, **kwargs).call
15
+ end
16
+
17
+ def initialize(params, base_url:, service:, store: Store.instance, attachment_store: AttachmentStore.instance)
18
+ @params = params.dup
19
+ @store = store
20
+ @attachment_store = attachment_store
21
+ @base_url = base_url
22
+ @service = service
23
+ end
24
+
25
+ def call
26
+ template_data = store.transaction { store["template-#{params['template_id']}"] }
27
+ send_email_from_template(template_data)
28
+ persist_status(template_data)
29
+ self
30
+ end
31
+
32
+ def success?
33
+ true
34
+ end
35
+
36
+ def to_json(*_args) # rubocop:disable Metrics/MethodLength
37
+ ::JSON.generate({
38
+ "id": id,
39
+ "reference": 'STRING',
40
+ "content": {
41
+ "body": message_body,
42
+ "from_number": 'govfakenotify@email.com'
43
+ },
44
+ "uri": "#{base_url}/v2/notifications/#{id}",
45
+ "template": {
46
+ "id": 'f33517ff-2a88-4f6e-b855-c550268ce08a',
47
+ "version": 1,
48
+ "uri": "#{base_url}/v2/template/ceb50d92-100d-4b8b-b559-14fa3b091cd"
49
+ }
50
+ })
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :params, :store, :attachment_store, :base_url, :message_body, :id, :service
56
+
57
+ def send_email_from_template(template_data) # rubocop:disable Metrics/MethodLength
58
+ pre_process_files
59
+ our_params = params
60
+ our_body = mail_message(template_data)
61
+ our_service = service
62
+ @message_body = our_body
63
+ mail = Mail.new do
64
+ from our_service['service_email']
65
+ to our_params['email_address']
66
+ subject template_data['subject']
67
+ html_part do
68
+ content_type 'text/html; charset=UTF-8'
69
+ body our_body
70
+ end
71
+ text_part do
72
+ body 'text part to go here'
73
+ end
74
+ end
75
+ mail.deliver
76
+ @id = SecureRandom.uuid
77
+ end
78
+
79
+ def persist_status(template_data) # rubocop:disable Metrics/MethodLength
80
+ store.transaction do
81
+ store["message-#{id}"] = { id: id,
82
+ email_address: params['email_address'],
83
+ type: 'email',
84
+ status: 'delivered',
85
+ template: {
86
+ Version: 1,
87
+ id: 'f33517ff-2a88-4f6e-b855-c550268ce08a',
88
+ uri: "#{base_url}/v2/template/ceb50d92-100d-4b8b-b559-14fa3b091cd"
89
+ },
90
+ body: message_body,
91
+ subject: template_data['subject'],
92
+ created_at: Time.now.to_s }
93
+ end
94
+ end
95
+
96
+ def pre_process_files
97
+ params['personalisation'].each_pair do |key, value|
98
+ next unless value.is_a?(Hash) && value.keys.include?('file')
99
+
100
+ params['personalisation'][key] = attachment_store.store(value)
101
+ end
102
+ end
103
+
104
+ def mail_message(template_data)
105
+ layout = Tilt.new(File.absolute_path('../../views/layouts/govuk.html.erb', __dir__))
106
+ layout.render do
107
+ template_content(template_data)
108
+ end
109
+ end
110
+
111
+ def template_content(template_data)
112
+ template = template_data['message']
113
+ buffer = ''.dup
114
+ template.each_line do |line|
115
+ buffer << format_line(line)
116
+ end
117
+ buffer
118
+ end
119
+
120
+ def format_line(line)
121
+ replaced = line.gsub(/\(\(([^)]*)\)\)/) do
122
+ post_process_value params['personalisation'][Regexp.last_match[1]]
123
+ end
124
+ wrap_line(replaced)
125
+ end
126
+
127
+ def post_process_value(value)
128
+ return value unless value.is_a?(Hash) && value.keys.include?('file')
129
+
130
+ render_file(value)
131
+ end
132
+
133
+ def wrap_line(line)
134
+ case line
135
+ when /^---/ then render_horizontal_line
136
+ else render_paragraph(line)
137
+ end
138
+ end
139
+
140
+ def render_horizontal_line
141
+ Tilt.new(File.absolute_path('../../views/govuk/horizontal_line.html.erb', __dir__)).render
142
+ end
143
+
144
+ def render_paragraph(line)
145
+ Tilt.new(File.absolute_path('../../views/govuk/paragraph.html.erb', __dir__)).render(nil, content: line)
146
+ end
147
+
148
+ def render_file(file_data)
149
+ Tilt.new(File.absolute_path('../../views/govuk/file.html.erb', __dir__)).render(nil, file_data: file_data,
150
+ base_url: base_url)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'fileutils'
5
+ require 'yaml'
6
+
7
+ # GovFakeNotify module
8
+ module GovFakeNotify
9
+ # Central configuration singleton
10
+ class Config
11
+ include Singleton
12
+
13
+ attr_accessor :smtp_address, :smtp_port, :smtp_user_name, :smtp_password,
14
+ :smtp_authentication, :smtp_enable_starttls_auto,
15
+ :base_url, :database_file, :attachments_path, :include_templates,
16
+ :include_api_keys, :delivery_method, :port
17
+
18
+ def from(hash)
19
+ hash.each_pair do |key, value|
20
+ next unless respond_to?(:"#{key}=")
21
+
22
+ send(:"#{key}=", value)
23
+ end
24
+ end
25
+ end
26
+
27
+ Config.instance.tap do |c| # rubocop:disable Metrics/BlockLength
28
+ c.smtp_address = ENV.fetch('GOV_FAKE_NOTIFY_SMTP_HOSTNAME', 'localhost')
29
+ c.smtp_port = ENV.fetch('GOV_FAKE_NOTIFY_SMTP_PORT', '1025').to_i
30
+ c.smtp_user_name = ENV['GOV_FAKE_NOTIFY_SMTP_USERNAME']
31
+ c.smtp_password = ENV['GOV_FAKE_NOTIFY_SMTP_PASSWORD']
32
+ c.base_url = ENV.fetch('GOV_FAKE_NOTIFY_BASE_URL', 'http://localhost:8080')
33
+ c.smtp_authentication = nil
34
+ c.smtp_enable_starttls_auto = false
35
+ c.database_file = "#{ENV['HOME']}/.gov_fake_notify/store"
36
+ c.attachments_path = "#{ENV['HOME']}/.gov_fake_notify/attachments"
37
+ c.include_templates = []
38
+ c.include_api_keys = []
39
+ c.delivery_method = 'smtp'
40
+ c.port = 8080
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'json'
5
+ require 'gov_fake_notify/commands/create_template_command'
6
+ module GovFakeNotify
7
+ # A group of endpoints dedicated to controlling the app from the command line or API - for development and test only
8
+ class ControlApp < Roda
9
+ plugin :request_headers
10
+ plugin :halt
11
+ plugin :sinatra_helpers
12
+ plugin :json_parser
13
+ route do |r|
14
+ r.is 'reset' do
15
+ r.post do
16
+ GovFakeNotify.reset!
17
+ end
18
+ end
19
+ r.is 'templates' do
20
+ r.post do
21
+ result = CreateTemplateCommand.call(request.params)
22
+ if result.success?
23
+ result.to_json
24
+ else
25
+ r.halt 422, { message: 'Command failed' }.to_json
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module GovFakeNotify
2
+ module CurrentService
3
+ def current_service(store: Store.instance)
4
+ header = request.headers['Authorization'].gsub(/^Bearer /, '')
5
+ store.transaction do
6
+ store.roots.each do |root|
7
+ next unless root.start_with?('apikey')
8
+
9
+ return store[root].dup if validate_jwt(header, store[root]['secret_token'])
10
+ end
11
+ end
12
+ nil
13
+ end
14
+
15
+ def validate_jwt(token, secret)
16
+ JWT.decode token, secret, 'HS256'
17
+ true
18
+ rescue JWT::DecodeError
19
+ false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'json'
5
+ require 'gov_fake_notify/commands/send_email_command'
6
+ require 'gov_fake_notify/commands/fetch_file_command'
7
+ require 'tilt'
8
+ module GovFakeNotify
9
+ # Serves all attachment files
10
+ class FilesApp < Roda
11
+ plugin :request_headers
12
+ plugin :halt
13
+ plugin :sinatra_helpers
14
+ route do |r|
15
+ unless (service = current_service)
16
+ r.halt 403, { 'Content-Type' => 'application/json' }, { message: 'Invalid or missing token' }.to_json
17
+ end
18
+ r.is 'download', String do |id|
19
+ Tilt.new(File.absolute_path('../views/files/download.html.erb', __dir__)).render(nil, service_name: 'Employment Tribunals', service_email: 'et@test.com', id: id)
20
+ end
21
+ r.is 'confirm', String do |id|
22
+ Tilt.new(File.absolute_path('../views/files/confirm.html.erb', __dir__)).render(nil, service_name: 'Employment Tribunals', service_email: 'et@test.com', id: id)
23
+ end
24
+ r.is String do |id|
25
+ r.get do
26
+ result = FetchFileCommand.call(id)
27
+ if result.success?
28
+ attachment result.filename
29
+ send_file result.filename
30
+ else
31
+ r.halt 422, { message: 'Email failed to send' }.to_json
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def base_url
38
+ request.url.gsub(%r{/v\d+/.*}, '')
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'iodine'
4
+ # Iodine setup - use conditional setup to allow command-line arguments to override these:
5
+ if defined?(Iodine)
6
+ Iodine.threads = ENV.fetch('RAILS_MAX_THREADS', 1).to_i if Iodine.threads.zero?
7
+ Iodine.workers = ENV.fetch('WEB_CONCURRENCY', 1).to_i if Iodine.workers.zero?
8
+ Iodine::DEFAULT_SETTINGS[:port] = ENV.fetch('PORT') if ENV.key?('PORT')
9
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'json'
5
+ require 'jwt'
6
+ require 'gov_fake_notify/commands/send_email_command'
7
+ require 'gov_fake_notify/commands/fetch_message_status_command'
8
+ require 'gov_fake_notify/commands/fetch_all_messages_status_command'
9
+ require 'gov_fake_notify/current_service'
10
+ module GovFakeNotify
11
+ # Serves all notifications resources
12
+ class NotificationsApp < Roda
13
+ include CurrentService
14
+ plugin :request_headers
15
+ plugin :halt
16
+ plugin :sinatra_helpers
17
+ plugin :json_parser
18
+ route do |r| # rubocop:disable Metrics:BlockLength
19
+ unless (service = current_service)
20
+ r.halt 403, { 'Content-Type' => 'application/json' }, { message: 'Invalid or missing token' }.to_json
21
+ end
22
+ r.is 'email' do
23
+ r.post do
24
+ result = SendEmailCommand.call(request.params, base_url: base_url, service: service)
25
+ if result.success?
26
+ result.to_json
27
+ else
28
+ r.halt 422, { message: 'Email failed to send' }.to_json
29
+ end
30
+ end
31
+ end
32
+ r.is String do |id|
33
+ r.get do
34
+ result = FetchMessageStatusCommand.call(id)
35
+ if result.success?
36
+ result.to_json
37
+ else
38
+ r.halt 404, { message: result.errors.join(', ') }.to_json
39
+ end
40
+ end
41
+ end
42
+ r.is do
43
+ r.get do
44
+ result = FetchAllMessagesStatusCommand.call(request.params)
45
+ if result.success?
46
+ result.to_json
47
+ else
48
+ r.halt 404, { message: result.errors.join(', ') }.to_json
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def base_url
55
+ request.url.gsub(%r{/v\d+/.*}, '')
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'gov_fake_notify/notifications_app'
5
+ require 'gov_fake_notify/files_app'
6
+ require 'gov_fake_notify/control_app'
7
+ require 'gov_fake_notify/templates_app'
8
+ require 'gov_fake_notify/current_service'
9
+
10
+ module GovFakeNotify
11
+ # The root application
12
+ class RootApp < Roda
13
+ include CurrentService
14
+ plugin :multi_run
15
+ plugin :common_logger
16
+ run 'v2/notifications', NotificationsApp
17
+ run 'v2/templates', TemplatesApp
18
+ run 'files', FilesApp
19
+ run 'control', ControlApp
20
+
21
+ route(&:multi_run)
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'pstore'
5
+ module GovFakeNotify
6
+ # A central store for storing all state in the app - uses a basic PStore
7
+ class Store
8
+ def self.instance
9
+ Thread.current[:gov_fake_notify_store] ||= ::PStore.new(GovFakeNotify.config.database_file)
10
+ end
11
+
12
+ def self.clear_messages!
13
+ instance.transaction do
14
+ instance.roots.each do |key|
15
+ next unless key =~ /^message-/
16
+
17
+ instance.delete(key)
18
+ end
19
+ end
20
+ clear_attachments!
21
+ end
22
+
23
+ def self.clear_attachments!(config: Config.instance)
24
+ FileUtils.rm_rf File.join(config.attachments_path, '.')
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'json'
5
+ require 'gov_fake_notify/commands/fetch_templates_command'
6
+ require 'gov_fake_notify/current_service'
7
+
8
+ module GovFakeNotify
9
+ # A group of endpoints for the templates
10
+ class TemplatesApp < Roda
11
+ include CurrentService
12
+ plugin :request_headers
13
+ plugin :halt
14
+ plugin :sinatra_helpers
15
+ plugin :json_parser
16
+ route do |r|
17
+ unless current_service
18
+ r.halt 403, { 'Content-Type' => 'application/json' }, { message: 'Invalid or missing token' }.to_json
19
+ end
20
+ r.is do
21
+ r.get do
22
+ result = FetchTemplatesCommand.call(request.params)
23
+ if result.success?
24
+ result.to_json
25
+ else
26
+ r.halt 404, { message: result.errors.join(', ') }.to_json
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GovFakeNotify
4
+ VERSION = '1.1.1'
5
+ end