[SMTP] 4. 봉투와 편지는 다르다.

지난 글에서는 SMTP 서버와 처음으로 대화해봤다.

TCP 연결을 열면 클라이언트가 먼저 말하는 것이 아니라, 서버가 먼저 인사했다.

S: 220 localhost Simple Mail Transfer Service Ready
 

그다음 클라이언트는 자신이 누구인지 말했다.

C: EHLO client.local
S: 250-localhost greets client.local
S: 250 HELP
 

아주 짧은 대화였지만, 이때부터 이메일은 조금 다르게 보이기 시작했다.

이전까지 이메일은 화면에서 보는 것이 전부인 줄 알았다.
보내는 사람, 받는 사람, 제목, 본문.
메일 앱에서 보이는 그 정보들이 곧 이메일이라고 생각했다.

하지만 SMTP 서버와 직접 대화해보니 순서가 조금 달랐다.

서버와 인사를 마친 뒤, 클라이언트는 곧바로 제목이나 본문을 보내지 않는다.

먼저 이런 말을 한다.

MAIL FROM:<sender@example.com>
 

그리고 이어서 이런 말을 한다.

RCPT TO:<user@example.net>
 

여기서 처음으로 헷갈림이 생긴다.

메일에는 분명 From:과 To:가 있다.
그런데 SMTP 명령에도 MAIL FROM과 RCPT TO가 있다.

둘은 같은 걸까?

아니면 다른 걸까?

이번 글에서는 이 차이를 확인해보려 한다.


메일에는 봉투가 있다

우리가 종이 편지를 보낸다고 생각해보자.

편지를 쓸 때는 편지지에 내용을 쓴다.

안녕하세요.
오랜만에 연락드립니다.
...
 

그리고 편지지 위쪽에 받는 사람 이름을 적을 수도 있다.

To. 홍길동
From. 김철수
 

하지만 우체국 직원은 편지지 안쪽을 보고 배달하지 않는다.

우체국이 보는 것은 봉투다.

봉투 겉면에는 받는 사람 주소가 있고, 경우에 따라 보내는 사람 주소도 있다.
배달 과정에서 중요한 것은 편지지 안쪽의 문장이 아니라, 봉투에 적힌 주소다.

이메일도 비슷한 층이 있다.

봉투
  ├─ 어디에서 온 메일인가
  └─ 어디로 배달해야 하는가

편지 내용
  ├─ From:
  ├─ To:
  ├─ Subject:
  └─ Body
 

처음에는 이 구분이 조금 낯설다.

메일 화면에서 우리에게 보이는 것은 대부분 편지 내용 쪽이다.
우리는 메일 앱에서 From, To, Subject, 본문을 본다.

하지만 SMTP 서버가 메일을 주고받을 때 먼저 다루는 것은 그 화면이 아니다.

먼저 봉투를 만든다.

RFC 5321에서도 SMTP가 전송하는 메일 객체는 봉투(envelope)내용(content)으로 나뉜다고 설명한다. 봉투는 SMTP 명령을 통해 전달되고, 내용은 나중에 DATA 단계에서 헤더와 본문 형태로 전달된다.

이제 MAIL FROM과 RCPT TO가 조금 다르게 보인다.

이 둘은 메일 본문 안에 들어가는 From:과 To:가 아니다.

이 둘은 봉투에 적히는 정보다.


MAIL FROM은 편지의 From:이 아니다

먼저 MAIL FROM을 보자.

C: MAIL FROM:<sender@example.com>
S: 250 OK
 

이 명령은 새로운 메일 트랜잭션을 시작한다.

여기서 sender@example.com은 메일 내용에 표시될 작성자라기보다, SMTP 봉투의 발신자 주소에 가깝다. 조금 더 정확히 말하면, 배달 실패 같은 문제가 생겼을 때 오류 보고가 돌아갈 주소로 사용될 수 있다.

그래서 MAIL FROM을 단순히 “메일 화면에 보이는 보낸 사람”이라고 생각하면 나중에 헷갈리기 쉽다.

메일 화면에 보이는 보낸 사람은 보통 DATA 이후의 메시지 헤더 안에 들어간다.

From: Alice <alice@example.com>
 

반면 SMTP 대화의 MAIL FROM은 이렇게 생겼다.

MAIL FROM:<bounce@example.com>
 

둘은 같을 수도 있다.

하지만 반드시 같아야 하는 것은 아니다.

예를 들어 어떤 서비스가 회원가입 인증 메일을 보낸다고 생각해보자.
사용자에게 보이는 발신자는 이렇게 표시하고 싶을 수 있다.

From: My Service <no-reply@myservice.com>
 

하지만 배달 실패 알림은 별도의 주소로 받고 싶을 수 있다.

MAIL FROM:<bounce@myservice.com>
 

사용자 입장에서는 no-reply@myservice.com에서 메일이 온 것처럼 보인다.
하지만 메일 시스템 입장에서는 배달 실패 같은 시스템 메시지를 bounce@myservice.com으로 돌려보낼 수 있다.

아직 이 연재에서 그런 기능까지 구현하지는 않는다.
지금은 그저 둘이 같은 층에 있지 않다는 사실만 기억하면 된다.

MAIL FROM은 봉투 쪽이다.

From:은 편지 내용 쪽이다.


RCPT TO는 편지의 To:가 아니다

이번에는 RCPT TO를 보자.

C: RCPT TO:<user@example.net>
S: 250 OK
 

RCPT는 recipient, 즉 수신자를 뜻한다.

이 명령은 이 메일을 실제로 어디로 배달할 것인지 서버에게 알려준다.
그리고 하나의 메일에는 수신자가 여러 명일 수 있기 때문에 RCPT TO는 여러 번 나올 수 있다.

C: MAIL FROM:<sender@example.com>
S: 250 OK

C: RCPT TO:<alice@example.net>
S: 250 OK

C: RCPT TO:<bob@example.net>
S: 250 OK

C: RCPT TO:<carol@example.net>
S: 250 OK
 

이 대화에서 봉투 수신자는 세 명이다.

alice@example.net
bob@example.net
carol@example.net
 

그런데 나중에 DATA 안에 들어가는 To: 헤더는 이와 다를 수 있다.

To: team@example.net
 

또는 어떤 수신자는 To: 헤더에 보이지 않을 수도 있다.
우리가 흔히 말하는 숨은참조, 즉 Bcc 같은 경우를 생각하면 된다.

메일을 받는 사람은 메일 화면에서 To: 헤더를 본다.
하지만 SMTP 서버가 실제로 배달 대상으로 삼는 것은 RCPT TO다.

이 차이를 이해하면 이메일이 조금 더 입체적으로 보인다.

메일 앱에서 보이는 수신자와, SMTP 서버가 배달하려는 수신자는 같은 개념이 아니다.

RCPT TO  → 실제 배달 목적지
To:      → 메일 내용에 표시되는 수신자 정보
 

SMTP 트랜잭션은 보통 MAIL 명령으로 발신자 정보를 지정하고, 하나 이상의 RCPT 명령으로 수신자를 지정한 뒤, DATA 명령으로 메일 내용을 전송하는 흐름을 가진다. RFC 5321도 이 순서로 메일 트랜잭션을 설명한다.


이름이 비슷해서 더 헷갈린다

정리하면 이렇게 볼 수 있다.

SMTP 봉투 메일 내용
MAIL FROM:<...> From: ...
RCPT TO:<...> To: ...
배달 시스템이 보는 정보 사용자가 메일 화면에서 보는 정보
DATA 이전에 전달 DATA 이후에 전달

이름이 비슷해서 처음에는 같은 것처럼 보인다.

MAIL FROM과 From:.
RCPT TO와 To:.

하지만 SMTP 서버 입장에서 이 둘은 서로 다른 위치에 있다.

먼저 봉투가 오고,
그다음 편지가 온다.

그래서 아직 우리는 메일 본문을 보내지 않았지만, 이미 메일의 중요한 일부를 만들고 있다.


이제 코드에 상태가 필요해졌다

지난 글의 서버는 아주 단순했다.

클라이언트가 접속하면 220을 보내고, EHLO나 HELO를 받으면 250을 반환했다.

하지만 이제는 순서가 중요해졌다.

RCPT TO는 아무 때나 올 수 없다.
먼저 MAIL FROM이 있어야 한다.

MAIL FROM도 아무 때나 올 수 없다.
먼저 EHLO나 HELO로 세션이 준비되어야 한다.

그래서 코드에 상태를 두었다.

const (
	StateGreeting = iota
	StateReady
	StateMail
	StateRcpt
	StateData
)
 

처음 연결되면 서버는 인사한다.

state := StateGreeting
 

EHLO나 HELO를 정상적으로 받으면 세션을 준비 상태로 바꾼다.

resetSession := func() {
	state = StateReady
	from = ""
	rcpt = []string{}
	message = ""
}
 

여기서 from과 rcpt가 오늘의 핵심이다.

from := ""
rcpt := []string{}
message := ""
 

from은 봉투 발신자를 담는다.
rcpt는 봉투 수신자 목록을 담는다.
message는 아직 비어 있다.

왜냐하면 편지 내용은 아직 받지 않았기 때문이다.

오늘은 봉투만 만든다.


MAIL FROM 구현하기

이제 MAIL FROM을 처리하는 부분을 보자.

case "MAIL":
	if state != StateReady {
		if err := writeLine("503 Bad sequence of commands"); err != nil {
			return
		}
		continue
	}

	resetSession()

	parts := strings.SplitN(arg, ":", 2)
	if len(parts) != 2 {
		if err := writeLine("501 Syntax: MAIL FROM:<address>"); err != nil {
			return
		}
		continue
	}

	mailCmd := parts[0]
	from = parts[1]

	if strings.ToUpper(mailCmd) != "FROM" || from == "" {
		if err := writeLine("501 Syntax: MAIL FROM:<address>"); err != nil {
			return
		}
		continue
	}

	state = StateMail

	if err := writeLine("250 OK"); err != nil {
		return
	}
 

가장 먼저 보는 것은 상태다.

if state != StateReady {
	writeLine("503 Bad sequence of commands")
	continue
}
 

아직 EHLO나 HELO를 하지 않았는데 MAIL FROM이 오면 받아주지 않는다.

서버 입장에서는 아직 제대로 인사도 하지 않은 클라이언트가 갑자기 봉투를 내미는 셈이다.

그다음에는 이전 트랜잭션 정보를 지운다.

resetSession()
 

MAIL FROM은 새로운 메일 트랜잭션의 시작이다.
새 편지를 보내려면 이전 봉투에 적힌 발신자와 수신자를 계속 들고 있으면 안 된다.

그다음 명령 인자를 파싱한다.

parts := strings.SplitN(arg, ":", 2)
 

클라이언트가 보낸 전체 명령이 이렇다면,

MAIL FROM:<sender@example.com>
 

앞에서 MAIL은 이미 명령어로 분리되었고, 남은 인자는 이것이다.

FROM:<sender@example.com>
 

이 문자열을 콜론 기준으로 나누면 이렇게 된다.

FROM
<sender@example.com>
 

앞부분이 FROM인지 확인하고, 뒤쪽 주소를 from 변수에 저장한다.

from = parts[1]
 

마지막으로 상태를 바꾼다.

state = StateMail
 

이제 서버는 이렇게 말할 수 있다.

S: 250 OK
 

아직 메일을 받은 것은 아니다.
아직 본문도 없고, 제목도 없다.

다만 서버는 이렇게 말한 것이다.

좋다. 이 봉투 발신자로 메일 트랜잭션을 시작하자.
 

RCPT TO 구현하기

이제 수신자를 받는다.

case "RCPT":
	if state != StateMail && state != StateRcpt {
		if err := writeLine("503 Bad sequence of commands"); err != nil {
			return
		}
		continue
	}

	parts := strings.SplitN(arg, ":", 2)
	if len(parts) != 2 {
		if err := writeLine("501 Syntax: RCPT TO:<address>"); err != nil {
			return
		}
		continue
	}

	rcptCmd := parts[0]
	rcptArg := parts[1]

	if strings.ToUpper(rcptCmd) != "TO" || rcptArg == "" {
		if err := writeLine("501 Syntax: RCPT TO:<address>"); err != nil {
			return
		}
		continue
	}

	if len(rcpt) >= 100 {
		if err := writeLine("452 Error: exceeded max recipients"); err != nil {
			return
		}
		continue
	}

	rcpt = append(rcpt, rcptArg)
	state = StateRcpt

	if err := writeLine("250 OK"); err != nil {
		return
	}
 

여기서도 먼저 상태를 확인한다.

if state != StateMail && state != StateRcpt {
	writeLine("503 Bad sequence of commands")
	continue
}
 

RCPT TO는 MAIL FROM 뒤에 와야 한다.

그리고 이미 한 명의 수신자를 받은 뒤에도 또 다른 수신자를 받을 수 있어야 한다.
그래서 StateMail뿐만 아니라 StateRcpt 상태에서도 RCPT TO를 허용한다.

이 차이가 중요하다.

MAIL FROM은 한 번으로 충분하다.

하지만 RCPT TO는 여러 번 올 수 있다.

C: RCPT TO:<alice@example.net>
S: 250 OK

C: RCPT TO:<bob@example.net>
S: 250 OK
 

코드에서는 수신자를 슬라이스에 추가한다.

rcpt = append(rcpt, rcptArg)
 

이제 봉투에는 수신자가 하나씩 쌓인다.

rcpt = []string{
	"<alice@example.net>",
	"<bob@example.net>",
}
 

지금 서버는 아직 이 주소가 실제로 존재하는지 확인하지 않는다.
메일박스가 있는지도 모른다.
도메인이 유효한지도 확인하지 않는다.

이번 단계의 목표는 주소 검증이 아니다.

이번 단계의 목표는 SMTP 대화 속에서 봉투 수신자를 받을 수 있는 상태를 만드는 것이다.


수신자 수에도 제한을 둔다

코드에는 이런 부분도 있다.

if len(rcpt) >= 100 {
	if err := writeLine("452 Error: exceeded max recipients"); err != nil {
		return
	}
	continue
}
 

처음 구현하는 입장에서는 “왜 벌써 100명 제한이 나오지?” 싶을 수 있다.

하지만 SMTP에서는 하나의 메일에 여러 수신자가 붙을 수 있다.
그래서 서버는 수신자 목록을 어느 정도 버퍼링할 수 있어야 한다.

구현 지침서에서도 RCPT TO는 여러 번 반복될 수 있고, 최소 100명의 수신자 버퍼를 지원해야 한다고 정리하고 있다. 또한 수신자가 너무 많을 때는 영구 실패가 아니라 452 같은 일시적 실패 응답을 사용하는 흐름으로 잡고 있다.

물론 지금 단계에서 이 제한이 핵심은 아니다.

다만 이 코드를 보면서 알 수 있는 것이 있다.

SMTP 서버는 단순히 한 줄을 받고 끝나는 프로그램이 아니다.

서버는 현재 트랜잭션의 상태를 기억해야 한다.

누가 보냈는지,
누구에게 보낼 것인지,
몇 명의 수신자를 받았는지,
다음에 어떤 명령이 와야 하는지.

이런 것들을 계속 들고 있어야 한다.


직접 대화해보기

이제 서버를 실행하고 telnet로 접속해본다.

go run main.go
 

다른 터미널에서 접속한다.

telnet localhost 2525
 

서버가 먼저 인사한다.

S: 220 localhost Simple Mail Transfer Service Ready
 

클라이언트가 인사한다.

C: EHLO client.local
S: 250-localhost greets client.local
S: 250 HELP
 

이제 봉투 발신자를 보낸다.

C: MAIL FROM:<alice@example.com>
S: 250 OK
 

그리고 봉투 수신자를 보낸다.

C: RCPT TO:<bob@example.net>
S: 250 OK
 

수신자를 하나 더 추가할 수도 있다.

C: RCPT TO:<carol@example.net>
S: 250 OK
 

전체 대화는 이렇게 된다.

아직 메일 본문은 없다.

제목도 없다.

사용자가 실제로 읽을 수 있는 내용도 없다.

하지만 봉투는 만들어졌다.

봉투 발신자: alice@example.com
봉투 수신자: bob@example.net
봉투 수신자: carol@example.net
 

이전 글에서 우리는 서버와 인사만 했다.

이번 글에서는 처음으로 “이 메일은 어디에서 왔고, 어디로 가야 하는가”를 서버에게 알려줬다.


순서를 어기면 어떻게 될까?

이번에는 일부러 순서를 어겨본다.

EHLO만 보낸 뒤, MAIL FROM 없이 바로 RCPT TO를 보내본다.

서버는 503을 반환한다.

이 응답은 “명령의 순서가 잘못되었다”는 뜻이다.

이제 SMTP가 단순히 문자열 몇 줄을 주고받는 것이 아니라는 느낌이 든다.

같은 명령이라도 언제 오느냐가 중요하다.

RCPT TO 자체는 올바른 명령이다.

하지만 아직 MAIL FROM이 없으니 지금 올 명령은 아니다.

프로토콜을 구현한다는 것은 명령어 이름을 알아보는 것만으로 끝나지 않는다.

그 명령이 지금 이 상태에서 가능한 말인지도 판단해야 한다.


아직 엄격한 주소 검사는 하지 않는다

현재 코드는 MAIL FROM과 RCPT TO를 아주 단순하게 파싱한다.

parts := strings.SplitN(arg, ":", 2)
 

즉, 콜론을 기준으로 앞부분이 FROM인지, TO인지 확인하고 뒤쪽 문자열을 주소처럼 저장한다.

하지만 아직 엄격한 검사는 하지 않는다.

예를 들어 주소가 정말 <...> 형태인지,
콜론 뒤에 공백이 들어갔는지,
주소 안에 허용되지 않는 문자가 있는지,
확장 매개변수가 붙었는지,
도메인이 올바른지까지 보지는 않는다.

지금은 일부러 거기까지 가지 않으려 한다.

이번 글에서 중요한 것은 완전한 주소 파서가 아니다.

중요한 것은 SMTP가 메일 본문을 받기 전에 봉투 정보를 먼저 받는다는 점이다.

그리고 그 봉투 정보는 코드 안에서 별도의 상태로 관리되어야 한다는 점이다.

정확한 주소 문법은 나중에 천천히 다듬으면 된다.

지금은 먼저 흐름을 본다.

EHLO
  ↓
MAIL FROM
  ↓
RCPT TO
  ↓
DATA
 

오늘은 이 중에서 여기까지 왔다.

EHLO
  ↓
MAIL FROM
  ↓
RCPT TO
 

From과 To를 다시 보게 된다

이번 구현을 하고 나니 메일 화면의 From과 To가 조금 다르게 보인다.

이전에는 이렇게 생각했다.

From은 보내는 사람
To는 받는 사람

틀린 말은 아니다.

하지만 SMTP 안쪽을 기준으로 보면 조금 더 나누어야 한다.

MAIL FROM은 봉투의 발신자
From:은 편지 내용에 표시되는 작성자

RCPT TO는 봉투의 수신자
To:는 편지 내용에 표시되는 수신자
 

이 차이는 당장 눈에 띄지 않는다.

메일 앱을 사용할 때는 보통 봉투를 직접 보지 않기 때문이다.

우리가 보는 것은 대부분 편지 내용이다.

하지만 서버들은 그 뒤에서 봉투를 주고받는다.

메일이 어디로 배달되어야 하는지,
배달 실패는 어디로 돌아가야 하는지,
한 번의 본문 전송으로 몇 명에게 전달해야 하는지.

이런 정보들은 편지 내용 밖에서 먼저 정해진다.


이번 글의 끝에서 남는 질문

오늘 확인한 것은 메일의 봉투다.

서버와 인사를 마친 클라이언트는 먼저 봉투 발신자를 말한다.

MAIL FROM:<sender@example.com>
 

그다음 봉투 수신자를 말한다.

RCPT TO:<user@example.net>
 

그리고 이 정보는 나중에 메일 화면에서 보이는 From:이나 To: 헤더와 반드시 같지는 않다.

이제 다음 질문이 남는다.

봉투를 만들었으니, 편지지는 언제 건네야 할까?

메일의 제목과 본문은 어디에서 시작될까?

그리고 서버는 본문이 끝났다는 것을 어떻게 알까?

다음 글에서는 드디어 DATA로 들어간다.

그때부터는 봉투가 아니라 편지 내용이다.

DATA
354 Start mail input; end with <CRLF>.<CRLF>
 

메일은 이제 처음으로 읽을 수 있는 모양을 갖추기 시작한다.