자체 수신함을 가진 에이전트로 '내 주문 어디 있나요?' 이메일에 답변하기
요약
이커머스 고객 지원을 자동화하기 위해 자체 수신함을 가진 에이전트 계정(Agent Account)을 구축하는 방법을 다룹니다. Nylas의 grant 추상화 계층을 활용하여 사람이 개입하지 않고도 주문 상태 조회 및 이메일 답장을 처리하는 프로그래밍 방식의 사서함 구현을 설명합니다.
핵심 포인트
- 결정론적 작업(주문 조회 등)을 통한 이커머스 CS 자동화 가능성
- Nylas의 grant 추상화 계층을 이용한 에이전트 전용 사서함 구축
- 개인 OAuth에 종속되지 않는 프로그래밍 방식의 이메일 처리
- 기존 API 엔드포인트를 그대로 활용한 에이전트 워크플로우 구현
세일 기간 동안 어떤 이커머스(ecommerce) 고객 지원 수신함을 열어보더라도 메시지 수를 세어보십시오. 그중 아주 큰 비중을 차지하는—보통 가장 큰 단일 비중을 차지하는—메시지들은 백 가지 다른 방식으로 똑같은 말을 합니다: "내 주문 어디 있나요?" "이거 발송되었나요?" "운송장 번호는요?" "5일이나 지났어요." 이것들은 어려운 질문이 아닙니다. 운송업체가 패키지를 스캔하는 즉시 답은 이미 귀하의 데이터베이스(database)에 존재합니다. 사람이 각 메시지를 읽고 운송장 번호를 복사하여 붙여넣는 유일한 이유는 질문이 API 호출 대신 이메일로 도착했기 때문입니다.
이 부분은 완전히 자동화가 가능하며, 이커머스 고객 지원 전체에서 가장 깔끔한 에이전트(agent) 작업입니다. 왜냐하면 조회(lookup) 과정이 결정론적(deterministic)이기 때문입니다. 환불 정책이나 파손된 품목에 대해 추론하는 것이 아닙니다. 발신자(또는 주문 번호)를 행(row)과 매칭하고, 운송장 필드를 읽고, 답장을 보내는 것입니다. 그래서 이 포스트는 정확히 그것을 연결합니다: 주문 상태 이메일을 처리하고, 귀하의 시스템에서 주문을 조회하며, 귀하의 상점 자체 주소로 동일한 스레드(thread) 내에서 답장하는 **에이전트 계정(Agent Account)**입니다. 매칭할 수 없는 모든 것은—솔직하게, 그리고 의도적으로—사람에게 전달됩니다.
대부분의 "AI 이메일" 데모는 모델을 사람의 수신함에 연결하여 사람이 여전히 승인해야 하는 초안 제안을 작성하게 합니다. 그것은 비서(assistant)로서는 괜찮습니다. 하지만 에이전트가 직접 참여자가 되기를 원할 때—즉, orders@yourstore.com으로 메일을 받고, 해당 주소로 답장하며, 일상적인 80%의 작업에 대해 사람의 개입(human in the loop) 없이 스레드를 온전하게 유지하기를 원할 때는 잘못된 형태입니다.
에이전트 계정(Agent Account)이란 실제로 무엇인가
에이전트 계정(Agent Account)은 Nylas **grant (권한 부여)**입니다. 이는 연결된 Gmail 또는 Microsoft 사서함을 지원하는 것과 동일한 grant_id 추상화 계층입니다. 차이점은 실제 개인의 OAuth 로그인에 종속되지 않는다는 것입니다. 대신 사용자가 제어하는 도메인 상의 프로그래밍 방식 사서함(programmatic mailbox)입니다. 이 세부 사항은 생각보다 중요합니다. 왜냐하면 데이터 평면(data plane)에서 새로 배울 것이 없다는 것을 의미하기 때문입니다. 이미 알고 있는 모든 grant 범위의 엔드포인트 — Messages, Threads, Folders, Drafts, Attachments — 가 동일하게 작동합니다. 에이전트는 GET /v3/grants/{grant_id}/messages로 읽고, POST /v3/grants/{grant_id}/messages/send로 전송하며, 사용자는 리프레시 토큰(refresh token)을 전혀 다룰 필요가 없습니다.
저는 Nylas CLI를 담당하고 있으므로, 아래의 터미널 명령어는 제가 서비스 코드를 작성하기 전에 프로토타입을 만들 때 실제로 사용하는 명령어들입니다. 계정을 생성하려면:
nylas agent account create orders@yourstore.com --name "Store Orders Bot"
이렇게 하면 등록된 도메인에 provider=nylas grant가 프로비저닝(provision)되며, API가 이를 위한 기본 워크스페이스(workspace)와 정책(policy)을 자동으로 생성합니다. HTTP를 통한 동일한 작업은 POST /v3/connect/custom입니다:
curl -X POST "https://api.us.nylas.com/v3/connect/custom" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
...
그러면 grant_id가 반환됩니다. 이를 영구적으로 저장(persist)하세요. 이것이 여러분의 전송 및 읽기 코드에 필요한 유일한 자격 증명(credential)입니다.
에이전트 계정이 처음이신가요? 에이전트에 자체 이메일 부여하기와 에이전트 계정 개요를 먼저 확인한 후 다시 돌아오세요.
시작하기 전에
다음 세 가지가 준비되어 있어야 합니다:
- 등록된 도메인 (A registered domain) — 커스텀 도메인 또는 Nylas의
*.nylas.email체험판 서브도메인입니다. 새로운 도메인은 약 4주에 걸쳐 워밍업(warm up) 기간이 필요하므로, 첫날부터 완전히 새로운 도메인을 깜짝 세일(flash sale)에 연결하지 마세요. - 주문 조회 기능 (Your order lookup) — 이메일 주소나 주문 번호를 입력받아 귀하의 데이터베이스에서 상태와 운송장 번호를 반환하는 함수입니다. Nylas는 귀하의 주문 정보를 저장하지 않습니다. 이 부분은 귀하가 직접 관리해야 합니다.
- 웹훅 엔드포인트 (A webhook endpoint) — HTTP POST 요청을 수신할 수 있는 공개 URL입니다. 로컬 개발을 위해 CLI에서 터널(tunnel)을 제공하며, 이에 대한 자세한 내용은 아래에서 설명합니다.
코드를 작성하기 전에 한 가지 분명히 짚고 넘어갈 점이 있습니다. 주문 ↔ 스레드(thread) 매핑은 Nylas가 아닌 귀하의 데이터베이스에 저장됩니다. 권한 부여(grant)에는 지속적인 주문 키로 유지되는 커스텀 메타데이터(custom-metadata) 필드가 없습니다. 에이전트가 메시지를 보내거나 확인할 때, 귀하의 테이블에 해당 주문과 함께 thread_id를 저장하세요. 그것이 전체 대화를 연결하는 조인 키(join key)가 됩니다.
인바운드 질문 수신하기
curl -X POST "https://api.us.nylas.com/v3/webhooks" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
...
또는 CLI를 사용하고, 개발 중에 실제 이벤트를 모니터링할 수 있도록 터널이 포함된 로컬 서버를 사용할 수도 있습니다:
nylas webhook create --url https://api.yourstore.com/hooks/orders --triggers message.created
nylas webhook server --port 4000 --tunnel cloudflared --secret "$NYLAS_WEBHOOK_SECRET"
페이로드(payload)를 신뢰하기 전에 반드시 서명(signature)을 검증하세요. Nylas는 웹훅 비밀키(webhook secret)를 사용하여 원시(raw) 요청 본문의 HMAC-SHA256 헥사(hex) 값을 X-Nylas-Signature 헤더로 각 웹훅에 서명합니다. CLI는 로컬에서 하나를 검증할 수 있습니다:
nylas webhook verify \
--payload-file ./event.json \
--signature "$SIG_FROM_HEADER" \
...
핸들러(handler) 내에서 직접 HMAC를 계산하고 상수 시간(constant time) 내에 비교하세요. 제가 겪었던 지뢰(landmine) 중 하나는, Node.js의 crypto.timingSafeEqual은 두 버퍼의 길이가 다르면 오류를 발생시킨다는 점입니다. 따라서 먼저 길이를 확인하는 방어 코드를 작성해야 합니다.
import crypto from "node:crypto";
function verifySignature(rawBody, signature, secret) {
...
이제 중복을 제거(dedup)합니다. API는 최소 한 번(at-least-once) 전달을 보장하므로, 동일한 이벤트가 최대 세 번 나타날 수 있습니다. 하나의 이벤트에 대한 모든 재시도 과정에서 일정하게 유지되는 **최상위 알림 id (top-level notification id)**를 기준으로 중복을 제거하세요. 이것이 전달 중복 제거 키(delivery dedup key)입니다. 추가적으로 내부의 data.object.id (메시지 ID)를 사용하여 보호하면, 서로 다른 이벤트 사이에서도 동일한 메시지에 대해 두 번 동작하는 일을 방지할 수 있습니다.
app.post("/hooks/orders", express.raw({ type: "*/*" }), async (req, res) => {
const raw = req.body; // Buffer — HMAC을 위해 원본 상태를 유지합니다
if (!verifySignature(raw, req.get("X-Nylas-Signature"), SECRET)) {
...
핸들러(handler)를 가볍게 유지하세요: 검증(verify), 승인(ack), 큐(queue)에 참조를 넣기, 그리고 반환(return)만 수행합니다. 비용이 많이 드는 모든 작업은 워커(worker)에서 처리합니다.
실제 질문 읽기
웹훅(webhook)은 메시지가 도착했음을 알려주지만, 고객이 무엇을 물었는지 읽으려면 본문(body)이 필요합니다. 본문을 웹훅 페이로드(payload)에 의존하지 마세요. Nylas 문서에 따르면 본문이 인라인(inline)으로 포함되는지 여부가 다를 수 있는데, 어떤 경우든 안전한 방법은 ID를 통해 전체 메시지를 가져오는 것입니다. 또한 message.created.truncated를 기준으로 분기 처리하세요. 이는 본문이 약 1MB를 초과하여 페이로드에서 생략되었을 때 Nylas가 보내는 유형으로, 다시 가져오라는(re-fetch) 신호입니다.
GET /v3/grants/{grant_id}/messages/{message_id}를 사용하여 HTTP로 가져옵니다:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
터미널에서는 nylas email read가 동일한 역할을 수행하며 파싱된 본문을 출력합니다:
nylas email read <message-id> <grant-id>
이를 통해 from, subject, 그리고 파싱된 body를 얻을 수 있습니다. 만약 고객이 세 번이나 메일을 보낸 경우처럼 지금까지의 대화 내용이 필요하다면 스레드(thread)를 가져오세요. HTTP를 통한 방법은 GET /v3/grants/{grant_id}/threads/{thread_id}입니다:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
터미널에서는 다음과 같습니다:
nylas email threads show <thread-id> <grant-id>
이제 Nylas가 아닌 여러분의 몫: 이것이 어떤 주문에 관한 것인지 파악해야 합니다. 자체적인 정규 표현식 (regex) 또는 LLM 호출을 사용하여 제목과 본문에서 주문 번호를 추출하세요. 주문 번호는 제목 줄에 수십 가지 형식(#5821, ORD-5821, "order 5821")으로 존재하므로, 작은 추출 단계를 거치는 것만으로도 충분한 가치를 합니다. 그런 다음 여러분의 데이터베이스와 대조하여 확인하세요. 먼저 발신자의 이메일로 매칭을 시도하고, 실패할 경우 추출된 주문 번호를 보조 수단으로 사용하세요. 그리고 무엇을 추출하든 실제로 조치를 취하기 전에 반드시 여러분의 기록과 대조하여 검증하십시오. 고객은 이메일에 어떤 내용이든 입력할 수 있습니다. 본문을 신뢰할 수 없는 입력값 (untrusted input)으로 취급하세요.
async function lookupOrder(message) {
const orderNo = extractOrderNumber(message.subject, message.body); // 여러분의 regex/LLM
// 여러분의 주문 DB와 대조 — 발신자 우선, 주문 번호는 보조 수단.
...
송장 번호와 함께 스레드 내에서 답장하기
조회 결과 확실한 매칭이 발견되면 답장을 보냅니다. 매번 새로운 이메일을 쏘아대는 로봇처럼 보이지 않게 만드는 단 하나의 규칙은 reply_to_message_id를 전달하는 것입니다. 이를 통해 In-Reply-To 및 References 헤더가 설정되어, 답장이 고객의 메일 클라이언트에서 orders@yourstore.com으로부터 온 동일한 제목의 같은 스레드 내에 위치하게 됩니다.
HTTP를 사용할 경우, 원본 메시지 ID와 함께 POST /v3/grants/{grant_id}/messages/send를 호출합니다:
curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
...
CLI 버전은 단일 명령어로 가능합니다. 원본 메시지를 가져와 수신자와 제목을 채우고, 스레딩 (threading)을 자동으로 유지해 줍니다:
nylas email reply <message-id> <grant-id> \
--body "좋은 소식입니다 — 주문 #5821이 발송되었습니다. 송장 번호: 1Z999AA10123456784 (UPS): https://yourstore.com/track/5821"
이것이 전체적인 해피 패스 (happy path)입니다. 웹훅 (Webhook)이 들어오면, 본문을 가져오고, 주문을 조회한 뒤, 스레드 내에서 송장 번호와 함께 답장을 보냅니다. 고객은 새벽 2시든, 깜짝 세일 중이든, 아무도 깨어 있지 않은 상황에서도 몇 초 만에 여러분의 상점으로부터 온 정상적인 스레드 형태의 답변을 받게 됩니다.
에이전트가 답변할 수 없을 때, 사람에게 에스컬레이션(Escalate)하기
이 부분은 데모에서 생략되곤 하지만, 단순히 출시하는 기능과, 출시했다가 '첫 번째 화난 고객'이 발생한 직후에 꺼버리게 되는 기능 사이의 차이를 만드는 핵심적인 부분입니다. 에이전트는 확신이 있을 때만 자동 응답(Auto-reply)을 해야 합니다. 그 외의 모든 상황은 사람에게 전달되어야 하며, 사람이 대기 중임을 알 수 있도록 가시적으로 처리되어야 합니다.
전달(Hand off)해야 하는 케이스:
- 일치하는 주문이 없음. 발신자가 시스템에 등록되어 있지 않거나 사용할 수 있는 주문 번호가 없는 경우입니다. 추측하는 것은 기다리는 것보다 상황을 더 악화시킵니다.
- 상태가 명확한 "배송됨(Shipped)"이 아님. 지연, 분실, 반품 또는 환불 진행 중인 주문은 운송장 번호를 붙여넣는 것이 아니라 대화가 필요한 영역입니다.
- 질문이 실제로는 상태에 관한 것이 아님. "제 주문은 어디 있나요? 그리고 사이즈도 틀려요"라는 질문은 두 가지 문제이며, 두 번째 문제는 사람이 해결해야 합니다.
에스컬레이션을 표면화하는 가장 깔끔한 방법은 메시지를 지원 팀이 이미 사용 중인 "사람의 확인 필요(Needs human)" 폴더로 이동시킨 다음, 사람이 인지할 수 있도록 읽지 않음(Unread) 상태로 표시하는 것입니다. 먼저 폴더 ID를 찾으세요. HTTP를 통한 요청은 GET /v3/grants/{grant_id}/folders입니다:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/folders" \
-H "Authorization: Bearer $NYLAS_API_KEY"
터미널에서:
nylas email folders list <grant-id> --id
이제 메시지를 이동시키고 읽지 않음으로 표시합니다. HTTP 상에서는 두 작업 모두 메시지에 대한 PUT 요청입니다. 이동은 folders 배열을 설정하고, 읽지 않음 표시는 unread: true를 설정합니다(읽음/읽지 않음 표시를 설정하는 것은 별도의 PUT이며, 본문을 읽기 위해 수행한 GET의 부수 효과로 발생하지 않습니다). 따라서 단일 요청으로 두 작업을 모두 수행할 수 있습니다:
curl -X PUT "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
...
터미널에서는 두 개의 명령어를 사용합니다. 하나는 이동을 위한 것이고, 하나는 읽지 않음 표시를 위한 것입니다:
nylas email move <message-id> <grant-id> --folder <needs-human-folder-id>
nylas email mark unread <message-id> <grant-id>
또한 앱 측에서 에이전트를 일시 중지할 수도 있습니다. 해당 스레드에 자체 상태(state) 플래그를 설정하여, 사람이 이를 해제할 때까지 워커(worker)가 건너뛰도록 설정하십시오. 어떤 방식이든 원칙은 동일합니다. 에이전트가 일상적인 대량 업무를 담당하며, 에이전트가 확신하지 못하는 순간에는 사람이 나머지를 담당합니다. 에이전트가 환불을 즉흥적으로 처리하게 두지 마십시오.
주의해야 할 몇 가지 사항
스레딩(Threading)은 reply_to_message_id에 의존합니다. 이 값을 누락하면
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기