Sending Emails
Your code sends email from your own domain, through your own sending provider, and we carry it. Whether you may email someone is yours to decide. We record what you send, and refuse a short list of things.
Using this doesn't make you compliant with anything. Commercial email is regulated, and CAN-SPAM's duties are the sender's: yours. Nothing here does them for you. The Email guide has the full division.
| We always do | Yours to do |
|---|---|
| Add a one-click unsubscribe to every Email | Have a reason to email someone, and an honest subject and sender |
| Refuse an address on your suppression list | Put your physical postal address in what you send |
| Suppress an address that hard-bounces or marks you as spam | Your domain's SPF, DKIM and DMARC, and your sending reputation |
| Keep one row per attempt, refusals included | Honor opt-outs that come any other way, such as a reply |
| Keep test mode away from any provider | What the messages say |
We don't track opens or clicks, and we don't rewrite your links.
const from = await hcc.emailAddresses.create({
address: 'sales@acme.com',
displayName: 'Acme Sales',
});
const email = await hcc.emails.send({
from: from.id,
to: 'ana@example.com',
subject: 'Your order shipped',
text: 'It left our warehouse today.',
html: '<p>It left our warehouse today.</p>',
});
email.status; // 'delivered' on the simulator; on Resend, 'accepted' or 'unknown'
const full = await hcc.emails.get(email.id); // With `text`, `html` and `attachments`
const raw = await hcc.emails.raw(email.id); // The message as it went, headers and all
for await (const each of hcc.emails.list({ threadId: email.threadId! })) each.subject;to is one address, and cc takes more, up to 50 in all. Give text, html or both: we never make up a text part you didn't write. replyTo names an Email this one answers: it joins that Thread, with its threading headers. Attachments are base64, and the whole message is at most 25 MiB.
A key needs emails:write to send and emails:read to read, and a developer's live key can't have emails:write. Test mode sends through the simulator. Live mode sends on your own Resend account: create the address with provider: 'resend' and credentials: { apiKey }, add its webhook.url in Resend, and save the signing secret with hcc.emailAddresses.update(id, { credentials: { webhookSecret } }). hcc.emailAddresses.check(id) reads the domain's status again. The Email guide has the steps.
Refusals
A refused Email throws, and its row stays. error.details.emailId names it.
error.code | Status | Why |
|---|---|---|
invalid_recipient | 400 | An address isn't one |
address_not_found | 404 | from names no Email address of yours |
suppressed | 422 | A recipient is on your suppression list |
address_not_ready | 422 | Your provider doesn't say the address can send |
provider_unavailable | 422 | The address's provider can't carry it |
too_many_recipients | 422 | Over 50 in to and cc |
too_large | 413 | Over 25 MiB |
billing_required, billing_past_due, billing_disputed, usage_limit_reached, plan_has_no_seats | 402 | The billing stop |
test_cap_reached | 429 | This month's Test limit is used up |
A send with no clear answer stays unknown and is never sent again by us. hcc.emails.send sends an Idempotency-Key; pass your own idempotencyKey in the params to make a retry safe for as long as the row exists.
Suppressions
An unsubscribe, a hard bounce or a spam complaint adds the address to the suppression list, in the same transaction as the Email's new status. A soft bounce doesn't. The list is per environment, has no expiry, and keeps its history when you lift an entry.
await hcc.emailSuppressions.add('ana@example.com', { note: 'Asked on the phone' });
await hcc.emailSuppressions.check('ana@example.com'); // The entry, or null
await hcc.emailSuppressions.remove('ana@example.com');Lifting an unsubscribe is yours to justify: CAN-SPAM forbids mailing someone who opted out.
Email events
email.sent, email.delivered, email.bounced, email.complained, email.failed, email.refused, email.received and email.unsubscribed. They name the Email, its Thread, its addresses and its Lead, and none carries a subject or a body: read those with hcc.emails.get. email.bounced says whether it suppressed the address.
Testing Emails
Test mode never reaches a provider. These addresses answer as the name says, and any other is delivered:
| To | What happens |
|---|---|
bounce@simulator.hcc.run | A hard bounce: email.bounced, and suppressed |
soft-bounce@simulator.hcc.run | A soft bounce: email.bounced, not suppressed |
complaint@simulator.hcc.run | Delivered, then email.complained, suppressed |
reject@simulator.hcc.run | The provider refuses it: email.failed |
Someone answering is hcc.simulator.email. It arrives through the address's forward, exactly as forwarded mail does, and a reply joins its Thread by its headers:
const { outcome, email } = await hcc.simulator.email({
to: 'ema_123', // one of your test Email addresses
from: 'ana@example.com',
subject: 'Re: Your quote',
text: 'Sounds good.',
inReplyTo: sent.id, // an Email's id, or any Message-ID
});
// Or a whole message: hcc.simulator.email({ to: 'ema_123', raw: base64Eml })outcome is received, duplicate (its Message-ID was already here), bounce (a delivery status notification, applied to the Email it reports on) or forward_verified (one of your own Emails came back through the forward). An automatic answer, such as an out-of-office, is kept with autoReply: true and doesn't reopen a closed Thread.