[SMTP] 6. 메일은 어디에 남아야 할까

지난 글에서는 DATA 명령을 구현했다.

이제 SMTP 클라이언트는 서버에게 봉투를 건넨 뒤, 실제 메일 내용을 보낼 수 있게 되었다.

이 흐름만 보면 꽤 그럴듯하다.

서버는 MAIL FROM으로 봉투 발신자를 받았다.
RCPT TO로 봉투 수신자도 받았다.
DATA 이후에는 제목과 본문이 들어 있는 메일 내용도 받았다.
그리고 마지막 마침표 한 줄을 만나면 250 OK를 돌려줬다.

하지만 지난 구현에는 중요한 빈틈이 있었다.

메일을 받기는 했지만, 그 메일은 어디에도 남지 않았다.

로그에 찍히고 끝이었다.

메일 수신 완료:
From: Alice <alice@example.com>
To: Bob <bob@example.net>
Subject: Hello

Hello SMTP.
 

서버 화면에는 보인다.

하지만 서버를 종료하면 사라진다.
로그를 지우면 사라진다.
다시 꺼내볼 방법도 없다.

그렇다면 이 서버는 정말 메일을 받은 걸까?

이번 글에서는 이 질문에서 시작한다.


로그에 찍힌 메일은 메일함이 아니다

처음에는 로그에 찍히는 것만으로도 꽤 만족스러웠다.

직접 만든 SMTP 서버가 클라이언트와 대화했고, DATA까지 읽었고, 본문 종료를 나타내는 마침표도 알아봤다.

그 자체로 큰 진전이었다.

하지만 조금만 생각해 보면 로그는 메일을 저장하는 장소가 아니다.

로그는 서버가 무슨 일을 했는지 남기는 기록에 가깝다.

메일 내용이 로그에 찍혔다고 해서, 그 메일이 사용자의 메일함에 들어간 것은 아니다.
나중에 사용자가 읽을 수 있는 형태로 보관된 것도 아니다.
서버가 책임지고 보관한다고 말하기도 어렵다.

SMTP에서 메일을 받는다는 것은 단순히 문자열을 한 번 읽는 것보다 조금 더 무겁다.

RFC 5321은 SMTP가 메일 객체를 전송하며, 이 메일 객체가 봉투와 내용으로 나뉜다고 설명한다. 봉투는 MAIL, RCPT 같은 SMTP 명령으로 전달되고, 내용은 DATA 이후에 전달된다. 그리고 최종 배달 시스템은 메일을 사용자 에이전트에 넘기거나, 사용자가 나중에 접근할 수 있는 메시지 저장소에 보관하는 역할을 한다.

아직 우리가 만든 것은 진짜 메일함은 아니다.

하지만 이제 최소한의 메시지 저장소를 흉내 내볼 수는 있다.

가장 단순한 방법은 파일이다.


이번 목표: 받은 메일을 파일로 남기기

이번 구현의 목표는 크지 않다.

Gmail처럼 메일함을 만들지 않는다.
IMAP 서버를 만들지도 않는다.
사용자별 받은편지함을 만들지도 않는다.
검색 기능도, 읽음 처리도, 삭제 기능도 없다.

이번에는 그저 받은 메일을 파일 하나로 저장한다.

mails/
  ├── 20260507-001.eml
  ├── 20260507-002.eml
  └── 20260507-003.eml
 

이렇게 파일로 남기면 적어도 한 가지는 달라진다.

서버를 종료해도 메일이 남아 있다.

이전까지는 메일이 네트워크 연결 안에서 잠깐 나타났다가 로그와 함께 사라졌다.

이제는 서버의 파일 시스템 안에 흔적이 남는다.

SMTP 서버가 처음으로 “받은 것”을 보관하기 시작한 것이다.

구현 지침서에서도 Phase 1의 최소 구현 흐름 안에 DATA 처리, 본문 수신, 마침표 종료 감지, 투명성 처리, 그리고 본문 파일 저장을 포함하고 있다. 즉, 파일 저장은 거창한 확장 기능이 아니라, 우리가 만든 작은 SMTP 서버가 메일을 실제로 받아들였다고 말하기 위한 첫 번째 마무리에 가깝다.


메일 파일에는 무엇을 저장해야 할까

이제 고민이 하나 생긴다.

파일에는 무엇을 저장해야 할까?

DATA 이후에 받은 내용만 저장하면 될까?

From: Alice <alice@example.com>
To: Bob <bob@example.net>
Subject: Hello

Hello SMTP.
 

아니면 SMTP 봉투 정보도 함께 저장해야 할까?

MAIL FROM:<alice@example.com>
RCPT TO:<bob@example.net>
 

이전 글에서 봤듯이, SMTP에는 봉투와 편지가 따로 있다.

MAIL FROM과 RCPT TO는 봉투다.

MAIL FROM:<alice@example.com>
RCPT TO:<bob@example.net>
 

DATA 이후의 From:, To:, Subject:는 편지 내용이다.

From: Alice <alice@example.com>
To: Bob <bob@example.net>
Subject: Hello
 

둘은 같아 보일 수 있지만 같은 층의 정보가 아니다.

그래서 이번에는 파일에 둘 다 남겨본다.

완성된 메일 파일은 대략 이런 모양이면 충분하다.

MAIL FROM: <alice@example.com>
RCPT TO: <bob@example.net>

From: Alice <alice@example.com>
To: Bob <bob@example.net>
Subject: Hello

Hello SMTP.
 

엄밀히 말하면 실제 .eml 파일 형식이나 메일박스 저장 방식과는 다를 수 있다.

하지만 지금 단계에서는 괜찮다.

이번 시즌의 목표는 완전한 메일 시스템을 만드는 것이 아니라, SMTP 대화를 직접 구현하면서 메일 한 통이 서버 안에서 어떤 형태를 갖추는지 확인하는 것이기 때문이다.

지금은 봉투와 내용을 함께 볼 수 있는 파일이면 충분하다.


저장할 디렉터리 만들기

먼저 메일을 저장할 디렉터리를 만든다.

예를 들어 mails라는 디렉터리를 사용한다.

if err := os.MkdirAll("mails", 0755); err != nil {
	log.Fatal(err)
}
 

이 코드는 서버가 시작될 때 mails 디렉터리가 없으면 만든다.

mails/
 

아직 아무 메일도 없지만, 이제 서버에는 메일을 남길 장소가 생겼다.

이전까지 서버는 클라이언트와 대화만 했다.

이제는 대화의 결과를 어딘가에 저장할 준비를 한다.


파일 이름 정하기

메일을 파일로 저장하려면 파일 이름도 필요하다.

가장 단순하게는 현재 시간을 사용할 수 있다.

filename := fmt.Sprintf("mails/%d.eml", time.Now().UnixNano())

이렇게 하면 메일을 받을 때마다 다른 이름의 파일이 만들어진다.

mails/1778123456789000000.eml
mails/1778123456791000000.eml
mails/1778123456793000000.eml

사람이 읽기 좋은 이름은 아니지만, 지금 단계에서는 충분하다.

중요한 것은 메일이 덮어써지지 않는 것이다.

만약 모든 메일을 같은 이름으로 저장하면, 새 메일이 올 때마다 이전 메일이 사라질 수 있다.

mails/message.eml

이런 방식은 간단하지만 위험하다.

그래서 지금은 시간 기반 이름으로 파일을 만든다.

나중에는 메시지 ID를 만들 수도 있고, 수신자별 디렉터리를 만들 수도 있고, 날짜별로 나눌 수도 있다.

하지만 이번에는 단순하게 간다.

받은 메일 하나 → 파일 하나

이 정도면 시즌 1의 마지막 목표로 충분하다.


DATA가 끝나면 파일로 저장한다

이제 DATA 처리 부분을 보자.

지난 글에서 우리는 마침표 하나만 있는 줄을 만날 때까지 본문을 읽었다.

msgLines := []string{}

for {
	line, err := reader.ReadString('\n')
	if err != nil {
		log.Println("error reading data", err)
		return
	}

	line = strings.TrimRight(line, "\r\n")

	if line == "." {
		break
	}

	line = strings.TrimPrefix(line, ".")
	msgLines = append(msgLines, line)
}

여기까지는 이전과 비슷하다.

다른 점은, 이제 이 메시지를 로그에만 찍지 않는다는 것이다.

먼저 본문을 하나의 문자열로 만든다.

message = strings.Join(msgLines, "\r\n")
 

그리고 봉투 정보와 함께 저장할 내용을 구성한다.

mailFileName := fmt.Sprintf("%d.eml", time.Now().UnixNano())
err := os.WriteFile("mails/"+mailFileName, []byte(message), 0666)
if err != nil {
    log.Println("error writing mail file", err)
    if err := writeLine("554 Error on DATA write"); err != nil {
        return
    }
    return
}

저장에 성공하면 그때 250 OK를 보낸다.

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

이 순서가 중요하다.

메일 데이터 수신
  ↓
파일 저장 시도
  ↓
저장 성공
  ↓
250 OK

반대로 하면 안 된다.

메일 데이터 수신
  ↓
250 OK
  ↓
파일 저장 실패

이렇게 되면 서버는 클라이언트에게 “메일을 받았다”라고 말해놓고 실제로는 메일을 잃어버린 셈이 된다.

250 OK는 저장한 뒤에 보내야 한다

지난 글에서도 잠깐 이야기했지만, DATA 이후의 250 OK는 가볍지 않다.

EHLO에 대한 250 OK는 “인사를 받았다”에 가깝다.

MAIL FROM에 대한 250 OK는 “봉투 발신자를 받았다”에 가깝다.

RCPT TO에 대한 250 OK는 “이 수신자를 받겠다”에 가깝다.

하지만 DATA가 끝난 뒤의 250 OK는 조금 다르다.

C: .
S: 250 OK

이 응답은 서버가 메일 데이터를 받아들였다는 뜻이다.

RFC 5321은 서버가 DATA 종료 후 긍정적인 완료 응답을 보내면 메시지 전달, 재시도, 실패 알림 등에 대한 책임을 수락한다고 설명한다. 즉, 이 시점 이후에는 서버가 메시지를 가볍게 잃어버리면 안 된다.

우리가 만든 서버는 아직 실제 전달도 하지 않는다.

재시도 큐도 없다.

배달 실패 알림도 없다.

그럼에도 이 원칙은 지금부터 신경 쓰는 편이 좋다.

그래서 이번 구현에서는 파일 저장에 성공한 뒤에만 250 OK를 보낸다.

저장에 실패하면 250 OK를 보내지 않는다.

예를 들어 디스크 오류가 났거나, 권한 문제로 파일을 만들 수 없다면 이렇게 응답할 수 있다.

451 Requested action aborted: local error in processing
 

지금은 아주 단순한 오류 처리지만, 방향은 중요하다.

서버가 처리하지 못한 메일에 대해 처리했다고 말하지 않는다.

mails 폴더 내에 메일 파일이 생성된 것을 확인할 수 있다.


아직 이것은 받은편지함이 아니다

여기서 너무 빨리 만족하면 안 된다.

파일로 저장했다고 해서 완전한 메일 서버가 된 것은 아니다.

아직 사용자별 메일함이 없다.

mails/bob/
mails/alice/
 

이런 구조도 없다.

수신자 주소가 실제로 존재하는지 확인하지도 않는다.

bob@example.net으로 보냈다고 해서 bob이라는 사용자의 받은 편지함에 들어가는 것도 아니다.

메일 파일 형식도 아직 단순하다.

실제 메일 시스템이라면 Received 헤더를 추가해야 할 수도 있고, 최종 배달 시 Return-Path를 다뤄야 할 수도 있다. RFC 5321은 SMTP 서버가 메시지를 전달 또는 추가 처리를 위해 수신하면 메시지 앞에 추적 정보인 Received: 헤더를 추가해야 한다고 설명한다. 또한 최종 배달 시에는 Return-Path 정보도 중요해진다.

하지만 지금은 여기까지 가지 않는다.

시즌 1의 목표는 실제 운영 가능한 메일 서버가 아니었다.

우리가 확인하고 싶었던 것은 이것이었다.

SMTP 서버는 어떻게 대화를 시작하는가.
클라이언트는 어떻게 자신을 소개하는가.
메일의 봉투는 어떻게 만들어지는가.
본문은 어떻게 전달되는가.
본문의 끝은 어떻게 표시되는가.
그리고 받은 메일은 어디에 남길 수 있는가.

이번 구현으로 그 첫 번째 흐름이 닫혔다.

연결
  ↓
220
  ↓
EHLO / HELO
  ↓
MAIL FROM
  ↓
RCPT TO
  ↓
DATA
  ↓
.
  ↓
파일 저장
  ↓
250 OK
  ↓
QUIT
 

처음으로 메일 한 통이 서버 안에 남았다.


시즌 1에서 만든 것

시즌 1을 시작할 때는 이메일이 어떻게 보내지는지 잘 몰랐다.

나에게 이메일 발송은 대체로 이런 코드에 가까웠다.

SendEmail(to, subject, body)
 

외부 메일 발송 서비스를 사용하면 이 정도로 충분했다.

API를 호출하면 메일이 갔다.
사용자는 인증 메일을 받았다.
비밀번호 재설정 링크도 도착했다.

그때는 그 뒤에서 무슨 일이 일어나는지 깊게 생각하지 않았다.

하지만 이번 시즌에서는 그 API 뒤쪽에 있는 가장 작은 대화를 직접 만들어봤다.

먼저 TCP 서버를 열었다.

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
 

그리고 봉투와 편지 내용이 다르다는 것도 확인했다.

MAIL FROM  → 봉투 발신자
RCPT TO    → 봉투 수신자

From:      → 메일 내용의 발신자 표시
To:        → 메일 내용의 수신자 표시
 

본문은 DATA로 시작했다.

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

그리고 마침표 하나만 있는 줄로 끝났다.

C: .
S: 250 OK
 

중간에는 RSET, NOOP, VRFY 같은 보조 명령도 추가했다.

RSET → 현재 트랜잭션 취소
NOOP → 아무것도 하지 않고 응답
VRFY → 사용자를 확인할 수 있는지 요청
 

그리고 마지막으로, 받은 메일을 파일로 저장했다.

mails/1778123456789000000.eml
 

아주 작은 서버다.

하지만 이제 이 서버는 SMTP 클라이언트와 대화하고, 메일의 봉투를 받고, 본문을 받고, 그 결과를 파일로 남길 수 있다.


시즌 1을 마치며

시즌 1에서는 아직 수신자의 받은편지함까지 가지 못했다.

하지만 적어도 메일이 서버에 도착하는 장면까지는 직접 확인했다.

그리고 그 메일이 사라지지 않도록 파일로 남겼다.

처음에는 이런 질문에서 시작했다.

우리 서버가 직접 이메일을 보낼 수 있었을까?
 

아직 이 질문에 완전히 답한 것은 아니다.

하지만 이제는 질문이 조금 더 구체적으로 바뀌었다.

우리 서버가 SMTP 대화를 이해할 수 있을까?
 

여기에는 어느 정도 답할 수 있게 되었다.

가능했다.

아주 작게나마, 가능했다.

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

220 localhost Simple Mail Transfer Service Ready
 

그리고 클라이언트가 메일을 건네면, 이렇게 답할 수 있다.

250 OK
 

단, 이제는 그 OK가 로그 한 줄로 사라지지 않는다.

파일로 남는다.

시즌 1은 여기서 마무리한다.

다음 시즌에서는 이 작은 서버를 조금 더 서버답게 만들어볼 수 있을 것 같다.

저장된 메일에 Received 헤더를 붙이고, 크기 제한과 타임아웃을 넣고, 수신자를 더 제대로 다루고, 언젠가는 직접 다른 메일 서버로 전달하는 흐름까지 가볼 수 있을 것이다.

하지만 지금은 여기서 멈춘다.

처음 만든 SMTP 서버가 메일 한 통을 받아서 파일로 남겼다.

그 작은 파일 하나가, 이번 시즌의 결과물이다.