Home Blog How to migrate from Mailchimp to Postmark + Temporal

Send a mail on a typewriter Photo by Markus Winkler

How to migrate from Mailchimp to Postmark + Temporal

MailChimp is a great SaaS platform for sending e-mails to your subscribers and work with automation to sell to your mailing list. However, once you get a sizable audience and you want to customize your e-mail interactions, you'll be hit with a hefty price tag. Mailchimp is very expensive! If you've got a mailing list of 2000 contacts and you're on the cheapest paid plan, you're already paying $34 per month. This is a great option for a marketing team that has no access to a software developer, but I am one of those software developers.

Before we get started with the migration, let's introduce the 2 heroes of the story: Postmark and Temporal. If you haven't heard of these two before, here's a quick introduction. If you have, you can skip to the sections that you want to read:

  1. What is Postmark?
  2. What is Temporal?
  3. Create e-mail templates in Postmark
  4. Create Temporal workflows
    1. The newsletter sending workflow
    2. Handling unsubscribes from the newsletter

What is Postmark?

Postmark homepage

You might have heard about Postmark before, it's quite a well-known e-mail platform that allows you to send transactional and marketing e-mails, and even deal with incoming e-mails. In my experience, its delivery rate is better than a few other options out there. One of the biggest benefits is the fact that you can create templates in code and synchronize those with Postmark. This is a very important aspect for this entire process, because this allows me to send e-mails using a REST API.

What is Temporal?

Temporal homepage

The second piece of software that makes all of this possible is Temporal. Temporal is software that keeps track of the state of your code. In simple terms, it executes each line of code only once and can resume code execution after system crashes. This makes your code incredibly stable. If that hasn't convinced you of its power, perhaps the following example will clarify it.

Example

If you have a banking application and your customer is sending a payment to another customer and your program crashes halfway through: what happened to the payment? What will happen when you restart the application? Will this automatic process charge your customer twice? Temporal knows where the application was in its code execution and resumes from that exact line. Temporal will help you to never charge your customer twice, not even after a crash.

Delay code by weeks

Another great benefit of this state machine is that you can let processes take weeks or months if you want to. In normal code, you can put some type of delay (sleep, time.Sleep, etc.) in your code, but you wouldn't think of doing this for 14 days. Usually, you'd only delay your code by about 10 seconds or so.

But why is this important? This is important, because it drastically simplifies your code. Charging a customer for a subscription is now just a for-loop with a delay of 30 days after the charge. 30 days later, check if the customer is still subscribed, charge them, and delay for another 30 days. It's really that simple. Keep this in the back of your head, because this will become very important in this the scheduling of e-mails to send to your contacts.

One last thing to highlight before we can get started! You can schedule code execution based on a cron schedule. This will become the heart of the newsletter application. Enough talk, let's get to some code!

Create e-mail templates in Postmark

If you've ever sent e-mails from your code, you might have worked with local templates and sent ready-made HTML e-mails to your contacts. This is always a pain and I can't even begin to count the number of times an e-mail just doesn't look good on a mobile device or looks a little off on Gmail in Safari. Creating emails in HTML just takes you back to the "Good ol' HTML 4 days" where everything is a table and nothing is responsive. Let's not even try to attempt this and let Postmark handle this for you.

If you use the mailmason starter I've also used as a base, you'll see minimal HTML tables and mostly just simple HTML content. You can style your emails using simple CSS files and Mailmason will even generate the plain text versions of your emails.

After you've synchronized your templates with your Postmark server, you'll have templates with easy-to-use template aliases. You can use these template aliases, alongside the variables you'll need for your templates (first name, etc.) and send them as JSON to Postmark to send your email. Sending emails with a simple REST API call is a fantastic feeling.

For the sake of this post, let's imagine we're using a template like this:

<h1>Hello!</h1>
<p>
    I hope you've had a great week! This is what I've written for your this week, I hope you enjoy!
</p>

<img src="{{image_url}}" >

<br />

<h1>{{title}}</h1>

<p>{{description}}</p>

<a href="{{url}}" class="cta-button">Read &quot;{{title}}&quot;</a>

<p>
    I'm looking forward to sending you the next email soon, take care!
</p>

As you can see, we've got a few variables we can fill in using the REST API:

  • image_url
  • title
  • description
  • url

Let's give this template the alias "weekly-newsletter".

This is a very basic email template with minimal styling that'll be stored on the Postmark servers, rather than in your own application.

If you're interested in learning more about mailmason and my automatic process for synchronizing templates with Postmark, please reach out to me.

Let's move onto the heart of automation: Temporal.

Create Temporal workflows

Temporal, the state machine, has a concept called workflows. A workflow is a collection of individual steps that perform a (more complicated) task. A workflow should be deterministic, which means it has the same result every time it executes with the same input data.

Any nondeterministic behavior should be moved to an activity, because the result of an activity is recorded in the workflow history. You can interpret an activity as a step in your workflow. Some examples of code that's great for an activity is: sending an e-mail or fetching something from an API.

When we break this down for sending a newsletter, you can see a workflow as these steps:

  1. Who do I send an e-mail to?
  2. Is there something for me to send? If no, check again next week.
  3. Tell Postmark to send contact X Template Y with variables Z
  4. Done, check again next week

During this workflow, I want to skip the code as quickly as possible and check again next week. "Checking again next week" highlights one of the features Temporal offers for workflows: Cronjob workflows. These Cronjob workflows can be executed whenever you want to, in my case every saturday at 15:00 (3pm). This cron schedule looks like: "0 15 * * 6".

Starting (scheduling) a workflow in my code, which is written in Go, looks like this:

// Create an easy-to-use workflow ID
const WorkflowID = "newsletter-%s"

// Register the workflow with a string name
w.RegisterWorkflowWithOptions(EmailNewsletterWorkflow, workflow.RegisterOptions{
    Name: "email.newsletter",
})

type SubscribeRequest struct {
    Email       string `json:"email"`
}

// Execute the workflow with a cron schedule
if _, err := s.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
    ID:           fmt.Sprintf(WorkflowID, "hello@example.com"),
    TaskQueue:    Queue,
    CronSchedule: "0 15 * * 6",
}, "email.newsletter", SubscribeRequest{
    Email: "hello@example.com",
}); err != nil {
    return err
}

There is a lot of code that I'm omitting in this example, like setting up a Temporal worker and configuring the Temporal client. I'm also hardcoding the e-mail address in this example for simplicity's sake. The input data for the workflow, "SubscribeRequest", has json tags, because temporal stores this input data in the database and by specifying the keys it should use as json, you avoid some rare encoding and decoding issues.

The workflow ID

I'm highlighting the easy-to-use Workflow ID, because this will make it easy for us to stop the workflow in case the contact is unsubscribing from the mailing list. By specifying this workflow ID, you also prevent the workflow from running multiple times, in case someone accidentally (or on purpose) signs up for your mailing list multiple times. If you don't specify the workflow ID, it'll be assigned a random UUID, which makes it very difficult to cancel the workflow without saving this random workflow ID in another database.

The newsletter sending workflow

We've seen that we can execute a workflow based on a cron schedule, but the workflow doesn't do anything yet. Let's change that! In the workflow, we'll need to do 2 things:

  1. Fetch the post we want to send our subscriber
  2. Send the email

This workflow could look something like this:

type Post struct {
    Title         string `json:"title"`
    Image         string `json:"image"`
    Description   string `json:"description"`
    URL           string `json:"url"`
    PostedDaysAgo int    `json:"posted_days_ago"`
}

w.RegisterActivityWithOptions(FetchLatestPost, activity.RegisterOptions{
    Name: "post.fetch-latest",
})

w.RegisterActivityWithOptions(SendPostmarkTemplate, activity.RegisterOptions{
    Name: "email.send",
})

func EmailNewsletterWorkflow(ctx workflow.Context, config SubscribeRequest) error {

    // We want to retry both of these activities a maximum of 10 times.
    ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
        TaskQueue:           Queue,
        StartToCloseTimeout: time.Minute,
        RetryPolicy: &temporal.RetryPolicy{
            InitialInterval:    time.Second,
            BackoffCoefficient: 2.0,
            MaximumInterval:    time.Minute,
            MaximumAttempts:    10,
        },
    })

    var latestPost Post

    // Call an endpoint within an activity to fetch the latest post
    if err := workflow.
        ExecuteActivity(ctx, "post.fetch-latest").
        Get(ctx, &latestPost); err != nil {
        return err
    }

    // We want to avoid sending emails if there wasn't a new post within the last 7 days 
    if latestPost.PostedDaysAgo > 7 {
        workflow.GetLogger(ctx).Info("latest post is posted more than 7 days ago, skipping...")
        return nil
    }

    if err := workflow.
        ExecuteActivity(ctx, "email.send", Config{
            TemplateAlias: "weekly-newsletter",
            Email:         config.Email,
            From:          "info@roelofjanelsinga.com",
            MessageStream: "newsletter",
            TemplateModel: map[string]interface{}{
                "title": latestPost.Title,
                "image": latestPost.Image,
                "description": latestPost.Description,
                "url": latestPost.URL,
            },
        }).
        Get(ctx, nil); err != nil {
        return err
    }

    return nil
}

This workflow fetches the latest post and checks whether it was posted in the past 7 days. If there hasn't been a new post in the past 7 days, the workflow returns and schedules a new workflow for next week.

If there was a new post in the past 7 days, the workflow executes an activity called "email.send" with the data the activity needs (Config)

This activity calls the Postmark API and sends the template with alias "weekly-newsletter" to the contact we've given to the workflow. In this post, we hardcoded this email as "hello@example.com". The TemplateModel map in the configuration are the variables you defined in your Postmark template. You can also choose to use a struct rather than a map, but I'm reusing this "email.send" activity for every workflow that sends emails, so this makes it easier to use for my application.

The email sending activity is quite straightforward and just converts the Config into an API call to Postmark:

type PostmarkResponse struct {
    To          string
    SubmittedAt time.Time
    MessageID   string
    ErrorCode   int
    Message     string
}

type PostmarkFailure struct {
    ErrorCode int
    Message   string
}

func SendPostmarkTemplate(_ context.Context, config Config) (*PostmarkResponse, error) {

    client := sling.New().Base("https://api.postmarkapp.com")

    var success PostmarkResponse
    var failure PostmarkFailure

    _, err := client.
        Post("/email/withTemplate").
        Add("Accept", "application/json").
        Add("X-Postmark-Server-Token", "your-token-here").
        BodyJSON(Body{
            From:          config.From,
            To:            config.Email,
            TemplateID:    config.TemplateID,
            TemplateAlias: config.TemplateAlias,
            TemplateModel: config.TemplateModel,
            MessageStream: config.MessageStream,
        }).
        Receive(&success, &failure)

    if err != nil {
        return &PostmarkResponse{
            ErrorCode: failure.ErrorCode,
            Message:   failure.Message,
        }, err
    }

    return &success, nil
}

We can now subscribe our contacts to our mailing list and send them a weekly email! Unfortunately, not every contact will stay subscribed indefinitely, so we'll need to handle unsubscribes. Let's see how we can do that!

Handling unsubscribes from the newsletter

One of your contacts wants to unsubscribe from your mailing list, that's too bad! However, it's not difficult to implement this in our application, because we've thought about this when we started our initial workflow!

Remember the workflow ID? That very nice and easy to use workflow ID? That's going to make this unsubscribe process much...much easier!

When we executed the workflow for this contact, we've customized the workflow ID to contain the contact's email: newsletter-hello@example.com. Now, all we need to unsubscribe a contact from our newsletter is their email.

Since we're using a workflow with a cron schedule, we can't just cancel the workflow, because this causes a new workflow to be scheduled. We want to stop the workflows from being scheduled after the contact unsubscribes, so we'll need to terminate the workflow.

This is what it looks like:

type UnsubscribeRequest struct {
    Email string `json:"email"`
}

func (s service) Unsubscribe(ctx context.Context, payload UnsubscribeRequest) error {
    err := s.client.TerminateWorkflow(ctx, fmt.Sprintf(WorkflowID, payload.Email), "", "unsubscribed")

    if err != nil {
        s.logger.Error("Error terminating workflow", err)
        return err
    }

    return nil
}

The empty string that we pass to the TerminateWorkflow method represents the RunID. By leaving this RunID empty, Temporal assumes you want to terminate the latest run for this workflow.

In the code, you can also see "unsubscribed", this is the reason of termination. It's an optional thing to add, but it's nice if you're working in a team and wondering why your workflow is terminated.

Conclusion

In this post, I've described how I've migrated my newsletters from Mailchimp to Postmark + Temporal. Now, with Postmark and Temporal, I've got complete control over who, how, and what to send to my mailing list. The upside is that I can still do all things I could with Mailchimp, for a fraction of the cost. The downside of building all of this yourself is that you'll need to have technical expertise and solve any issues that arise by yourself.

I can live with those downsides, because working with both Postmark and Temporal is a delight! If something is unclear, Temporal has a great community to help you out.

If you have any questions about this process, don't hesitate to reach out!

Posted on: March 4th, 2022

I help you achieve great SEO, higher conversions, and help you grow your business

Contact me now to start growing your business online

Roelof Jan Elsinga