diff --git a/integrations/Gemfile b/integrations/Gemfile index 74553c43..45940f9a 100644 --- a/integrations/Gemfile +++ b/integrations/Gemfile @@ -77,6 +77,8 @@ gem "google-cloud-ai_platform-v1" gem "tiny_tds" +gem "MailchimpMarketing" + group :development, :test do gem "simplecov", require: false gem "simplecov_json_formatter", require: false diff --git a/integrations/Gemfile.lock b/integrations/Gemfile.lock index 3861d551..a510a347 100644 --- a/integrations/Gemfile.lock +++ b/integrations/Gemfile.lock @@ -44,6 +44,9 @@ PATH GEM remote: https://rubygems.org/ specs: + MailchimpMarketing (3.0.80) + excon (>= 0.76.0, < 1) + json (~> 2.1, >= 2.1.0) activesupport (7.1.3.3) base64 bigdecimal @@ -151,6 +154,7 @@ GEM bigdecimal (>= 3.1.4) ethon (0.16.0) ffi (>= 1.15.0) + excon (0.112.0) faraday (2.8.1) base64 faraday-net_http (>= 2.0, < 3.1) @@ -407,6 +411,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + MailchimpMarketing activesupport async-websocket (~> 0.8.0) aws-sdk-athena diff --git a/integrations/lib/multiwoven/integrations.rb b/integrations/lib/multiwoven/integrations.rb index 32a1bf58..d64b7d96 100644 --- a/integrations/lib/multiwoven/integrations.rb +++ b/integrations/lib/multiwoven/integrations.rb @@ -36,6 +36,7 @@ require "aws-sdk-sagemakerruntime" require "google/cloud/ai_platform/v1" require "grpc" +require "MailchimpMarketing" # Service require_relative "integrations/config" @@ -90,6 +91,7 @@ require_relative "integrations/destination/oracle_db/client" require_relative "integrations/destination/microsoft_excel/client" require_relative "integrations/destination/microsoft_sql/client" +require_relative "integrations/destination/mailchimp/client" module Multiwoven module Integrations diff --git a/integrations/lib/multiwoven/integrations/destination/mailchimp/client.rb b/integrations/lib/multiwoven/integrations/destination/mailchimp/client.rb new file mode 100644 index 00000000..cfb56f01 --- /dev/null +++ b/integrations/lib/multiwoven/integrations/destination/mailchimp/client.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Multiwoven + module Integrations + module Destination + module Mailchimp + include Multiwoven::Integrations::Core + + API_VERSION = "3.0" + + class Client < DestinationConnector + prepend Multiwoven::Integrations::Core::RateLimiter + + def check_connection(connection_config) + connection_config = connection_config.with_indifferent_access + initialize_client(connection_config) + authenticate_client + success_status + rescue StandardError => e + failure_status(e) + end + + def discover(_connection_config = nil) + catalog = build_catalog(load_catalog) + catalog.to_multiwoven_message + rescue StandardError => e + handle_exception(e, { + context: "MAILCHIMP:DISCOVER:EXCEPTION", + type: "error" + }) + end + + def write(sync_config, records, _action = "create") + @sync_config = sync_config + initialize_client(sync_config.destination.connection_specification) + process_records(records, sync_config.stream) + rescue StandardError => e + handle_exception(e, { + context: "MAILCHIMP:WRITE:EXCEPTION", + type: "error", + sync_id: @sync_config.sync_id, + sync_run_id: @sync_config.sync_run_id + }) + end + + private + + def initialize_client(config) + config = config.with_indifferent_access + @client = MailchimpMarketing::Client.new + @client.set_config({ + api_key: config[:api_key], + server: config[:api_key].split("-").last + }) + @list_id = config[:list_id] + @email_template_id = config[:email_template_id] || "" + end + + def process_records(records, stream) + log_message_array = [] + write_success = 0 + write_failure = 0 + properties = stream.json_schema[:properties] + + records.each do |record_object| + record = extract_data(record_object, properties) + args = [stream.name, "Id", record] + begin + response = send_to_mailchimp(record, stream.name) + write_success += 1 + log_message_array << log_request_response("info", args, response) + rescue StandardError => e + handle_exception(e, { + context: "MAILCHIMP:WRITE:EXCEPTION", + type: "error", + sync_id: @sync_config.sync_id, + sync_run_id: @sync_config.sync_run_id + }) + write_failure += 1 + log_message_array << log_request_response("error", args, e.message) + end + end + tracking_message(write_success, write_failure, log_message_array) + end + + def send_to_mailchimp(record, stream_name) + case stream_name + when "Audience" + @client.lists.set_list_member(@list_id, Digest::MD5.hexdigest(record[:email].downcase), { + email_address: record[:email], + status_if_new: "subscribed", + merge_fields: { + FNAME: record[:first_name], + LNAME: record[:last_name] + } + }) + when "Tags" + @client.lists.update_list_member_tags(@list_id, Digest::MD5.hexdigest(record[:email].downcase), { + tags: record[:tags].map { |tag| { name: tag, status: "active" } } + }) + when "Campaigns" + campaign = @client.campaigns.create({ + type: "regular", + recipients: { list_id: @list_id }, + settings: { + subject_line: record[:subject], + from_name: record[:from_name], + reply_to: record[:reply_to] + } + }) + if @email_template_id + @client.campaigns.set_content(campaign["id"], { + template: { id: @email_template_id } + }) + else + @client.campaigns.set_content(campaign["id"], { + plain_text: record[:content] + }) + end + @client.campaigns.send(campaign["id"]) + else + raise "Unsupported stream type: #{stream_name}" + end + end + + def authenticate_client + @client.lists.get_all_lists + end + + def load_catalog + read_json(CATALOG_SPEC_PATH) + end + + def log_debug(message) + Multiwoven::Integrations::Service.logger.debug(message) + end + end + end + end + end +end diff --git a/integrations/lib/multiwoven/integrations/destination/mailchimp/config/catalog.json b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/catalog.json new file mode 100644 index 00000000..58f86ccd --- /dev/null +++ b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/catalog.json @@ -0,0 +1,141 @@ +{ + "request_rate_limit": 100000, + "request_rate_limit_unit": "day", + "request_rate_concurrency": 10, + "streams": [ + { + "name": "Audience", + "action": "create", + "json_schema": { + "type": "object", + "additionalProperties": true, + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["subscribed", "unsubscribed", "cleaned", "pending"] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "merge_fields": { + "type": "object", + "additionalProperties": true, + "properties": { + "FNAME": { + "type": "string" + }, + "LNAME": { + "type": "string" + } + } + }, + "language": { + "type": "string" + }, + "vip": { + "type": "boolean" + }, + "timestamp_signup": { + "type": "string", + "format": "date-time" + }, + "ip_signup": { + "type": "string", + "format": "ipv4" + }, + "timestamp_opt": { + "type": "string", + "format": "date-time" + }, + "ip_opt": { + "type": "string", + "format": "ipv4" + } + } + }, + "supported_sync_modes": ["incremental"], + "source_defined_cursor": true, + "default_cursor_field": ["timestamp_opt"], + "source_defined_primary_key": [["email"]] + }, + { + "name": "Tags", + "action": "create", + "json_schema": { + "type": "object", + "additionalProperties": true, + "required": ["email", "tags"], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "supported_sync_modes": ["incremental"], + "source_defined_cursor": true, + "default_cursor_field": ["updated"] + }, + { + "name": "Campaigns", + "action": "create", + "json_schema": { + "type": "object", + "additionalProperties": true, + "required": ["subject", "from_name", "reply_to", "recipients"], + "properties": { + "subject": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "reply_to": { + "type": "string", + "format": "email" + }, + "recipients": { + "type": "object", + "properties": { + "list_id": { + "type": "string" + } + } + }, + "template_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "send_time": { + "type": "string", + "format": "date-time" + } + } + }, + "supported_sync_modes": ["full_refresh"], + "source_defined_cursor": false + } + ] +} diff --git a/integrations/lib/multiwoven/integrations/destination/mailchimp/config/meta.json b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/meta.json new file mode 100644 index 00000000..74e7f842 --- /dev/null +++ b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/meta.json @@ -0,0 +1,15 @@ +{ + "data": { + "name": "Mailchimp", + "title": "Mailchimp", + "connector_type": "destination", + "category": "Marketing Automation", + "documentation_url": "https://docs.multiwoven.com/destinations/crm/mailchimp", + "github_issue_label": "destination-mailchimp", + "icon": "icon.svg", + "license": "MIT", + "release_stage": "alpha", + "support_level": "community", + "tags": ["language:ruby", "multiwoven"] + } +} diff --git a/integrations/lib/multiwoven/integrations/destination/mailchimp/config/spec.json b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/spec.json new file mode 100644 index 00000000..1cc28e48 --- /dev/null +++ b/integrations/lib/multiwoven/integrations/destination/mailchimp/config/spec.json @@ -0,0 +1,28 @@ +{ + "documentation_url": "https://docs.multiwoven.com/integrations/destination/mailchimp", + "stream_type": "static", + "connection_specification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Mailchimp", + "type": "object", + "required": ["api_key", "list_id"], + "properties": { + "api_key": { + "type": "string", + "multiwoven_secret": true, + "title": "API Key", + "order": 0 + }, + "list_id": { + "type": "string", + "title": "List Id", + "order": 1 + }, + "email_template_id": { + "type": "string", + "title": "Email Template Id", + "order": 2 + } + } + } +} diff --git a/integrations/lib/multiwoven/integrations/destination/mailchimp/icon.svg b/integrations/lib/multiwoven/integrations/destination/mailchimp/icon.svg new file mode 100644 index 00000000..228a95e7 --- /dev/null +++ b/integrations/lib/multiwoven/integrations/destination/mailchimp/icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/integrations/lib/multiwoven/integrations/rollout.rb b/integrations/lib/multiwoven/integrations/rollout.rb index 26ab3073..b2f6dd7f 100644 --- a/integrations/lib/multiwoven/integrations/rollout.rb +++ b/integrations/lib/multiwoven/integrations/rollout.rb @@ -2,7 +2,7 @@ module Multiwoven module Integrations - VERSION = "0.12.0" + VERSION = "0.13.0" ENABLED_SOURCES = %w[ Snowflake @@ -41,6 +41,7 @@ module Integrations Oracle MicrosoftExcel MicrosoftSql + Mailchimp ].freeze end end diff --git a/integrations/multiwoven-integrations.gemspec b/integrations/multiwoven-integrations.gemspec index ece63499..8a4174d1 100644 --- a/integrations/multiwoven-integrations.gemspec +++ b/integrations/multiwoven-integrations.gemspec @@ -67,6 +67,7 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "zendesk_api" spec.add_development_dependency "byebug" + spec.add_development_dependency "MailchimpMarketing" spec.add_development_dependency "rspec" spec.add_development_dependency "rubocop" spec.add_development_dependency "simplecov" diff --git a/integrations/spec/multiwoven/integrations/destination/mailchimp/client_spec.rb b/integrations/spec/multiwoven/integrations/destination/mailchimp/client_spec.rb new file mode 100644 index 00000000..2534025f --- /dev/null +++ b/integrations/spec/multiwoven/integrations/destination/mailchimp/client_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.describe Multiwoven::Integrations::Destination::Mailchimp::Client do + include WebMock::API + + before(:each) do + WebMock.disable_net_connect!(allow_localhost: true) + end + + let(:client) { described_class.new } + let(:api_key) { "api_key" } + let(:list_id) { "list_id" } + let(:server) { api_key.split("-").last } + let(:email_template_id) { "email_template_id" } + let(:connection_config) do + { + api_key: api_key, + list_id: list_id, + email_template_id: email_template_id + } + end + + let(:mailchimp_audience_json_schema) do + catalog = client.discover.catalog + catalog.streams.find { |stream| stream.name == "Audience" }.json_schema + end + + let(:mailchimp_tags_json_schema) do + catalog = client.discover.catalog + catalog.streams.find { |stream| stream.name == "Tags" }.json_schema + end + + let(:mailchimp_capaigns_json_schema) do + catalog = client.discover.catalog + catalog.streams.find { |stream| stream.name == "Campaigns" }.json_schema + end + + let(:sync_config_json) do + { source: { + name: "SourceConnectorName", + type: "source", + connection_specification: { + private_api_key: "test_api_key" + } + }, + destination: { + name: "Mailchimp", + type: "destination", + connection_specification: connection_config + }, + model: { + name: "ExampleModel", + query: "SELECT * FROM CALL_CENTER LIMIT 1", + query_type: "raw_sql", + primary_key: "id" + }, + stream: { + name: "Audience", + action: "create", + request_rate_limit: 4, + rate_limit_unit_seconds: 1, + json_schema: mailchimp_audience_json_schema + }, + sync_mode: "incremental", + cursor_field: "timestamp", + destination_sync_mode: "insert" }.with_indifferent_access + end + + let(:records) do + [ + { email: "jane@example.com", + first_name: "Jane", + last_name: "Doe" }, + { email: "jane@example.com", + first_name: "Jane", + last_name: "Doe" } + ] + end + + describe "#check_connection" do + context "when the connection is successful" do + before do + stub_request(:get, "https://#{server}.api.mailchimp.com/3.0/lists") + .to_return(status: 200, body: "", headers: {}) + end + + it "returns a successful connection status" do + allow(client).to receive(:authenticate_client).and_return(true) + + response = client.check_connection(connection_config) + + expect(response).to be_a(Multiwoven::Integrations::Protocol::MultiwovenMessage) + expect(response.connection_status.status).to eq("succeeded") + end + end + + context "when the connection fails" do + it "returns a failed connection status with an error message" do + allow(client).to receive(:authenticate_client).and_raise(StandardError.new("connection failed")) + + response = client.check_connection(connection_config) + + expect(response).to be_a(Multiwoven::Integrations::Protocol::MultiwovenMessage) + expect(response.connection_status.status).to eq("failed") + expect(response.connection_status.message).to eq("connection failed") + end + end + end + + describe "#write" do + let(:list_member_url) { "https://#{server}.api.mailchimp.com/3.0/lists/#{list_id}/members" } + + context "when the write operation is successful" do + before do + stub_request(:put, "#{list_member_url}/#{Digest::MD5.hexdigest(records.first[:email].downcase)}") + .to_return(status: 200, body: '{"detail": "Success"}', headers: {}) + end + + it "increments the success count" do + response = client.write(sync_config, records) + + expect(response.tracking.success).to eq(records.size) + expect(response.tracking.failed).to eq(0) + log_message = response.tracking.logs.first + expect(log_message).to be_a(Multiwoven::Integrations::Protocol::LogMessage) + expect(log_message.level).to eql("info") + expect(log_message.message).to include("request") + expect(log_message.message).to include("response") + end + end + + context "when the write operation fails" do + before do + # Mock the request to simulate a failure when trying to add/update a list member + stub_request(:put, "#{list_member_url}/#{Digest::MD5.hexdigest(records.first[:email].downcase)}") + .to_return(status: 400, body: '{"detail": "Invalid Request"}', headers: {}) + end + + it "increments the failure count" do + response = client.write(sync_config, records) + + expect(response.tracking.failed).to eq(records.size) + expect(response.tracking.success).to eq(0) + log_message = response.tracking.logs.first + expect(log_message).to be_a(Multiwoven::Integrations::Protocol::LogMessage) + expect(log_message.level).to eql("error") + expect(log_message.message).to include("request") + expect(log_message.message).to include("response") + end + end + end + + describe "#meta_data" do + it "serves it github image url as icon" do + image_url = "https://raw.githubusercontent.com/Multiwoven/multiwoven/main/integrations/lib/multiwoven/integrations/destination/mailchimp/icon.svg" + expect(client.send(:meta_data)[:data][:icon]).to eq(image_url) + end + end + + private + + def sync_config + Multiwoven::Integrations::Protocol::SyncConfig.from_json( + sync_config_json.to_json + ) + end + + describe "#discover" do + it "returns a catalog" do + message = client.discover + catalog = message.catalog + expect(catalog).to be_a(Multiwoven::Integrations::Protocol::Catalog) + catalog.streams.each do |stream| + case stream.name + when "Audience" + expect(stream.supported_sync_modes).to eql(["incremental"]) + when "Tags" + expect(stream.supported_sync_modes).to eql(["incremental"]) + when "Campaigns" + expect(stream.supported_sync_modes).to eql(["full_refresh"]) + end + end + end + end +end