justrelate_sdk 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,148 @@
1
+ require 'multi_json'
2
+ require 'addressable/uri'
3
+
4
+ module JustRelate; module Core
5
+ class RestApi
6
+ METHOD_TO_NET_HTTP_CLASS = {
7
+ :get => Net::HTTP::Get,
8
+ :put => Net::HTTP::Put,
9
+ :post => Net::HTTP::Post,
10
+ :delete => Net::HTTP::Delete,
11
+ }.freeze
12
+
13
+ def self.instance=(instance)
14
+ @instance = instance
15
+ end
16
+
17
+ def self.instance
18
+ if @instance
19
+ @instance
20
+ else
21
+ raise "Please run JustRelate.configure first"
22
+ end
23
+ end
24
+
25
+ def initialize(uri, login, api_key)
26
+ @uri = uri
27
+ @login = login
28
+ @api_key = api_key
29
+ @connection_manager = ConnectionManager.new(uri)
30
+ end
31
+
32
+ def get(resource_path, payload = nil)
33
+ response_for_request(:get, resource_path, payload, {})
34
+ end
35
+
36
+ def put(resource_path, payload, headers = {})
37
+ response_for_request(:put, resource_path, payload, headers)
38
+ end
39
+
40
+ def post(resource_path, payload)
41
+ response_for_request(:post, resource_path, payload, {})
42
+ end
43
+
44
+ def delete(resource_path, payload = nil, headers = {})
45
+ response_for_request(:delete, resource_path, payload, headers)
46
+ end
47
+
48
+ def resolve_uri(url)
49
+ input_uri = Addressable::URI.parse(url)
50
+ input_uri.path = Addressable::URI.escape(input_uri.path)
51
+ @uri + input_uri.to_s
52
+ end
53
+
54
+ private
55
+
56
+ def response_for_request(method, resource_path, payload, headers)
57
+ path = resolve_uri(resource_path).path
58
+ request = method_to_net_http_class(method).new(path)
59
+ set_headers(request, headers)
60
+ request.body = MultiJson.encode(payload) if payload.present?
61
+
62
+ response = nil
63
+ retried = false
64
+ begin
65
+ ActiveSupport::Notifications.instrument("request.justrelate") do |msg|
66
+ msg[:method] = method
67
+ msg[:resource_path] = "#{resource_path}"
68
+ msg[:request_payload] = payload
69
+ end
70
+ response = ActiveSupport::Notifications.instrument("response.justrelate") do |msg|
71
+ # lower timeout back to DEFAULT_TIMEOUT once the backend has been fixed
72
+ msg[:response] = @connection_manager.request(request, 25)
73
+ end
74
+ rescue Errors::NetworkError => e
75
+ if method == :post || retried
76
+ raise e
77
+ else
78
+ retried = true
79
+ retry
80
+ end
81
+ end
82
+
83
+ handle_response(response)
84
+ end
85
+
86
+ def parse_payload(payload)
87
+ MultiJson.load(payload)
88
+ rescue MultiJson::DecodeError
89
+ raise Errors::ServerError.new("Server returned invalid json: #{payload}")
90
+ end
91
+
92
+ def handle_response(response)
93
+ body = parse_payload(response.body)
94
+ if response.code.start_with?('2')
95
+ body
96
+ else
97
+ message = body['message']
98
+
99
+ case body['id']
100
+ when 'unauthorized'
101
+ raise Errors::UnauthorizedAccess.new(message)
102
+ when 'authentication_failed'
103
+ raise Errors::AuthenticationFailed.new(message)
104
+ when 'forbidden'
105
+ raise Errors::ForbiddenAccess.new(message)
106
+ when 'not_found'
107
+ raise Errors::ResourceNotFound.new(message, body['missing_ids'])
108
+ when 'item_state_precondition_failed'
109
+ raise Errors::ItemStatePreconditionFailed.new(message, body['unmet_preconditions'])
110
+ when 'conflict'
111
+ raise Errors::ResourceConflict.new(message)
112
+ when 'invalid_keys'
113
+ raise Errors::InvalidKeys.new(message, body['validation_errors'])
114
+ when 'invalid_values'
115
+ raise Errors::InvalidValues.new(message, body['validation_errors'])
116
+ # leaving out 'params_parse_error', since the client should always send valid json.
117
+ when 'rate_limit'
118
+ raise Errors::RateLimitExceeded.new(message)
119
+ when 'internal_server_error'
120
+ raise Errors::ServerError.new(message)
121
+ when 'too_many_params'
122
+ raise Errors::TooManyParams.new(message)
123
+ else
124
+ if response.code == '404'
125
+ raise Errors::ResourceNotFound.new('Not Found.')
126
+ elsif response.code.start_with?('4')
127
+ raise Errors::ClientError.new("HTTP Code #{response.code}: #{body}")
128
+ else
129
+ raise Errors::ServerError.new("HTTP Code #{response.code}: #{body}")
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ def set_headers(request, additional_headers)
136
+ request.basic_auth(@login, @api_key)
137
+ request['Content-Type'] = 'application/json'
138
+ request['Accept'] = 'application/json'
139
+ additional_headers.each do |key, value|
140
+ request[key] = value
141
+ end
142
+ end
143
+
144
+ def method_to_net_http_class(method)
145
+ METHOD_TO_NET_HTTP_CLASS.fetch(method)
146
+ end
147
+ end
148
+ end; end
@@ -0,0 +1,207 @@
1
+ module JustRelate; module Core
2
+ # +SearchConfigurator+ provides methods to incrementally configure a search request
3
+ # using chainable methods and to {#perform_search perform} this search.
4
+ # @example
5
+ # search_config = JustRelate::Contact.
6
+ # where('last_name', 'equals', 'Johnson').
7
+ # and('locality', 'equals', 'New York').
8
+ # and_not('language', 'equals', 'en').
9
+ # sort_by('first_name').
10
+ # sort_order('desc').
11
+ # offset(1).
12
+ # limit(3)
13
+ # # => JustRelate::Core::SearchConfigurator
14
+ #
15
+ # results = search_config.perform_search
16
+ # # => JustRelate::Core::ItemEnumerator
17
+ #
18
+ # results.length # => 3
19
+ # results.total # => 17
20
+ # results.map(&:first_name) # => ['Tim', 'Joe', 'Ann']
21
+ #
22
+ # @example Unlimited search results
23
+ # search_config = JustRelate::Contact.
24
+ # where('last_name', 'equals', 'Johnson').
25
+ # unlimited
26
+ # # => JustRelate::Core::SearchConfigurator
27
+ #
28
+ # results = search_config.perform_search
29
+ # # => JustRelate::Core::ItemEnumerator
30
+ #
31
+ # results.length # => 85
32
+ # results.total # => 85
33
+ # results.map(&:first_name) # => an array of 85 first names
34
+ # @api public
35
+ class SearchConfigurator
36
+ include Enumerable
37
+
38
+ def initialize(settings = {})
39
+ @settings = {
40
+ filters: [],
41
+ }.merge(settings)
42
+ end
43
+
44
+ # Executes the search request based on this configuration.
45
+ # @return [ItemEnumerator] the search result.
46
+ # @api public
47
+ def perform_search
48
+ @perform_search ||= JustRelate.search(
49
+ filters: @settings[:filters],
50
+ query: @settings[:query],
51
+ limit: @settings[:limit],
52
+ offset: @settings[:offset],
53
+ sort_by: @settings[:sort_by],
54
+ sort_order: @settings[:sort_order],
55
+ include_deleted: @settings[:include_deleted]
56
+ )
57
+ end
58
+
59
+ # @!group Chainable methods
60
+
61
+ # Returns a new {SearchConfigurator} constructed by combining
62
+ # this configuration and the new filter.
63
+ #
64
+ # Supported conditions:
65
+ # * +contains_word_prefixes+ - +field+ contains words starting with +value+.
66
+ # * +contains_words+ - +field+ contains the words given by +value+.
67
+ # * +equals+ - +field+ exactly corresponds to +value+ (case insensitive).
68
+ # * +is_blank+ - +field+ is blank (omit +value+).
69
+ # * +is_earlier_than+ - date time +field+ is earlier than +value+.
70
+ # * +is_later_than+ - date time +field+ is later than +value+.
71
+ # * +is_true+ - +field+ is true (omit +value+).
72
+ # @param field [Symbol, String] the attribute name.
73
+ # @param condition [Symbol, String] the condition, e.g. +:equals+.
74
+ # @param value [Symbol, String, Array<Symbol, String>, nil] the value.
75
+ # @return [SearchConfigurator]
76
+ # @api public
77
+ def add_filter(field, condition, value = nil)
78
+ new_filter = Array(@settings[:filters]) + [{field: field, condition: condition, value: value}]
79
+ SearchConfigurator.new(@settings.merge(filters: new_filter))
80
+ end
81
+ alias and add_filter
82
+
83
+ # Returns a new {SearchConfigurator} constructed by combining
84
+ # this configuration and the new negated filter.
85
+ #
86
+ # All filters (and their conditions) passed to {#add_filter} can be negated.
87
+ # @param field [Symbol, String] the attribute name.
88
+ # @param condition [Symbol, String] the condition, e.g. +:equals+.
89
+ # @param value [Symbol, String, Array<Symbol, String>, nil] the value.
90
+ # @return [SearchConfigurator]
91
+ # @api public
92
+ def add_negated_filter(field, condition, value = nil)
93
+ negated_condition = "not_#{condition}"
94
+ add_filter(field, negated_condition, value)
95
+ end
96
+ alias and_not add_negated_filter
97
+
98
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
99
+ # with the given query.
100
+ # @param new_query [String] the new query.
101
+ # @return [SearchConfigurator]
102
+ # @api public
103
+ def query(new_query)
104
+ SearchConfigurator.new(@settings.merge(query: new_query))
105
+ end
106
+
107
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
108
+ # with the given limit.
109
+ # @param new_limit [Fixnum] the new limit.
110
+ # @return [SearchConfigurator]
111
+ # @api public
112
+ def limit(new_limit)
113
+ SearchConfigurator.new(@settings.merge(limit: new_limit))
114
+ end
115
+
116
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
117
+ # without limiting the number of search results.
118
+ # @return [SearchConfigurator]
119
+ # @api public
120
+ def unlimited
121
+ limit(:none)
122
+ end
123
+
124
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
125
+ # with the given offset.
126
+ # @param new_offset [Fixnum] the new offset.
127
+ # @return [SearchConfigurator]
128
+ # @api public
129
+ def offset(new_offset)
130
+ SearchConfigurator.new(@settings.merge(offset: new_offset))
131
+ end
132
+
133
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
134
+ # with the given sort criterion.
135
+ # @param new_sort_by [String]
136
+ # See {JustRelate.search} for the list of supported +sort_by+ values.
137
+ # @return [SearchConfigurator]
138
+ # @api public
139
+ def sort_by(new_sort_by)
140
+ SearchConfigurator.new(@settings.merge(sort_by: new_sort_by))
141
+ end
142
+
143
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
144
+ # with the given sort order.
145
+ # @param new_sort_order [String]
146
+ # See {JustRelate.search} for the list of supported +sort_order+ values.
147
+ # @return [SearchConfigurator]
148
+ # @api public
149
+ def sort_order(new_sort_order)
150
+ SearchConfigurator.new(@settings.merge(sort_order: new_sort_order))
151
+ end
152
+
153
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
154
+ # with the ascending sort order.
155
+ # @return [SearchConfigurator]
156
+ # @api public
157
+ def asc
158
+ sort_order('asc')
159
+ end
160
+
161
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
162
+ # with the descending sort order.
163
+ # @return [SearchConfigurator]
164
+ # @api public
165
+ def desc
166
+ sort_order('desc')
167
+ end
168
+
169
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
170
+ # with the given +include_deleted+ flag.
171
+ # @param new_include_deleted [Boolean] whether to include deleted items in the results.
172
+ # @return [SearchConfigurator]
173
+ # @api public
174
+ def include_deleted(new_include_deleted = true)
175
+ SearchConfigurator.new(@settings.merge(include_deleted: new_include_deleted))
176
+ end
177
+
178
+ # Returns a new {SearchConfigurator} constructed by combining this configuration
179
+ # and excluding deleted items.
180
+ # @return [SearchConfigurator]
181
+ # @api public
182
+ def exclude_deleted
183
+ include_deleted(false)
184
+ end
185
+
186
+ # @!endgroup
187
+
188
+ # Iterates over the search results.
189
+ # Implicitly triggers {#perform_search} and caches its result.
190
+ # See {ItemEnumerator#each} for details.
191
+ # @api public
192
+ def each(&block)
193
+ return enum_for(:each) unless block_given?
194
+
195
+ perform_search.each(&block)
196
+ end
197
+
198
+ # Returns the total number of items that match this search configuration.
199
+ # It can be greater than +limit+.
200
+ # Implicitly triggers {#perform_search} and caches its result.
201
+ # @return [Fixnum] the total.
202
+ # @api public
203
+ def total
204
+ perform_search.total
205
+ end
206
+ end
207
+ end; end
@@ -0,0 +1,6 @@
1
+ module JustRelate
2
+ # @api public
3
+ module Core
4
+ JustRelate.autoload_module(self, File.expand_path(__FILE__))
5
+ end
6
+ end
@@ -0,0 +1,169 @@
1
+ module JustRelate
2
+ # @api public
3
+ module Errors
4
+ # +BaseError+ is the superclass of all JustRelate errors.
5
+ # @api public
6
+ class BaseError < StandardError
7
+ end
8
+
9
+ # +ServerError+ is raised if an internal error occurs in the API server.
10
+ # @api public
11
+ class ServerError < BaseError
12
+ end
13
+
14
+ # +ClientError+ is the superclass of all errors that are a result of client-supplied input.
15
+ # @api public
16
+ class ClientError < BaseError
17
+ end
18
+
19
+ # +UnauthorizedAccess+ is raised if the API user credentials are invalid.
20
+ # Set the correct API user credentials using {JustRelate.configure}.
21
+ # @api public
22
+ class UnauthorizedAccess < ClientError
23
+ end
24
+
25
+ # +AuthenticationFailed+ is raised if the credentials used with
26
+ # {JustRelate::Contact.authenticate!} are invalid.
27
+ # @api public
28
+ class AuthenticationFailed < ClientError
29
+ end
30
+
31
+ # +ForbiddenAccess+ is raised if the API user is not permitted to access the resource.
32
+ # @api public
33
+ class ForbiddenAccess < ClientError
34
+ end
35
+
36
+ # +TooManyParams+ is raised if more than 1000 keys are passed as parameters to
37
+ # {Core::Mixins::Modifiable::ClassMethods#create Modifiable.create} or
38
+ # {Core::Mixins::Modifiable#update Modifiable#update}.
39
+ # @api public
40
+ class TooManyParams < ClientError
41
+ end
42
+
43
+ # +ResourceNotFound+ is raised if the requested IDs could not be found.
44
+ # @api public
45
+ class ResourceNotFound < ClientError
46
+ # Returns the IDs that could not be found.
47
+ # @return [Array<String>]
48
+ # @example
49
+ # ["9762b2b4382f6bf34adbdeb21ce588aa"]
50
+ # @api public
51
+ attr_reader :missing_ids
52
+
53
+ def initialize(message = nil, missing_ids = [])
54
+ super("#{message} Missing IDs: #{missing_ids.to_sentence}")
55
+
56
+ @missing_ids = missing_ids
57
+ end
58
+ end
59
+
60
+ # +ItemStatePreconditionFailed+ is raised if one or more preconditions
61
+ # for the attempted action were not satisfied. For example, a deleted item cannot be updated.
62
+ # It must be undeleted first.
63
+ # @api public
64
+ class ItemStatePreconditionFailed < ClientError
65
+ # Returns the unmet preconditions.
66
+ # The items in the list are hashes consisting of a +code+ (the name of the precondition),
67
+ # and an English translation (+message+).
68
+ # @return [Array<Hash{String => String}>]
69
+ # @example
70
+ # [
71
+ # {
72
+ # "code" => "is_internal_mailing",
73
+ # "message" => "The mailing is not an internal mailing.",
74
+ # },
75
+ # ]
76
+ # @api public
77
+ attr_reader :unmet_preconditions
78
+
79
+ def initialize(message = nil, unmet_preconditions)
80
+ precondition_messages = unmet_preconditions.map{ |p| p['message'] }
81
+ new_message = ([message] + precondition_messages).join(' ')
82
+ super(new_message)
83
+
84
+ @unmet_preconditions = unmet_preconditions
85
+ end
86
+ end
87
+
88
+ # +ResourceConflict+ is raised if the item has been changed concurrently.
89
+ # {Core::BasicResource#reload Reload} the item, review the changes and retry.
90
+ # @api public
91
+ class ResourceConflict < ClientError
92
+ end
93
+
94
+ # +InvalidKeys+ is raised if a create or update request contains unknown attributes.
95
+ # @api public
96
+ class InvalidKeys < ClientError
97
+ # Returns the list of validation errors.
98
+ # The items in the list are hashes consisting of a +code+ (always +unknown+),
99
+ # the invalid +attribute+ name and an English translation (+message+).
100
+ # @return [Array<Hash{String => String}>]
101
+ # @example
102
+ # [
103
+ # {
104
+ # "attribute" => "foo",
105
+ # "code" => "unknown",
106
+ # "message" => "foo is unknown",
107
+ # },
108
+ # ]
109
+ # @api public
110
+ attr_reader :validation_errors
111
+
112
+ def initialize(message = nil, validation_errors = {})
113
+ super("#{message} #{validation_errors.map{ |h| h['message'] }.to_sentence}.")
114
+
115
+ @validation_errors = validation_errors
116
+ end
117
+ end
118
+
119
+ # +InvalidValues+ is raised if the keys of a create or update request are recognized
120
+ # but include incorrect values.
121
+ # @api public
122
+ class InvalidValues < ClientError
123
+ # Returns the list of validation errors.
124
+ # The items in the list are hashes consisting of a +code+ (the name of the validation error,
125
+ # i.e. one of the rails validation error codes),
126
+ # an +attribute+ name and an English translation (+message+).
127
+ # You may use +code+ to translate the message into other languages.
128
+ # @example
129
+ # [
130
+ # {
131
+ # "code" => "blank",
132
+ # "attribute" => "name",
133
+ # "message" => "name is blank",
134
+ # },
135
+ # ]
136
+ # @return [Array<Hash{String => String}>]
137
+ # @api public
138
+ attr_reader :validation_errors
139
+
140
+ def initialize(message = nil, validation_errors = {})
141
+ super("#{message} #{validation_errors.map{ |h| h['message'] }.to_sentence}.")
142
+
143
+ @validation_errors = validation_errors
144
+ end
145
+ end
146
+
147
+ # +RateLimitExceeded+ is raised if too many requests were issued within a given time frame.
148
+ # @api public
149
+ class RateLimitExceeded < ClientError
150
+ end
151
+
152
+ # +NetworkError+ is raised if a non-recoverable network-related error occurred
153
+ # (e.g. connection timeout).
154
+ # @api public
155
+ class NetworkError < BaseError
156
+ # Returns the underlying network error.
157
+ # E.g. {http://www.ruby-doc.org/stdlib/libdoc/timeout/rdoc/Timeout/Error.html Timeout::Error}
158
+ # @return [Exception]
159
+ # @api public
160
+ attr_reader :cause
161
+
162
+ def initialize(message = nil, cause = nil)
163
+ super(message)
164
+
165
+ @cause = cause
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,17 @@
1
+ module JustRelate
2
+ # A JustRelate event contains all data associated with an event such
3
+ # as a conference or a trade show. An event has participants ({EventContact}).
4
+ # @api public
5
+ class Event < Core::BasicResource
6
+ include Core::Mixins::Findable
7
+ include Core::Mixins::Modifiable
8
+ include Core::Mixins::ChangeLoggable
9
+ include Core::Mixins::Searchable
10
+ include Core::Mixins::Inspectable
11
+ inspectable :id, :title
12
+
13
+ # @!parse extend Core::Mixins::Findable::ClassMethods
14
+ # @!parse extend Core::Mixins::Modifiable::ClassMethods
15
+ # @!parse extend Core::Mixins::Searchable::ClassMethods
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module JustRelate
2
+ # A JustRelate event contact is a participant or a potential participant of an {Event}.
3
+ # @api public
4
+ class EventContact < Core::BasicResource
5
+ include Core::Mixins::Findable
6
+ include Core::Mixins::Modifiable
7
+ include Core::Mixins::ChangeLoggable
8
+ include Core::Mixins::Searchable
9
+ include Core::Mixins::Inspectable
10
+ inspectable :id
11
+
12
+ # @!parse extend Core::Mixins::Findable::ClassMethods
13
+ # @!parse extend Core::Mixins::Modifiable::ClassMethods
14
+ # @!parse extend Core::Mixins::Searchable::ClassMethods
15
+ end
16
+ end
@@ -0,0 +1,111 @@
1
+ module JustRelate
2
+ # The purpose of a JustRelate mailing is to send an e-mail, e.g. a newsletter,
3
+ # to several recipients.
4
+ # The e-mails will be sent to the members of the contact collection associated with the mailing
5
+ # (+mailing.collection_id+).
6
+ #
7
+ # JustRelate uses the {http://liquidmarkup.org/ Liquid template engine} for evaluating
8
+ # mailing content.
9
+ # @api public
10
+ class Mailing < Core::BasicResource
11
+ include Core::Mixins::Findable
12
+ include Core::Mixins::Modifiable
13
+ include Core::Mixins::ChangeLoggable
14
+ include Core::Mixins::Searchable
15
+ include Core::Mixins::Inspectable
16
+ inspectable :id, :title
17
+
18
+ # @!parse extend Core::Mixins::Findable::ClassMethods
19
+ # @!parse extend Core::Mixins::Modifiable::ClassMethods
20
+ # @!parse extend Core::Mixins::Searchable::ClassMethods
21
+
22
+ # Renders a preview of the e-mail for the given contact.
23
+ # @example
24
+ # mailing.html_body
25
+ # # => "<h1>Welcome {{contact.first_name}} {{contact.last_name}}</h1>"
26
+ #
27
+ # contact.email
28
+ # # => "john.doe@example.com"
29
+ #
30
+ # mailing.render_preview(contact)
31
+ # # => {
32
+ # # "email_from" => "Marketing <marketing@example.org>",
33
+ # # "email_reply_to" => "marketing-replyto@example.com",
34
+ # # "email_subject" => "Invitation to exhibition",
35
+ # # "email_to" => "john.doe@example.com",
36
+ # # "text_body" => "Welcome John Doe",
37
+ # # "html_body" => "<h1>Welcome John Doe</h1>"
38
+ # # }
39
+ # @param render_for_contact_or_id [String, Contact]
40
+ # the contact for which the e-mail preview is rendered.
41
+ # @return [Hash{String => String}] the values of the mailing fields evaluated
42
+ # in the context of the contact.
43
+ # @api public
44
+ def render_preview(render_for_contact_or_id)
45
+ Core::RestApi.instance.post("#{path}/render_preview", {
46
+ 'render_for_contact_id' => extract_id(render_for_contact_or_id)
47
+ })
48
+ end
49
+
50
+ # Sends a proof e-mail (personalized for a contact) to the current user (the API user).
51
+ # @example
52
+ # mailing.send_me_a_proof_email(contact)
53
+ # # => {
54
+ # # "message" => "e-mail sent to api_user@example.com"
55
+ # # }
56
+ # @param render_for_contact_or_id [String, Contact]
57
+ # the contact for which the proof e-mail is rendered.
58
+ # @return [Hash{String => String}] a status report.
59
+ # @api public
60
+ def send_me_a_proof_email(render_for_contact_or_id)
61
+ Core::RestApi.instance.post("#{path}/send_me_a_proof_email", {
62
+ 'render_for_contact_id' => extract_id(render_for_contact_or_id)
63
+ })
64
+ end
65
+
66
+ # Sends this mailing to a single contact.
67
+ #
68
+ # Use case: If someone registers for a newsletter, you can send them the most recent issue
69
+ # that has already been released.
70
+ # @example
71
+ # contact.email
72
+ # # => "john.doe@example.org"
73
+ #
74
+ # mailing.released_at
75
+ # # => 2014-12-01 12:48:00 +0100
76
+ #
77
+ # mailing.send_single_email(contact)
78
+ # # => {
79
+ # # "message" => "e-mail sent to john.doe@example.org"
80
+ # # }
81
+ # @param recipient_contact_or_id [String, Contact]
82
+ # the contact to send a single e-mail to.
83
+ # @return [Hash{String => String}] a status report.
84
+ # @api public
85
+ def send_single_email(recipient_contact_or_id)
86
+ Core::RestApi.instance.post("#{path}/send_single_email", {
87
+ 'recipient_contact_id' => extract_id(recipient_contact_or_id)
88
+ })
89
+ end
90
+
91
+ # Releases this mailing.
92
+ #
93
+ # Sends the mailing to all recipients, marks the mailing as
94
+ # released (+released_at+, +released_by+), and also sets +planned_release_at+ to now.
95
+ # @return [self] the updated mailing.
96
+ # @api public
97
+ def release
98
+ load_attributes(Core::RestApi.instance.post("#{path}/release", {}))
99
+ end
100
+
101
+ private
102
+
103
+ def extract_id(contact_or_id)
104
+ if contact_or_id.respond_to?(:id)
105
+ contact_or_id.id
106
+ else
107
+ contact_or_id
108
+ end
109
+ end
110
+ end
111
+ end