Skip to content

Swift package for interfacing with your Mailchimp account, audiences, campaigns, and more.

License

Notifications You must be signed in to change notification settings

brightdigit/Spinetail

Repository files navigation

Spinetail

Spinetail

A Swift package for interfacing with your Mailchimp account, audiences, campaigns, and more.

SwiftPM Twitter GitHub GitHub issues

GitHub Workflow Status Bitrise

Demonstration of Spinetail

Table of Contents

🎬 Introduction

Spinetail is a Swift package for interfacing with your Mailchimp account, audiences, campaigns, and more.

Built on top of the code generated by Swaggen by Yonas Kolb from Mailchimp's OpenAPI Spec and optimized.

What's a Spinetail?

A Spinetail is a type of Swift bird which shares it's habitat with chimps (such as the chimp in Mailchimp).

How to create and send an email campaign

let listID : String = "[Your List ID]"
let mailchimpAPI = try Mailchimp.API(
  apiKey: "[ Your API Key : xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us00 ]"
)
let client = Client(api: mailchimpAPI, session: URLSession.shared)

// create the campaign template
let templateRequest = Templates.PostTemplates.Request(
  body: .init(html: html, name: name)
)
let template = try await self.request(templateRequest)

// get the templateID
guard let templateID = template.id else { 
  return
}

// setup the email
let settings: Campaigns.PostCampaigns.Request.Body.Settings = .init(
  fromName: "Leo", 
  replyTo: "leo@brightdigit.com", 
  subjectLine: "Hello World - Test Email", 
  templateId: templateID
)

// setup the campaign
let body: Campaigns.PostCampaigns.Request.Body = .init(
  type: .regular, 
  contentType: .template, 
  recipients: .init(listId: listID), 
  settings: settings
)

let request = Campaigns.PostCampaigns.Request(body: body)
try await client.request(request)

🎁 Features

Here's what's currently implemented with this library:

  • Pulling Your Current List of Campaigns
  • Send Email Campaigns to Your Lists
  • Get Your Audience List
  • Add to Your Audience List
  • Updating Subscribers Tags and Interests

... and more

🏗 Installation

To integrate Spinetail into your project using SPM, specify it in your Package.swift file:

let package = Package(
  ...
  dependencies: [
	.package(url: "https://github.com/brightdigit/Spinetail", from: "0.2.0")
  ],
  targets: [
	  .target(
		  name: "YourTarget",
		  dependencies: ["Spinetail", ...]),
	  ...
  ]
)

Spinetail uses URLSession for network communication via Prch.

However if you are building a server-side application in Swift and wish to take advantage of SwiftNIO, then you'll want import PrchNIO package as well:

let package = Package(
  ...
  dependencies: [
	.package(url: "https://github.com/brightdigit/Spinetail", from: "0.2.0"),
	.package(url: "https://github.com/brightdigit/PrchNIO", from: "0.2.0")
  ],
  targets: [
	  .target(
		  name: "YourTarget",
		  dependencies: ["Spinetail", "PrchNIO", ...]),
	  ...
  ]
)

PrchNIO adds support for EventLoopFuture and using the networking infrastructure already supplied by SwiftNIO.

If you are using Vapor, then you may also want to consider using SpinetailVapor package:

let package = Package(
  ...
  dependencies: [
	.package(url: "https://github.com/brightdigit/Spinetail", from: "0.2.0"),
	.package(url: "https://github.com/brightdigit/SpinetailVapor", from: "0.2.0")
  ],
  targets: [
	  .target(
		  name: "YourTarget",
		  dependencies: ["Spinetail", "SpinetailVapor", ...]),
	  ...
  ]
)

The SpinetailVapor package adds helper properties and methods to help with setting up and accessing the Prch.Client.

Setting Up Your Mailchimp Client with Prch

In order to get started with the Mailchimp API, make sure you have created an API key. Typically the API key looks something like this:

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us00

Once you have that, decide what you'll be using for your session depending on your platform:

  • URLSession - iOS, tvOS, watchOS, macOS from Prch
  • AsyncHTTPClient - Linux/Server from PrchNIO
  • Vapor.Client - Vapor from PrchVapor

Here's an example for setting up a client for Mailchimp on a standard Apple platform app:

let api = Mailchimp,API(apiKey: "")
let client = Client(api: api, session: URLSession.shared)

If you are using Vapor then you'll want to configure your client inside your application configuration:

app.mailchimp.configure(withAPIKey: "")

... then you'll have access to it throughout your application and in your requests:

request.mailchimp.client.request(...)
application.mailchimp.client.request(...)

Now that we have setup the client, we'll be using let's begin to access the Mailchimp API.

💪 Usage

🕊 Prch Basics

To make a request via Prch, we have three options using our client:

  • closure-based completion calls
  • async/await
  • synchronous calls

Closure-based Completion

client.request(request) { result in
  switch result {
  case let .success(member):
	  // Successful Retrieval
	break
  case let .defaultResponse(statusCode, response):
	  // Non-2xx Response (ex. 404 member not found)
	break
  case let .failure(error):
	  // Other Errors (ex. networking, decoding or encoding JSON...)
	break
  }
}

Async/Await

do {
  // Successful Retrieval
  let member = try await client.request(request)
} catch let error as ClientResponseResult<Lists.GetListsIdMembersId.Response>.FailedResponseError {
  // Non-2xx Response (ex. 404 member not found)
} catch  {
  // Other Errors (ex. networking, decoding or encoding JSON...)
}

Synchronous

do {
  // Successful Retrieval
  let member = try client.requestSync(request)
} catch let error as ClientResponseResult<Lists.GetListsIdMembersId.Response>.FailedResponseError {
  // Non-2xx Response (ex. 404 member not found)
} catch  {
  // Other Errors (ex. networking, decoding or encoding JSON...)
}

In each case there are possible results:

  • The call was successful
  • The call failed but the response was valid such as a 4xx status code
  • The call failed due to an internal error (ex. decoding, encoding, networking, etc...)

Let's start with an example using audience member lists.

👩 Audience List Members

Getting an Audience List Member

According to the documentation for the Mailchimp API, we can get a member of our audience list based on their subscriber_hash. This is described as:

The MD5 hash of the lowercase version of the list member's email address. This endpoint also accepts a list member's email address or contact_id.

The means we can use:

  • MD5 hash of the lowercase version of the list member's email address but also
  • email address or
  • contact_id

In our case, we'll be using an email address to see if we have someone subscribed. Additionally we need our audience's listID which is found on the audience settings page.

ListID at the Mailchimp Admin Page

With that email address, we can create a Request:

import Spinetail 

let api = Mailchimp.API(apiKey: "")
let client = Client(api: api, session: URLSession.shared)
let request = Lists.GetListsIdMembersId.Request(listId: listId, subscriberHash: emailAddress)

As previously noted there are three ways to execute a call. In this case, let's use the synchronous call:

do {
  // Successful Retrieval
  let member = try client.requestSync(request)
} catch let error as ClientResponseResult<Lists.GetListsIdMembersId.Response>.FailedResponseError {
  // Non-2xx Response (ex. 404 member not found)
} catch  {
  // Other Errors (ex. networking, decoding or encoding JSON...)
}

This is a good example of where we'd want to handle a 404. If the member is found, we may need to just update them, otherwise we want go ahead and add that subscriber.

Adding new Audience List Members

To add a new audience member we need to create a Lists.PostListsIdMembers.Request:

let request = Lists.PostListsIdMembers.Request(
  listId: listID, 
  body: .init(
    emailAddress: emailAddress, 
	status: .subscribed, 
	timestampOpt: .init(), 
	timestampSignup: .init()
  )
)

Now that we have a request let's use the completion handler call for adding a new member:

client.request(request) { result in
  switch result {
  case let .success(newMember):
	  // Successful Adding
	break
  case let .defaultResponse(statusCode, response):
	  // Non-2xx Response
	break
  case let .failure(error):
	  // Other Errors (ex. networking, decoding or encoding JSON...)
	break
  }
}

Updating Existing Audience List Members

Let's say our attempt to find an existing subscriber member succeeds but we need to update the member's interests. We can get subscriberHash from our found member and the interestID can be queried.

// get the subscriber hash id
let subscriberHash = member.id
let patch = Lists.PatchListsIdMembersId.Request(
  body: .init(
    emailAddress: emailAddress,
	emailType: nil, 
	interests: [interestID: true] 
  ), 
  options: .init(
    listId: Self.listID, 
	subscriberHash: subscriberHash
  )
)

Putting it together in Vapor

Here's an example in Vapor using Model Middleware provided by Fluent:

import Fluent
import Prch
import PrchVapor
import Spinetail
import Vapor

struct MailchimpMiddleware: ModelMiddleware {
  // our client created during server initialization
  let client: Prch.Client<PrchVapor.SessionClient, Spinetail.Mailchimp.API>
  
  // the list id
  let listID: String
  
  // the interest id 
  let interestID : String

  func upsertSubscriptionForUser(
    _ user: User, 
    withEventLoop eventLoop: EventLoop
  ) -> EventLoopFuture<Void> {
	let memberRequest = Lists.GetListsIdMembersId.Request(listId: listID, subscriberHash: user.email)
	// find the subscription member
	return client.request(memberRequest).flatMapThrowing { response -> in
	  switch response {
	  case .defaultResponse(statusCode: 404, _):
		return nil
	  case let .success(member):
		return member
	  default:
		throw ClientError.invalidResponse
	  }

	}.flatMap { member in
	  // if the subscriber already exists and has the interest id, don't do anything
	  if member?.interests?[self.interestID] == true {
		return eventLoop.future()
	  // if the subscriber already exists but doesn't have the interest id
	  } else if let subscriberHash = member?.id {
	  	// update the subscriber
		let patch = Lists.PatchListsIdMembersId.Request(body: 
		  .init(
		    emailAddress: user.email, 
		    emailType: nil, 
		    interests: [self.interestID: true]), 
		    options: Lists.PatchListsIdMembersId.Request.Options(
		      listId: self.listID, 
		      subscriberHash: subscriberHash
		      )
		    )
		// transform to `Void` on success
		return client.request(patch).success()
	  // if the subscriber doesn't already exists
	  } else {
	  	// update the subscriber add them
		let post = Lists.PostListsIdMembers.Request(
		  listId: self.listID, 
		  body: .init(
		    emailAddress: user.email, 
		    status: .subscribed, 
		    interests: [self.interestID: true], 
		    timestampOpt: .init(), 
		    timestampSignup: .init()
		  )
		)
		// transform to `Void` on success
		return client.request(post).success()
	  }
	}
  }

  // after adding the row to the db, add the user to our subscription list with the interest id
  func create(model: User, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
	next.create(model, on: db).transform(to: model).flatMap { user in
	  self.upsertSubscriptionForUser(user, withEventLoop: db.eventLoop)
	}
  }
}

Now that we have an example dealing with managing members, let's look at how to get a list of campaigns and email our subscribers in Swift.

📩 Templates and Campaigns

With newsletters there are campaigns and templates. Campaigns are how you send emails to your Mailchimp list. A template is an HTML file used to create the layout and basic design for a campaign. Before creating our own campaign and template, let's look at how to pull a list of campaigns.

Pulling List of Campaigns

On the BrightDigit web site, I want to link to each newsletter that's sent out. To do this you just need the listID again. We'll be pulling up to 1000 sent campaigns sorted from last sent to first sent:

let request = Campaigns.GetCampaigns.Request(
  count: 1000, 
  status: .sent, 
  listId: listID, 
  sortField: .sendTime, 
  sortDir: .desc
)
let response = try self.requestSync(request)
let campaigns = response.campaigns ?? []

To get the content we to grab it based on each campaign's campaignID.

Get Newsletter Content

Before grabbing the content, we need to grab the campaignID from the campaign:

let campaign : Campaigns.GetCampaigns.Response.Status200.Campaigns
let html: String

guard let campaignID = campaign.id else {
  return
}

html = try self.htmlFromCampaign(withID: campaignProperties.campaignID)

Creating a Template

To actually send we need to create an template using the POST request. Here's an example with async and await:

let templateName = "Example Email"
let templateHTML = "<strong>Hello World</strong>"
let templateRequest = Templates.PostTemplates.Request(body: .init(html: templateHTML, name: templateName))
let template = try await client.request(templateRequest)

Let's use the template to create a campaign and send it.

Send an Campaign Email to Our Audience List

// make sure to get the templateID
guard let templateID = template.id else {
  return
}

// set the email settings
let settings: Campaigns.PostCampaigns.Request.Body.Settings = .init(
  fromName: "Leo", 
  replyTo: "leo@brightdigit.com", 
  subjectLine: "Hello World - Test Email", 
  templateId: templateID
)
// set the type and list you're sending to
let body: Campaigns.PostCampaigns.Request.Body = .init(
  type: .regular, 
  contentType: .template, 
  recipients: .init(listId: listID), 
  settings: settings
)
let request = Campaigns.PostCampaigns.Request(body: body)
await client.request(request)

📞 Requests

List of APIs and the status of their support. If you have any requests feel free to submit an issue or pull-request to improve current support. For more information on the Mailchimp Marketing API, checkout their API documentation.

😁 Fully Supported

Due to the limitation of existing 32-bit watchOS devices, the library need to exclude certain APIs to limit size. Therefore these sets of APIs are available on all operating systems and platforms including watchOS.

Campaigns

Request Tested Documented watchOS
DeleteCampaignsId
DeleteCampaignsIdFeedbackId
GetCampaigns
GetCampaignsId
GetCampaignsIdContent
GetCampaignsIdFeedback
GetCampaignsIdFeedbackId
GetCampaignsIdSendChecklist
PatchCampaignsId
PatchCampaignsIdFeedbackId
PostCampaigns
PostCampaignsIdActionsCancelSend
PostCampaignsIdActionsCreateResend
PostCampaignsIdActionsPause
PostCampaignsIdActionsReplicate
PostCampaignsIdActionsResume
PostCampaignsIdActionsSchedule
PostCampaignsIdActionsSend
PostCampaignsIdActionsTest
PostCampaignsIdActionsUnschedule
PostCampaignsIdFeedback
PutCampaignsIdContent

Lists

Request Tested Documented watchOS
DeleteListsId
DeleteListsIdInterestCategoriesId
DeleteListsIdInterestCategoriesIdInterestsId
DeleteListsIdMembersId
DeleteListsIdMembersIdNotesId
DeleteListsIdMergeFieldsId
DeleteListsIdSegmentsId
DeleteListsIdSegmentsIdMembersId
DeleteListsIdWebhooksId
GetListMemberTags
GetLists
GetListsId
GetListsIdAbuseReports
GetListsIdAbuseReportsId
GetListsIdActivity
GetListsIdClients
GetListsIdGrowthHistory
GetListsIdGrowthHistoryId
GetListsIdInterestCategories
GetListsIdInterestCategoriesId
GetListsIdInterestCategoriesIdInterests
GetListsIdInterestCategoriesIdInterestsId
GetListsIdLocations
GetListsIdMembers
GetListsIdMembersId
GetListsIdMembersIdActivity
GetListsIdMembersIdActivityFeed
GetListsIdMembersIdEvents
GetListsIdMembersIdGoals
GetListsIdMembersIdNotes
GetListsIdMembersIdNotesId
GetListsIdMergeFields
GetListsIdMergeFieldsId
GetListsIdSegmentsId
GetListsIdSegmentsIdMembers
GetListsIdSignupForms
GetListsIdWebhooks
GetListsIdWebhooksId
PatchListsId
PatchListsIdInterestCategoriesId
PatchListsIdInterestCategoriesIdInterestsId
PatchListsIdMembersId
PatchListsIdMembersIdNotesId
PatchListsIdMergeFieldsId
PatchListsIdSegmentsId
PatchListsIdWebhooksId
PostListMemberEvents
PostListMemberTags
PostLists
PostListsId
PostListsIdInterestCategories
PostListsIdInterestCategoriesIdInterests
PostListsIdMembers
PostListsIdMembersHashActionsDeletePermanent
PostListsIdMembersIdNotes
PostListsIdMergeFields
PostListsIdSegments
PostListsIdSegmentsId
PostListsIdSegmentsIdMembers
PostListsIdSignupForms
PostListsIdWebhooks
PreviewASegment
PutListsIdMembersId
SearchTagsByName

Templates

Request Tested Documented watchOS
GetTemplates
GetTemplatesId
GetTemplatesIdDefaultContent
PatchTemplatesId
PostTemplates

Testing Pending

Request Tested Documented watchOS
DeleteCampaignFoldersId
GetCampaignFolders
GetCampaignFoldersId
PatchCampaignFoldersId
PostCampaignFolders

Template Folders

Request Tested Documented watchOS
DeleteTemplateFoldersId
GetTemplateFolders
GetTemplateFoldersId
PatchTemplateFoldersId
PostTemplateFolders
DeleteTemplatesId

Search Campaigns

Request Tested Documented watchOS
GetSearchCampaigns

Search Members

Request Tested Documented watchOS
GetSearchMembers

Reports

Request Tested Documented watchOS
GetReports
GetReportsId
GetReportsIdAbuseReportsId
GetReportsIdAbuseReportsIdId
GetReportsIdAdvice
GetReportsIdClickDetails
GetReportsIdClickDetailsId
GetReportsIdClickDetailsIdMembers
GetReportsIdClickDetailsIdMembersId
GetReportsIdDomainPerformance
GetReportsIdEcommerceProductActivity
GetReportsIdEepurl
GetReportsIdEmailActivity
GetReportsIdEmailActivityId
GetReportsIdLocations
GetReportsIdOpenDetails
GetReportsIdOpenDetailsIdMembersId
GetReportsIdSentTo
GetReportsIdSentToId
GetReportsIdSubReportsId
GetReportsIdUnsubscribed
GetReportsIdUnsubscribedId

Root

Request Tested Documented watchOS
GetRoot

😊 Pending Next Support

These are the next set of API for which migrating to watchOS is desired as well as more robust testing and documentation. If you have any requests feel free to submit an issue or pull-request to improve current support.

File Manager

Request Tested Documented watchOS
DeleteFileManagerFilesId
DeleteFileManagerFoldersId
GetFileManagerFiles
GetFileManagerFilesId
GetFileManagerFolders
GetFileManagerFoldersId
PatchFileManagerFilesId
PatchFileManagerFoldersId
PostFileManagerFiles
PostFileManagerFolders

Batches

Request Tested Documented watchOS
DeleteBatchesId
GetBatches
GetBatchesId
PostBatches
DeleteBatchWebhookId
GetBatchWebhook
GetBatchWebhooks
PatchBatchWebhooks
PostBatchWebhooks

Automations

Request Tested Documented watchOS
ArchiveAutomations
DeleteAutomationsIdEmailsId
GetAutomations
GetAutomationsId
GetAutomationsIdEmails
GetAutomationsIdEmailsId
GetAutomationsIdEmailsIdQueue
GetAutomationsIdEmailsIdQueueId
GetAutomationsIdRemovedSubscribers
GetAutomationsIdRemovedSubscribersId
PatchAutomationEmailWorkflowId
PostAutomations
PostAutomationsIdActionsPauseAllEmails
PostAutomationsIdActionsStartAllEmails
PostAutomationsIdEmailsIdActionsPause
PostAutomationsIdEmailsIdActionsStart
PostAutomationsIdEmailsIdQueue
PostAutomationsIdRemovedSubscribers

😌 Remaining Requests

These are the least priority set of API for which migrating to watchOS as well as robust testing and documentation have been prioritized. If you have any requests feel free to submit an issue or pull-request to improve current support.

Activity Feed

Request Tested Documented watchOS
GetActivityFeedChimpChatter

Authorized Apps

Request Tested Documented watchOS
GetAuthorizedApps
GetAuthorizedAppsId

Connected Sites

Request Tested Documented watchOS
DeleteConnectedSitesId
GetConnectedSites
GetConnectedSitesId
PostConnectedSites
PostConnectedSitesIdActionsVerifyScriptInstallation

Conversations

Request Tested Documented watchOS
GetConversations
GetConversationsId
GetConversationsIdMessages
GetConversationsIdMessagesId

Customer Journeys

Request Tested Documented watchOS
PostCustomerJourneysJourneysIdStepsIdActionsTrigger

Ecommerce Stores

Request Tested Documented watchOS
DeleteEcommerceStoresId
DeleteEcommerceStoresIdCartsId
DeleteEcommerceStoresIdCartsLinesId
DeleteEcommerceStoresIdCustomersId
DeleteEcommerceStoresIdOrdersId
DeleteEcommerceStoresIdOrdersIdLinesId
DeleteEcommerceStoresIdProductsId
DeleteEcommerceStoresIdProductsIdImagesId
DeleteEcommerceStoresIdProductsIdVariantsId
DeleteEcommerceStoresIdPromocodesId
DeleteEcommerceStoresIdPromorulesId
GetEcommerceOrders
GetEcommerceStores
GetEcommerceStoresId
GetEcommerceStoresIdCarts
GetEcommerceStoresIdCartsId
GetEcommerceStoresIdCartsIdLines
GetEcommerceStoresIdCartsIdLinesId
GetEcommerceStoresIdCustomers
GetEcommerceStoresIdCustomersId
GetEcommerceStoresIdOrders
GetEcommerceStoresIdOrdersId
GetEcommerceStoresIdOrdersIdLines
GetEcommerceStoresIdOrdersIdLinesId
GetEcommerceStoresIdProducts
GetEcommerceStoresIdProductsId
GetEcommerceStoresIdProductsIdImages
GetEcommerceStoresIdProductsIdImagesId
GetEcommerceStoresIdProductsIdVariants
GetEcommerceStoresIdProductsIdVariantsId
GetEcommerceStoresIdPromocodes
GetEcommerceStoresIdPromocodesId
GetEcommerceStoresIdPromorules
GetEcommerceStoresIdPromorulesId
PatchEcommerceStoresId
PatchEcommerceStoresIdCartsId
PatchEcommerceStoresIdCartsIdLinesId
PatchEcommerceStoresIdCustomersId
PatchEcommerceStoresIdOrdersId
PatchEcommerceStoresIdOrdersIdLinesId
PatchEcommerceStoresIdProductsId
PatchEcommerceStoresIdProductsIdImagesId
PatchEcommerceStoresIdProductsIdVariantsId
PatchEcommerceStoresIdPromocodesId
PatchEcommerceStoresIdPromorulesId
PostEcommerceStores
PostEcommerceStoresIdCarts
PostEcommerceStoresIdCartsIdLines
PostEcommerceStoresIdCustomers
PostEcommerceStoresIdOrders
PostEcommerceStoresIdOrdersIdLines
PostEcommerceStoresIdProducts
PostEcommerceStoresIdProductsIdImages
PostEcommerceStoresIdProductsIdVariants
PostEcommerceStoresIdPromocodes
PostEcommerceStoresIdPromorules
PutEcommerceStoresIdCustomersId
PutEcommerceStoresIdProductsIdVariantsId

Facebook Ads

Request Tested Documented watchOS
GetAllFacebookAds
GetFacebookAdsId

Landing Pages

Request Tested Documented watchOS
DeleteLandingPageId
GetAllLandingPages
GetLandingPageId
GetLandingPageIdContent
PatchLandingPageId
PostAllLandingPages
PostLandingPageIdActionsPublish
PostLandingPageIdActionsUnpublish

Verified Domains

Request Tested Documented watchOS
CreateVerifiedDomain
DeleteVerifiedDomain
GetVerifiedDomain
GetVerifiedDomains
VerifyDomain

🙏 Acknowledgments

Thanks to Yonas Kolb for his work on a variety of project but especially Swaggen.

📜 License

This code is distributed under the MIT license. See the LICENSE file for more info.