dotmailer 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -21,31 +21,182 @@ To install as part of a project managed by bundler, add to your Gemfile:
21
21
  Usage
22
22
  -----
23
23
 
24
- Use the `Dotmailer::Client` class to access the dotMailer REST API.
24
+ Interaction with the dotMailer API is done via a `DotMailer::Account` object, which is initialized with an API username and password:
25
25
 
26
- You must initialize the Client with an API user and password (see [here](http://www.dotmailer.co.uk/api/more_about_api/getting_started_with_the_api.aspx) for instructions on obtaining these):
26
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
27
27
 
28
- client = Dotmailer::Client.new('your-api-username', 'your-api-password')
28
+ All interaction via this object will be for the dotMailer account associated with the API credentials.
29
+
30
+ For instructions on how to obtain your API username and password, see [here](http://www.dotmailer.co.uk/api/more_about_api/getting_started_with_the_api.aspx).
29
31
 
30
32
  Data Fields
31
33
  -----------
32
34
 
33
35
  ### List
34
36
 
35
- `Dotmailer::Client#get_data_fields` will return an Array of `Dotmailer::DataField` objects representing the data fields for the global address book:
37
+ `DotMailer::Account#data_fields` will return an Array of `DotMailer::DataField` objects representing the data fields for the account's global address book:
38
+
39
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
36
40
 
37
- client.get_data_fields
41
+ account.data_fields
38
42
  => [
39
- Dotmailer::DataField name: "FIELD1", type: "String", visibility: "Public", default: "",
40
- Dotmailer::DataField name: "FIELD2", type: "Numeric", visibility: "Private", default: 0
43
+ DotMailer::DataField name: "FIELD1", type: "String", visibility: "Public", default: "",
44
+ DotMailer::DataField name: "FIELD2", type: "Numeric", visibility: "Private", default: 0
41
45
  ]
42
46
 
47
+ NOTE: The returned data fields are cached in memory for `DotMailer::Account::CACHE_LIFETIME` seconds to avoid unnecessarily hitting the API.
48
+
43
49
  ### Create
44
50
 
45
- `Dotmailer::Client#create_data_field` will attempt to create a new data field. On success it returns true, on failure it raises an exception:
51
+ `DotMailer::Account#create_data_field` will attempt to create a new data field. On failure it raises an exception:
52
+
53
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
54
+
55
+ account.create_data_field 'FIELD3', :type => 'String'
56
+
57
+ account.create_data_field 'FIELD3', :type => 'String'
58
+ => DotMailer::InvalidRequest: Field already exists. ERROR_NON_UNIQUE_DATAFIELD
59
+
60
+ NOTE: successfully creating a data field via this method will invalidate any cached data fields.
61
+
62
+ Contacts
63
+ --------
64
+
65
+ ### Finding A Contact
66
+
67
+ There are two ways to find contacts via the API, using a contact's email address or id.
68
+
69
+ The gem provides two methods for doing so: `DotMailer::Account#find_contact_by_email` and `DotMailer::Account#find_contact_by_id`.
70
+
71
+ Suppose you have one contact with email john@example.com and id 12345, then:
72
+
73
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
74
+
75
+ account.find_contact_by_email 'john@example.com'
76
+ => DotMailer::Contact id: 12345, email: john@example.com
77
+
78
+ account.find_contact_by_email 'sue@example.com'
79
+ => nil
80
+
81
+ account.find_contact_by_id 12345
82
+ => DotMailer::Contact id: 12345, email: john@example.com
83
+
84
+ account.find_contact_by_id 54321
85
+ => nil
86
+
87
+ ### Finding contacts modified since a particular time
88
+
89
+ Contacts modified since a particular time can be retrieved by passing a Time object to `DotMailer::Account#find_contacts_modified_since`:
90
+
91
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
92
+
93
+ time = Time.parse('1st March 2013 15:30')
94
+
95
+ account.find_contacts_modified_since(time)
96
+ => [
97
+ DotMailer::Contact id: 123, email: bob@example.com,
98
+ DotMailer::Contact id: 345, email: sue@example.com
99
+ ]
100
+
101
+ ### Updating a contact
102
+
103
+ Contacts can be updated by assigning new values and calling `DotMailer::Contact#save`:
104
+
105
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
106
+
107
+ contact = account.find_contact_by_email 'john@example.com'
108
+ => DotMailer::Contact id: 12345, email: john@example.com, email_type: Html
109
+
110
+ contact.email_type
111
+ => 'Html'
112
+ contact.email_type = 'PlainText'
113
+ => 'PlainText
114
+
115
+ contact.save
46
116
 
47
- client.create_data_field 'FIELD3', :type => 'String'
117
+ contact = DotMailer.find_contact_by_email 'john@example.com'
118
+ => DotMailer::Contact id: 12345, email: john@example.com, email_type: PlainText
119
+
120
+ ### Resubscribing a contact
121
+
122
+ The dotMailer API provides a specific endpoint for resubscribing contacts which will initiate the resubscribe process via email, then redirect the contact to a specified URL.
123
+
124
+ This can be accessed through the `DotMailer::Contact#resubscribe` method:
125
+
126
+ contact = DotMailer.find_contact_by_email 'john@example.com'
127
+ => DotMailer::Contact id: 12345, email: john@example.com, status: Unsubscribed
128
+
129
+ contact.subscribed?
130
+ => false
131
+ contact.resubscribe 'http://www.example.com/resubscribed'
132
+
133
+ Then, once the contact has gone through the resubscribe process and been redirected to the specified URL:
134
+
135
+ contact = DotMailer.find_contact_by_email 'john@example.com'
136
+ => DotMailer::Contact id: 12345, email: john@example.com, status: Subscribed
137
+
138
+ contact.subscribed?
48
139
  => true
49
140
 
50
- client.create_data_field 'FIELD3', :type => 'String'
51
- => Dotmailer::DuplicateDataField
141
+ ### Bulk Import
142
+
143
+ `DotMailer::Account#import_contacts` will start a batch import of contacts into the global address book, and return a `DotMailer::ContactImport` object which has a `status`:
144
+
145
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
146
+
147
+ import = account.import_contacts [
148
+ { 'Email' => 'joe@example.com' },
149
+ { 'Email' => 'sue@example.com' },
150
+ { 'Email' => 'bob@example.com' },
151
+ { 'Email' => 'invalid@email' }
152
+ ]
153
+ => DotMailer::ContactImport contacts: [{"Email"=>"joe@example.com" }, {"Email"=>"sue@example.com" }, {"Email"=>"bob@example.com"}]
154
+
155
+ import.finished?
156
+ => false
157
+ import.status
158
+ => "NotFinished"
159
+
160
+ Then, once the import has finished, you can query the status and get any errors (as a CSV::Table object):
161
+
162
+ import.finished?
163
+ => true
164
+ import.status
165
+ => "Finished"
166
+
167
+ errors = import.errors
168
+ => #<CSV::Table>
169
+ errors.count
170
+ => 1
171
+ errors.first
172
+ => #<CSV::Row "Reason":"Invalid Email" "Email":"invalid@email">
173
+
174
+ **NOTE** The specified contacts can only have the following keys (case insensitive):
175
+
176
+ * `id`
177
+ * `email`
178
+ * `optInType`
179
+ * `emailType`
180
+ * Any data field name for the account (i.e. any value in `account.data_fields.map(&:name)`)
181
+
182
+ If any other key is present in any of the contacts, a `DotMailer::UnknownDataField` error will be raised
183
+
184
+ Suppressions
185
+ ------------
186
+
187
+ The dotMailer API provides an endpoint for retrieving suppressions since a particular point in time, where a "suppression" is the combination of a contact, a removal date, and a reason for the suppression.
188
+
189
+ To fetch these suppressions, pass a Time object to `DotMailer::Account#find_suppressions_since`:
190
+
191
+ account = DotMailer::Account.new('your-api-username', 'your-api-password')
192
+
193
+ time = Time.parse('1st March 2013 15:30')
194
+
195
+ suppressions = account.find_suppressions_since(time)
196
+ => [
197
+ DotMailer::Suppression reason: Unsubscribed, date_removed: 2013-03-02 14:00:00 UTC,
198
+ DotMailer::Suppression reason: Unsubscribed, date_removed: 2013-03-04 16:00:00 UTC
199
+ ]
200
+
201
+ suppressions.first.contact
202
+ => DotMailer::Contact id: 12345, email: john@example.com
data/TODO.md ADDED
@@ -0,0 +1,6 @@
1
+ Test against live API
2
+ ---------------------
3
+
4
+ The dotMailer does not have a sandbox mode so we will need to be careful that we do not send mail to real email addresses whilst testing.
5
+
6
+ We should consider using [VCR](https://github.com/vcr/vcr) to cache HTTP requests from the API.
@@ -1,10 +1,10 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  $:.push File.expand_path("../lib", __FILE__)
3
- require "dotmailer/version"
3
+ require "dot_mailer/version"
4
4
 
5
5
  Gem::Specification.new do |s|
6
6
  s.name = "dotmailer"
7
- s.version = Dotmailer::VERSION
7
+ s.version = DotMailer::VERSION
8
8
  s.authors = ["Econsultancy"]
9
9
  s.email = ["tech@econsultancy.com"]
10
10
  s.homepage = "https://github.com/econsultancy/dotmailer"
@@ -13,10 +13,11 @@ Gem::Specification.new do |s|
13
13
  s.rubyforge_project = "dotmailer"
14
14
 
15
15
  s.files = `git ls-files`.split("\n")
16
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
+ s.test_files = `git ls-files -- {spec}/*`.split("\n")
17
17
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
18
  s.require_paths = ["lib"]
19
19
 
20
+ s.add_runtime_dependency 'activesupport'
20
21
  s.add_runtime_dependency 'rest-client'
21
22
 
22
23
  s.add_development_dependency 'rspec'
@@ -0,0 +1,11 @@
1
+ require 'dot_mailer/exceptions'
2
+ require 'dot_mailer/data_field'
3
+ require 'dot_mailer/contact_import'
4
+ require 'dot_mailer/contact'
5
+ require 'dot_mailer/suppression'
6
+ require 'dot_mailer/account'
7
+ require 'dot_mailer/client'
8
+
9
+ module DotMailer
10
+ SUBSCRIBED_STATUS = 'Subscribed'
11
+ end
@@ -0,0 +1,66 @@
1
+ require 'active_support/core_ext/numeric/time'
2
+ require 'active_support/cache'
3
+
4
+ module DotMailer
5
+ class Account
6
+ CACHE_LIFETIME = 1.minute
7
+
8
+ attr_reader :client
9
+
10
+ def initialize(api_user, api_pass)
11
+ self.client = Client.new(api_user, api_pass)
12
+ end
13
+
14
+ def data_fields
15
+ cache.fetch 'data_fields' do
16
+ DataField.all self
17
+ end
18
+ end
19
+
20
+ def create_data_field(name, options = {})
21
+ DataField.create self, name, options
22
+
23
+ cache.delete 'data_fields'
24
+ end
25
+
26
+ def import_contacts(contacts)
27
+ ContactImport.import self, contacts
28
+ end
29
+
30
+ def find_contact_by_email(email)
31
+ Contact.find_by_email self, email
32
+ end
33
+
34
+ def find_contact_by_id(id)
35
+ Contact.find_by_id self, id
36
+ end
37
+
38
+ def find_contacts_modified_since(time)
39
+ Contact.modified_since(self, time)
40
+ end
41
+
42
+ def find_suppressions_since(time)
43
+ Suppression.suppressed_since(self, time)
44
+ end
45
+
46
+ def suppress(email)
47
+ client.post_json '/contacts/unsubscribe', 'Email' => email
48
+ end
49
+
50
+ def to_s
51
+ "#{self.class.name} client: #{client}"
52
+ end
53
+
54
+ def inspect
55
+ to_s
56
+ end
57
+
58
+ private
59
+ attr_writer :client
60
+
61
+ def cache
62
+ # Auto expire content after CACHE_LIFETIME seconds
63
+ @cache ||= ActiveSupport::Cache::MemoryStore.new :expires_in => CACHE_LIFETIME
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,108 @@
1
+ # encoding: utf-8
2
+ require 'tempfile'
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'restclient'
6
+
7
+ module DotMailer
8
+ class Client
9
+ def initialize(api_user, api_pass)
10
+ self.api_user = api_user
11
+ self.api_pass = api_pass
12
+ end
13
+
14
+ def get(path)
15
+ rescue_api_errors do
16
+ endpoint = endpoint_for(path)
17
+ response = RestClient.get endpoint, :accept => :json
18
+
19
+ JSON.parse response
20
+ end
21
+ end
22
+
23
+ def get_csv(path)
24
+ rescue_api_errors do
25
+ endpoint = endpoint_for(path)
26
+ response = RestClient.get endpoint, :accept => :csv
27
+
28
+ # Force the encoding to UTF-8 as that is what the
29
+ # API returns (otherwise it will be ASCII-8BIT, see
30
+ # http://bugs.ruby-lang.org/issues/2567)
31
+ response.force_encoding('UTF-8')
32
+
33
+ # Remove the UTF-8 BOM if present
34
+ response.sub!(/\A\xEF\xBB\xBF/, '')
35
+
36
+ CSV.parse response, :headers => true
37
+ end
38
+ end
39
+
40
+ def post_json(path, params)
41
+ post path, params.to_json, :content_type => :json
42
+ end
43
+
44
+ # Need to use a Tempfile as the API will not accept CSVs
45
+ # without filenames
46
+ def post_csv(path, csv)
47
+ file = Tempfile.new(['dotmailer-contacts', '.csv'])
48
+ file.write csv
49
+ file.rewind
50
+
51
+ post path, :csv => file
52
+ end
53
+
54
+ def post(path, data, options = {})
55
+ rescue_api_errors do
56
+ endpoint = endpoint_for(path)
57
+ response = RestClient.post endpoint, data, options.merge(:accept => :json)
58
+
59
+ JSON.parse response
60
+ end
61
+ end
62
+
63
+ def put_json(path, params)
64
+ put path, params.to_json, :content_type => :json
65
+ end
66
+
67
+ def put(path, data, options = {})
68
+ rescue_api_errors do
69
+ endpoint = endpoint_for(path)
70
+ response = RestClient.put endpoint, data, options.merge(:accept => :json)
71
+
72
+ JSON.parse response
73
+ end
74
+ end
75
+
76
+ def to_s
77
+ "#{self.class.name} api_user: #{api_user}"
78
+ end
79
+
80
+ def inspect
81
+ to_s
82
+ end
83
+
84
+ private
85
+ attr_accessor :api_user, :api_pass
86
+
87
+ def rescue_api_errors
88
+ yield
89
+ rescue RestClient::BadRequest => e
90
+ raise InvalidRequest, extract_message_from_exception(e)
91
+ rescue RestClient::ResourceNotFound => e
92
+ raise NotFound, extract_message_from_exception(e)
93
+ end
94
+
95
+ def extract_message_from_exception(exception)
96
+ JSON.parse(exception.http_body)['message']
97
+ end
98
+
99
+ def endpoint_for(path)
100
+ URI::Generic.build(
101
+ :scheme => 'https',
102
+ :userinfo => "#{CGI.escape(api_user)}:#{CGI.escape(api_pass)}",
103
+ :host => 'api.dotmailer.com',
104
+ :path => "/v2#{path}"
105
+ ).to_s
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,179 @@
1
+ require 'active_support/core_ext/object/try'
2
+ require 'active_support/core_ext/object/blank'
3
+ require 'time'
4
+
5
+ require 'dot_mailer/opt_in_type'
6
+
7
+ module DotMailer
8
+ class Contact
9
+ def self.find_by_email(account, email)
10
+ response = account.client.get("/contacts/#{email}")
11
+
12
+ new(account, response)
13
+ rescue DotMailer::NotFound
14
+ nil
15
+ end
16
+
17
+ # The API makes no distinction between finding
18
+ # by email or id, so we just delegate to
19
+ # Contact.find_by_email
20
+ def self.find_by_id(account, id)
21
+ find_by_email account, id
22
+ end
23
+
24
+ def self.modified_since(account, time)
25
+ response = account.client.get("/contacts/modified-since/#{time.utc.xmlschema}")
26
+
27
+ response.map do |attributes|
28
+ new(account, attributes)
29
+ end
30
+ end
31
+
32
+ def initialize(account, attributes)
33
+ self.account = account
34
+ self.attributes = attributes
35
+ end
36
+
37
+ def id
38
+ attributes['id']
39
+ end
40
+
41
+ def email
42
+ attributes['email']
43
+ end
44
+
45
+ def email=(email)
46
+ attributes['email'] = email
47
+ end
48
+
49
+ def opt_in_type
50
+ attributes['optInType']
51
+ end
52
+
53
+ def opt_in_type=(opt_in_type)
54
+ raise UnknownOptInType, opt_in_type unless OptInType.exists?(opt_in_type)
55
+
56
+ attributes['optInType'] = opt_in_type
57
+ end
58
+
59
+ def email_type
60
+ attributes['emailType']
61
+ end
62
+
63
+ def email_type=(email_type)
64
+ attributes['emailType'] = email_type
65
+ end
66
+
67
+ def status
68
+ attributes['status']
69
+ end
70
+
71
+ def to_s
72
+ %{#{self.class.name} id: #{id}, email: #{email}, opt_in_type: #{opt_in_type}, email_type: #{email_type}, status: #{status}, data_fields: #{data_fields.to_s}}
73
+ end
74
+
75
+ def inspect
76
+ to_s
77
+ end
78
+
79
+ # A wrapper method for accessing data field values by name, e.g.:
80
+ #
81
+ # contact['FIRSTNAME']
82
+ #
83
+ def [](key)
84
+ if data_fields.has_key?(key)
85
+ data_fields[key]
86
+ else
87
+ raise UnknownDataField, key
88
+ end
89
+ end
90
+
91
+ # A wrapper method for assigning data field values, e.g.:
92
+ #
93
+ # contact['FIRSTNAME'] = 'Lewis'
94
+ #
95
+ def []=(key, value)
96
+ if data_fields.has_key?(key)
97
+ data_fields[key] = value
98
+ else
99
+ raise UnknownDataField, key
100
+ end
101
+ end
102
+
103
+ def save
104
+ client.put_json "/contacts/#{id}", attributes.merge('dataFields' => data_fields_for_api)
105
+ end
106
+
107
+ def subscribed?
108
+ status == SUBSCRIBED_STATUS
109
+ end
110
+
111
+ def resubscribe(return_url)
112
+ return false if subscribed?
113
+
114
+ client.post_json '/contacts/resubscribe',
115
+ 'UnsubscribedContact' => {
116
+ 'id' => id,
117
+ 'Email' => email
118
+ },
119
+ 'ReturnUrlToUseIfChallenged' => return_url
120
+ end
121
+
122
+ private
123
+ attr_accessor :attributes, :account
124
+
125
+ def client
126
+ account.client
127
+ end
128
+
129
+ # Convert data fields from the API into a flat hash.
130
+ #
131
+ # We coerce Date fields from strings into Time objects.
132
+ #
133
+ # The API returns data field values in the following format:
134
+ #
135
+ # 'dataFields' => [
136
+ # { 'key' => 'FIELD1', 'value' => 'some value'},
137
+ # { 'key' => 'FIELD2', 'value' => 'some other value'}
138
+ # ]
139
+ #
140
+ # We convert that here to:
141
+ #
142
+ # { 'FIELD1' => 'some value', 'FIELD2' => 'some other value' }
143
+ #
144
+ def data_fields
145
+ # Some API calls (e.g. modified-since) don't return data fields
146
+ return [] unless attributes['dataFields'].present?
147
+
148
+ @data_fields ||=
149
+ begin
150
+ account.data_fields.each_with_object({}) do |data_field, hash|
151
+ value = attributes['dataFields'].detect { |f| f['key'] == data_field.name }.try(:[], 'value')
152
+
153
+ if value.present? && data_field.date?
154
+ value = Time.parse(value)
155
+ end
156
+
157
+ hash[data_field.name] = value
158
+ end
159
+ end
160
+ end
161
+
162
+ # Convert data fields from a flat hash to an API compatible hash:
163
+ #
164
+ # { 'FIELD1' => 'some value', 'FIELD2' => 'some other value' }
165
+ #
166
+ # Becomes:
167
+ #
168
+ # [
169
+ # { 'key' => 'FIELD1', 'value' => 'some value'},
170
+ # { 'key' => 'FIELD2', 'value' => 'some other value'}
171
+ # ]
172
+ #
173
+ def data_fields_for_api
174
+ data_fields.map do |key, value|
175
+ { 'key' => key, 'value' => value.to_s }
176
+ end
177
+ end
178
+ end
179
+ end