email_api 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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