Zoho Mail 발송 도메인 구성: SMTP·IMAP 연동에서 막힌 지점들
애플리케이션에서 메일을 자동으로 보내려고 발송 전용 도메인을 하나 세웠습니다. DNS 레코드를 넣고 인증을 붙이는 작업 자체는 30분쯤 걸렸습니다. 나머지 세 시간은 전부 다른 데서 썼습니다.
이 글은 그 세 시간에 대한 기록입니다. SPF와 DKIM을 어떻게 쓰는지는 검색하면 나오지만, 제가 막힌 지점들은 대부분 그런 문서에 없었습니다.
왜 도메인을 따로 세웠나
기존 회사 도메인으로 영업 메일을 보내면 스팸 신고와 바운스가 그 도메인 평판에 쌓입니다. 그리고 그 평판을 견적서와 계약 메일이 같이 씁니다. 어느 날 거래처가 "메일 못 받았는데요"라고 말하는 상황이 여기서 나옵니다.
그래서 발송용 도메인을 따로 잡았습니다. 여기까지는 흔한 판단입니다.
제가 처음에 놓친 것은 도메인만 나누면 절반만 나뉜다는 점이었습니다. Zoho Mail은 한 조직에 도메인을 여러 개 추가할 수 있고, 도메인 추가에 별도 비용이 붙지 않습니다. 편해 보여서 기존 조직에 붙일 뻔했습니다.
같은 조직에 넣으면 발신 IP 풀을 공유합니다. 도메인 평판은 갈리지만 IP 평판은 함께 씁니다. 격리가 목적인데 절반만 격리하면 의미가 옅어집니다. 결국 조직을 새로 만들었습니다. Mail Lite 요금제로 연 15달러, 사용자 1명 기준입니다.
DNS는 5줄이면 끝납니다
Cloudflare에 넣은 레코드는 이게 전부입니다.
@ MX mx.zoho.com 10
@ MX mx2.zoho.com 20
@ MX mx3.zoho.com 50
@ TXT v=spf1 include:zoho.com include:zohomail.com ~all
zmail._domainkey TXT v=DKIM1; k=rsa; p=MIGfMA0GCS...
_dmarc TXT v=DMARC1; p=none; rua=mailto:...여기서 두 가지를 의식적으로 골랐습니다.
DMARC를 p=none으로 시작했습니다. 자료를 보면 quarantine 이상을 권하는 글이 많습니다. 맞는 말이지만 순서가 있습니다. 이 시점에는 DKIM이 아직 붙기 전이었고, 인증이 제대로 되는지 확인도 안 된 상태였습니다. 여기서 정책을 올리면 설정 실수로 제 정상 메일이 격리됩니다. 계기판도 없이 브레이크부터 밟는 셈입니다.
DMARC 리포트 주소를 회사 도메인으로 두지 않았습니다. rua=mailto:ceo@회사도메인 으로 지정하면 공개 DNS에 두 도메인의 관계가 그대로 박힙니다. dig 한 번이면 누구나 봅니다. Cloudflare DNS를 쓰고 있어서 Cloudflare DMARC Management를 켰습니다. 전 플랜 무료이고, 리포트 수신 주소를 Cloudflare 것으로 대신 써 줍니다.
솔직히 말하면 이 분리의 효과는 제한적입니다. 도메인 이름 자체가 회사명을 담고 있어서 사람이 보면 5초면 관계를 압니다. 자동화된 평판 시스템에 대한 조치이지 사람 눈을 속이는 장치는 아닙니다.
누르지 않은 버튼
Zoho 설정 화면에는 곳곳에 자동 구성, 내 DNS 로그인 같은 버튼이 있습니다. Zoho가 Cloudflare 계정에 직접 접속해서 레코드를 대신 넣어주는 기능입니다.
전부 직접 구성을 골랐습니다.
SPF 레코드는 도메인당 반드시 하나여야 합니다. 두 개가 되는 순간 규격 위반이 되어 SPF 인증이 통째로 무효가 됩니다. 저는 이미 include를 두 개 넣은 SPF를 손으로 만들어 둔 상태였고, Zoho가 자기 SPF를 하나 더 만들면 그게 깨집니다.
실제로 도메인 소유확인 단계에서 Zoho가 경고창을 띄웠습니다. "여러 SPF 레코드"라며 두 가지 대안을 제시했는데, 하나는 include:zoho.com이 빠진 버전이었고 다른 하나는 이랬습니다.
v=spf1 include:zohomail.com include:zoho.com include:zohomail.com ~allzohomail.com이 두 번 들어가 있습니다. SPF는 DNS 조회 10회 제한이 있어서 중복 하나가 조회 예산을 먹습니다. 지금은 여유롭지만 나중에 발송 서비스를 하나 더 붙일 때 걸립니다. 그냥 확인만 누르고 기존 레코드를 유지했습니다.
이런 종류의 실수가 위험한 이유는 겉으로 아무 문제가 없어 보이기 때문입니다. SPF가 두 개여도 화면 어디에도 빨간 표시가 뜨지 않습니다. 메일도 발송됩니다. 다만 인증에서 조용히 실패할 뿐입니다.
DKIM 값을 넣기 전에 한 검증
DKIM 공개키는 216자짜리 base64 문자열입니다. I와 l과 1, O와 0이 섞여 있고, 한 글자만 틀려도 서명 검증이 전부 실패합니다.
DNS에 넣기 전에 유효한 RSA 키인지 먼저 확인했습니다.
printf -- "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n" "$P" \
| openssl pkey -pubin -noout -textPublic-Key: (1024 bit)
Modulus:
00:84:54:ef:8f:97:28:a8:58:...1024비트 RSA로 정상 파싱됐습니다. base64 길이도 216자로 맞았습니다. 1024비트 RSA 공개키를 SPKI 형식으로 인코딩하면 정확히 216자가 나오기 때문에, 길이만으로도 한 글자가 빠지거나 더 붙은 경우는 걸러집니다.
넣은 뒤에는 게시된 값과 원본을 문자열 비교했습니다.
PUB=$(dig +short TXT zmail._domainkey.도메인 @1.1.1.1 | tr -d '"' | sed 's/.*p=//' | tr -d ' ')
[ "$PUB" = "$ORIG" ] && echo "일치"과한 확인처럼 보이지만, DKIM은 틀렸을 때 증상이 "메일이 스팸함으로 간다" 하나뿐입니다. 원인을 역추적하기가 아주 번거롭습니다.
설정이 됐는지는 헤더로 확인합니다
DNS에 값이 있는 것과 인증이 실제로 통과하는 것은 다릅니다. 본인 Gmail로 한 통 보내고 원본 보기를 눌렀습니다.
Authentication-Results: mx.google.com;
spf=pass smtp.mailfrom=발신주소
dkim=pass header.i=@도메인 header.s=zmail
dmarc=pass (p=NONE sp=NONE dis=NONE)세 줄 모두 pass입니다. header.s=zmail은 제가 만든 셀렉터 이름이라, DNS에 값만 있는 게 아니라 그 키로 서명 검증까지 됐다는 뜻입니다.
정렬(alignment)도 봤습니다. SPF의 smtp.mailfrom 도메인과 DKIM의 d= 도메인이 둘 다 header.from과 일치했습니다. DMARC는 둘 중 하나만 정렬돼도 통과하는데 둘 다 맞아서, 한쪽이 깨져도 다른 쪽이 받쳐주는 상태가 됐습니다.
받은편지함에도 들어갔습니다. 발송 이력이 없는 새 도메인의 첫 메일이라 스팸함을 예상했는데 아니었습니다.
여기부터가 세 시간
인증까지는 순조로웠습니다. 문제는 메일 클라이언트를 붙이는 단계였습니다.
비밀번호가 없었습니다
Mac에서 Exchange 계정을 추가했더니 계정 이름 또는 암호를 확인할 수 없습니다가 떴습니다.
저는 서버 주소를 의심했습니다. 오타를 확인하고, 도메인 칸을 비웠다 채웠다 하고, 사용자 이름을 전체 주소로 넣었다 아이디만 넣었다 했습니다. 전부 아니었습니다.
Zoho 계정을 구글 소셜 로그인으로 만들었기 때문이었습니다. IMAP과 SMTP, ActiveSync는 아이디와 비밀번호만 받는 프로토콜이라 OAuth를 이해하지 못합니다. 코드를 입력받을 방법도, 브라우저를 띄울 방법도 없습니다. 그런데 저는 Zoho 계정 비밀번호를 한 번도 만든 적이 없었습니다.
증상이 암호를 확인할 수 없습니다인데 정작 확인할 암호가 존재하지 않았던 겁니다. 이걸 알아차리는 데 시간이 꽤 걸렸습니다. 판별법은 간단했습니다. 웹메일에서 소셜 버튼 말고 이메일과 비밀번호로 직접 로그인이 되는지 보면 됩니다.
IMAP은 기본으로 꺼져 있습니다
비밀번호를 만들고 다시 시도했는데 같은 오류였습니다.
Zoho는 새 계정에서 IMAP과 ActiveSync가 꺼진 상태로 시작합니다. 관리콘솔의 사용자 → 사서함 설정 → 사서함 작업에 토글이 있습니다. POP, IMAP, ACTIVESYNC가 나란히 있는데 전부 꺼져 있었습니다.
비밀번호가 맞아도 이걸 켜기 전에는 무조건 실패합니다. 오류 메시지는 여전히 인증 실패로 나오기 때문에, 비밀번호를 계속 의심하게 됩니다.
macOS와 iOS는 다른 프로토콜을 씁니다
ActiveSync를 켜고 Mac에서 또 시도했습니다. 또 같은 오류였습니다.
여기서 화면을 다시 봤더니 서버 URL 입력칸의 예시가 이랬습니다.
https://exchange.example.com/EWS/Exchange.asmxEWS입니다. macOS 메일은 Exchange를 EWS(Exchange Web Services) 로 연결하는데, Zoho는 EWS를 지원하지 않습니다. ActiveSync만 지원합니다. iPhone은 ActiveSync를 쓰기 때문에 같은 서버 주소로 붙지만, Mac은 프로토콜 자체가 달라서 애초에 불가능했습니다.
저는 기기를 구분하지 않고 같은 방법을 반복하고 있었습니다. 정리하면 이렇습니다.
| 기기 | 프로토콜 | 서버 |
|---|---|---|
| iPhone | Exchange (ActiveSync) | msync.zoho.com |
| Mac | IMAP | imappro.zoho.com (수신) / smtppro.zoho.com (발신) |
Mac을 IMAP으로 바꾸니 바로 붙었습니다.
유료 조직은 서버 이름이 다릅니다
이건 다행히 미리 확인해서 안 걸렸는데, 걸렸으면 오래 헤맬 만한 지점입니다.
| 무료·개인 | 유료 조직 | |
|---|---|---|
| IMAP | imap.zoho.com |
imappro.zoho.com |
| POP | pop.zoho.com |
poppro.zoho.com |
| SMTP | smtp.zoho.com |
smtppro.zoho.com |
pro가 붙습니다. 검색으로 나오는 설정 안내는 무료 계정 기준인 경우가 많아서, 유료로 결제하고 나서 오히려 안 되는 상황이 생깁니다.
발신 서버 칸의 "선택 사항"
iOS와 macOS에서 IMAP을 수동 설정하면 발신 서버의 사용자 이름과 암호가 선택 사항으로 표시됩니다. 비워두면 계정은 만들어지고 메일도 받아집니다. 그런데 보내려고 하면 실패합니다.
선택 사항이 아닙니다. 반드시 채워야 합니다.
애플리케이션 연동 전에 인증부터 확인했습니다
클라이언트가 붙었으니 이제 애플리케이션에서 보낼 차례인데, 코드를 짜기 전에 자격증명이 유효한지부터 봤습니다. 안 되는 자격증명으로 코드를 짜면 나중에 원인이 코드인지 계정인지 가릴 수 없습니다.
메일을 실제로 보내지 않고 SMTP 인증까지만 하는 스크립트를 만들었습니다. TLS로 붙어서 EHLO, AUTH LOGIN, 계정과 비밀번호를 base64로 보내고, 응답 코드만 확인한 뒤 QUIT 합니다.
✓ 서버 인사 220
✓ EHLO 250
✓ AUTH 개시 334
✓ 사용자 전송 334
✓ 비밀번호 검증 235
✓ 종료 221235가 인증 성공입니다. 이걸 확인하고 나서야 구현을 시작했습니다.
서버에 넣을 비밀번호
애플리케이션이 SMTP에 로그인하려면 자격증명을 환경변수에 둬야 합니다. 여기에 계정 비밀번호를 넣으면 안 됩니다.
이 계정은 조직의 수퍼 관리자입니다. 계정 비밀번호가 새면 웹메일뿐 아니라 관리콘솔, 도메인 설정, 결제 정보까지 전부 넘어갑니다. 대응도 전체 비밀번호 교체와 모든 기기 재설정입니다.
앱 전용 비밀번호는 메일 프로토콜에만 쓰이고 관리콘솔 접근이 안 됩니다. 용도별로 따로 발급해서, 하나가 새면 그것만 폐기하면 됩니다. 발급 조건이 2단계 인증 활성화라, 결국 2FA를 켜는 쪽으로 가게 됩니다.
코드에 넣은 가드
발송 기능을 붙이면서 세 가지를 코드 레벨에서 막았습니다. 이건 사람 기억에 맡길 수 없는 종류의 것들입니다.
수신거부 차단. 수신거부 의사를 밝힌 주소로 보내면 법적 문제가 됩니다. 데이터에 emailOptOutAt 같은 칸을 두고, 값이 있으면 발송 함수가 거부하도록 했습니다. 화면에서 버튼을 숨기는 것으로는 부족합니다. 일괄 발송 경로가 하나 더 생기는 순간 뚫립니다.
일일 한도. 새 도메인은 워밍업이 필요합니다. 발송 이력이 없는 도메인이 갑자기 수십 통을 보내면 필터가 스팸 발송 패턴으로 읽습니다. 하루 상한을 환경변수로 두고 넘으면 거부하게 했습니다. 환경변수로 뺀 이유는 워밍업 주차마다 값이 바뀌기 때문입니다. 코드에 박으면 매주 배포해야 합니다.
Zoho Mail 자체의 한도도 있습니다. 하루 약 500통이고, 남용 감시 시스템이 계정 평판에 따라 동적으로 조정합니다. 그리고 Zoho Mail은 약관상 대량 발송과 마케팅 메일을 금지합니다. 1대1 서신은 괜찮지만 캠페인 발송은 다른 서비스를 써야 합니다.
발송 기록. 누구에게 언제 무엇을 보냈는지를 활동 로그에 자동으로 남깁니다. 사람이 손으로 남기게 하면 반드시 빠집니다. 별도 발송 로그 테이블은 만들지 않았습니다. 초안 테이블에 sentAt을 추가하면 일일 한도 집계도 그걸로 되고, 테이블을 늘리면 두 곳이 어긋납니다.
마지막에 걸린 것
발송 도메인에 웹사이트가 없으면 필터가 일회용 도메인으로 봅니다. 새 사이트를 만들 필요는 없어서 회사 사이트로 301 리다이렉트만 걸었습니다. Cloudflare Redirect Rules 한 줄입니다.
조건 (http.host eq "도메인")
동작 301 → concat("https://회사도메인", http.request.uri.path)A 레코드는 192.0.2.1을 넣고 프록시를 켭니다. RFC 5737 문서용 예약 IP라 실제로 존재하지 않는 주소인데, 프록시가 요청을 가로채서 리다이렉트만 하고 원서버로 보내지 않기 때문에 서버가 필요 없습니다.
규칙을 배포하고 확인했는데 접속이 안 됐습니다. 포트 443이 닫혀 있다고 나왔습니다.
규칙 표현식을 의심했습니다. http.host eq 가 맞는지, 대소문자 문제인지 찾아봤습니다. 전부 아니었습니다.
DNS를 우회해서 Cloudflare 엣지 IP로 직접 요청해 보니 정상이었습니다.
301 → https://회사도메인/
/about → https://회사도메인/about원인은 제 컴퓨터의 음수 캐시였습니다. A 레코드를 만들기 전에 조회한 "이 도메인 없음" 결과가 로컬에 남아 있었습니다. 음수 캐시의 유효 시간은 SOA 레코드의 최소값을 따르는데, 이 존은 1800초, 즉 30분이었습니다.
설정은 처음부터 정상이었고 제 쪽에서만 안 보이던 겁니다. sudo dscacheutil -flushcache 로 끝났습니다.
정리하면
기술 설정 자체는 어렵지 않았습니다. DNS 6줄, SMTP 설정 3줄입니다. 시간을 쓴 곳은 전부 다른 데였습니다.
- 소셜 로그인 계정에는 프로토콜이 쓸 비밀번호가 없다는 것
- IMAP과 ActiveSync가 기본으로 꺼져 있다는 것
- macOS와 iOS가 Exchange를 다른 프로토콜로 연결한다는 것
- 유료 조직은 서버 이름에
pro가 붙는다는 것 - 로컬 DNS 캐시가 30분 동안 거짓말을 한다는 것
공통점이 하나 있습니다. 오류 메시지가 원인을 가리키지 않았습니다. 비밀번호가 없어도, IMAP이 꺼져 있어도, 프로토콜이 달라도 화면에는 똑같이 "계정 이름 또는 암호를 확인할 수 없습니다"가 떴습니다.
그래서 다음에 비슷한 작업을 한다면 순서를 바꾸겠습니다. 클라이언트를 붙이기 전에 서버 쪽 상태부터 확인하는 겁니다. 엔드포인트가 살아 있는지, 계정에 비밀번호가 존재하는지, 프로토콜이 켜져 있는지. 이 셋을 먼저 확인했다면 세 시간이 삼십 분이었을 것 같습니다.
DMARC 정책은 아직 p=none입니다. 리포트가 며칠 쌓이는 것을 보고 quarantine으로 올릴 예정입니다. 워밍업도 이제 1주차라, 이 구성이 실제로 잘 도착하는지는 한 달쯤 뒤에 알게 될 것 같습니다.
함께 읽기
- 커널 패치는 왜 재부팅해야 적용될까? Livepatch 와 자동 재부팅새로 만든 서버를 점검하다가 이걸 봤습니다.
- ingress 와 egress: 터널 설정과 청구서에서 만난 두 방향하루에 두 번 이 단어들을 만났습니다. 오전에는 터널 설정 화면에서 ingress 를 봤고, 오후에는 데이터베이스 청구 항목에서 egress 를 봤습니다. 몇 년째 둘 다 쓰고 있었는데, 그날에서야 같은 축의 양쪽이라는 게 눈에 들어왔습니다.
- Cloudflare 무료 플랜과 한국 엣지: TTFB 1초의 원인 분해데모 사이트 하나가 느렸습니다. 첫 바이트까지 1초가 걸렸습니다. 대시보드를 여는 화면이라 무겁긴 한데, 그래도 1초는 이상했습니다. 앱을 뜯어볼 생각으로 시작했다가, 결국 앱은 한 줄도 안 고쳤습니다.
- Vercel Web Analytics 요금과 설치: Enable 버튼만으로는 안 되는 이유회사 홈페이지의 방문자 수를 Vercel 대시보드에서 보려고 Web Analytics를 켰습니다. Enable을 누르고 잠시 기다렸는데 지표가 0이었습니다. 스크립트를 넣어야 한다는 걸 그때 알았고, 넣고 배포한 뒤에도 한 번 더 헷갈렸습니다. 그 과정을 정리했습니다.
- Prisma 마이그레이션 이전 리허설: migrate diff로는 못 잡는 것들운영 중인 서비스의 데이터베이스를 온프레미스에서 Supabase로 옮기는 중입니다. 지난번에 1단계로 앱을 무상태화했고, 이번이 2단계인 스키마 복제입니다. 프로젝트를 만들고 접속 문자열을 받아 prisma migrate deploy 한 줄 돌리면 끝나는 일이라고 생각했습니다. 그 전에 빈 데이터베이스에 한 번 리허설을…