열람 및 클릭 추적
GET /tracking: 메시지를 읽었는지, 무엇을 눌렀는지.
이 페이지의 6개 호출을 본인 키로 워크스페이스에 실제로 실행합니다.
무엇이 기록되는가
서로 독립적인 두 개의 스위치이며, 메시지를 보내는 주소나 All addresses에서 꺼 두지 않았다면 둘 다 켜져 있습니다. opens는 1×1 이미지를 덧붙이고, clicks는 본문의 새로 쓴 부분에 있는 링크를 다시 씁니다. 답장 아래 인용된 이력은 다른 사람의 메시지이므로 건드리지 않습니다. 발송 시 tracking: { opens, clicks }를 지정해 메시지 하나에 대해 결정할 수 있으며(양방향으로 가능하므로 false는 프로그램이 주소의 설정을 거절하는 방법입니다), 생략한 필드는 발송 주소의 설정, 그다음 All addresses의 설정으로 돌아갑니다. 이 API가 워크스페이스를 대신해 고른 기본값으로 돌아가지 않습니다.
{ "from": "Acme Billing <[email protected]>", "to": ["[email protected]"], "subject": "Your September invoice", "html": "<p>Invoice attached.</p>", "tracking": { "opens": true, "clicks": true } }메시지당 최대 100개의 목적지만, 각각 한 번씩 다시 쓰입니다. 헤더 이미지와 버튼과 푸터에서 링크된 같은 URL은 한 행입니다. 같은 질문을 세 번 한 것이기 때문입니다. 상한을 넘은 나머지 링크는 쓰인 그대로 남습니다. 추적되지 않는 링크도 여전히 동작하며, 메시지가 마지막 200개의 링크를 조용히 잃어버리는 것은 불완전한 리포트보다 훨씬 나쁜 실패입니다.
다시 쓰인 링크와 픽셀은 기본적으로 OpenEmail API 호스트를 가리킵니다. 발송 도메인에 tracking.status가 active인 커스텀 추적 도메인이 있으면, 그 도메인에서 나가는 새 메일은 대신 https://<추적 호스트>/t/...를 사용하며, 설정은 PATCH /domains/{id}에서 합니다.
이 모든 것에 emails:read가 필요하며, 별도의 추적 스코프는 없습니다. 그 스코프는 이미 "발송된 메시지와 그 전달 상태를 읽는다"는 뜻이고, 누군가 메시지를 열었는지는 가장 문자 그대로의 전달 상태입니다.
엔드포인트
| 호출 | 반환 내용 |
|---|---|
| `GET /tracking` | 추적된 메시지를 최신순으로. opened, clicked, days(1–365, 기본 30), limit(최대 200). |
| `GET /tracking/stats` | 기간에 대한 비율. days(기본 30)와 offsetMinutes를 받아, 하루가 읽는 사람의 하루 기준으로 나뉩니다. |
| `GET /tracking/{id}` | 리포트 하나. tmsg_ 추적 id나 발송이 반환한 msg_ id를 받습니다. |
| `GET /tracking/{id}/opens` | 개별 요청 기록. includeMachine, limit(최대 200). |
| `GET /tracking/{id}/clicks` | 위와 같되 각 행에 linkId와 url이 붙습니다. |
| `GET /emails/{id}/tracking` | 같은 리포트를, 이미 갖고 있는 발송 id로 조회합니다. |
쿼리 문자열의 불리언은 true, false, 1, 0으로 명시해야 하며 그 밖의 값은 거부됩니다. Boolean("false")는 true이므로, 강제 변환된 ?opened=false는 요청한 것과 정반대의 결과를 돌려주게 됩니다.
이것이 /emails의 몇몇 필드가 아니라 별도의 리소스인 이유는 포괄 범위 때문입니다. 그 목록은 발송 기록을 담는데, 작성기와 MCP 도구와 어시스턴트는 모두 발송 기록을 남기지 않고 메일을 보냅니다. 그 위에 세운 리포트는 메일함이 아니라 여러분의 API 트래픽에 대한 리포트가 됩니다.
리포트
{ "object": "tracking", "id": "tmsg_9c1f7b2e4a5d40b8a3e61d2f", "sendId": "msg_c5f21cc6bfec4e848caf905b", "threadId": "thread_2f9b…", "messageId": "<2598…@acme.com>", "subject": "Your September invoice", "from": "[email protected]", "source": "api", "sentAt": "2026-08-29T08:19:08.000Z", "opens": true, "clicks": true, "opened": true, "clicked": true, "attributable": true, "openCount": 3, "openCountRaw": 7, "clickCount": 1, "clickCountRaw": 2, "firstOpenAt": "2026-08-29T09:04:11.000Z", "lastOpenAt": "2026-08-30T07:42:55.000Z", "firstClickAt": "2026-08-29T09:05:02.000Z", "lastClickAt": "2026-08-29T09:05:02.000Z", "recipients": [ { "email": "[email protected]", "kind": "to", "attributed": true, "openCount": 3, "clickCount": 1, "firstOpenAt": "2026-08-29T09:04:11.000Z", "lastOpenAt": "2026-08-30T07:42:55.000Z", "firstClickAt": "2026-08-29T09:05:02.000Z", "lastClickAt": "2026-08-29T09:05:02.000Z" } ], "links": [ { "id": "lnk_4f0a1c8d29b74e6fa3c05d17", "url": "https://acme.com/invoices/42", "label": "View invoice", "clickCount": 1, "clickCountRaw": 2 } ] }opens와 clicks는 그 메시지에 적용된 설정이고, opened와 clicked는 실제로 일어난 일입니다. openCount는 읽힌 횟수를, openCountRaw는 이미지 요청 횟수를 셉니다. 여기서 4인 그 차이는 스캐너와 프라이버시 프록시이며, 로그와 총계 사이의 간극이 설명되지 않은 채 남지 않고 들여다볼 수 있도록 보존해 둡니다. 누군가의 이름을 대기 전에 읽어야 할 필드는 attributable입니다. false는 해당 열람이 목록 전체에 나간 사본에서 발생했다는 뜻이고, 그 뒤에 특정 수신자에 대해 하는 모든 말은 추측입니다.
source는 그것을 보낸 표면을 가리킵니다. 이 API를 통한 발송은 api, 앱 자체가 보낸 모든 것은 composer입니다. 두 번째 경우 sendId는 null인데, 추적 id가 존재하는 이유가 바로 그것입니다.
email이 null이고 attributed: false인 행은 특정인에게 귀속시킬 수 없는 열람이 떨어지는 자리이며, 실제로 그런 열람이 있었을 때만 리포트에 나타납니다. 수신자가 한 명인 메시지에는 아예 없는데, 본문 하나와 수신자 한 명은 같은 진술이기 때문입니다. 수신자가 여럿인 메시지는 나가는 순간부터 그 행을 뒤에 두고 있습니다. 전송은 발송 시점까지 확정되지 않기 때문이며, 무언가가 도착하기 전까지는 리포트에 드러나지 않습니다. 이름이 적힌 수신자들 옆에 "누군가: 열지 않음"이 영구히 붙어 있다면 그 행은 오해만 낳습니다. 이 행이 있는 경우 이름이 적힌 행들은 0에 머물러 있고 attributable은 false입니다. 열람은 실재하고, 읽은 사람은 이 메시지에 포함된 사람 중 하나이며, "이 메시지의 누군가"가 데이터가 뒷받침하는 유일한 표현입니다. 수신자 목록에서 이름을 골라 채워 넣지 마세요.
기간에 대한 비율
{ "object": "tracking_stats", "tracked": 128, "trackedForOpens": 128, "trackedForClicks": 47, "opened": 91, "clicked": 34, "openRate": 71.1, "clickRate": 72.3, "totalOpens": 240, "totalClicks": 52, "machineOpens": 173, "medianTimeToOpenSeconds": 2714, "byDay": [{ "day": "2026-08-27", "sent": 12, "opened": 9, "clicked": 3 }], "topLinks": [{ "url": "https://acme.com/pricing", "label": "See pricing", "clickCount": 18 }], "clients": [{ "client": "Gmail", "count": 96 }], "countries": [{ "country": "GB", "count": 71 }] }비율은 발송한 모든 메일이 아니라 추적된 메시지에 대한 백분율입니다. 열 통 중 한 통을 추적하는 워크스페이스는 그 열 통에 대한 열람률을 갖는 것이며, 지금까지 보낸 전체로 나눈다면 누군가 추적하지 않는 답장을 보낼 때마다 수치가 떨어질 것입니다. 다섯 번 열린 메시지는 열린 메시지 하나입니다. 비율은 메시지를 세고 총계는 히트를 세며, 이 둘을 뒤섞는 것이 100%가 넘는 열람률이 발표되는 경로입니다.
byDay는 희소합니다. 아무것도 추적되지 않은 날은 0이 아니라 아예 없으므로, 차트로 그리기 전에 빈 날을 채우세요. 하루는 UTC 기준 동쪽으로 offsetMinutes(−840~840)만큼 이동해 구간이 나뉘므로, 읽는 사람의 하루와 같은 지점에서 끊깁니다. medianTimeToOpenSeconds는 평균이 아니라 중앙값인데, 3주 뒤에 열린 메시지 하나가 평균을 어떤 메시지도 실제로 있지 않은 지점으로 끌고 가기 때문입니다.
개별 히트
{ "object": "list", "data": [ { "object": "open", "id": "opn_1a7c…", "trackedMessageId": "tmsg_9c1f7b2e4a5d40b8a3e61d2f", "recipient": "[email protected]", "kind": "machine", "counted": false, "client": "Apple Mail Privacy Protection", "device": "unknown", "os": "macOS", "country": "GB", "region": "England", "city": "London", "createdAt": "2026-08-29T08:19:11.000Z" } ] }kind는 human, proxy, machine 중 하나이고, counted는 그 히트가 수치를 움직였는지 알려 줍니다. 기계 히트는 includeMachine=true를 전달하지 않는 한 제외되며, 이것이 정직한 기본값입니다. 기계 히트를 기록하는 이유는 그것이 참여도여서가 아니라, 버리면 설명할 수 없는 간극이 남기 때문입니다.
위치 정보가 성긴 이유는 그것이 가진 전부이기 때문입니다. 어떤 히트에 대해서도 IP 주소는 저장하지 않습니다. 국가, 지역, 도시는 엣지가 이미 알고 있던 정보이고, 그 밖에 보관하는 식별자는 매일 솔트가 바뀌는 해시 하나뿐이라, 하루 안에서는 두 요청을 구분할 수 있지만 다음 날에는 아무 쓸모가 없습니다.
숫자가 말할 수 없는 것
- Apple Mail 개인 정보 보호 기능은 누가 보든 말든 전달 시점에 모든 메시지의 모든 이미지를 가져옵니다. User-Agent와 네트워크로 분류해
machine으로 기록하며, 발송 후 10초 이내에 도착하는 것도 마찬가지입니다. 사람이 하는 행동은 그렇게 빠를 수 없기 때문입니다. - Gmail의 이미지 프록시는
machine이 아니라proxy입니다. 누군가 메시지를 표시했으므로 열람은 실재하지만, 기기와 클라이언트와 위치는 알 수 없습니다. 이 프록시는 캐시도 하므로 두 번째 열람은 아예 우리에게 도달하지 않을 수 있습니다. Gmail을 통한 집계는 항상 총계가 아니라 하한선입니다. - 같은 사본을 30초 안에 두 번 가져오면 한 번의 열람입니다. 미리보기 창이 다시 그려지거나 메시지를 스크롤해 다시 보면 이미지를 새로 가져오지만, 한 시간 뒤의 진짜 두 번째 방문은 여전히 집계됩니다.
- 수신자의 이름을 특정하려면 사람별로 다시 만들 수 있을 만큼 작은 메시지여야 합니다. 추정 크기에 수신자 수를 곱한 값이 8MB 미만이어야 합니다. 그보다 크면 본문 하나가 모두에게 나가고, 그에 대한 모든 히트는 귀속되지 않습니다.
- 클릭은 있는데 열람이 없는 메시지는 분명히 읽힌 것입니다. 링크를 누르지 않는 경우보다 이미지가 차단되는 경우가 훨씬 많습니다. 두 카운터를 더하지 말고 따로 읽으세요.
- 링크가 없는 본문에 클릭 추적을 요청하면 아무것도 기록되지 않습니다. 나간 바이트는 추적하지 않은 발송과 동일하며, 그와 다르게 주장하는 행은 무엇과도 대조할 수 없습니다. 다시 쓸 본문이 아예 없는 메시지도 마찬가지입니다.
- OpenEmail은 자기 사용자가 읽는 메일에서 1×1 이미지를 제거하며, 여기에는 우리가 보낸 픽셀도 포함됩니다. 대신 이미지를 표시한 상태로 메시지가 열리면 열람을 직접 기록합니다. 그 히트는 클라이언트가
OpenEmail인human입니다. 이미지를 숨긴 상태라면 아무것도 기록되지 않습니다.
GET /tracking/{id}와 GET /emails/{id}/tracking은 한 번도 추적된 적 없는 메시지에 대해 빈 리포트가 아니라 404를 응답합니다. "우리는 아무것도 기록하지 않았다"와 "아무도 열지 않았다"는 서로 다른 답이며 하나의 응답을 공유해서는 안 됩니다. 목록 엔드포인트는 추적된 메시지만 담으므로, 추적되지 않은 메시지는 0으로 채워져 나타나는 대신 그냥 목록에 없습니다.
묻지 말고 통지받기
집계된 열람은 구독 중인 모든 엔드포인트에 email.opened를, 집계된 클릭은 email.clicked를 발생시키며, 이 API를 거쳐 나간 메시지라면 둘 다 그 메시지의 이벤트 이력에도 기록됩니다. 스캐너나 프라이버시 프록시에 대해서는 어느 쪽도 발생하지 않습니다. 그것까지 밀어 보내면 분류기가 애초에 수치에서 걸러 내려던 바로 그 트래픽으로 수신자의 로그가 가득 찰 것입니다.
다운로드 링크로 나간 파일도 같은 방식으로 보고됩니다. 집계된 다운로드는 email.downloaded를 발생시키고 같은 이력에 기록되며, 같은 분류기가 스캐너와 링크 미리보기를 걸러 내므로 그 수치는 사람입니다. 페이로드에는 파일 정보(shareId, fileId, filename, mimeType, sizeBytes, url)와 downloadCount, first, downloadedAt이, 클릭이 담는 클라이언트 및 위치 필드와 함께 담깁니다. recipient는 항상 null이고 attributed는 항상 false입니다. 다운로드 링크는 메시지의 모든 수신자에게 동일한 URL 하나이므로, 다운로드를 그중 한 명에게 귀속시킬 수 없습니다.
SDK에서
const report = await openemail.tracking.get('msg_c5f21cc6bfec…')const cold = await openemail.tracking.list({ days: 30, opened: false })const stats = await openemail.tracking.getStats({ days: 30, offsetMinutes: -new Date().getTimezoneOffset(),})여기의 모든 호출은 단순한 읽기이며, 클라이언트는 각각을 개별적으로 재시도합니다. get은 한 번도 추적된 적 없는 메시지에 대해 isNotFound가 true인 OpenEmailApiError를 던지는데, 이 구분은 결과를 무엇에 넣든 보존할 가치가 있습니다.