HCC Docs

Emails in a campaign

.email is a step. Your handler writes the subject and the body, as there's no default copy, and we send each lead one Email per step per run. Everything in Emails holds here: whether you may email someone, and what you say, is yours to decide. Read lead.consent and the clock in your handler, and return c.skip() when you shouldn't send.

export const renewals = defineCampaign('renewals')
  .email(
    (lead, c) =>
      c.send({
        subject: 'Your plan renews Friday',
        text: `Hi ${lead.name}, your plan renews Friday. Acme, 1 Main St, Springfield.`,
      }),
    { name: 'heads-up', from: 'sales@acme.com' },
  )
  .wait('2d')
  .call(sales)
  .onEmailReply((email, lead, c) => {
    if (/unsubscribe|remove me/i.test(email.text ?? '')) return c.unsubscribe();
    return c.assign(lead.fields.ownerRep);
  });

It goes from c.send's from, else the step's from, else your oldest Email address whose provider says it can send. Name one with its address, such as sales@acme.com. A lead with no email skips the step with no_email, and your handler never hears of it.

How an Email step ends

The lead moves on once your provider has taken it, not on delivery. lastOutcome says how: email_sent, email_failed, email_unknown, skipped or no_email. A bounce later doesn't move anyone, though it suppresses the address for the next .email step.

A send that may have reached your provider is never sent again, including after a crash, as each has a key per lead, step and run. A lead that started over gets a new Email.

Refused becauseThe lead
suppressedLeaves as suppressed
invalid_recipientLeaves as removed
too_large, too_many_recipientsGoes to the next step
billing_*, usage_limit_reached, plan_has_no_seats, test_cap_reachedWaits, and the campaign pauses
address_not_found, address_not_ready, provider_unavailableParks as step_failed, with the reason as lastOutcome
rate_limitedComes back in 5 minutes

address_not_found is also what parks a lead when there's no address to send from at all. Fix the address, then retry the parked leads.

What an Email reply does

An Email from a lead reaches every campaign it's in, and goals and exits hear it as lead.email_received, with event.email. Only an Email that answers one this campaign sent, by its In-Reply-To or References headers and never by its subject, goes further.

What came inWhat happens
A one-click unsubscribeIt isn't an Email. The address is suppressed, and the next .email step takes the lead out as suppressed.
An out-of-office or other automatic answerGoals and exits hear it, with event.email.autoReply. Nothing else does, and it ends nothing.
An answer to this campaign's EmailGoals and exits first. If neither ends it, .onEmailReply decides. With no .onEmailReply, the lead leaves as exit if the campaign emails.
Any other Email from the leadGoals and exits only.

.onEmailReply returns one of c.continue(), c.exit(), c.assign(repId) or c.unsubscribe(). With assign, the Rep owns the Thread and the lead leaves as exit. unsubscribe suppresses the sender's address, as manual, and the lead leaves as suppressed: a reply that says "please stop" is yours to read, and CAN-SPAM expects you to honor it.

Texts have .onReply, and Emails .onEmailReply, so each hook gets its own channel's message and actions. A campaign that does both can have both.

If .onEmailReply keeps failing, the default stands. While a campaign is paused, none of your code runs, and a person's answer still ends the sequence.