email_api 1.0.0

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.
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'dotenv/load'
2
+ require 'bundler/gem_tasks'
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.pattern = 'tests/**/*_tests.rb'
7
+ end
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
data/config.ru ADDED
@@ -0,0 +1,4 @@
1
+ require 'dotenv/load'
2
+ require File.absolute_path('lib/email_api')
3
+
4
+ run EmailApi
data/email_api.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = 'email_api'
6
+ spec.version = '1.0.0'
7
+ spec.authors = ['Vasili Moisiadis']
8
+ spec.email = ['vasili@moisiadis.com']
9
+
10
+ spec.summary = 'Email API'
11
+ spec.description = 'Backend Email API that accepts necessary information and sends emails using email service providers'
12
+ spec.homepage = 'https://github.com/VasiliMoisiadis/email-api'
13
+ spec.license = 'MIT'
14
+
15
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
16
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
17
+ unless spec.respond_to?(:metadata)
18
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
19
+ 'public gem pushes.'
20
+ end
21
+
22
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
23
+ f.match(%r{^(tests|spec|features)/})
24
+ end
25
+ spec.bindir = 'exe'
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ['lib']
28
+
29
+ spec.add_development_dependency 'bundler', '~> 1.16'
30
+ spec.add_development_dependency 'dotenv', '~> 2.2.1'
31
+ spec.add_development_dependency 'json', '~> 2.1.0'
32
+ spec.add_development_dependency 'minitest', '~> 5.11.3'
33
+ spec.add_development_dependency 'minitest-reporters', '~> 1.1.19'
34
+ spec.add_development_dependency 'mocha', '~> 1.4.0'
35
+ spec.add_development_dependency 'puma', '~> 3.11.3'
36
+ spec.add_development_dependency 'rack-test', '~> 0.8.3'
37
+ spec.add_development_dependency 'rake', '~> 10.0'
38
+ spec.add_development_dependency 'rest-client', '~> 2.0.2'
39
+ spec.add_development_dependency 'rspec', '~> 3.0'
40
+ spec.add_development_dependency 'rspec-rails', '~> 3.7.2'
41
+ spec.add_development_dependency 'simplecov', '~> 0.16.1'
42
+ spec.add_development_dependency 'sinatra', '~> 2.0.1'
43
+ spec.add_development_dependency 'thin', '~> 1.2.5'
44
+ end
data/lib/email_api.rb ADDED
@@ -0,0 +1,47 @@
1
+ require 'sinatra/base'
2
+ require 'json'
3
+ require 'date'
4
+ require './lib/email_api/api/api_parser'
5
+ require './lib/email_api/email/client/email_client'
6
+ require './lib/email_api/email/data/email_object'
7
+
8
+ # Main class of Email API Project
9
+ class EmailApi < Sinatra::Base
10
+
11
+ # Ping with current time
12
+ get '/ping' do
13
+ puts "Received new Ping Request from #{request.ip}"
14
+ { time: Time.now.utc }.to_json
15
+ end
16
+
17
+ # Send Email (Supports calling through a web browser)
18
+ get '/send' do
19
+ puts "Received new Email Send GET Request from #{request.ip}"
20
+ handle_api(params).to_hash.to_json
21
+ end
22
+
23
+ # Send Email (Proper usage, as a POST request)
24
+ post '/send' do
25
+ puts "Received new Email Send POST Request from #{request.ip}"
26
+ handle_api(params).to_hash.to_json
27
+ end
28
+
29
+ # Handle request received through API
30
+ def handle_api(api_params)
31
+ return nil if !api_params.respond_to?(:[]) && !api_params.is_a?(Hash)
32
+ from = api_params['from']
33
+ to = api_params['to']
34
+ cc = api_params['cc']
35
+ bcc = api_params['bcc']
36
+ subject = api_params['subject']
37
+ content = api_params['content']
38
+ email_obj = ApiParser.parse_email(from, to, cc, bcc, subject, content)
39
+ EmailClient.send_email(email_obj)
40
+ rescue StandardError => e
41
+ puts "Error: #{e.class}: #{e.message}"
42
+ response = ClientResponse.new
43
+ response.set_internal_err
44
+ response
45
+ end
46
+ end
47
+
@@ -0,0 +1,100 @@
1
+ require './lib/email_api/email/data/email_object'
2
+ require './lib/email_api/email/data/email_address'
3
+ require 'json'
4
+
5
+ # Parser for Email API
6
+ class ApiParser
7
+ @email_delim ||= ','
8
+
9
+ # Accessor for the supported email delimiter
10
+ def self.email_delim
11
+ @email_delim
12
+ end
13
+
14
+ # Parses raw text into custom EmailAddress object
15
+ #
16
+ # @param [String] email_text Raw text denoting email
17
+ # @return [Email Address] email_address
18
+ def self.parse_email_text(email_text)
19
+ return nil if email_text.nil? || !email_text.is_a?(String)
20
+
21
+ restricted_chars = ',<>'
22
+
23
+ # Try to parse correct format: "DISPLAY NAME <EMAIL ADDRESS>"
24
+ name = nil
25
+ email = email_text[/.*<([^>]*)/, 1]
26
+ name = email_text.gsub(email, '') unless email.nil?
27
+ name = name.delete(restricted_chars).strip unless name.nil?
28
+ email = email.delete(restricted_chars).strip unless email.nil?
29
+
30
+ # If only one or the other found, assign value to both fields
31
+ name = email if (!email.nil? && !email.empty?) && (name.nil? || name.empty?)
32
+ email = name if (!name.nil? && !name.empty?) && (email.nil? || email.empty?)
33
+
34
+ # If neither found, take entire String as values
35
+ name = email = email_text.delete(restricted_chars).strip if name.nil? && email.nil?
36
+
37
+ EmailAddress.new(name, email)
38
+ end
39
+
40
+ # Parse a String[] and returns an EmailAddres[]
41
+ #
42
+ # @param [String[]] email_text_arr Array of text, each being an email address
43
+ # @return [EmailAddress[]] email_arr
44
+ def self.parse_email_text_arr(email_text_arr)
45
+ return nil if email_text_arr.nil? || !email_text_arr.is_a?(String)
46
+
47
+ email_arr = []
48
+ # Split on email delimiter, parse each email
49
+ split_arr = email_text_arr.split(/(?<=[#{email_delim}])/, -1)
50
+
51
+ # Push value on through even if empty -> handled further down
52
+ email_arr.push(parse_email_text(email_text_arr)) if split_arr.empty?
53
+ split_arr.each do |email_text|
54
+ email_arr.push(parse_email_text(email_text))
55
+ end
56
+
57
+ email_arr = nil if !email_arr.nil? && email_arr.empty?
58
+ email_arr
59
+ end
60
+
61
+ # Parses parameter inputs into an EmailObject using the supported notation
62
+ #
63
+ # @param [Splat] params Raw Email Attribute Values
64
+ # @return [EmailObject] email_object Parsed email object
65
+ def self.parse_email(*params)
66
+ email_object = EmailObject.new
67
+
68
+ return email_object if params.nil? || !params.is_a?(Array) || params.empty?
69
+
70
+ to = cc = bcc = subject = content = nil
71
+ from = params[0]
72
+ to = params[1] if params.count > 1
73
+ cc = params[2] if params.count > 2
74
+ bcc = params[3] if params.count > 3
75
+ subj_raw = params[4] if params.count > 4
76
+ cont_raw = params[5] if params.count > 5
77
+
78
+ subject = nil
79
+ if !subj_raw.nil? && (subj_raw.is_a?(String) || subj_raw.is_a?(Numeric))
80
+ subject = subj_raw
81
+ end
82
+
83
+ content = nil
84
+ if !cont_raw.nil? && (cont_raw.is_a?(String) || cont_raw.is_a?(Numeric))
85
+ content = cont_raw
86
+ end
87
+
88
+ # Parse and assign Message Attributes
89
+ email_object.from = parse_email_text(from)
90
+ email_object.to = parse_email_text_arr(to)
91
+ email_object.cc = parse_email_text_arr(cc)
92
+ email_object.bcc = parse_email_text_arr(bcc)
93
+ email_object.subject = (subject.nil? || subject.empty? ? nil : subject)
94
+ email_object.content = (content.nil? || content.empty? ? nil : content)
95
+
96
+ email_object
97
+ end
98
+
99
+ private_class_method :parse_email_text_arr, :parse_email_text
100
+ end
@@ -0,0 +1,69 @@
1
+ require './lib/email_api/email/client/mailgun_client'
2
+ require './lib/email_api/email/client/sendgrid_client'
3
+ require './lib/email_api/email/data/client_response'
4
+ require 'json'
5
+
6
+ # General client that sends email utilizing multiple service providers
7
+ class EmailClient
8
+
9
+ # Accessor for the OK Code
10
+ def self.ok_code
11
+ 200
12
+ end
13
+
14
+ # Accessor for the Bad Request Code
15
+ def self.bad_req_code
16
+ 400
17
+ end
18
+
19
+ # Accessor for the Internal Server Error Code
20
+ def self.internal_err_code
21
+ 500
22
+ end
23
+
24
+ # Sends email utilizing multiple service providers
25
+ #
26
+ # @param [EmailObject] email_object
27
+ # @return [ClientResponse] response
28
+ def self.send_email(email_object)
29
+ return ClientResponse.new if email_object.nil? || !email_object.is_a?(EmailObject)
30
+
31
+ puts 'Attempting Email Sending via primary client: SENDGRID'
32
+ response = use_client_client SendgridClient, email_object
33
+
34
+ if response != ok_code
35
+ puts 'Primary client failed. Attempting secondary client: MAILGUN'
36
+ response = use_client_client MailgunClient, email_object
37
+ end
38
+
39
+ client_response = ClientResponse.new email_object
40
+ if response == ok_code
41
+ client_response.set_ok
42
+ elsif response == bad_req_code
43
+ client_response.set_bad_req
44
+ elsif response == internal_err_code
45
+ client_response.set_internal_err
46
+ end
47
+
48
+ client_response
49
+ end
50
+
51
+ # Sends email using a single email client
52
+ #
53
+ # @param [MailgunClient|SendgridClient] email_client
54
+ # @param [EmailObject] email_object
55
+ # @return [Integer] response_code
56
+ def self.use_client_client(email_client, email_object)
57
+ begin
58
+ response = email_client.send_email(email_object)
59
+ puts 'Successful attempt' if response == ok_code
60
+ puts 'Bad Request' if response == bad_req_code
61
+ puts 'Internal Server Error' if response == internal_err_code
62
+ rescue StandardError => e
63
+ # Log error and fail send
64
+ puts "Error: #{e.message}"
65
+ response = internal_err_code
66
+ end
67
+ response
68
+ end
69
+ end
@@ -0,0 +1,123 @@
1
+ require './lib/email_api/email/data/email_address'
2
+ require './lib/email_api/email/data/email_object'
3
+ require 'rest-client'
4
+ require 'json'
5
+
6
+ # Client for sending email via Mailgun v3 API
7
+ class MailgunClient
8
+ @exp_msg ||= 'Queued. Thank you.'
9
+ @ok_code ||= 200
10
+ @bad_req_code ||= 400
11
+ @internal_err_code ||= 500
12
+
13
+ # Accessor for the expected success message
14
+ def self.exp_msg
15
+ @exp_msg
16
+ end
17
+
18
+ # Accessor for the OK Code
19
+ def self.ok_code
20
+ @ok_code
21
+ end
22
+
23
+ # Accessor for the Bad Request Code
24
+ def self.bad_req_code
25
+ @bad_req_code
26
+ end
27
+
28
+ # Accessor for the Internal Server Error Code
29
+ def self.internal_err_code
30
+ @internal_err_code
31
+ end
32
+
33
+ # Sends an email over HTTPS
34
+ #
35
+ # @param [EmailObject] email_object
36
+ # @return [Response] rest_client_response
37
+ def self.send_email(email_object)
38
+
39
+ # Build Mailgun-specific URL and POST data
40
+ api_key = ENV['MAILGUN_PRIVATE_KEY']
41
+ domain = ENV['MAILGUN_DOMAIN']
42
+ return internal_err_code if api_key.nil? || domain.nil?
43
+
44
+ post_data = parse_post_data(email_object, domain)
45
+ return bad_req_code if post_data.nil?
46
+
47
+ url = "https://api:#{api_key}@api.mailgun.net/v3/#{domain}/messages"
48
+
49
+ # Send Email, return response
50
+ begin
51
+ response = RestClient.post url, post_data
52
+ rescue StandardError => e
53
+ # Log error and fail send -> occurs when Code 400 due to implementation
54
+ puts "Error: #{e.message}"
55
+ return bad_req_code
56
+ end
57
+
58
+ puts "Secondary Client Response: #{response}"
59
+
60
+ # Handle expected output. Note that it is API specific.
61
+ return ok_code if JSON.parse(response)['message'] == exp_msg
62
+
63
+ bad_req_code
64
+ end
65
+
66
+ # Parses email address into proper, supported text format
67
+ #
68
+ # @param [EmailAddress] email_address
69
+ # @return [String] email_address_text
70
+ def self.parse_addr_text(email_address)
71
+ return nil if email_address.nil? || !email_address.is_a?(EmailAddress)
72
+ "#{email_address.name} <#{email_address.email}>"
73
+ end
74
+
75
+ # Parses array of email addresses into proper, supported text format
76
+ #
77
+ # @param [EmailAddress[]] email_address_arr
78
+ # @return [String] email_field
79
+ def self.parse_addr_arr(email_address_arr)
80
+ return '' if email_address_arr.nil? || !email_address_arr.is_a?(Array)
81
+
82
+ # Convert array of multiple email addresses to proper text format
83
+ email_field = ''
84
+ email_address_arr.each do |email_address|
85
+ addr_text = parse_addr_text(email_address)
86
+ unless addr_text.nil?
87
+ email_field += ', ' unless email_field.empty?
88
+ email_field += addr_text
89
+ end
90
+ end
91
+
92
+ email_field
93
+ end
94
+
95
+ # Parses an EmailObject into POST data for use in an API call
96
+ #
97
+ # @param [EmailObject] email_object
98
+ # @return [String] post_data
99
+ def self.parse_post_data(email_object, domain)
100
+ # Handle missing or unsupported input parameter
101
+ return nil if email_object.nil? || domain.nil? || !email_object.is_a?(EmailObject)
102
+
103
+ # Handle missing mandatory Email Attributes
104
+ return nil if email_object.from.nil? || email_object.to.nil? ||
105
+ email_object.subject.nil? || email_object.content.nil?
106
+
107
+ # Parse environment value and build Mailgun-specific FROM email
108
+ from_email = EmailAddress.new(email_object.from.name, "mailgun@#{domain}")
109
+
110
+ # Build Message Attributes
111
+ post_data = {}
112
+ post_data[:from] = parse_addr_text(from_email)
113
+ post_data[:to] = parse_addr_arr(email_object.to)
114
+ post_data[:cc] = parse_addr_arr(email_object.cc) unless email_object.cc.nil?
115
+ post_data[:bcc] = parse_addr_arr(email_object.bcc) unless email_object.bcc.nil?
116
+ post_data[:subject] = email_object.subject.to_s
117
+ post_data[:text] = email_object.content.to_s
118
+
119
+ post_data
120
+ end
121
+
122
+ private_class_method :parse_addr_text, :parse_addr_arr, :parse_post_data
123
+ end
@@ -0,0 +1,115 @@
1
+ require './lib/email_api/email/data/email_object'
2
+ require 'rest-client'
3
+ require 'json'
4
+
5
+ # Client for sending email via Sendgrid v2 API
6
+ class SendgridClient
7
+ @exp_msg ||= 'success'
8
+ @ok_code ||= 200
9
+ @bad_req_code ||= 400
10
+ @internal_err_code ||= 500
11
+
12
+ # Accessor for the expected success message
13
+ def self.exp_msg
14
+ @exp_msg
15
+ end
16
+
17
+ # Accessor for the OK Code
18
+ def self.ok_code
19
+ @ok_code
20
+ end
21
+
22
+ # Accessor for the Bad Request Code
23
+ def self.bad_req_code
24
+ @bad_req_code
25
+ end
26
+
27
+ # Accessor for the Internal Server Error Code
28
+ def self.internal_err_code
29
+ @internal_err_code
30
+ end
31
+
32
+ # Sends an email over HTTPS
33
+ #
34
+ # @param [EmailObject] email_object
35
+ # @return [int] status_code
36
+ def self.send_email(email_object)
37
+
38
+ # Parse Environment Values
39
+ api_user = ENV['SENDGRID_API_USER']
40
+ api_key = ENV['SENDGRID_API_KEY']
41
+ return internal_err_code if api_user.nil? || api_key.nil?
42
+
43
+ # Build Sendgrid-specific URL and POST data
44
+ url = 'https://api.sendgrid.com/api/mail.send.json'
45
+ post_data = parse_post_data(email_object, api_user, api_key)
46
+ return bad_req_code if post_data.nil?
47
+
48
+ # Send Email, return response
49
+ begin
50
+ response = RestClient.post url, post_data
51
+ rescue StandardError => e
52
+ # Log error and fail send -> occurs when Code 400 due to implementation
53
+ puts "Error: #{e.message}"
54
+ return bad_req_code
55
+ end
56
+
57
+ puts "Primary Client Response: #{response}"
58
+
59
+ return bad_req_code if response.nil? || !JSON.parse(response).respond_to?(:[])
60
+
61
+ # Handle expected output. Note that it is API specific.
62
+ json_response = JSON.parse(response)
63
+ if !json_response.key?('message').nil? && json_response['message'] == exp_msg
64
+ return ok_code
65
+ end
66
+
67
+ internal_err_code # Unhandled response
68
+ end
69
+
70
+ # Parses an EmailObject into POST data for use in an API call
71
+ #
72
+ # @param [EmailObject] email_object
73
+ # @return [String] post_data
74
+ def self.parse_post_data(email_object, api_user, api_key)
75
+
76
+ # Handle missing Environment Variables
77
+ return nil if api_user.nil? || api_key.nil?
78
+
79
+ # Handle missing or unsupported input parameter
80
+ return nil if email_object.nil? || !email_object.is_a?(EmailObject)
81
+
82
+ # Handle missing mandatory Email Attributes
83
+ return nil if email_object.from.nil? || email_object.to.nil? ||
84
+ email_object.subject.nil? || email_object.content.nil?
85
+
86
+
87
+ # Build Message Attributes
88
+ post_data = "api_user=#{api_user}"
89
+ post_data += "&api_key=#{api_key}"
90
+ post_data += "&subject=#{email_object.subject}"
91
+ post_data += "&text=#{email_object.content}"
92
+ post_data += "&from=#{email_object.from.email}"
93
+ post_data += "&fromname[]=#{email_object.from.name}"
94
+ email_object.to.each do |address|
95
+ post_data += "&to[]=#{address.email}"
96
+ post_data += "&toname[]=#{address.name}"
97
+ end
98
+ unless email_object.cc.nil?
99
+ email_object.cc.each do |address|
100
+ post_data += "&cc[]=#{address.email}"
101
+ post_data += "&ccname[]=#{address.name}"
102
+ end
103
+ end
104
+ unless email_object.bcc.nil?
105
+ email_object.bcc.each do |address|
106
+ post_data += "&bcc[]=#{address.email}"
107
+ post_data += "&bccname[]=#{address.name}"
108
+ end
109
+ end
110
+
111
+ post_data
112
+ end
113
+
114
+ private_class_method :parse_post_data
115
+ end