Lee989's avatar
Level 1

Best approach to time based user journey emails

Quite often I'm faced with creating time-triggered emails, such as reminders. These email chains can get quite complicated, such as:

  • User submits Form X
    • If user does not do Action A after 24 hours, send Email 1
    • Action A still not taken after 48 hours, send Email 2
    • Action A still not taken after 1 week, send final Email 3
  • Action A is taken, but Action B is not:
    • 24h email
    • 48h email
    • 1w email

(if you're thinking, that's too many emails, I'm inclined to agree, this is just small snippet of 1 journey. Quite often there's several of these chains with many more points. I often push back to reduce them, it's just overly spammy in my opinion, but ultimately my job is to implement what's asked, not just refuse to do my job)

My current approach to this is often to use a regularly scheduled task (cron). Let's say every 10 minutes, fetch all FormX submissions without Action A that happened 24 hours ago (minus 10 minutes)

// Emails after 24h
whereBetween('created_at', [ now()->subDay()->subMinutes(10), now()->subDay() ])

// Emails after 48h
whereBetween('created_at', [ now()->subDays(2)->subMinutes(10), now()->subDays(2) ])

The above is a rough idea, but i'd also have to account for differences in seconds too.

I'm never truly happy with this approach, it seems fragile to either missing something, or sending duplicate emails because of a minor time gap or overlap. It's also intolerant to any failures. If the server goes down or into maintenance mode, then some emails won't be sent.

This is a common enough problem, there has to be a better way?

One idea I had was to queue up ALL of the emails when FormX is submitted, with a delay. Then when the job is run 24h later, it first checked if ActionA was taken, and if it was, just exits early and doesn't send. But that seems like a good way to fill the queue with lots of tasks that aren't needed.

Another idea was to create a 24h cache lock for every email/user pair, then I can afford to have my cron queries with several hours of overlap in the whereBetweens, duplicates will get filtered by checking the locks.

I'm sure most developers have had to tackle this, whats your strategy?

0 likes
3 replies
martinbean's avatar

@lee989 If using a third-party service isn’t an option here, then I’d just have schedule tasks for each of the emails you need to send.

So for example, the “If user does not do Action A after 24 hours, send Email 1” task would just query all forms created a day ago where Action A hasn’t been performed, and send emails.

Lee989's avatar
Level 1

@martinbean This is pretty much what I already do. The problem is you don't want to send all emails at the same time, so it has to run fairly frequently. No point scheduling it for 9am UTC every day, if one of your customers is UTC -08:00. Ideally, we want to email them roughly the same time as they first submitted the form, as that's our best guess at when they might be available rather than our emails sitting stale for hours.

I could do it once per hour, instead of every 10 minutes. With my date filter being subDay()->startOfHour(), subDay()->endOfHour() . This is really kicking the can far enough down the road that it's unlikely to be an issue.

I think i'm leaning towards creating a notifications_log table, which has a unique email_key / user_id pair for every email, then I can run the cron whenever I like as it will de-duplicate. If a server outage occurs or some other downtime, it doesn't matter, it'll pick up the ones it missed when back online (obviously with a sanity date filter).

I think it would be simple enough to build a DoesDeduplicate trait for notifications and have each notification implement a getUniqueKey method. Then it's all self-contained. Maybe I could even throw it on github...

Anyway, i'm using the forum as a rubber duck at this point. I'll shut up unless someone pops up with a better/simpler solution.

martinbean's avatar

@Lee989 You could create your table, and use it as the transaction outbox for it.

When a form is created, you add rows to the table saying what notification needs sending at what time. You then have a scheduled task that checks if minute if any notifications need sending that minute. Once you’ve sent a notification, you remove the row. This way, if you do have an outage, you can still send notifications; they’ll just be sent a little later than intended.

Please or to participate in this conversation.