DEV Community

Qasim
Qasim

Posted on

Send shipment updates from a logistics email agent

Ask a freight customer what they actually want from your TMS and you'll get two answers, every time. They want to be told — proactively, the moment a milestone fires — that their load was picked up, that it's rolling, that it delivered. And when something goes sideways, they want to email someone and get a fast, specific answer to "where is my freight?" without filing a ticket or waiting on hold for the carrier desk.

Most teams build the first half and stop. They wire milestone events from the TMS into a one-way email service — SendGrid, Resend, an SMTP relay — and fire a templated "your shipment is in transit" message. That works right up until the customer hits Reply. The reply bounces, or it black-holes into a no-reply@ mailbox nobody reads, or it lands in a shared inbox a dispatcher checks twice a day. The proactive half shipped; the responsive half didn't. And the responsive half is where customers judge you.

This post builds both halves with a Nylas Agent Account — a real mailbox your code owns. The outbound side stays milestone-driven: your TMS or carrier feed fires an event, your service sends the matching email. The inbound side is the part a one-way relay can't do: when "where's my freight on PRO 7741-022?" lands in that same mailbox, your agent reads it, looks the shipment up in your system, and replies in-thread with a real status. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for — and I'll show both angles for every operation, the nylas command and the raw curl, because your milestone worker will probably shell out to one and your services will hit the other.

Why a real mailbox beats a one-way ESP here

Be honest about the tradeoff up front, because it's the whole reason to read on. A transactional ESP is great at the outbound half. If all you ever need is to push "picked up / in transit / delivered" and never hear back, an ESP is simpler and you should use it. The moment you want the email to be a two-way channel, the ESP model breaks:

  • It can push milestones but it can't answer the reply. An ESP sends and forgets. There's no inbox behind the From address, so "where is my freight?" has nowhere to go. You end up bolting on a separate support system and praying the customer uses it instead of replying.
  • Replies land where the agent can act. With an Agent Account, the customer's reply arrives in a mailbox your code reads via webhook. The same service that sent the milestone can answer the exception. One channel, both directions.
  • You can keep it one conversation. Because it's a real mailbox doing real In-Reply-To threading, you can chain each milestone update — and the customer's "where is it?" reply — into a single thread instead of three disconnected blasts. (It's not automatic; you chain it explicitly, shown below.)
  • It sends from your domain, programmatically. No human logs into Google and grants OAuth consent for tracking@yourfreight.com. You mint the mailbox from an API call, it sends with your DKIM signature, and there's no token to expire at 2am and break the overnight pickup-notification batch.

That's the pitch: an ESP gives you one-way milestones; an Agent Account gives you milestones plus the reply lane.

The grant is the whole data plane

Here's what makes this tractable. An Agent Account is just a grant. It has a grant_id, and that ID works with every grant-scoped endpoint Nylas already exposes — Messages, Threads, Folders, Drafts, Attachments. There's nothing new to learn on the data plane. You provision one mailbox like tracking@yourfreight.com, and from then on sending a milestone email is the same POST /v3/grants/{grant_id}/messages/send you'd use for any message, reading an inbound question is the same GET /v3/grants/{grant_id}/messages/{message_id}, and replying is that same send endpoint with a reply_to_message_id. If you've built against a connected Gmail or Microsoft grant before, you already know this API. Same endpoints, same auth, same payloads.

One line worth nailing down now, because it's the most common confusion: the shipment data is yours, not Nylas's. Nylas has no idea what a PRO number is, doesn't know your load went from "at pickup" to "in transit," and can't look up a BOL. Your TMS knows all of that. Your milestone events come from your carrier feed or EDI 214 messages; your shipment lookup for inbound questions is a query against your database. Nylas is the delivery and receive layer in the middle — it gives the agent a real mailbox to send from and an inbox for replies to come back to. Keep that boundary clean and everything else is plumbing.

(And yes — custom metadata on the message isn't supported for Agent Accounts, so don't try to stash the shipment ID on the Nylas message. Keep the mapping of thread_id → shipment in your database, where it belongs anyway.)

Before you begin

You need three things:

  1. The CLI. On macOS or Linux it's a Homebrew tap:
   brew install nylas/nylas-cli/nylas
Enter fullscreen mode Exit fullscreen mode
  1. A Nylas API key. If you don't have one, nylas init creates an account and mints a key in a single guided command. You can also pass an existing key non-interactively with nylas init --api-key <your-key>.

  2. A domain for the sender. Every Agent Account lives on a domain. For prototyping, Nylas hands out trial *.nylas.email subdomains, so you can create tracking@yourfreight.nylas.email immediately. For production, register a dedicated subdomain like tracking.yourfreight.com and publish the DKIM and SPF records Nylas gives you. New domains warm over roughly four weeks, so don't register one the morning you go live, and if your updates go to real shippers, walk the deliverability checklist before you turn on volume.

Every API example below uses the US host https://api.us.nylas.com and a bearer token: Authorization: Bearer <NYLAS_API_KEY>. For the EU region, point at https://api.eu.nylas.com (the CLI honors the NYLAS_API_BASE_URL environment variable for the same purpose).

Provision the tracking mailbox

This is the only step specific to Agent Accounts, and it's one line:

nylas agent account create tracking@tracking.yourfreight.com --name "YourFreight Tracking"
Enter fullscreen mode Exit fullscreen mode

The --name sets the display name, so customers see YourFreight Tracking <tracking@tracking.yourfreight.com> instead of a bare address. The command prints the new grant's id, status, and connector details — save that id, it's the handle for every send and read below. The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up — custom send/spam policies are out of scope for this post.

Under the hood the CLI is a thin wrapper over POST /v3/connect/custom with provider: "nylas" — the same call your provisioning code makes directly:

curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "YourFreight Tracking",
    "settings": { "email": "tracking@tracking.yourfreight.com" }
  }'
Enter fullscreen mode Exit fullscreen mode

The response contains a grant_id. That's your handle. Store it next to your other infrastructure IDs — it doesn't change, and there's no refresh token to rotate.

Send a milestone update

The trigger comes from your system. Your carrier integration receives an EDI 214, or your TMS flips a load's status, or a driver app posts a "delivered" event. That event handler is where the send lives. Nylas never decides when to notify — your milestone logic does. It hands Nylas a finished body at exactly one moment: when the milestone fires and you know who to tell.

Here's the shape of that handler, with the data assembly that's yours and the send that's Nylas's:

# your_app/milestones.py — runs when a milestone event fires
def on_milestone(event):                    # YOUR carrier/TMS event
    shipment = db.get_shipment(event.pro)   # YOUR lookup
    body = render_milestone(shipment, event.status)  # template or LLM — your choice
    send_update(shipment.customer_email, shipment.pro, event.status, body)
Enter fullscreen mode Exit fullscreen mode

render_milestone can be a plain template ("Shipment {pro} was picked up in {origin} and is now in transit") or an LLM call if you want friendlier prose — Nylas doesn't care which. It enters the picture when body is ready. The CLI send first:

nylas email send tracking@tracking.yourfreight.com \
  --to shipping@acmedist.com \
  --subject "PRO 7741-022 picked up — in transit to Dallas, TX" \
  --body "$MILESTONE_HTML"
Enter fullscreen mode Exit fullscreen mode

Pass the grant by its email (or its grant_id) as the first argument; --body takes HTML or plain text. There's no --from flag — Nylas defaults the sender to the Agent Account's own address and display name, which is exactly right for a tracking notice.

The same operation against the API is POST /v3/grants/{grant_id}/messages/send:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "shipping@acmedist.com", "name": "Acme Receiving" }],
    "subject": "PRO 7741-022 picked up — in transit to Dallas, TX",
    "body": "<h2>Shipment update</h2><p>PRO 7741-022 was picked up in Memphis, TN and is now in transit. ETA Dallas, TX: 2026-06-27.</p>"
  }'
Enter fullscreen mode Exit fullscreen mode

That's the pickup send. But here's a gotcha worth catching early: independent sends do not thread together. Pickup, in-transit, and delivery are three separate messages/send calls, and by default each lands in the customer's mailbox as a brand-new email — three disconnected messages, not one tracking conversation. If you want them to group, you chain them yourself: capture the message id the pickup send returns, set reply_to_message_id to it on the in-transit send, then capture that id and chain delivery to it.

So store the id every send returns, keyed by shipment, and pass it as the parent of the next milestone. On the CLI that's the --reply-to flag, which takes the message id to thread under:

# in-transit milestone, chained to the pickup message you stored
nylas email send tracking@tracking.yourfreight.com \
  --to shipping@acmedist.com \
  --subject "Re: PRO 7741-022 — now in transit, ETA Dallas 2026-06-27" \
  --body "$INTRANSIT_HTML" \
  --reply-to "$PICKUP_MESSAGE_ID"
Enter fullscreen mode Exit fullscreen mode

The same chaining on the API is reply_to_message_id on the send body:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "shipping@acmedist.com", "name": "Acme Receiving" }],
    "subject": "Re: PRO 7741-022 — now in transit, ETA Dallas 2026-06-27",
    "body": "<p>PRO 7741-022 is now in transit. ETA Dallas, TX: 2026-06-27.</p>",
    "reply_to_message_id": "<PICKUP_MESSAGE_ID>"
  }'
Enter fullscreen mode Exit fullscreen mode

Chain delivery to the in-transit id the same way and the customer sees one conversation that grows as the load moves — which also means their "where is it?" reply (next section) lands in that same thread.

Schedule a milestone for a specific time

Sometimes you don't want to send the instant the event fires — say you batch overnight pickups and want them all to land at 7am. The CLI holds the send with --schedule, which accepts a time, a relative duration like 2h, or a 2026-06-27 07:00 timestamp:

nylas email send tracking@tracking.yourfreight.com \
  --to shipping@acmedist.com \
  --subject "PRO 7741-022 picked up — in transit to Dallas, TX" \
  --body "$MILESTONE_HTML" \
  --schedule "tomorrow 7am"
Enter fullscreen mode Exit fullscreen mode

On the API the same thing is a send_at field on the send body — a Unix timestamp in seconds. Generate it with date so you're not hand-counting epoch seconds:

SEND_AT=$(date -d "tomorrow 07:00" +%s)   # GNU date; macOS: date -j -f '%Y-%m-%d %H:%M' '2026-06-27 07:00' +%s

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "shipping@acmedist.com", "name": "Acme Receiving" }],
    "subject": "PRO 7741-022 picked up — in transit to Dallas, TX",
    "body": "<p>PRO 7741-022 was picked up in Memphis, TN.</p>",
    "send_at": '"$SEND_AT"'
  }'
Enter fullscreen mode Exit fullscreen mode

send_at must be at least a minute out and at most 30 days ahead — it shifts a single message's delivery, it's not a recurring scheduler. The recurrence comes from your milestone events.

Receive a 'where's my freight?' reply

This is the half the ESP can't do. When the customer replies to a milestone — "where is my freight on PRO 7741-022? It was due yesterday" — that message lands in the tracking mailbox and fires the standard message.created webhook.

Webhooks are application-scoped, not grant-scoped: you subscribe once at the app level, and every grant's events arrive at that one endpoint, each payload carrying a grant_id you filter on. So the first thing your handler does is confirm this event belongs to the tracking grant. Subscribe with the CLI:

nylas webhook create \
  --url https://yourfreight.com/webhooks/nylas \
  --triggers message.created
Enter fullscreen mode Exit fullscreen mode

--url is your handler endpoint and --triggers takes one or more trigger types (comma-separated or repeated). The same subscription on the API is POST /v3/webhooks:

curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://yourfreight.com/webhooks/nylas"
  }'
Enter fullscreen mode Exit fullscreen mode

Two things to get right before you act on the event:

  • Dedupe on the top-level notification id. Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The top-level id is constant across all retries of one delivery, so it's your dedup key. (The inner data.object.id is the message id; you can additionally guard on it so you never act on the same message twice.)
  • Don't trust the payload for the body — fetch by id. The docs are cautious here: the message.created payload may carry the body inline, but when a message exceeds ~1 MB the trigger becomes message.created.truncated and the body is omitted. Both cases agree on the safe move: branch on message.created.truncated, and fetch the full message with GET /v3/grants/{grant_id}/messages/{message_id} when you need the real text.
// Node.js / Express
app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature (hex HMAC-SHA256 of the raw body) first.
  res.status(200).end();

  const event = req.body;
  if (await seen(event.id)) return;            // dedup on the notification id
  if (!event.type.startsWith("message.created")) return;

  const msg = event.data.object;
  if (msg.grant_id !== TRACKING_GRANT_ID) return;
  if (msg.from?.[0]?.email === TRACKING_ADDRESS) return; // ignore our own sends

  // Fetch the full body — don't rely on the webhook payload.
  await handleFreightQuestion(msg.id, msg.thread_id);
});
Enter fullscreen mode Exit fullscreen mode

Read the inbound question

With the message id in hand, fetch the full message so your extraction step has real text to work with. The CLI:

nylas email read <MESSAGE_ID> tracking@tracking.yourfreight.com
Enter fullscreen mode Exit fullscreen mode

nylas email read takes the message id, with the grant as an optional second argument. The equivalent API call is GET /v3/grants/{grant_id}/messages/{message_id}:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode

Marking the question read is a separate operation — a GET doesn't clear the unread flag. On the CLI, add --mark-read to the read command (nylas email read <MESSAGE_ID> tracking@tracking.yourfreight.com --mark-read); on the API it's a PUT /v3/grants/{grant_id}/messages/{message_id} with {"unread": false}:

curl --request PUT \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "unread": false }'
Enter fullscreen mode Exit fullscreen mode

Now the work that's yours: pull the identifier out of the message and look the shipment up. A PRO, BOL, or tracking number is a regex away in most freight emails (PRO\s*#?\s*([\d-]+)), and for the messier "the load from Memphis you told me about Tuesday" phrasings, hand the body to an LLM to extract the reference. Either way you end with an identifier you match against your system:

ref = extract_reference(body)              # regex or LLM — your code
shipment = db.find_shipment(ref) or db.find_by_thread(thread_id)  # YOUR lookup
status_line = render_status(shipment)      # "In transit, ETA Dallas 2026-06-27, on PRO 7741-022"
Enter fullscreen mode Exit fullscreen mode

If the regex misses, fall back to the thread_id → shipment mapping you stored when you sent the original milestone — that's why you keep that map in your own database. Nylas can't do this lookup for you; it doesn't know what a PRO number means.

Reply in-thread with the status

Once you have the status line, reply to the customer's message — in the same thread, so it groups with the milestone updates they already received. The CLI has a dedicated reply command that fetches the original to populate recipient and subject automatically:

nylas email reply <MESSAGE_ID> tracking@tracking.yourfreight.com \
  --body "PRO 7741-022 is in transit, currently near Little Rock, AR. Revised ETA Dallas: 2026-06-27 by end of day. We'll email you again on delivery."
Enter fullscreen mode Exit fullscreen mode

nylas email reply takes the inbound message id (grant optional as the second arg) and preserves threading via the message's reply-to relationship, so the exchange stays one conversation in the customer's mail client. On the API, the same in-thread reply is a normal POST /v3/grants/{grant_id}/messages/send with reply_to_message_id set to the message you're answering:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "shipping@acmedist.com", "name": "Acme Receiving" }],
    "subject": "Re: PRO 7741-022 picked up — in transit to Dallas, TX",
    "body": "PRO 7741-022 is in transit, currently near Little Rock, AR. Revised ETA Dallas: 2026-06-27 by end of day.",
    "reply_to_message_id": "<MESSAGE_ID>"
  }'
Enter fullscreen mode Exit fullscreen mode

The result is the thing the ESP couldn't give you: the agent proactively reported pickup and in-transit, the customer asked an exception question, and the agent answered it with a real status — all in one thread, from one mailbox, with no human in the loop unless the answer needs one.

Guardrails and gotchas

A few things I'd want a teammate to know before they ship this:

  • Milestones are yours; Nylas only delivers. If updates stop going out, look at your event feed first — a stalled EDI 214 ingest or a TMS webhook that didn't fire is far more likely than a send failure. Nylas can't notify on a milestone it was never handed.
  • Escalate when the lookup fails. If extract_reference and the thread map both miss — the customer replied from a different address, or referenced a shipment that isn't yours — don't have the agent guess. Route that reply to a human dispatch queue. A wrong ETA is worse than a slightly slower human answer.
  • Make the reply path idempotent. Dedup on the notification id so a redelivered webhook doesn't send two status replies, and guard on the message id so a customer who replies twice doesn't trigger two lookups. Freight customers re-ask; don't double-answer.
  • Watch send volume against your plan. The free plan caps an account at 200 messages per day. Three milestones per shipment plus the occasional reply adds up fast across a busy lane — mind the cap and your plan before you fan out to a large book of business.
  • Mind retention. The free plan keeps inbox mail for 30 days. If a "where's my freight?" exchange needs to live in your audit trail longer than that, copy it into your own store when you process it — don't treat the mailbox as the system of record.
  • Monitor deliverability. Agent Accounts emit message.delivered, message.bounced, and message.complaint webhooks for outbound mail. Wire those into your shipment records: if a customer's address hard-bounces, flag the contact so dispatch knows the automated updates aren't landing.

What's next

Drive the milestones from your TMS, answer the exceptions from your shipment data, and let Nylas own the mailbox in between — and "where is my freight?" stops being a support ticket and becomes a reply the agent handles before anyone picks up the phone.

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)