constant_contact 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.
Files changed (34) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +56 -0
  3. data/lib/constant_contact.rb +13 -0
  4. data/lib/constant_contact/activity.rb +41 -0
  5. data/lib/constant_contact/base.rb +150 -0
  6. data/lib/constant_contact/campaign.rb +112 -0
  7. data/lib/constant_contact/contact.rb +82 -0
  8. data/lib/constant_contact/contact_event.rb +7 -0
  9. data/lib/constant_contact/email_address.rb +6 -0
  10. data/lib/constant_contact/formats.rb +3 -0
  11. data/lib/constant_contact/formats/atom_format.rb +78 -0
  12. data/lib/constant_contact/formats/html_encoded_format.rb +17 -0
  13. data/lib/constant_contact/list.rb +11 -0
  14. data/lib/constant_contact/member.rb +5 -0
  15. data/lib/constant_contact/version.rb +3 -0
  16. data/test/constant_contact/activity_test.rb +41 -0
  17. data/test/constant_contact/atom_format_test.rb +28 -0
  18. data/test/constant_contact/base_test.rb +85 -0
  19. data/test/constant_contact/contact_test.rb +135 -0
  20. data/test/constant_contact/list_test.rb +17 -0
  21. data/test/constant_contact/member_test.rb +25 -0
  22. data/test/constant_contact_test.rb +8 -0
  23. data/test/fixtures/all_contacts.xml +51 -0
  24. data/test/fixtures/contactlistscollection.xml +96 -0
  25. data/test/fixtures/contactlistsindividuallist.xml +30 -0
  26. data/test/fixtures/memberscollection.xml +763 -0
  27. data/test/fixtures/new_list.xml +16 -0
  28. data/test/fixtures/nocontent.xml +12 -0
  29. data/test/fixtures/service_document.xml +15 -0
  30. data/test/fixtures/single_contact_by_email.xml +34 -0
  31. data/test/fixtures/single_contact_by_id.xml +71 -0
  32. data/test/fixtures/single_contact_by_id_with_no_contactlists.xml +65 -0
  33. data/test/test_helper.rb +46 -0
  34. metadata +186 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 [name of plugin creator]
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ ConstantContact
2
+ ===============
3
+ This is a very ActiveResource-like ruby wrapper to the Constant Contact API. It's not quite done yet and if you are interested in using this code, check it out, and message me with questions.
4
+
5
+ See the [ActiveResource::Base docs](http://api.rubyonrails.org/classes/ActiveResource/Base.html) for more information on how to use this ActiveResource-based wrapper.
6
+
7
+ Examples
8
+ --------
9
+
10
+ All examples require setting up either the specific class you'll be use or the Base object before use:
11
+
12
+ ConstantContact::Base.user = 'user'
13
+ ConstantContact::Base.api_key = 'api-key'
14
+ ConstantContact::Base.password = 'password'
15
+
16
+
17
+ ### Find Lists
18
+
19
+ ConstantContact::List.find(1)
20
+ ConstantContact::List.find :all
21
+
22
+ ### Find A Contact
23
+
24
+
25
+ ConstantContact::Contact.find(1)
26
+ ConstantContact::Contact.find(:first, :params => {:email => 'jon@example.com'})
27
+ ConstantContact::Contact.find_by_email('jon@example.com') # => same as previous line
28
+
29
+ ### Create a Contact (with rescue if it already exists)
30
+
31
+ ConstantContact::Base.user = 'user'
32
+ ConstantContact::Base.api_key = 'api-key'
33
+ ConstantContact::Base.password = 'password'
34
+
35
+ # Contact not found. Create it.
36
+ begin
37
+ @contact = ConstantContact::Contact.new(
38
+ :email_address => "jon@example.com",
39
+ :first_name => "jon",
40
+ :last_name => "smith"
41
+ )
42
+ @contact.save
43
+ rescue ActiveResource::ResourceConflict => e
44
+ # contact already exists
45
+ puts 'Contact already exists. Saving contact failed.'
46
+ puts e
47
+ end
48
+
49
+ ### Find a Contact By Email Address, Check if They're a Member of the Default List
50
+
51
+ c = ConstantContact::Contact.find_by_email('jon@example.com')
52
+ @contact = ConstantContact::Contact.find(@contact.int_id)
53
+ puts 'In default contact list.' if @contact.contact_lists.include?(1) # contact_lists is an array of list ids
54
+
55
+
56
+ Copyright (c) 2009 Timothy Case, released under the MIT license
@@ -0,0 +1,13 @@
1
+ directory = File.expand_path(File.dirname(__FILE__))
2
+
3
+ require 'active_resource'
4
+ require 'action_pack'
5
+ require File.join(directory, 'constant_contact', 'formats')
6
+ require File.join(directory, 'constant_contact', 'base')
7
+ require File.join(directory, 'constant_contact', 'list');
8
+ require File.join(directory, 'constant_contact', 'member')
9
+ require File.join(directory, 'constant_contact', 'contact')
10
+ require File.join(directory, 'constant_contact', 'campaign')
11
+ require File.join(directory, 'constant_contact', 'contact_event')
12
+ require File.join(directory, 'constant_contact', 'activity')
13
+ require File.join(directory, 'constant_contact', 'email_address')
@@ -0,0 +1,41 @@
1
+
2
+ module ConstantContact
3
+ class Activity < Base
4
+ self.format = ActiveResource::Formats::HtmlEncodedFormat
5
+ attr_accessor :contacts, :lists, :activity_type
6
+
7
+ def encode
8
+ post_data = "activityType=#{self.activity_type}"
9
+ post_data += self.encoded_data
10
+ post_data += self.encoded_lists
11
+ return post_data
12
+ end
13
+
14
+ def activity_type
15
+ @activity_type ||= "SV_ADD"
16
+ end
17
+
18
+ protected
19
+ def encoded_data
20
+ result = "&data="
21
+ result += CGI.escape("Email Address,First Name,Last Name\n")
22
+ contact_strings = []
23
+ self.contacts.each do |contact|
24
+ contact_strings << "#{contact.email_address}, #{contact.first_name}, #{contact.last_name}"
25
+ end
26
+ result += CGI.escape(contact_strings.join("\n"))
27
+ return result
28
+ end
29
+
30
+
31
+ def encoded_lists
32
+ result = ""
33
+ self.lists.each do |list|
34
+ result += "&lists="
35
+ result += CGI.escape(list.id)
36
+ end
37
+ return result
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,150 @@
1
+ module ConstantContact
2
+ class Base < ActiveResource::Base
3
+
4
+ self.site = "https://api.constantcontact.com"
5
+ self.format = :atom
6
+
7
+ DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
8
+
9
+ class << self
10
+ # Returns an integer which can be used in #find calls.
11
+ # Assumes url structure with the id at the end, e.g.:
12
+ # http://api.constantcontact.com/ws/customers/yourname/contacts/29
13
+ def parse_id(url)
14
+ url.to_s.split(/\//).last.to_i
15
+ end
16
+
17
+ def api_key
18
+ if defined?(@api_key)
19
+ @api_key
20
+ elsif superclass != Object && superclass.api_key
21
+ superclass.api_key.dup.freeze
22
+ end
23
+ end
24
+
25
+ def api_key=(api_key)
26
+ @connection = nil
27
+ @api_key = api_key
28
+ end
29
+
30
+ def connection(refresh = false)
31
+ if defined?(@connection) || superclass == Object
32
+ @connection = ActiveResource::Connection.new(site, format) if refresh || @connection.nil?
33
+ @connection.user = "#{api_key}%#{user}" if user
34
+ @connection.password = password if password
35
+ @connection.timeout = timeout if timeout
36
+ @connection
37
+ else
38
+ superclass.connection
39
+ end
40
+ end
41
+
42
+ def collection_path(prefix_options = {}, query_options = nil)
43
+ prefix_options, query_options = split_options(prefix_options) if query_options.nil?
44
+ "/ws/customers/#{self.user}#{prefix(prefix_options)}#{collection_name}#{query_string(query_options)}"
45
+ end
46
+
47
+ def element_path(id, prefix_options = {}, query_options = nil)
48
+ prefix_options, query_options = split_options(prefix_options) if query_options.nil?
49
+ integer_id = parse_id(id)
50
+ id_val = integer_id.zero? ? nil : "/#{integer_id}"
51
+ "#{collection_path}#{id_val}#{query_string(query_options)}"
52
+ end
53
+
54
+ # Slight modification to AR::Base.find_every to handle instances
55
+ # where a single element is returned. This enables calling
56
+ # <tt>find(:first, {:params => {:email => 'sample@example.com'}})
57
+ def find_every(options)
58
+ case from = options[:from]
59
+ when Symbol
60
+ instantiate_collection(get(from, options[:params]))
61
+ when String
62
+ path = "#{from}#{query_string(options[:params])}"
63
+ instantiate_collection(connection.get(path, headers) || [])
64
+ else
65
+ prefix_options, query_options = split_options(options[:params])
66
+ path = collection_path(prefix_options, query_options)
67
+ result = connection.get(path, headers)
68
+ case result.class.name
69
+ when 'Hash': instantiate_collection( [ result ], prefix_options )
70
+ else
71
+ instantiate_collection( (result || []), prefix_options )
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ # Slightly tweaked ARes::Base's implementation so all the
78
+ # attribute names are looked up using camelcase since
79
+ # that's how the CC API returns them.
80
+ def method_missing(method_symbol, *arguments) #:nodoc:
81
+ method_name = method_symbol.to_s
82
+
83
+ case method_name.last
84
+ when "="
85
+ attributes[method_name.first(-1).camelize] = arguments.first
86
+ when "?"
87
+ attributes[method_name.first(-1).camelize]
88
+ else
89
+ attributes.has_key?(method_name.camelize) ? attributes[method_name.camelize] : super
90
+ end
91
+ end
92
+
93
+ # Caching accessor for the the id integer
94
+ def int_id
95
+ @id ||= self.class.parse_id(self.attributes['id'])
96
+ end
97
+
98
+ # Mimics ActiveRecord's version
99
+ def update_attributes(atts={})
100
+ camelcased_hash = {}
101
+ atts.each{|key, val| camelcased_hash[key.to_s.camelize] = val}
102
+ self.attributes.update(camelcased_hash)
103
+ save
104
+ end
105
+
106
+ # Mimic ActiveRecord (snagged from HyperactiveResource).
107
+ def save
108
+ return false unless valid?
109
+ before_save
110
+ successful = super
111
+ after_save if successful
112
+ successful
113
+ end
114
+
115
+ def before_save
116
+ end
117
+
118
+ def after_save
119
+ end
120
+
121
+ def validate
122
+ end
123
+
124
+ # So client-side validations run
125
+ def valid?
126
+ errors.clear
127
+ validate
128
+ super
129
+ end
130
+
131
+ def encode
132
+ "<entry xmlns=\"http://www.w3.org/2005/Atom\">
133
+ <title type=\"text\"> </title>
134
+ <updated>#{Time.now.strftime(DATE_FORMAT)}</updated>
135
+ <author>Bluesteel</author>
136
+ <id>#{id.blank? ? 'data:,none' : id}</id>
137
+ <summary type=\"text\">Bluesteel</summary>
138
+ <content type=\"application/vnd.ctct+xml\">
139
+ #{self.to_xml}
140
+ </content>
141
+ </entry>"
142
+ end
143
+
144
+ # TODO: Move this out to a lib
145
+ def html_encode(txt)
146
+ mapping = { '&' => '&amp;', '>' => '&gt;', '<' => '&lt;', '"' => '&quot;' }
147
+ txt.to_s.gsub(/[&"><]/) { |special| mapping[special] }
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,112 @@
1
+ # http://developer.constantcontact.com/doc/manageCampaigns
2
+ module ConstantContact
3
+ class Campaign < Base
4
+ STATUS_CODES = ['SENT', 'SCHEDULED', 'DRAFT', 'RUNNING']
5
+ # SENT All campaigns that have been sent and not currently scheduled for resend
6
+ # SCHEDULED All campaigns that are currently scheduled to be sent some time in the future
7
+ # DRAFT All campaigns that have not yet been scheduled for delivery
8
+ # RUNNING All campaigns that are currently being processed and delivered
9
+ @@column_names = [:archive_status, :archive_url, :bounces, :campaign_type, :clicks, :contact_lists, :date,
10
+ :email_content, :email_content_format, :email_text_content, :forward_email_link_text, :forwards,
11
+ :from_email, :from_name, :greeting_name, :greeting_salutation, :greeting_string,
12
+ :include_forward_email, :include_subscribe_link, :last_edit_date, :name, :opens, :opt_outs,
13
+ :organization_address1, :organization_address2, :organization_address3, :organization_city,
14
+ :organization_country, :organization_international_state, :organization_name, :organization_postal_code,
15
+ :organization_state, :permission_reminder, :reply_to_email, :sent, :spam_reports, :status,
16
+ :style_sheet, :subject, :subscribe_link_text, :view_as_webpage, :view_as_webpage_link_text, :view_as_webpage_text]
17
+
18
+
19
+ # Setup defaults when creating a new object since
20
+ # CC requires so many extraneous fields to be present
21
+ # when creating a new Campaign.
22
+ def initialize
23
+ obj = super
24
+ obj.set_defaults
25
+ obj
26
+ end
27
+
28
+ def to_xml
29
+ xml = Builder::XmlMarkup.new
30
+ xml.tag!("Campaign", :xmlns => "http://ws.constantcontact.com/ns/1.0/") do
31
+ self.attributes.each{ |k, v| xml.tag!(k.to_s.camelize, v) }
32
+ # Overrides the default formatting above to CC's required format.
33
+ xml.tag!("ReplyToEmail") do
34
+ xml.tag!('Email', :id => self.reply_to_email_url)
35
+ end
36
+ xml.tag!("FromEmail") do
37
+ xml.tag!('Email', :id => self.from_email_url)
38
+ end
39
+ xml.tag!("ContactLists") do
40
+ xml.tag!("ContactList", :id => self.list_url)
41
+ end
42
+ end
43
+ end
44
+
45
+ def list_url
46
+ id = defined?(self.list_id) ? self.list_id : 1
47
+ List.find(id).id
48
+ end
49
+
50
+ def from_email_url
51
+ id = defined?(self.from_email_id) ? self.from_email_id : 1
52
+ EmailAddress.find(id).id
53
+ end
54
+
55
+ def reply_to_email_url
56
+ from_email_url
57
+ end
58
+
59
+
60
+ protected
61
+ def set_defaults
62
+ self.view_as_webpage = 'NO' unless attributes.has_key?('ViewAsWebpage')
63
+ self.from_name = self.class.user unless attributes.has_key?('FromName')
64
+ self.permission_reminder = 'YES' unless attributes.has_key?('PermissionReminder')
65
+ self.permission_reminder_text = %Q{You're receiving this email because of your relationship with us. Please <ConfirmOptin><a style="color:#0000ff;">confirm</a></ConfirmOptin> your continued interest in receiving email from us.} unless attributes.has_key?('PermissionReminderText')
66
+ self.greeting_salutation = 'Dear' unless attributes.has_key?('GreetingSalutation')
67
+ self.greeting_name = "FirstName" unless attributes.has_key?('GreetingName')
68
+ self.greeting_string = 'Greetings!' unless attributes.has_key?('GreetingString')
69
+ self.status = 'DRAFT' unless attributes.has_key?('Status')
70
+ self.style_sheet = '' unless attributes.has_key?('StyleSheet')
71
+ self.include_forward_email = 'NO' unless attributes.has_key?('IncludeForwardEmail')
72
+ self.forward_email_link_text = '' unless attributes.has_key?('ForwardEmailLinkText')
73
+ self.subscribe_link_text = '' unless attributes.has_key?('SubscribeLinkText')
74
+ self.include_subscribe_link = 'NO' unless attributes.has_key?('IncludeSubscribeLink')
75
+ self.organization_name = self.class.user unless attributes.has_key?('OrganizationName')
76
+ self.organization_address1 = '123 Main' unless attributes.has_key?('OrganizationAddress1')
77
+ self.organization_address2 = '' unless attributes.has_key?('OrganizationAddress2')
78
+ self.organization_address3 = '' unless attributes.has_key?('OrganizationAddress3')
79
+ self.organization_city = 'Kansas City' unless attributes.has_key?('OrganizationCity')
80
+ self.organization_state = 'KS' unless attributes.has_key?('OrganizationState')
81
+ self.organization_international_state = '' unless attributes.has_key?('OrganizationInternationalState')
82
+ self.organization_country = 'US' unless attributes.has_key?('OrganizationCountry')
83
+ self.organization_postal_code = '64108' unless attributes.has_key?('OrganizationPostalCode')
84
+ end
85
+
86
+ # Formats data if present.
87
+ def before_save
88
+ self.email_text_content = "<Text>#{email_text_content}</Text>" unless email_text_content.match /^\<Text/
89
+ self.date = self.date.strftime(DATE_FORMAT) if attributes.has_key?('Date')
90
+ end
91
+
92
+
93
+ def validate
94
+ # NOTE: Needs to be uppercase!
95
+ unless attributes.has_key?('EmailContentFormat') && ['HTML', 'XHTML'].include?(email_content_format)
96
+ errors.add(:email_content_format, 'must be either HTML or XHTML (the latter for advanced email features)')
97
+ end
98
+
99
+ if attributes.has_key?('ViewAsWebpage') && view_as_webpage.downcase == 'yes'
100
+ unless attributes['ViewAsWebpageLinkText'].present? && attributes['ViewAsWebpageText'].present?
101
+ errors.add(:view_as_webpage, "You need to set view_as_webpage_link_text and view_as_webpage_link if view_as_webpage is YES")
102
+ end
103
+ end
104
+
105
+ errors.add(:email_content, 'cannot be blank') unless attributes.has_key?('EmailContent')
106
+ errors.add(:email_text_content, 'cannot be blank') unless attributes.has_key?('EmailTextContent')
107
+ errors.add(:name, 'cannot be blank') unless attributes.has_key?('Name')
108
+ errors.add(:subject, 'cannot be blank') unless attributes.has_key?('Subject')
109
+ end
110
+
111
+ end
112
+ end
@@ -0,0 +1,82 @@
1
+ # Value limits: http://constantcontact.custhelp.com/cgi-bin/constantcontact.cfg/php/enduser/std_adp.php?p_faqid=2217
2
+ module ConstantContact
3
+ class Contact < Base
4
+ attr_accessor :opt_in_source
5
+
6
+ # NOTE: All column names are available only when finding a contact by id.
7
+ # @@column_names = [ :addr1, :addr2, :addr3, :city, :company_name, :country_code, :country_name,
8
+ # :custom_field1, :custom_field10, :custom_field11, :custom_field12, :custom_field13,
9
+ # :custom_field14, :custom_field15, :custom_field2, :custom_field3, :custom_field4, :custom_field5,
10
+ # :custom_field6, :custom_field7, :custom_field8, :custom_field9, :email_address, :email_type,
11
+ # :first_name, :home_phone, :insert_time, :job_title, :last_name, :last_update_time, :name, :note,
12
+ # :postal_code, :state_code, :state_name, :status, :sub_postal_code, :work_phone ]
13
+
14
+ def to_xml
15
+ xml = Builder::XmlMarkup.new
16
+ xml.tag!("Contact", :xmlns => "http://ws.constantcontact.com/ns/1.0/") do
17
+ self.attributes.reject {|k,v| k == 'ContactLists'}.each{|k, v| xml.tag!( k.to_s.camelize, v )}
18
+ xml.tag!("OptInSource", self.opt_in_source)
19
+ xml.tag!("ContactLists") do
20
+ @contact_lists = [1] if @contact_lists.nil? && self.new?
21
+ self.contact_lists.sort.each do |list_id|
22
+ xml.tag!("ContactList", :id=> self.list_url(list_id))
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def opt_in_source
29
+ @opt_in_source ||= "ACTION_BY_CUSTOMER"
30
+ end
31
+
32
+ # see http://developer.constantcontact.com/doc/manageContacts#create_contact for more info about the two values.
33
+ def opt_in_source=(val)
34
+ @opt_in_source = val if ['ACTION_BY_CONTACT','ACTION_BY_CUSTOMER'].include?(val)
35
+ end
36
+
37
+ def list_url(id=nil)
38
+ id ||= defined?(self.list_id) ? self.list_id : 1
39
+ "http://api.constantcontact.com/ws/customers/#{self.class.user}/lists/#{id}"
40
+ end
41
+
42
+ # Can we email them?
43
+ def active?
44
+ status.downcase == 'active'
45
+ end
46
+
47
+ def contact_lists
48
+ return @contact_lists if defined?(@contact_lists)
49
+ # otherwise, attempt to assign it
50
+ @contact_lists = if self.attributes.keys.include?('ContactLists')
51
+ if self.ContactLists
52
+ if self.ContactLists.ContactList.is_a?(Array)
53
+ self.ContactLists.ContactList.collect { |list|
54
+ ConstantContact::Base.parse_id(list.id)
55
+ }
56
+ else
57
+ [ ConstantContact::Base.parse_id(self.ContactLists.ContactList.id) ]
58
+ end
59
+ else
60
+ [] # => Contact is not a member of any lists (legitimatly empty!)
61
+ end
62
+ else
63
+ nil
64
+ end
65
+ end
66
+
67
+ def contact_lists=(val)
68
+ @contact_lists = val.kind_of?(Array) ? val : [val]
69
+ end
70
+
71
+ def self.find_by_email(email_address)
72
+ find :first, {:params => {:email => email_address.downcase}}
73
+ end
74
+
75
+ protected
76
+ def validate
77
+ # errors.add(:opt_in_source, 'must be either ACTION_BY_CONTACT or ACTION_BY_CUSTOMER') unless ['ACTION_BY_CONTACT','ACTION_BY_CUSTOMER'].include?(attributes['OptInSource'])
78
+ # errors.add(:email_address, 'cannot be blank') unless attributes.has_key?('EmailAddress')
79
+ end
80
+
81
+ end
82
+ end