Engineering

이메일 릴레이 시스템 만들어보기

SMTP 세션, 주소 토큰과 회신 파서를 연결해 이메일 릴레이 시스템을 설계합니다.

검증일 근거 자료

GitHub에서 풀 리퀘스트 리뷰 코멘트를 받으면 알림 메일이 온다. 이 메일에는 재미있는 성질이 하나 있는데, 메일 앱에서 그냥 답장을 쓰면 그 내용이 PR에 코멘트로 달린다는 것이다. GitHub을 열 필요도, 로그인할 필요도 없다. 지하철에서 폰으로 "네 반영하겠습니다"라고 답장을 보내면 몇 초 뒤 PR 타임라인에 같은 문장이 나타난다.

처음 이 기능을 접하면 잠깐 어리둥절해진다. 이메일은 40년도 더 된 프로토콜이고, 답장 버튼은 GitHub이 아니라 Gmail이 그리는 버튼이다. 그런데 어떻게 Gmail에서 보낸 답장이 GitHub 데이터베이스의 코멘트 테이블에 도착하는 걸까? Jira 이슈, Zendesk 문의 티켓, Basecamp 게시글도 전부 같은 재주를 부린다. 이메일이라는 낡은 우편 체계와 웹 서비스의 데이터베이스를 잇는 이 회로를 이 글에서는 이메일 릴레이 시스템이라고 부르겠다.

이번 글에서는 이 릴레이를 바닥부터 만들어본다. 시나리오는 실무에서 가장 흔한 형태인 헬프데스크다. 커머스 서비스의 고객 문의 티켓에 상담원이 답변을 달면 고객에게 알림 메일이 나가고, 고객이 그 메일에 답장하면 답장 내용이 티켓의 코멘트로 저장되는 시스템을 TypeScript로, 외부 라이브러리 없이 만든다. 메일을 받는 SMTP 서버부터 MIME 파싱, 위조 방지 토큰, 인용문 제거까지 전부 직접 구현한다. 글에 나오는 코드와 실행 로그는 모두 실제로 돌려서 확인한 것이다.

미리 말해두자면 실무에서 이걸 전부 직접 만들 일은 드물다. 받는 쪽은 AWS SES나 Mailgun 같은 서비스에 맡기는 편이 낫다. 그런데도 직접 만들어볼 가치가 있는 이유는, 릴레이 시스템이 이메일이라는 프로토콜의 거의 모든 지층을 한 번씩 통과하기 때문이다. DNS와 SMTP, 봉투와 편지지의 구분, MIME 인코딩, 스푸핑과 서명. 완성하고 나면 매일 받는 알림 메일의 헤더가 전혀 다르게 보일 것이다.

답장은 어디로 가는가

만들기 전에 먼저 완성품을 관찰하자. GitHub 알림 메일을 아무거나 열어 원문 보기(Gmail 기준 "Show original")를 누르면 헤더에서 이런 줄을 찾을 수 있다.

From: Sunhyoup Lee <notifications@github.com>
Reply-To: kciter/blog <reply+ABWMULQTBBNBK3...UDGE5F4@reply.github.com>

보낸 사람은 notifications@github.com인데, 답장 주소는 reply+로 시작하는 긴 난수 문자열이 붙은 reply.github.com의 주소다. 메일에는 원래 From과 별개로 "답장은 이쪽으로 보내달라"는 Reply-To 헤더를 넣을 수 있고, 메일 앱은 답장 버튼을 누르면 이 주소를 받는 사람으로 채운다. 즉 사용자가 답장을 쓰는 순간, 그 메일의 목적지는 Gmail도 GitHub 웹사이트도 아닌 reply.github.com이라는 도메인이 된다.

그 도메인으로 보낸 메일은 누가 받을까? DNS를 조회해보면 답이 나온다.

$ dig +short mx reply.github.com
30 in-5.smtp.github.com.
30 in-6.smtp.github.com.
30 in-7.smtp.github.com.
30 in-8.smtp.github.com.
30 in-9.smtp.github.com.
30 in-10.smtp.github.com.

in-5부터 in-10까지, GitHub이 직접 운영하는 메일 수신 서버들이다.1 답장 메일은 이 서버들 중 하나에 도착하고, 서버는 받는 주소의 reply+ 뒤에 붙은 문자열을 해석해 "이 답장은 어느 저장소의 몇 번 PR, 어느 사용자의 것"인지 알아낸 뒤, 메일 본문에서 새로 쓴 부분만 발라내 코멘트로 저장한다.

정리하면 릴레이의 전체 회로는 네 조각이다.

  1. 나가는 길: 알림 메일을 보낼 때 Reply-To에 "누구의 어느 티켓인지"를 식별할 수 있는 전용 주소를 심는다.
  2. 받는 문: 그 주소의 도메인으로 오는 메일을 받아주는 SMTP 서버를 세운다.
  3. 해석: 도착한 메일의 주소에서 식별 정보를 꺼내 검증하고, MIME으로 포장된 본문을 푼다.
  4. 정제: 본문에서 인용문과 시그니처를 걷어내고 새로 쓴 문장만 남겨 저장한다.

각 조각은 혼자 보면 별것 아닌데, 조각마다 40년 묵은 프로토콜의 함정이 하나씩 숨어 있다. 순서대로 만들어보자.

이메일이 도착하는 길

받는 문을 만들려면 먼저 이메일이 어떻게 배달되는지 알아야 한다. 웹 개발자에게 이메일은 대개 "보내는 것"이다. 회원가입 인증 메일을 SES API로 쏘아본 경험은 흔하지만, 받아본 경험은 드물다. 받는 쪽 세계는 생각보다 단순한 두 가지 부품으로 돌아간다. DNS와 SMTP다.

hanna.kim@gmail.com으로 메일을 보낸다고 하자. 보내는 서버가 가장 먼저 하는 일은 "gmail.com의 우편함은 어느 서버가 관리하는가"를 DNS에 묻는 것이다. 이 질문에 답하는 것이 MX 레코드(Mail eXchanger)다.

$ dig +short mx gmail.com
5 gmail-smtp-in.l.google.com.
10 alt1.gmail-smtp-in.l.google.com.
20 alt2.gmail-smtp-in.l.google.com.
30 alt3.gmail-smtp-in.l.google.com.
40 alt4.gmail-smtp-in.l.google.com.

앞의 숫자는 우선순위로, 작을수록 먼저 시도한다. 보내는 서버는 gmail-smtp-in.l.google.com의 25번 포트에 TCP로 접속해서 SMTP(Simple Mail Transfer Protocol)라는 프로토콜로 메일을 넘긴다. 1982년에 만들어진 이 프로토콜은2 이름값을 하는 몇 안 되는 프로토콜이다. 정말로 단순해서, 사람이 텔넷으로 접속해 손으로 쳐도 메일을 보낼 수 있다. 대화는 이런 식으로 흘러간다.

S: 220 gmail-smtp-in.l.google.com ESMTP ready
C: EHLO mail.example.com          ← 인사: 자신이 누구인지 밝힌다
S: 250 OK
C: MAIL FROM:<agent@example.com>  ← 봉투에 발신자를 적는다
S: 250 OK
C: RCPT TO:<hanna.kim@gmail.com>  ← 봉투에 수신자를 적는다
S: 250 OK
C: DATA                           ← 이제 내용물을 넘기겠다
S: 354 End data with <CR><LF>.<CR><LF>
C: (메일 원문 전체)
C: .                              ← 마침표 하나만 있는 줄 = 끝
S: 250 OK: queued
C: QUIT
S: 221 Bye

여기서 짚고 넘어가야 할 중요한 구분이 하나 있다. MAIL FROMRCPT TO로 주고받는 주소와, DATA로 넘기는 원문 안의 From:/To: 헤더는 서로 다른 정보라는 점이다. 우편에 비유하면 전자는 봉투에 적는 주소이고 후자는 편지지에 적는 주소다. 배달부(SMTP 서버)는 봉투만 보고 배달하며, 편지지에 뭐라고 적혀 있는지는 신경 쓰지 않는다. 그래서 전자를 봉투(envelope), 후자를 헤더라고 구분해 부른다.

둘은 얼마든지 다를 수 있다. 숨은 참조(BCC)가 동작하는 원리가 바로 이것이다. BCC 수신자는 봉투의 RCPT TO에는 올라가지만 편지지(헤더)에는 적히지 않으므로, 배달은 되는데 다른 수신자 눈에는 보이지 않는다.3 뒤에서 보겠지만 이 구분은 릴레이 시스템의 보안 설계에서도 갈림길이 된다. 편지지에 적힌 발신자는 아무나 마음대로 적을 수 있기 때문이다.

받는 서버를 직접 만들기

배달 경로를 알았으니 받는 문을 세울 차례다. 필요한 것은 두 가지다. 답장 전용 도메인(예: relay.example.com)의 MX 레코드가 우리 서버를 가리키게 하고, 그 서버의 25번 포트에서 SMTP 대화를 받아주면 된다. GitHub이 reply.github.com에 해둔 것과 똑같은 구성이다.

SMTP 서버 구현이라고 하면 거창하게 들리지만, 위 대화 스크립트를 다시 보자. 줄 단위로 명령을 읽고, 정해진 응답을 돌려주고, DATA. 사이의 내용을 모으면 끝이다. Node.js의 net 모듈로 TCP 소켓을 직접 다뤄서 만들어보면 이렇다.

// smtp.ts — 미니 SMTP 수신 서버
import net from "node:net";

export interface InboundMail {
  from: string; // 봉투의 발신자 (MAIL FROM)
  to: string[]; // 봉투의 수신자들 (RCPT TO)
  raw: string; // DATA로 받은 메시지 원문
}

export function createSmtpServer(onMail: (mail: InboundMail) => void) {
  return net.createServer((socket) => {
    let from = "";
    let to: string[] = [];
    let dataLines: string[] | null = null; // null이면 명령 모드, 배열이면 DATA 수신 모드
    let buffer = "";

    const reply = (line: string) => socket.write(line + "\r\n");
    reply("220 relay.example.com ESMTP ready");

    socket.on("data", (chunk) => {
      buffer += chunk.toString("utf8");
      // SMTP는 줄 단위 프로토콜이다. CRLF가 나올 때까지 모았다가 한 줄씩 처리한다
      let idx;
      while ((idx = buffer.indexOf("\r\n")) !== -1) {
        const line = buffer.slice(0, idx);
        buffer = buffer.slice(idx + 2);

        if (dataLines !== null) {
          // DATA 수신 모드: 마침표 하나만 있는 줄이 본문의 끝이다
          if (line === ".") {
            const raw = dataLines
              .map((l) => (l.startsWith("..") ? l.slice(1) : l)) // dot-stuffing 복원
              .join("\r\n");
            onMail({ from, to, raw });
            dataLines = null;
            from = "";
            to = [];
            reply("250 OK: queued");
          } else {
            dataLines.push(line);
          }
          continue;
        }

        const cmd = line.split(" ")[0].toUpperCase();
        if (cmd === "HELO" || cmd === "EHLO") {
          reply("250 relay.example.com");
        } else if (cmd === "MAIL") {
          from = line.match(/<(.*)>/)?.[1] ?? "";
          reply("250 OK");
        } else if (cmd === "RCPT") {
          const rcpt = line.match(/<(.*)>/)?.[1] ?? "";
          if (rcpt.startsWith("reply+")) {
            to.push(rcpt);
            reply("250 OK");
          } else {
            reply("550 No such user"); // 답장 주소가 아니면 받지 않는다
          }
        } else if (cmd === "DATA") {
          dataLines = [];
          reply("354 End data with <CR><LF>.<CR><LF>");
        } else if (cmd === "QUIT") {
          reply("221 Bye");
          socket.end();
        } else {
          reply("500 Unknown command");
        }
      }
    });
  });
}

80줄이 안 되는 코드로 메일을 받을 수 있다. 상태 기계로 보면 두 상태뿐이다. 평소에는 명령 모드로 줄마다 명령을 해석하고, DATA를 받으면 수신 모드로 전환해 . 하나만 있는 줄이 나올 때까지 원문을 모은다.

한 군데만 낯선 처리가 있는데, ..으로 시작하는 줄의 마침표를 하나 떼어내는 부분이다. 생각해보면 본문 끝 표시가 "마침표 하나만 있는 줄"이라는 규칙에는 구멍이 있다. 본문 자체에 마침표 하나만 있는 줄이 들어 있으면 어떻게 되는가? 예를 들어 누군가 문장을 끊어 쓰다가 . 한 글자로 줄을 끝냈다면, 서버는 거기서 본문이 끝났다고 착각하고 나머지를 명령으로 해석해버린다. 그래서 SMTP는 보내는 쪽이 마침표로 시작하는 모든 줄 앞에 마침표를 하나 덧붙이고, 받는 쪽이 다시 떼어내기로 약속했다. 이를 dot-stuffing이라고 부른다.4 이런 규칙이 존재한다는 것 자체가, 텍스트 스트림 안에서 "여기가 끝"이라는 신호를 안전하게 전달하는 일이 얼마나 신경 쓸 게 많은지 보여준다.

아래 데모는 이 대화가 오가는 과정을 애니메이션으로 재생한 것이다. 각 명령이 서버의 상태(모드, 봉투)를 어떻게 바꾸는지 함께 표시했다.

RCPT TO 처리에 한 가지 설계 결정이 숨어 있다. 이 서버는 reply+로 시작하지 않는 주소를 전부 550으로 거절한다. 릴레이 서버는 일반 우편함이 아니다. 받을 이유가 있는 메일만 받는 것이 스팸 처리량과 보안 양쪽에 이롭고, 거절은 빠를수록 좋다. DATA까지 다 받고 버리는 것보다 봉투 단계에서 거절하면 본문 전송 비용도 아낄 수 있다.

봉투를 열면 나오는 것

서버가 넘겨준 raw에는 메일 원문이 그대로 들어 있다. 그런데 이 원문은 그대로 쓸 수 있는 물건이 아니다. 뒤에서 만들 전체 시스템에 실제 답장 메일이 도착했을 때 서버가 받는 원문 일부를 미리 보면 이렇다.

Subject: =?UTF-8?B?UmU6IFvti7DsvJMgIzQyXSDso7zrrLjtlZwg7IOB7ZKIIOuwsOyGoeyngOulvCDrsJTqvrjqs6Ag7Iu27Ja07JqU?=
From: =?UTF-8?B?6rmA7ZWc64KY?= <hanna.kim@gmail.com>
Content-Type: multipart/alternative; boundary="0000000000009f3c2b0639aa11cd"

--0000000000009f3c2b0639aa11cd
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable

=EC=95=88=EB=85=95=ED=95=98=EC=84=B8=EC=9A=94, =EB=B9=A0=EB=A5=B8 =EB=8B=
=B5=EB=B3=80 =EA=B0=90=EC=82=AC=ED=95=A9=EB=8B=88=EB=8B=A4!
...

제목도 발신자 이름도 본문도 전부 암호문처럼 보인다. 물론 암호는 아니고 인코딩이다. 이메일 원문 형식은 모든 것이 7비트 ASCII이던 시절에 설계됐고, 한글 같은 8비트 세계의 문자를 실어 나르려면 7비트로 변환하는 포장이 필요했다. 그 포장 규격이 MIME(Multipurpose Internet Mail Extensions)이다.5 릴레이 서버는 이 포장을 손으로 뜯을 수 있어야 한다. 뜯어야 할 겹이 세 가지다.

첫 번째 겹은 헤더의 encoded-word다. 헤더는 원칙적으로 ASCII만 허용되므로, 한글이 들어가는 제목이나 이름은 =?문자셋?인코딩?데이터?= 형태로 포장된다. 인코딩 자리의 B는 Base64라는 뜻이다. 위 제목을 풀면 Re: [티켓 #42] 주문한 상품 배송지를 바꾸고 싶어요가 나온다. 이 디코더는 라우팅에 꼭 필요하지는 않지만(라우팅은 봉투 주소로 한다), 티켓 화면에 제목이나 보낸 사람 이름을 표시하려면 필요하다.

// =?UTF-8?B?...?= 또는 =?UTF-8?Q?...?= 형태의 encoded-word를 디코딩한다
export function decodeEncodedWords(value: string): string {
  return value.replace(
    /=\?([^?]+)\?([BQbq])\?([^?]*)\?=/g,
    (_, charset, enc, payload) => {
      const bytes =
        enc.toUpperCase() === "B"
          ? Buffer.from(payload, "base64")
          : Buffer.from(
              payload
                .replace(/_/g, " ")
                .replace(/=([0-9A-F]{2})/gi, (_m, h) =>
                  String.fromCharCode(parseInt(h, 16)),
                ),
              "latin1",
            );
      return new TextDecoder(charset).decode(bytes);
    },
  );
}

두 번째 겹은 본문의 quoted-printable이다. 본문 쪽은 Content-Transfer-Encoding 헤더가 포장 방식을 알려준다. 위 예시의 quoted-printable은 8비트 바이트를 =EC처럼 = 뒤에 16진수 두 자리로 적는 방식이다. 영문 위주 텍스트에 한글이 조금 섞일 때 Base64보다 원문이 덜 부풀고, 포장된 상태로도 영문 부분은 눈으로 읽힌다는 장점이 있다. 줄 끝의 외로운 =는 soft line break라고 해서, "여기서 줄이 바뀐 것은 76자 제한 때문이지 원문의 줄바꿈이 아니다"라는 표시다. 디코딩할 때 이어 붙여야 한다.

// quoted-printable 디코딩: =XX를 바이트로 되돌리고, 줄 끝의 =(soft break)를 지운다
export function decodeQuotedPrintable(body: string): string {
  const joined = body.replace(/=\r\n/g, "");
  const bytes: number[] = [];
  for (let i = 0; i < joined.length; i++) {
    if (joined[i] === "=" && /[0-9A-F]{2}/i.test(joined.slice(i + 1, i + 3))) {
      bytes.push(parseInt(joined.slice(i + 1, i + 3), 16));
      i += 2;
    } else {
      bytes.push(joined.charCodeAt(i));
    }
  }
  return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
}

마지막 겹은 multipart 구조다. Content-Type: multipart/alternative는 "같은 내용을 여러 형식으로 담았으니 읽을 수 있는 것을 골라 읽어라"는 뜻이다. Gmail을 비롯한 대부분의 클라이언트는 답장을 평문(text/plain)과 HTML 두 벌로 보낸다. 각 벌은 boundary로 지정된 경계 문자열로 구분되고, 각 파트는 다시 자기만의 작은 헤더와 본문을 가진다. 즉 메일은 재귀 구조다. 첨부파일이 있으면 multipart 안에 multipart가 또 들어간다.

multipart/mixed                 ← 전체
├── multipart/alternative       ← 본문 (형식만 다른 같은 내용)
│   ├── text/plain              ← 우리가 원하는 것
│   └── text/html
└── application/pdf             ← 첨부파일

재귀 구조는 재귀로 파싱하면 된다. 헤더와 본문은 빈 줄 하나로 구분된다는 규칙을 이용해, 헤더를 떼고 multipart면 파트를 쪼개 각 파트에 같은 함수를 다시 적용한다.

// 메시지(또는 파트)에서 text/plain 본문을 찾아 디코딩한다
export function parseMail(raw: string): ParsedMail {
  const sep = raw.indexOf("\r\n\r\n"); // 빈 줄이 헤더와 본문의 경계다
  if (sep === -1) return { headers: parseHeaders(raw), text: "" };
  const headers = parseHeaders(raw.slice(0, sep));
  const body = raw.slice(sep + 4);
  const contentType = headers.get("content-type") ?? "text/plain";

  if (contentType.startsWith("multipart/")) {
    const boundary = contentType.match(/boundary="?([^";]+)"?/)?.[1];
    if (!boundary) return { headers, text: "" };
    // 경계선으로 파트를 쪼갠다. 첫 조각(preamble)과 --boundary-- 뒤는 버린다
    const parts = body.split(`--${boundary}`).slice(1, -1);
    for (const part of parts) {
      // 파트도 헤더 + 빈 줄 + 본문 구조이므로 재귀로 처리하면 된다.
      // 첨부가 있어 multipart가 겹겹인 구조도 이 재귀가 알아서 파고든다
      const parsed = parseMail(part.replace(/^\r\n/, "").replace(/\r\n$/, ""));
      const partType = parsed.headers.get("content-type") ?? "text/plain";
      if (
        partType.startsWith("text/plain") ||
        (partType.startsWith("multipart/") && parsed.text)
      ) {
        return { headers, text: parsed.text };
      }
    }
    return { headers, text: "" };
  }

  return { headers, text: decodeBody(body, headers) }; // CTE에 따라 base64/QP 디코딩
}

parseHeaders에는 자잘한 함정이 하나 더 있다. 헤더는 78자를 넘기면 여러 줄로 접는(folding) 것이 관례고, 이어지는 줄은 공백이나 탭으로 시작한다. 파싱 전에 raw.replace(/\r\n[ \t]+/g, " ")로 접힌 줄을 펴야(unfolding) References처럼 긴 헤더가 잘리지 않는다.

HTML이 아니라 text/plain 파트를 고르는 이유도 적어두자. 답장 파싱(뒤에서 다룬다)은 줄 단위 텍스트를 다루는 작업인데, HTML 파트는 클라이언트마다 마크업 구조가 제각각이라 지옥도가 펼쳐진다. 다행히 HTML만 보내고 평문을 빼먹는 클라이언트는 거의 없어서, 릴레이 시스템은 대부분 text/plain을 기준으로 삼는다. GitHub도 마찬가지다.

답장 주소에 표를 심는다

이제 문도 있고 포장을 뜯는 법도 안다. 다음 질문은 라우팅이다. 도착한 답장이 어느 티켓의, 누구의 답장인지 어떻게 알아내는가?

후보를 순서대로 떠올려보자. 첫 번째 후보는 제목이다. 알림 메일 제목에 [티켓 #42]를 박아두고, 답장 제목의 Re: [티켓 #42] ...를 파싱하는 방법이다. 실제로 오래된 헬프데스크 제품들이 이렇게 했고 지금도 보조 수단으로 쓰인다. 문제는 제목이 사용자 손에 있다는 것이다. 답장하며 제목을 지우는 사람, "Re:"가 "답장:"으로 바뀌는 로컬라이즈된 클라이언트, 제목의 번호를 실수로 고치는 경우까지, 통제할 수 없는 변수가 너무 많다. 게다가 발신자 이메일 주소로 사용자를 찾아야 하는데, 곧 보겠지만 발신자 주소는 신뢰할 수 없는 정보다.

두 번째 후보가 이 장의 주인공, 주소 자체에 정보를 심는 방법이다. 앞서 본 GitHub의 reply+ABWMULQ...@reply.github.com이 바로 이것이다. 이메일 주소에서 @ 앞부분(로컬 파트)에 +를 붙여 태그를 다는 관례를 플러스 어드레싱 혹은 서브어드레싱이라고 부른다.6 Gmail 사용자라면 myname+shopping@gmail.com처럼 회원가입용 태그를 달아본 적이 있을 텐데, 같은 원리를 서버 쪽에서 쓰는 것이다. + 뒤에 무엇이 오든 같은 우편함으로 배달되므로, 그 자리에 애플리케이션이 원하는 데이터를 실을 수 있다.

답장 주소는 알림 메일마다 새로 만들어 심는다. 티켓 42번의 알림을 사용자 7번에게 보낸다면 reply+{티켓42, 사용자7}@relay.example.com 같은 주소를 Reply-To에 넣는 것이다. 답장이 돌아오면 봉투의 RCPT TO에서 이 정보를 꺼내면 된다. 제목과 달리 답장 주소는 메일 클라이언트가 기계적으로 복사하는 값이라 사용자가 건드릴 일이 거의 없고, 사용자와 티켓 정보가 한 몸으로 돌아오니 발신자 주소에 기댈 필요도 없다.

From 헤더는 증명서가 아니다

그런데 reply+42.7@relay.example.com처럼 티켓 번호와 사용자 번호를 평문으로 넣으면 어떻게 될까? 여기서 이메일의 어두운 진실을 마주해야 한다. 이메일의 발신자 정보는 자기 신고제다. SMTP 대화를 다시 보면 MAIL FROM도, 원문의 From: 헤더도 클라이언트가 그냥 문자열로 적어 보내는 값이다. 프로토콜 수준에서는 아무 검증이 없다. 텔넷으로 아무 SMTP 서버에 붙어 From: ceo@yourcompany.com이라고 적으면 그대로 전달된다. 이를 스푸핑(spoofing)이라고 한다.7

평문 주소의 문제가 이제 보인다. 공격자가 자기 티켓의 답장 주소에서 번호만 바꾸면, 남의 티켓에 남의 이름으로 코멘트를 다는 주소가 완성된다. 주소 형식이 공개된 것이나 다름없으니(자기한테 온 메일에 적혀 있다) 추측 난이도는 0에 가깝다.

해결책은 주소에 서명을 함께 싣는 것이다. 서버만 아는 비밀 키로 내용의 지문을 찍어두고, 답장이 돌아왔을 때 지문이 맞는지 확인한다. 이 용도의 표준 도구가 HMAC(Hash-based Message Authentication Code)이다. 해시 함수에 비밀 키를 섞어, "이 데이터는 비밀 키를 아는 자가 만들었고 변조되지 않았다"를 보증하는 서명값을 만든다. 밀랍 봉인과 비슷하다. 내용물(티켓·사용자 번호)은 누구나 볼 수 있지만, 인장(비밀 키) 없이는 뜯었다 다시 봉할 수 없다.

// token.ts — 답장 주소용 HMAC 서명 토큰
import crypto from "node:crypto";

const SECRET = "relay-secret-please-change"; // 실제로는 환경 변수로

export interface ReplyContext {
  ticketId: number;
  userId: number;
  exp: number; // 만료 시각 (unix seconds)
}

export function signReplyToken(ctx: ReplyContext): string {
  const payload = `${ctx.ticketId}.${ctx.userId}.${ctx.exp}`;
  const sig = crypto
    .createHmac("sha256", SECRET)
    .update(payload)
    .digest("hex")
    .slice(0, 16); // 로컬 파트 64자 제한 안에 들어가도록 잘라 쓴다
  return `${payload}.${sig}`;
}

export function verifyReplyToken(
  token: string,
  now: number,
): ReplyContext | null {
  const parts = token.split(".");
  if (parts.length !== 4) return null;
  const [ticketId, userId, exp, sig] = parts;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ticketId}.${userId}.${exp}`)
    .digest("hex")
    .slice(0, 16);
  const ok =
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return null;
  if (Number(exp) < now) return null;
  return {
    ticketId: Number(ticketId),
    userId: Number(userId),
    exp: Number(exp),
  };
}

export function replyAddress(ctx: ReplyContext): string {
  return `reply+${signReplyToken(ctx)}@relay.example.com`;
}

// 수신 주소에서 토큰을 꺼낸다
export function extractToken(address: string): string | null {
  return address.match(/^reply\+(.+)@/)?.[1] ?? null;
}

만들어진 주소를 실제로 위조해보자. 티켓 42번용 정상 주소에서 티켓 번호만 43으로 바꿔치기한 토큰을 검증기에 넣으면 이렇게 된다.

정상 주소: reply+42.7.1784209600.a58f8f3c9a6a265e@relay.example.com
정상 검증: {"ticketId":42,"userId":7,"exp":1784209600}
위조 토큰: 43.7.1784209600.a58f8f3c9a6a265e
위조 검증: null
만료 검증: null

페이로드가 한 글자라도 달라지면 서명이 통째로 어긋나므로 위조 토큰은 null이 된다. 43번 티켓용 유효한 서명을 만들려면 SECRET이 필요한데, 그것은 서버 밖으로 나간 적이 없다. 아래 데모는 정상·위조·만료 세 가지 주소가 검증기를 통과하는 과정을 차례로 보여준다. 서명 계산은 흉내가 아니라 브라우저에서 실제 HMAC-SHA256으로 수행한 것이다.

구현의 세부에도 이유가 있다. 서명을 16자로 자른 것은 이메일 로컬 파트에 64자 제한이 있기 때문이다.8 SHA-256 전체(64자)를 붙이면 페이로드 넣을 자리가 없다. 잘라도 2⁶⁴ 경우의 수라 무차별 대입으로는 어림없다. 비교에 timingSafeEqual을 쓰는 것은 일반 문자열 비교가 앞에서부터 맞는 만큼 시간이 더 걸려, 응답 시간을 정밀 측정하면 서명을 한 글자씩 맞춰갈 이론적 여지를 주기 때문이다. 만료 시각을 넣은 것은 답장 주소가 영원히 유효한 열쇠가 되는 것을 막기 위해서다. 메일함이 유출되면 그 안의 답장 주소도 전부 유출된 셈인데, 만료가 있으면 피해 범위가 시간으로 잘린다.

토큰에 userId를 넣은 것이 특히 중요하다. 코멘트의 작성자를 정할 때 메일의 From 헤더를 쓰면 스푸핑에 뚫리지만, 토큰 안의 userId를 쓰면 "그 알림 메일을 받은 사람"이 작성자가 된다. 물론 이 설계에도 그림자는 있다. 사용자가 알림 메일을 통째로 남에게 전달하면 받은 사람이 대신 답장할 수 있다. 메일함 접근 권한이 곧 답장 권한이 되는 셈인데, 이는 이메일 릴레이라는 기능 자체가 안고 가는 한계다. GitHub도 같은 트레이드오프 위에 서 있고, 그래서 보안이 중요한 조직은 이 기능을 끌 수 있게 되어 있다.

참고로 이 기법의 족보는 꽤 오래됐다. 메일링 리스트 시대에 반송 메일(바운스)이 어느 구독자 때문인지 알아내려고 발신 주소에 구독자 정보를 심던 VERP라는 기법이 원형이다.9 주소를 데이터 운반체로 쓰는 발상은 30년 넘게 현역인 셈이다.

새로 쓴 문장만 남기기

여기까지 만들면 답장이 올바른 티켓을 찾아간다. 그런데 도착한 본문을 그대로 코멘트로 저장하면 결과물이 처참하다. 답장 버튼을 누르면 메일 클라이언트가 원문 전체를 인용으로 붙여주기 때문이다. 헬프데스크를 붙여본 팀이라면 한 번쯤 봤을 풍경인데, 고객이 "네 감사합니다" 한 줄을 보냈는데 티켓에는 지금까지의 대화 전체가 겹겹이 인용된 30줄짜리 코멘트가 달린다. 대화가 세 번만 오가면 코멘트가 눈덩이처럼 불어난다.

그래서 릴레이 시스템에는 새로 쓴 부분만 발라내는 파서가 필요하다. 문제는 "인용문"의 생김새가 표준이 아니라는 것이다. 클라이언트마다 스타일이 다르고, 언어 설정에 따라 문구도 바뀐다.

클라이언트인용 스타일
Gmail2026년 7월 10일 (금) 오후 2:07, 박상담 <...>님이 작성: + > 인용
Apple Mail2026. 7. 10. 오후 2:07, 박상담 <...> 작성: + > 인용
Outlook보낸 사람: / 보낸 날짜: / 받는 사람: / 제목: 헤더 블록, 인용 표시 없음
모바일 각종본문 끝에 iPhone에서 보냄 류의 시그니처

특히 Outlook 계열이 고약하다. >를 붙이지 않고 구분선과 헤더 블록만 남기므로, ">로 시작하는 줄을 지운다"는 순진한 접근은 Outlook 답장에서 원문 전체를 코멘트로 흘려보낸다. GitHub은 이 문제를 풀려고 만든 파서를 email_reply_parser라는 라이브러리로 공개했는데, 소스를 열어보면 클라이언트별 관용구를 상대하는 정규식 목록이 늘어서 있다. 우아한 이론이 아니라 사례 수집의 산물이다.

같은 접근으로 한국어 클라이언트까지 상대하는 파서를 만들어보자. 구조는 2단계다. 먼저 위에서 아래로 각 줄의 종류를 분류하고, 그다음 아래에서 위로 걷어낸다.

// replyParser.ts — 답장 본문에서 새로 쓴 부분만 발라내는 파서

// 인용 헤더: 클라이언트가 인용문 위에 끼워 넣는 "누가 언제 썼다" 문구.
// 다음 줄부터 실제 인용(>)이 이어질 때만 인용 헤더로 취급한다
const QUOTE_HEADER_PATTERNS = [
  /^On .+ wrote:$/, // Gmail 영문
  /^20\d{2}.+님이 작성:$/, // Gmail 한글
  /^\d{4}\. \d{1,2}\. \d{1,2}\..* 작성:$/, // Apple Mail 한글
];

// 강한 구분선: 이 줄 아래는 전부 원문 인용이다.
// Outlook 계열은 인용문에 > 를 붙이지 않기 때문에
// "다음 줄이 > 로 시작하는가"로는 판별할 수 없다
const HARD_SEPARATOR_PATTERNS = [
  /^-{2,}\s*Original Message\s*-{2,}$/i, // Outlook 영문
  /^-{2,}\s*원본 (메일|메시지)\s*-{2,}$/, // Outlook 한글
  /^_{10,}$/, // Outlook 웹의 밑줄 구분선
  /^보낸 ?사람\s*:.+/, // Outlook식 인라인 헤더 블록의 첫 줄
  /^From\s*:\s*.+@.+/, // 같은 블록의 영문판
];

// 시그니처의 시작
const SIGNATURE_PATTERNS = [
  /^-- $/, // 표준 시그니처 구분자 (대시 둘 + 공백 하나)
  /^--$/, // 공백을 지워버리는 클라이언트가 많아 이것도 받아준다
  /^(Sent from my|Get Outlook for) /,
  /^i(Phone|Pad)에서 보냄$/,
  /^Android에서 .*보냄$/,
];

export function classifyLines(body: string): ClassifiedLine[] {
  const lines = body.replace(/\r\n/g, "\n").split("\n");

  // 1차: 위에서 아래로 줄의 종류를 정한다
  const kinds: LineKind[] = [];
  let afterHardSeparator = false;
  let inSignature = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trimEnd();
    if (afterHardSeparator) {
      kinds.push("quote"); // 강한 구분선 아래는 전부 인용이다
    } else if (HARD_SEPARATOR_PATTERNS.some((p) => p.test(line))) {
      afterHardSeparator = true;
      kinds.push("quote-header");
    } else if (line.startsWith(">")) {
      inSignature = false;
      kinds.push("quote");
    } else if (QUOTE_HEADER_PATTERNS.some((p) => p.test(line))) {
      const next = lines.slice(i + 1).find((l) => l.trim() !== "");
      kinds.push(
        next === undefined || next.startsWith(">") ? "quote-header" : "content",
      );
    } else if (inSignature) {
      kinds.push("signature");
    } else if (SIGNATURE_PATTERNS.some((p) => p.test(line))) {
      inSignature = true;
      kinds.push("signature");
    } else {
      kinds.push("content");
    }
  }

  // 2차: 아래에서 위로 걷어낸다. 맨 아래에 붙은 인용·인용 헤더·시그니처·빈 줄
  // 덩어리가 걷어낼 대상이고, 본문(content)을 만나는 순간 걷어내기를 멈춘다
  const hidden = new Array<boolean>(lines.length).fill(false);
  for (let i = lines.length - 1; i >= 0; i--) {
    if (kinds[i] === "content" && lines[i].trim() !== "") break;
    hidden[i] = true;
  }

  return lines.map((text, i) => ({
    text,
    kind: kinds[i],
    hidden: hidden[i],
  }));
}

export function extractReply(body: string): string {
  return classifyLines(body)
    .filter((l) => !l.hidden)
    .map((l) => l.text)
    .join("\n")
    .trim();
}

2차 처리를 아래에서 위로 하는 것이 이 파서의 핵심 아이디어로, email_reply_parser도 같은 방향으로 스캔한다. 왜 방향이 중요할까? "인용이면 지운다"를 위에서 아래로 적용하면, 인용문 사이사이에 답을 다는 인라인 답장이 부서진다. 이런 답장을 생각해보자.

> 변경하실 주소를 알려주세요.

테헤란로 123입니다.

> 수령인도 바뀌나요?

아니요, 그대로입니다.

2026년 7월 10일 (금) 오후 2:07, 박상담 <...>님이 작성:
> (원문 전체 인용)

위쪽의 인용 두 줄은 답의 문맥이라 지우면 안 되고, 아래쪽의 인용 덩어리는 클라이언트가 붙인 관례라 지워야 한다. 종류만 봐서는 둘 다 똑같은 인용이다. 차이는 위치에 있다. 맨 아래에 붙은 덩어리만 걷어내고, 본문을 만나는 순간 멈추면 인라인 답장의 인용은 자연히 살아남는다. 실제로 위 입력을 파서에 넣은 결과다.

"> 변경하실 주소를 알려주세요.\n\n테헤란로 123입니다.\n\n> 수령인도 바뀌나요?\n\n아니요, 그대로입니다."

Outlook식 답장(인용 표시가 없는 경우)과 모바일 시그니처도 확인해보자.

입력(Outlook식): "네 확인했습니다. 회사 주소로 변경해주세요.\n\n보낸 사람: 박상담 ..."
결과: "네 확인했습니다. 회사 주소로 변경해주세요."

입력(모바일): "회사 주소로 부탁드려요\n\niPhone에서 보냄"
결과: "회사 주소로 부탁드려요"

입력(시그니처만 있고 새 내용이 없는 답장)
결과: ""   ← 빈 문자열이면 코멘트를 만들지 않는다

아래 데모는 이 파서 구현을 그대로 가져와 브라우저에서 실행한 것이다. Gmail·Outlook·인라인·모바일 네 가지 답장이 줄 단위로 분류되고, 아래에서 위로 걷혀 나가 코멘트가 남는 과정을 차례로 재생한다.

정직하게 말하면 이 파서는 완벽하지 않으며, 완벽해질 수도 없다. 인용 헤더 문구는 표준이 아니라 각 클라이언트의 UI 문자열이라, 새 클라이언트나 새 언어 설정이 나타나면 패턴을 추가해야 한다. "보낸 사람:"으로 시작하는 문장을 본문에 쓰는 사용자는 오분류된다. GitHub 규모의 서비스도 이 문제를 정규식 목록의 꾸준한 유지보수로 풀고 있다는 사실이 위안이라면 위안이다. 파싱이 애매하면 원문 전체를 보존해두고 정제본만 보여주는 식으로, 틀렸을 때 복구할 길을 남겨두는 것이 실무적인 안전장치다.

스레드가 끊기지 않게

받는 쪽 부품은 다 모였다. 이제 나가는 쪽, 알림 메일을 조립할 차례다. 겉보기엔 단순한 일 같지만 여기서 헤더 몇 개를 빼먹으면 사용자의 메일함에서 릴레이의 사용성이 무너진다.

메일 클라이언트가 여러 메일을 하나의 대화로 묶어주는 스레드 기능을 생각해보자. Gmail은 무엇을 근거로 "이 메일이 저 메일의 답장"임을 알까? 제목의 Re:가 아니다. 거의 모든 메일에는 세계에서 유일한 Message-ID 헤더가 붙고, 답장에는 원본의 Message-ID를 가리키는 In-Reply-To와, 대화에 등장한 ID의 족보를 담는 References 헤더가 붙는다.10 클라이언트는 이 그래프를 따라 대화를 묶는다. Message-ID가 표준상 의무는 아니지만 정상적인 발신 시스템치고 안 붙이는 곳이 없어서, 사실상 메일의 주민등록번호처럼 통한다.

릴레이가 보내는 알림 메일이 이 족보에 참여하지 않으면, 티켓 하나에 대한 알림이 사용자의 메일함에 낱개로 흩어진다. 반대로 티켓의 첫 알림에서 만든 Message-ID를 기억해두고 이후 알림에 In-Reply-To로 이어 붙이면, 티켓 하나가 메일함에서도 깔끔한 대화 하나로 접힌다. GitHub 알림에서 PR 하나가 스레드 하나로 묶이는 것이 바로 이 헤더들 덕분이다.

// helpdesk.ts — 상담원이 답변을 달면 고객에게 나가는 알림 메일을 조립한다
export function composeNotification(
  ticket: Ticket,
  agentName: string,
  body: string,
  now: number,
): string {
  const messageId = `<ticket-${ticket.id}-${crypto.randomBytes(8).toString("hex")}@relay.example.com>`;
  const replyTo = replyAddress({
    ticketId: ticket.id,
    userId: ticket.requester.userId,
    exp: now + 14 * 24 * 3600, // 2주 뒤 만료
  });
  const headers = [
    `From: ${encodeHeader(`${agentName} (도움말센터)`)} <notify@relay.example.com>`,
    `Reply-To: ${replyTo}`,
    `To: ${ticket.requester.email}`,
    `Subject: ${encodeHeader(`Re: [티켓 #${ticket.id}] ${ticket.subject}`)}`,
    `Message-ID: ${messageId}`,
    ...(ticket.lastMessageId
      ? [
          `In-Reply-To: ${ticket.lastMessageId}`,
          `References: ${ticket.lastMessageId}`,
        ]
      : []),
    `Auto-Submitted: auto-generated`, // 자동 발송 메일임을 표시해 응답 루프를 예방한다
    `MIME-Version: 1.0`,
    `Content-Type: text/plain; charset=utf-8`,
    `Content-Transfer-Encoding: base64`,
  ];
  ticket.lastMessageId = messageId;
  const encodedBody = Buffer.from(body, "utf8")
    .toString("base64")
    .replace(/(.{76})/g, "$1\r\n");
  return headers.join("\r\n") + "\r\n\r\n" + encodedBody;
}

encodeHeader는 헤더 값에 한글이 있으면 앞 장에서 뜯었던 encoded-word 포장(=?UTF-8?B?...?=)을 거꾸로 해주는 함수다. 뜯는 법을 알면 싸는 법도 아는 셈이라 구현은 base64 인코딩 한 줄이다.

설계 결정 두 가지를 짚자.

먼저, 토큰 주소를 From이 아니라 Reply-To에 넣었다. From을 아예 reply+토큰@으로 하면 Reply-To가 필요 없지 않나 싶지만, From은 사용자 눈에 보이는 얼굴이다. 발신자마다 이름이 난수 문자열로 바뀌면 흉하고, 발신 도메인 인증(뒤에서 다룰 SPF/DKIM) 정책과 얽히면 스팸함행 확률도 올라간다. 보이는 얼굴(From)은 일정하게 유지하고 기능적인 주소(Reply-To)를 따로 두는 것이 관례다. GitHub 알림이 정확히 이 구성이다.

다음으로, 여기서 만든 Message-ID는 중복 방어의 열쇠 역할도 겸한다. 이메일은 최소 한 번 배달(at-least-once)의 세계다. 받는 서버가 응답을 늦게 주면 보내는 서버는 실패로 간주하고 재전송하므로, 같은 메일이 두 번 도착하는 일은 언제든 생긴다. 답장 메일의 Message-ID를 처리 완료 목록에 기록해두고 이미 본 ID면 건너뛰어야, 고객의 답장 하나가 코멘트 두 개가 되는 사고를 막을 수 있다.

마지막 방어선이 하나 더 있다. 받은 메일이 기계가 보낸 자동 응답일 가능성이다. 휴가 중 부재 알림, 수신 확인, 반송 통지 같은 메일이 답장 주소로 돌아오는데, 이걸 코멘트로 저장하면 티켓마다 "휴가 중입니다"가 달리는 코미디가 벌어진다. 더 나쁜 경우는 루프다. 자동 응답이 코멘트가 되고, 코멘트 알림이 다시 나가고, 거기에 또 자동 응답이 오는 식으로 두 시스템이 서로 답장하며 무한히 도는 사고는 메일 서버 운영의 고전적인 참사다.11 그래서 자동 발송 메일에는 Auto-Submitted: auto-replied 같은 헤더를 달아 "기계가 보냈으니 답하지 말라"고 표시하는 표준이 있고, 릴레이는 이 헤더가 달린 메일을 반드시 버려야 한다. 위의 composeNotification이 자신의 알림에 Auto-Submitted: auto-generated를 달아두는 것도 같은 맥락인데, 상대편의 자동 응답 시스템이 우리 알림을 무시할 수 있게 해주는 표시다.

전체 조립

부품이 다 모였다. 수신 메일 하나가 코멘트가 되기까지의 전 과정을 한 함수로 엮으면 이렇다.

// 수신 메일 → 코멘트. 실패 사유를 문자열로 돌려준다
export function handleInbound(mail: InboundMail, now: number): string {
  // 1. 봉투 수신 주소에서 토큰을 꺼내 검증한다
  const token = extractToken(mail.to[0]);
  if (!token) return "reject: 토큰 없음";
  const ctx = verifyReplyToken(token, now);
  if (!ctx) return "reject: 토큰 검증 실패";

  const ticket = tickets.get(ctx.ticketId);
  if (!ticket) return "reject: 없는 티켓";

  // 2. MIME을 풀어 text/plain 본문을 얻는다
  const parsed = parseMail(mail.raw);

  // 3. 같은 메일이 두 번 배달되어도 코멘트가 두 개 달리지 않게 막는다
  const messageId = parsed.headers.get("message-id");
  if (messageId && processedMessageIds.has(messageId))
    return "skip: 이미 처리한 메일";
  if (messageId) processedMessageIds.add(messageId);

  // 4. 자동 응답(부재중 알림 등)은 버린다
  const autoSubmitted = parsed.headers.get("auto-submitted");
  if (autoSubmitted && autoSubmitted !== "no") return "skip: 자동 응답 메일";

  // 5. 인용문과 시그니처를 걷어내고 새로 쓴 부분만 남긴다
  const body = extractReply(parsed.text);
  if (!body) return "reject: 본문이 비어 있음";

  // 6. 작성자는 From 헤더가 아니라 토큰의 userId로 정한다
  ticket.comments.push({ author: ticket.requester.name, body, via: "email" });
  return `ok: 티켓 #${ticket.id}에 코멘트 추가됨`;
}

이제 실제로 왕복시켜 보자. 티켓 42번에 상담원 답변이 달려 알림 메일이 조립된다.

=== 나가는 알림 메일 ===
From: =?UTF-8?B?67CV7IOB64u0ICjrj4Tsm4Drp5DshLzthLAp?= <notify@relay.example.com>
Reply-To: reply+42.7.1784209600.a58f8f3c9a6a265e@relay.example.com
To: hanna.kim@gmail.com
Subject: =?UTF-8?B?UmU6IFvti7DsvJMgIzQyXSDso7zrrLjtlZwg7IOB7ZKIIOuwsOyGoeyngOulvCDrsJTqvrjqs6Ag7Iu27Ja07JqU?=
Message-ID: <ticket-42-92078ed459737ad7@relay.example.com>
Auto-Submitted: auto-generated
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

7JWI64WV7ZWY7IS47JqUIOq5gO2VnOuCmCDri5gsIOuPhOybgOunkOyEvO2EsCDrsJXsg4Hri7Ts
noXri4jri6QuCgrslYTsp4Eg7Lac6rOgIOyghOydtOudvCDrsLDshqHsp4Ag67OA6rK97J20IOqw
...

고객 김한나 씨가 Gmail에서 답장을 보낸다. Gmail이 만드는 그대로, multipart/alternative에 quoted-printable로 포장되고 시그니처와 인용문이 딸린 답장이다. 미니 SMTP 서버가 기록한 세션 로그의 앞부분은 이렇다(앞 장의 서버 코드에 주고받는 줄을 기록하는 콜백 하나만 붙여서 돌렸다).

S: 220 relay.example.com ESMTP ready
C: EHLO mail-yb1-f171.google.com
S: 250 relay.example.com
C: MAIL FROM:<hanna.kim@gmail.com>
S: 250 OK
C: RCPT TO:<reply+42.7.1784209600.a58f8f3c9a6a265e@relay.example.com>
S: 250 OK
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: MIME-Version: 1.0
C: Date: Fri, 10 Jul 2026 14:12:33 +0900
C: Message-ID: <CAF+9k7nZ0vX@mail.gmail.com>
C: In-Reply-To: <ticket-42-92078ed459737ad7@relay.example.com>
C: References: <ticket-42-92078ed459737ad7@relay.example.com>
C: Subject: =?UTF-8?B?UmU6IFvti7DsvJMgIzQyXSDso7zrrLjtlZwg...?=
C: From: =?UTF-8?B?6rmA7ZWc64KY?= <hanna.kim@gmail.com>
C: To: reply+42.7.1784209600.a58f8f3c9a6a265e@relay.example.com
C: Content-Type: multipart/alternative; boundary="0000000000009f3c2b0639aa11cd"
C: (...quoted-printable로 포장된 본문...)
C: .
S: 250 OK: queued
C: QUIT
S: 221 Bye

서버가 handleInbound를 호출한 결과와 티켓의 최종 상태다. 같은 메일을 일부러 한 번 더 넣어 중복 배달 상황도 재현했다.

=== 처리 결과 ===
ok: 티켓 #42에 코멘트 추가됨
skip: 이미 처리한 메일          ← 같은 메일이 다시 배달된 경우

=== 티켓 #42 코멘트 ===
[web] 김한나: 어제 주문했는데 배송지를 회사로 바꿀 수 있나요?
[email] 김한나: 안녕하세요, 빠른 답변 감사합니다!\n\n서울시 강남구 테헤란로 123, 45층 (주)어썸커머스 앞으로 부탁드립니다.\n수령인은 그대로 두시면 됩니다.

quoted-printable 덩어리로 도착한 답장에서 시그니처("김한나 드림")와 인용문이 걷히고, 새로 쓴 세 문장만 코멘트로 남았다. 두 번째 배달은 Message-ID 중복으로 걸러졌다. GitHub PR에 답장이 코멘트로 달리기까지의 여정을, 부품 다섯 개(SMTP 서버, MIME 파서, 토큰, 답장 파서, 조립 함수)로 완주한 셈이다.

현실은 조금 더 험하다

여기까지의 시스템은 원리적으로 완결이지만, 실제 인터넷에 내놓으려면 몇 가지 현실의 벽을 더 넘어야 한다. 직접 다 구현하는 대신, 어떤 벽이 있고 실무에서는 무엇으로 넘는지 지도를 그려두자.

우선 25번 포트부터 험지다. 스팸 발송 악용 때문에 대부분의 가정용 인터넷과 클라우드는 25번 포트 통신을 막거나 제한한다.12 인바운드를 열 수 있는 환경이라도 TLS(STARTTLS), 커넥션당 타임아웃, 동시 접속 제한, IP 블랙리스트 조회까지 갖춰야 그럭저럭 버티는 수신 서버가 된다. 우리의 미니 서버는 이 중 아무것도 없는 맨몸이다.

보내는 쪽은 평판 게임이라는 또 다른 전장이다. 알림 메일이 스팸함에 들어가면 릴레이는 시작도 못 한다. 발신 도메인에 SPF(이 도메인의 메일은 이 IP들이 보낸다는 DNS 선언)와 DKIM(발신 서버가 메일에 하는 암호학적 서명), 그리고 둘의 정책을 정하는 DMARC를 설정해야 하고,13 그러고도 발신 IP의 평판을 꾸준히 관리해야 한다. 신규 IP에서 갑자기 대량 발송하면 그것만으로 스팸 판정을 받는다.

이런 사정 때문에 실무에서는 관리형 서비스를 쓴다. AWS SES의 수신 규칙, Mailgun Routes, SendGrid Inbound Parse, Postmark Inbound 같은 서비스는 MX 운영과 SMTP 수신, TLS, 스팸 필터링에 MIME 파싱까지 대신 해주고, 파싱된 결과를 웹훅으로 우리 API에 넘겨준다. 이 글에서 만든 부품에 대응시키면 smtp.tsmime.ts가 통째로 서비스로 대체되는 것이다. 반면 토큰 설계와 검증, 답장 파싱, 멱등성 처리, 자동 응답 필터링은 웹훅 너머의 우리 몫으로 그대로 남는다. 관리형 서비스는 배관을 맡아줄 뿐이고, 무엇을 믿고 무엇을 저장할지의 판단은 여전히 애플리케이션에 남는다.

답장에 이미지가 첨부되는 경우도 결국 우리 몫이다. multipart 트리에서 Content-Disposition: attachment 파트를 찾아 스토리지에 올리고 코멘트에 연결해야 한다. 수십 MB짜리 메일을 어디까지 받아줄지, 거절한다면 보낸 사람에게 어떻게 알릴지(반송 메일을 우리가 보내야 한다) 같은 정책도 정해야 한다. 이 글의 파서는 첨부를 건너뛰고 text/plain만 줍지만, 트리를 이미 재귀로 걷고 있으므로 확장 지점은 마련되어 있다.

마치며

돌아보면 릴레이 시스템은 새로운 발명품이 아니라 오래된 부품들의 조립이었다. 1982년의 SMTP가 문을 열고, 1992년의 MIME이 포장을 풀고, 메일링 리스트 시대의 VERP가 주소에 데이터를 심는 법을 가르쳐주고, HMAC이 그 위에 자물쇠를 채운다. 새로 발명할 것은 답장 파서의 정규식 목록 정도인데, 공교롭게도 시스템에서 가장 우아하지 않은 그 부분이 가장 꾸준한 유지보수를 요구한다.

이 글에서 만든 것들을 다시 세어보자. TCP 소켓 위의 SMTP 서버 80줄, MIME 파서 90줄, HMAC 토큰 50줄, 답장 파서 80줄, 조립 함수 40줄. 이메일이라는 거대한 유산 위에 "답장이 코멘트가 되는" 마법을 세우는 데 필요한 코드는 그리 많지 않다. 마법처럼 보였던 것이 부품의 목록으로 분해되고 나면, 다음에 GitHub 알림 메일의 원문을 열었을 때 Reply-To의 난수 문자열이 무엇을 감추고 있는지, References 헤더가 왜 그렇게 긴지 읽어낼 수 있을 것이다. 바퀴를 다시 발명하는 일의 보상은 언제나 그 읽는 눈이다.

Footnotes

  1. 재미있는 대비로, 회사 메일 도메인인 github.com의 MX는 Microsoft(Outlook)의 메일 보호 서비스를 가리킨다. 직원 메일함은 남에게 맡기고, 릴레이용 수신 서버는 직접 운영하는 것이다. 릴레이 수신은 우편함이 아니라 애플리케이션 로직이기 때문이다.

  2. RFC 821, Jonathan Postel, 1982년 8월. 현행 표준은 2008년의 RFC 5321이지만 기본 대화 구조는 40년 전 그대로다.

  3. 반대로 봉투에만 있고 헤더에 없는 수신자가 "나한테 왜 이 메일이 왔지?"의 정체이기도 하다. 스팸이 To에 엉뚱한 주소를 적고도 배달되는 이유가 봉투와 편지지의 분리다.

  4. RFC 5321 4.5.2절 "Transparency". 프로토콜 설계에서 이스케이프 규칙을 빼먹으면 데이터가 명령을 사칭하는 인젝션이 된다는 점에서, SQL 인젝션과 정확히 같은 계보의 문제다.

  5. RFC 2045~2049, 1996년(초판은 1992년). MIME은 이메일용으로 태어났지만 Content-Type이라는 발명품은 HTTP로 건너가 웹 전체의 형식 표기법이 됐다.

  6. Sieve 필터 표준인 RFC 5233이 서브어드레싱이라는 이름으로 다룬다. 구분자가 +라는 것은 표준이 아니라 관례라서, 받는 서버가 지원해야만 동작한다. 우리 서버는 reply+를 직접 파싱하므로 당연히 지원하는 셈이다.

  7. 발신 위조를 막기 위한 SPF·DKIM·DMARC가 있지만(마지막 장에서 다룬다) 이는 발신 도메인 수준의 검증이고, 도입 여부도 도메인마다 제각각이다. 수신 애플리케이션 입장에서 From 헤더는 여전히 "참고인 진술" 이상이 될 수 없다.

  8. RFC 5321 4.5.3.1.1절. 로컬 파트는 64옥텟, 도메인까지 합친 전체 주소는 254자가 상한이다. 토큰에 더 많은 정보를 실어야 한다면 페이로드를 base64url로 압축하거나, 난수 ID만 싣고 데이터는 서버 테이블에 두는 식으로 우회한다.

  9. Variable Envelope Return Paths. qmail을 만든 Daniel J. Bernstein이 1997년에 정리한 기법으로, 구독자 만 명에게 보낸 메일 중 누구 것이 반송됐는지 알아내기 위해 봉투 발신자를 list-owner-user=example.com@list.org처럼 구독자마다 다르게 적었다.

  10. Message-ID와 References로 스레드를 복원하는 알고리즘은 넷스케이프 엔지니어 Jamie Zawinski가 정리한 것이 사실상의 표준이다. 이메일뿐 아니라 유즈넷 뉴스리더 시절부터 쓰이던 유산이다.

  11. 이를 막는 표준이 RFC 3834다. 자동 응답 시스템은 Auto-Submitted 헤더가 달린 메일에 응답해서는 안 되고, 자신이 보내는 메일에 이 헤더를 달아야 한다. 양쪽 중 한쪽만 지켜도 루프는 끊긴다.

  12. AWS EC2는 아웃바운드 25번 포트를 기본 제한하고 해제 신청을 받아준다. 가정용 회선은 대부분 ISP가 차단한다. 서버 간 메일 전송(25번)과 사용자의 메일 제출(587번)이 포트 수준에서 분리된 것도 스팸과의 전쟁의 산물이다.

  13. SPF는 RFC 7208, DKIM은 RFC 6376, DMARC는 RFC 7489. 2024년부터 Gmail은 대량 발송자에게 세 가지 모두를 사실상 의무화했다. 발신 인증 없이 알림 메일을 보내는 서비스는 이제 도달률부터 막힌다.