dotmailer 0.0.1 → 0.0.2

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