허동연님 문의 후속 분석 — "네이버 회신이 이전 기록과 연결 안 되는 케이스" 데이터 추적
같은 outbound 메일에 대해 회신이 2번 이상 오면, 두 번째 회신이 첫 번째 회신의
email_replies 매핑을 덮어쓰며 사라지게 만듭니다. 결과: 첫
회신은 inbox UI 에서 "이전 기록"으로 보이지 않습니다.
elysia-server/src/services/unipile-reply.service.ts:218-258
| 분류 | 건수 | 비율 | 설명 |
|---|---|---|---|
| 정상 매핑 | 173 | 95.1% | In-Reply-To 헤더 매칭 성공 |
| 매핑 손실 | 7 | 3.8% | In-Reply-To 있고 outbound 도 DB에 있는데 매핑이 사라짐 ← 버그 |
| In-Reply-To 없음 | 2 | 1.1% | 사용자가 "새 메일"로 답장한 경우 (헤더 자체 없음) |
네이버는 RFC 5322 표준을 잘 준수합니다 (173/182 = 95% 에서 In-Reply-To 정상 전송). 문제는 헤더가 아니라 서버 매핑 코드에 있습니다.
7건 모두 in_reply_to 가 outbound message_id 와
정확히 일치합니다. 즉, 우리 코드가 매칭만 했다면 100% 연결될 수 있는
케이스. 그러나 매핑 행이 사라졌습니다.
| 회신자 (네이버) | 매핑 잃은 회신 시각 | 덮어쓴 회신 시각 | 시간차 |
|---|---|---|---|
| yumore_25@naver.com | 4-15 18:52:12 | 4-15 18:52:54 | 42초 후 |
| mybestlife4u@naver.com | 5-08 02:15:06 | 5-08 02:16:01 | 55초 후 |
| gounjae@naver.com | 4-24 04:47:01 | 4-24 04:48:25 | 84초 후 |
| thedajeon@naver.com | 4-26 11:41:44 | 4-29 06:24:25 | 2.8일 후 |
| eowiz@naver.com | 4-21 01:51:03 | 5-15 03:52:34 | 24일 후 |
나머지 2건은 outbound 가 DB 에 없는 케이스 (다른 워크스페이스에서 발송했거나, BCC 로 받은 메일에 답장한 경우 등).
한 스레드에 6개 메일이 모두 정상 저장되어 있지만, UI에는 첫 회신이 안 보입니다.
관찰: 6개 메일 모두 동일 thread_id (
<010c019dd50982ba-1022d4c9-...amazonses.com> ) 로 잘 묶여 있음. 즉
스레드 수준은 정상이지만, email_replies 매핑 수준에서 IN 1 이 사라짐.
허동연님이 보고한 "이전 기록 안 보임" 의 정확한 원인.
unipile-reply.service.ts:214-258
코드 버그
// 5. Create or update email_replies record
const existingReplyResults = await db
.select({ id: emailReplies.id })
.from(emailReplies)
.where(eq(emailReplies.originalEmailId, originalEmail.id)) // ← 원본별 1행만 검색
.limit(1)
const existingReply = existingReplyResults[0]
if (existingReply) {
// Update existing reply with the LATEST reply email
const [updated] = await db
.update(emailReplies)
.set({
replyEmailId: inboundEmail.id, // ← 새 inbound id로 덮어쓰기!
intent: null,
sentiment: null,
})
.where(eq(emailReplies.id, existingReply.id))
emailReply = updated
} else {
// Create new email_replies record
await db.insert(emailReplies).values({ ... })
}
unipile-webhook-handler.service.ts:279 (mail_received) 와
email-event.service.ts:346 (saveInboundReply) 는 INSERT 만 함. 그러나
email.replied 이벤트가 따로 와서 위 코드가 실행되면 덮어씀.
네이버 lead 22,187명 (20%) 이 2~3개 thread 로 분산되어 있음:
| thread 분산 정도 | lead 수 | 비율 |
|---|---|---|
| 1 thread (정상) | 90,464 | 80.3% |
| 2-3 threads | 22,187 | 19.7% |
| 4-6 threads | 12 | ~0% |
| 7+ threads | 3 | ~0% |
원인: Sequence step 마다 새 outbound 가 발송되며, 발송 시 새
message_id + 새 thread_id 가 생성됨. 같은 lead 라도 step 별로
다른 thread.
영향: Lead 가 step 3 에 답장하면 step 1~2 의 outbound 는 inbox 에서 "같은 대화"로 묶이지 않음 — UI 가 thread_id 단위라면.
완화책: Sequence step 발송 시 첫 step 의 thread_id 를
이어받기 (이미 일부 코드는 그렇게 되어있지만 메일 클라이언트별로 차이). 또는 inbox UI 가
lead_id 단위로 그룹화하도록 변경.
| 패턴 | 네이버 영향 | 원인 | 해결 |
|---|---|---|---|
| A. 멀티-회신 덮어쓰기 | 5건 / 60일 | unipile-reply.service.ts UPDATE 로직 |
UPDATE → INSERT 변경 + dedupe by (original, reply_email_id) |
| B. outbound 없는 회신 | 2건 / 60일 | BCC, 외부 발송에 답장한 케이스 | 현재 보류 (사용자가 잘못 보낸 케이스) |
| C. 새 메일로 답장 | 2건 / 60일 | In-Reply-To 헤더 자체 없음 | from_email + Subject fuzzy 매칭 fallback |
| D. Sequence step thread 분리 | 22K lead 영향 | step 별 새 thread_id 생성 | inbox UI 를 lead_id 단위 그룹으로 변경 |
INSERT INTO email_replies (workspace_id, original_email_id, reply_email_id, is_read)
SELECT
out.workspace_id, out.id, inb.id, false
FROM emails inb
JOIN emails out
ON out.message_id = inb.in_reply_to
AND out.direction = 'outbound'
WHERE inb.direction = 'inbound'
AND inb.in_reply_to IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM email_replies r
WHERE r.original_email_id = out.id
AND r.reply_email_id = inb.id
);
email_replies 에 (original, reply) unique 가 없다면 모든 회신을 복구. 단,
email_replies.original_email_id 에만 의존하는 UI 가 있으면 1행만
노출되므로 ②번 패치 동반 필수.
unipile-reply.service.ts UPSERT → INSERT 로 변경// AFTER
const existingResults = await db
.select({ id: emailReplies.id })
.from(emailReplies)
.where(and(
eq(emailReplies.originalEmailId, originalEmail.id),
eq(emailReplies.replyEmailId, inboundEmail.id), // ← reply_email_id 까지 매칭
))
.limit(1)
if (existingResults[0]) {
// 정말 중복 webhook (같은 reply 두 번 처리) — skip
emailReply = existingResults[0]
} else {
// 새 회신 — 무조건 INSERT
const [inserted] = await db
.insert(emailReplies)
.values({ ... })
.returning({ id: emailReplies.id })
emailReply = inserted
}
email_replies 가 N 행 있을 때 UI 가 모두 표시하는지 검증. 만약 UI 가
SELECT ... FROM email_replies WHERE original_email_id = ? LIMIT 1 같은
단일 행 조회를 한다면 ②번 패치 후에도 효과 없음.