DEV28 OCT 2026 — [17 MIN]

gmail 강점기를 끝내러 왔다 - 영원히 내 소유인 메일 서버 만들기

집에 둔 미니 PC 한 대로 내 도메인의 메일 서버를 세웠다. 계정 요금은 0원이다. 그 대신 가정용 회선의 한계, 서로를 필요로 하는 가입 절차, 이름 하나를 잘못 정해 잠긴 관리 화면, 받은편지함을 45초씩 늦추던 통신사 DNS를 하나씩 만났다. 하룻밤 동안 부딪친 일을 발견한 순서대로 적는다.

1. 메일함까지 내 서버로 옮기고 싶었다

나는 집에 미니 PC를 두고 있다. Fedora Server 위에 Incus 컨테이너 네 개를 올려 Forgejo, n8n, NocoDB, 사진과 영상 서버, 개발 에이전트를 돌린다. 바깥에서는 Tailscale로만 들어온다. 공개 인터넷에 열린 포트는 하나도 없다. 데이터와 도구를 내가 가진 기계에 두는 것이 이 홈랩의 원칙이다.

아직 홈랩으로 옮기지 못한 업무 도구 중 가장 중요한 건 메일이었다. 조사해보니 Stalwart가 메일과 함께 CalDAV와 CardDAV를 갖고 있었다. 메일 서버 하나로 캘린더와 연락처까지 관리할 수 있다는 뜻이었다.

남은 질문은 하나였다. 정말 이 집에서 메일 서버를 돌릴 수 있는가.

2. 집 회선은 메일을 받을 수 있는가

메일 서버를 세운다는 말은 두 가지를 뜻한다. 계정을 내 마음대로 만들 수 있고, 사 둔 도메인으로 내 주소를 가질 수 있다. 여기까지는 어디서든 된다. 어려운 것은 인터넷으로 메일을 주고받는 일이다.

방향 필요한 것 가정용 회선에서
받기 바깥 메일 서버가 내 서버의 25번 포트로 들어올 수 있어야 한다 통신사가 막는지 알 수 없었다
보내기 받는 쪽이 내 IP를 믿어야 한다 역방향 DNS(PTR)를 통신사만 정할 수 있다

먼저 메일을 받을 수 있는지 직접 측정하기로 했다. 공유기에 임시 포트 포워딩을 걸고 서버에 SMTP 인사말만 돌려주는 15분짜리 리스너를 띄운다. 그런 다음 해외 측정 노드에서 접속해 보는 방식이다.

계획은 첫 단계에서 막혔다. 공유기의 UPnP가 꺼져 있어 서버에서 포워딩을 걸 수 없었다. 사람에게 관리 화면을 눌러 달라고 부탁할 수도 있었다. 그런데 ipTIME의 새 관리 화면이 Flutter 앱이라 코드를 살펴보았다. 통신 방식은 /cgi/service.cgi 하나에 {"method": ..., "params": ...}를 보내는 JSON-RPC였다. 앱 코드(5MB짜리 main.dart.js)에서 session/login과 portforward/add의 인자 이름을 찾아냈다.

그런데 로그인에 실패했다. 오류 메시지도 없었다. 응답은 매번 로그인 페이지로 돌려보내는 짧은 스크립트였다. 요청 형식은 앱과 같았는데, 다른 점은 브라우저가 자동으로 붙이는 Referer와 Origin 헤더뿐이었다. 두 헤더를 붙이자 {"result":"done"}이 돌아왔다. 공유기는 출처를 검사하면서도 거절 이유를 말하지 않았다.1

측정에는 대조군을 하나 두었다. 외부 25번만 열었다가 접속에 실패하면 원인을 가리기 어렵다. 통신사가 막았을 수도 있고 공유기 설정이나 서버 방화벽에 문제가 있을 수도 있다. 그래서 같은 리스너에 외부 2526번도 함께 연결했다.

외부 포트 결과
2526 (대조군) 측정 노드 8곳 모두 성공
25 스페인, 프랑스, 이란, 스웨덴 노드 성공. 미국과 러시아 노드는 시간 초과

서버 리스너에서 실제 접속 기록을 확인했다. 시간 초과가 난 두 노드는 호스팅 업체가 나가는 25번을 막았을 것으로 보았다. 집 회선으로 들어오는 25번은 열려 있었다. 측정이 끝난 뒤 포워딩 규칙과 리스너, 방화벽 허용을 모두 되돌렸다.

보내는 쪽은 따로 측정할 필요가 없었다. 나가는 25번은 열려 있었지만 역방향 DNS가 비어 있었다. 가정용 대역에서 PTR도 없이 보낸 메일은 Gmail이나 Outlook이 스팸으로 분류하기 쉽다.

Cloudflare로 우회할 수 있는지도 따져 보았다. Cloudflare Tunnel은 HTTP용이라 바깥 메일 서버가 보내는 SMTP를 실어 나르지 못한다. Email Routing은 받은 메일을 이미 있는 주소로 전달할 뿐 메일함을 만들어 주지 않는다. 그래서 메일은 집 회선으로 직접 받고, 보낼 때만 SMTP 릴레이의 평판을 빌리기로 했다. 처음에는 메일을 받을 VPS도 생각했지만, 측정 결과 25번이 열려 있어 두지 않기로 했다.

3. 릴레이에 가입하려면 메일 서버가 있어야 했다

릴레이는 SMTP2GO로 골랐다. 무료로 월 1,000통을 보낼 수 있고, 가입하면 SMTP 자격 증명을 바로 준다. 개인 메일에는 넉넉한 양이다.

가입 화면에 개인 Gmail 주소를 넣자 이런 오류가 떴다.

Error code 6 - Please use an email at your own domain to sign up

무료 메일 주소로는 누구나 릴레이 계정을 계속 만들 수 있으니 자기 도메인 주소만 받는다는 뜻이다. 문제는 내 도메인 duckout.dev에 아직 메일을 받을 곳이 없다는 데 있었다. 메일 서버를 세우려면 릴레이가 필요하고, 릴레이에 가입하려면 메일을 받을 수 있어야 했다.

Cloudflare Email Routing을 임시로 켜서 가입 메일을 받기로 했다. admin@duckout.dev로 오는 메일을 개인 Gmail로 넘기도록 했다. 켜는 과정에서도 한 번 막혔다. 대시보드가 “기존 MX 레코드와 충돌한다”며 진행을 거부했다. 이 도메인에는 메일을 쓰지 않을 때 두는 기본 설정이 들어 있었다.

레코드 값 뜻
MX 0 . 이 도메인은 메일을 받지 않는다 (Null MX)
SPF v=spf1 -all 이 도메인 이름으로 보낸 메일은 전부 가짜다
DMARC p=reject 검사에 실패한 메일은 거절하라

예전에 사칭을 막으려고 넣은 설정 때문에 진행할 수 없었다. Null MX와 SPF를 백업하고 지운 뒤에야 Email Routing을 켤 수 있었다.

전달이 되는지 확인하려고 서버에서 admin@duckout.dev로 시험 메일을 보냈다. Cloudflare가 거절했다.

550 5.7.1 DMARC checks failed.

처음에는 라우팅이 고장 난 줄 알았다. 원인은 시험 방식에 있었다. Cloudflare는 전달하기 전에 보낸 쪽 도메인의 DMARC를 검사한다. 내가 발신자로 쓴 tailnet 도메인의 SPF에 집 IP가 들어 있을 리 없었다. 오히려 거절 응답으로 수신 경로가 작동한다는 것을 확인했다. MX에 접속해 TLS 연결을 맺었고, Cloudflare가 수신자까지 받은 뒤 정책 검사 단계에서 거절했기 때문이다. 실제 확인은 SMTP2GO 가입으로 대신했다. 인증 메일이 Gmail에 도착했다.

발신 도메인을 증명하는 CNAME 두 개

SMTP2GO는 가입 뒤 Verified Sender부터 등록하라고 했다. 이 도메인으로 보낼 권리가 내게 있다는 것을 DNS로 증명하는 단계다. 화면에는 CNAME 세 개가 나왔다.

레코드 하는 일
s<번호>._domainkey → dkim.smtp2go.net SMTP2GO가 duckout.dev 이름으로 DKIM 서명을 한다
em<번호> → return.smtp2go.net 반송 주소를 duckout.dev 하위 도메인으로 맞춘다
link → track.smtp2go.net 메일 속 링크를 추적용 주소로 바꾼다

앞의 두 개만 넣었다. 세 번째는 누가 언제 링크를 눌렀는지 기록하는 마케팅 기능이다. 열람 추적도 비슷하다. 본문에 보이지 않는 1×1 이미지를 넣어 누가 언제 메일을 열었는지 수집한다. 개인 메일에서 받는 사람의 열람 시각과 IP를 몰래 모을 이유는 없다. 이런 장치가 있으면 스팸 필터도 메일을 의심한다.

이 과정에서 Claude Code의 안내가 한 번 틀렸다. 추적을 끄는 곳이 “Settings 아래 Tracking 메뉴”라고 했는데, 그런 메뉴는 없었다. SMTP2GO는 추적을 계정 전체가 아니라 SMTP 사용자마다 켜고 끈다. 메뉴 위치를 확인하지 않고 짐작으로 말한 것이었다.

첫 발송

SMTP 사용자를 만들고 서버에서 admin@duckout.dev 이름으로 한 통을 보냈다. Gmail이 받은 헤더를 아래에서 위로 읽으면 메일이 지나간 길이 그대로 보인다.

단계 판정
SMTP2GO가 d=duckout.dev로 DKIM 서명, 반송 주소를 em<번호>.duckout.dev로 교체
Cloudflare MX 도착 dkim=pass, spf=pass, dmarc=pass, 스팸 점수 0
Cloudflare가 ARC 봉인을 붙여 Gmail로 전달
Gmail 도착 dkim=pass 세 개, arc=pass, dmarc=pass (p=REJECT)

보낸 뒤 Gmail에 닿기까지 11초가 걸렸다. 헤더 어디에도 집 IP는 남지 않았다.

여기서도 한 번 잘못 판단했다. 메일 앱에서 내보낸 .eml 파일을 보니 본문이 텍스트와 HTML 두 부분으로 되어 있었다. 그래서 “SMTP2GO가 HTML판을 덧붙였다”고 판단했다. 그런데 그 파일에는 Received, DKIM-Signature 같은 서버 헤더가 하나도 없었고, 내보낼 때마다 경계 문자열이 달라졌다. 내보내기 도구가 헤더를 버리고 다시 조립한 사본이었다. Gmail의 원본 보기로 확인한 원문에는 텍스트 한 부분만 있었다.

4. Stalwart를 세우다

메일 서버로는 Stalwart를 골랐다. 다른 후보와 비교할 때 기준은 하나였다. 캘린더까지 한 번에 되는가.

Stalwart Mox Mailu
메일 SMTP, IMAP, JMAP SMTP, IMAP Postfix, Dovecot
캘린더·연락처 CalDAV, CardDAV 내장 없음 없음
구성 컨테이너 1개, 내장 RocksDB 바이너리 1개 컨테이너 10개

Mox나 Mailu를 고르면 캘린더 서버를 하나 더 세워야 한다. Stalwart는 웹메일이 없다는 약점이 있었지만, 화면은 나중에 붙이면 된다고 보았다.

전체 구조는 다음과 같이 잡았다.

flowchart LR
    EXT["바깥 메일 서버"] -->|":25"| ROUTER["공유기<br/>25번만 포워딩"]
    ROUTER --> HOST["GEM12 호스트"]
    HOST -->|"incus proxy<br/>nat 모드"| SW["Stalwart<br/>(apps 컨테이너)"]
    SW -->|":587 인증"| RELAY["SMTP2GO"]
    RELAY --> DEST["상대 메일 서버"]
    DEV["맥 · 아이폰<br/>(tailnet)"] -->|":993 :465 :443"| SERVE["tailscale serve<br/>TCP 그대로"]
    SERVE --> SW

인터넷에 여는 포트는 25번 하나다. 메일 앱이 붙는 IMAP 993, 제출 465, CalDAV와 관리 화면 443은 tailnet 안에만 둔다. 그러려면 이름이 둘 필요했다. 바깥 서버가 찾아오는 mx.duckout.dev는 집 공인 IP를, 내 기기가 붙는 mail.duckout.dev는 tailnet IP를 가리킨다. 이름 하나로 두면 메일 앱이 993과 465도 공인 IP로 찾아가므로 그 포트까지 인터넷에 열어야 한다.

배포하면서 세 가지를 확인하고 설정을 정했다.

보낸 쪽 IP를 지킨다. 호스트의 25번을 컨테이너로 넘길 때 Incus proxy 장치를 기본 모드로 쓰면 연결을 새로 맺는다. 그러면 모든 메일이 호스트 주소(10.10.10.1)에서 온 것처럼 보인다. SPF 검사와 DNS 블랙리스트 조회, 그레이리스팅에서 보낸 쪽 IP를 알 수 없게 된다. nat=true를 주면 커널이 주소만 바꿔 넘긴다. 맥에서 LAN 주소로 SMTP 대화를 한 번 시도했다. Stalwart 로그에 remoteIp = 192.168.0.4로 맥의 주소가 그대로 남았다.

포트 하나가 겹쳤다. Stalwart는 첫 실행 때 8080번에 설정 마법사를 띄운다. 그런데 같은 컨테이너의 8080은 이미 NocoDB가 쓰고 있었다. 마법사 포트를 18080으로 옮기고 사설 주소에만 묶었다.

tailnet 입구는 TLS를 풀지 않는다. 다른 웹 콘솔은 tailscale serve가 HTTPS를 받아 풀어서 넘기지만, 메일은 Stalwart가 mail.duckout.dev 인증서로 직접 TLS를 끝내야 한다. 그래서 TCP를 그대로 넘겼다. 커밋 직후 자동 보안 리뷰가 이 부분을 짚었다. TCP로 넘기면 Stalwart는 tailnet에서 오는 모든 접속을 같은 주소에서 온 것으로 인식한다는 지적이었다. 이 경로에는 내 기기만 닿을 수 있어 그대로 두었다. 다만 부작용 하나는 기록해 두었다. 비밀번호가 틀린 기기가 접속을 되풀이해 그 주소가 차단되면 모든 기기가 함께 막힌다.

5. 마법사가 DNS를 덮어썼다

마법사는 다섯 화면으로 이루어져 있었다. 대부분 기본값으로 두었고 다음 값만 바꾸었다.

화면 바꾼 값 이유
저장소 Block cache 256MB 비워 두면 컨테이너 메모리 한도의 1/8, 약 2GB를 캐시로 잡는다
로깅 Console, 색 끔 기본값인 파일 로그는 컨테이너 안에 쌓였다가 다시 만들 때 사라진다
DNS Cloudflare 자동 관리 DKIM 레코드를 손으로 옮기지 않고, 인증서 발급에도 같은 연결을 쓴다

재시작하자 로그가 DNS 기록으로 가득 찼다. DKIM 키 두 개, SRV 레코드 여섯 개, autoconfig와 autodiscover, MTA-STS, TLS 보고 주소가 차례로 생겼다. 그런데 그중 일부는 추가가 아니라 교체였다.

레코드 마법사 전 마법사 후
루트 SPF include:_spf.mx.cloudflare.net ~all v=spf1 mx -all
DMARC p=reject; sp=reject; adkim=s; aspf=s p=reject; rua=mailto:postmaster@duckout.dev

SPF 교체로 Cloudflare 전달 경로의 SPF가 깨졌다. MX를 곧 옮길 예정이라 그대로 두었다. DMARC는 엄격한 정렬이 느슨한 정렬로 바뀌었다. 내가 보내는 메일은 DKIM 서명 도메인이 From:과 정확히 같아 어느 쪽이든 통과한다. 그래서 이것도 그대로 두었다. 마법사를 돌리기 전에 DNS 전체를 백업해 둔 덕분에 무엇이 바뀌었는지 정확히 비교할 수 있었다.

인증서 발급도 지켜보았다. Stalwart는 Cloudflare API로 _acme-challenge TXT를 만들고 Let’s Encrypt에 DNS-01 검증을 요청했다. 그런데 로그가 멈췄다. 레코드는 1.1.1.1에서 바로 보였는데, Stalwart는 레코드가 퍼졌는지 확인하는 단계에서 5분을 기다린 뒤 propagation-timeout을 남겼다. 그래도 그다음 줄은 acme.auth-valid였다. Stalwart의 자체 확인에는 실패했지만 인증서 검증은 통과한 것이다. 와일드카드 인증서라 같은 과정을 두 번 거쳤다. 약 10분 뒤 *.duckout.dev 인증서를 받았다.2

6. 이름 하나 때문에 관리 화면에 들어갈 수 없었다

인증서를 적용한 뒤 맥에서 mail.duckout.dev의 993, 465, 443 모두 정식 인증서로 접속할 수 있었다. 관리 화면에 로그인하려고 이메일 주소를 넣자, 비밀번호를 입력하기도 전에 이런 화면이 떴다.

Unable to continue
Unknown client id

비밀번호 문제가 아니었다. Stalwart의 OAuth 설정을 직접 조회하니 원인을 알 수 있었다.

{"issuer":"https://mx.duckout.dev",
 "authorization_endpoint":"https://mx.duckout.dev/login", ...}

마법사 첫 화면에서 서버 호스트 이름을 mx.duckout.dev로 넣었다. 받는 메일이 찾아오는 이름이니 그게 맞다고 생각했고, Claude Code도 그렇게 권했다. 그런데 Stalwart는 이 이름으로 OAuth 발급자, JMAP 주소, SRV와 autoconfig 레코드를 모두 만든다. mx.duckout.dev는 공인 IP를 가리키고 그 IP의 443은 닫혀 있다. mail.duckout.dev로 접속했는데 로그인 과정에서 브라우저가 다른 주소로 이동하고 있었다. 서버 호스트 이름은 사용자가 접속하는 이름이어야 했다.

고치는 방법은 호스트 이름을 mail.duckout.dev로 바꾸는 것이다. 그런데 그 설정을 바꾸려면 먼저 로그인해야 했다. 문제를 고치려면 막힌 단계를 먼저 지나야 하는 상황이었다.

잠시 맥에서 mx.duckout.dev를 tailnet 주소로 찾도록 바꾸기로 했다. 처음에는 시스템을 건드리지 않으려고 Chrome에 --host-resolver-rules 플래그를 준 별도 창을 띄웠는데, 브라우저가 이를 거부했다. 결국 다른 터미널에서 /etc/hosts에 한 줄을 넣고 로그인했다. Claude Code 안에서 sudo를 실행하면 비밀번호를 물을 터미널이 없어 실패했기 때문이다.

Network 설정에서 Default Hostname을 mail.duckout.dev로 바꾸고 저장했다. 저장만으로는 발급자가 바뀌지 않았다. 컨테이너를 재시작하자 발급자가 https://mail.duckout.dev로 바뀌었다.

그런데 DNS에는 여전히 옛 이름이 남아 있었다. Stalwart는 호스트 이름이 바뀌어도 이미 만든 SRV와 autoconfig 레코드를 고치지 않는다. 레코드의 수정 시각이 모두 처음 만든 시각 그대로였다. 메일 앱이 자동 설정을 시도하면 공인 IP로 찾아가 실패할 상황이었다. API를 써서 SRV 다섯 개와 CNAME 세 개가 가리키는 이름을 직접 mail.duckout.dev로 고쳤다. 공개 HTTPS 없이는 쓸 수 없는 MTA-STS 레코드와 열지 않은 POP3의 SRV는 지웠다.

고친 직후 Cloudflare 권한 서버에 물어보니 옛 값이 나왔다. 수정에 실패한 줄 알고 다시 확인해 보니 권한 서버 두 대 가운데 한 대만 늦었다. Cloudflare 서버 사이에서 새 값을 전달하는 데 잠시 시간이 걸린 것이었다.

7. 메일은 도착했지만 SPF 조회는 늦고 있었다

Cloudflare Email Routing을 끄니 Cloudflare가 MX와 전달용 DKIM을 함께 지웠다. 그 순간 이 도메인에는 MX가 하나도 없었다. 바로 10 mx.duckout.dev를 넣었다. 공유기에는 25번 포워딩을 영구 규칙으로 걸었다.

Gmail에서 admin@duckout.dev로 첫 메일을 보냈다. 메일은 정상(ham) 판정을 받고 메일함에 들어갔다. 겉으로는 성공이었다. 그런데 로그의 시각이 이상했다.

19:00:21  EHLO  mail-ot1-f43.google.com
19:00:36  SPF EHLO check failed  result = SPF temporary error
19:00:51  SPF EHLO check failed  result = SPF temporary error
19:01:07  SPF From check failed  from = …@gmail.com, result = SPF temporary error
19:01:16  Message ingested (ham)

SPF 조회는 매번 정확히 15초를 기다린 뒤 temp-error로 끝났다. 세 번이니 메일 한 통에 45초다. DKIM과 DMARC 조회는 통과했으니 DNS 전체의 문제는 아니었다. 메일은 들어왔지만 SPF를 판정하지 못하면 스팸 점수에 불리하게 작용한다. 그대로 두면 Gmail에서 온 메일도 스팸으로 의심할 여지가 커진다. 나중에 판정 경계에 걸린 메일이 스팸함으로 가더라도 이유를 찾기 어려웠을 것이다.

각 단계에 같은 질의를 보내 어디서 응답이 끊기는지 찾았다. Gmail의 SPF 레코드는 _netblocks.google.com이라는 이름을 참조한다.

질의를 받은 곳 _netblocks.google.com TXT
컨테이너의 resolver (10.10.10.1) 시간 초과
호스트의 systemd-resolved (127.0.0.53) 시간 초과
통신사 DNS 두 대 (DHCP로 받은 것) 둘 다 시간 초과
1.1.1.1 112바이트 정상 응답, 즉시

통신사 DNS는 같은 시각에 gmail.com TXT에는 바로 답했다. 응답 크기도 작아서 TCP 재시도 문제도 아니었다. 가정용 회선의 DNS 서버가 이 이름 하나에만 답하지 않았다. 앞서 인증서를 발급할 때 자체 확인에 5분씩 걸린 뒤 실패한 일과 가짜 도메인의 SPF 조회에 15초씩 걸린 일도 비슷한 문제였을 수 있다.

고치는 범위는 좁게 잡았다. 호스트 전체의 DNS를 바꾸면 다른 서비스에도 모두 영향을 준다. 스팸 판정처럼 DNS 조회 하나하나에 결과가 걸리는 곳은 메일 서버뿐이다. 그래서 Stalwart 컨테이너에만 --dns 1.1.1.1 --dns 8.8.8.8을 주고 다시 만들었다.

조치 전 조치 후
보낸 주소의 SPF temp-error pass
접속부터 메일함 도착까지 약 55초 약 5초

8. 메일을 어디서 읽을 것인가

이제 남은 고민은 클라이언트였다. Stalwart의 관리 화면은 서버를 설정하는 곳이라 메일 본문을 보여 주지 않는다. 애플 기본 메일 앱은 애플 생태계에 묶이고, Thunderbird는 화면을 읽기 불편하다. 게다가 이 GEM12는 곧 서버를 다른 기계로 넘기고 Linux 데스크톱이 될 예정이다. 맥, Linux, 아이폰에서 모두 쓸 수 있어야 했다.

답은 Bulwark였다. Stalwart를 위해 만든 JMAP 웹메일이다. 메일과 캘린더, 연락처를 한 화면에서 다루고 PWA로 설치할 수 있다. 서버를 하나 더 띄우지 않아도 됐다. Stalwart 관리 화면의 Web Applications 메뉴에서 GitHub 릴리스의 zip 파일을 받아 정해진 경로에 띄울 수 있었다. 관리 화면 자체도 그렇게 올라가 있었다. Bulwark의 Stalwart용 zip 주소와 /webmail 경로를 등록하고, OAuth 클라이언트 bulwark-webmail을 시크릿 없는 자사 앱으로 만든 뒤 Update applications를 누르면 된다.

여기서도 두 가지를 더 확인해야 했다. 경로 접두어 칸에 /webmail을 입력하고 저장하니 “Minimum length is 1”이라는 검증 오류가 났다. 목록형 칸이라 입력 뒤 +를 눌러 항목으로 붙여야 했다. 또 zip 주소는 최신판을 자동으로 따라가는 latest 대신 버전을 고정했다. Stalwart는 내려받은 zip 파일의 해시와 서명을 검사하지 않는다.

https://mail.duckout.dev/webmail/에 들어가자 한국어 화면의 받은편지함에 Gmail에서 보낸 두 통이 나란히 보였다.

9. 직접 운영하며 맡아야 할 일

시행착오 발견한 방법 원인 조치
공유기 로그인이 조용히 실패 앱 요청과 헤더 비교 Referer, Origin 검사 헤더 추가
릴레이 가입 거절 가입 오류 메시지 무료 메일 주소 차단 Email Routing으로 임시 수신
Email Routing 활성화 거부 대시보드 충돌 경고 사칭 방지용 Null MX와 SPF 백업 후 삭제
시험 메일 550 거절 SMTP 응답 코드 보낸 쪽 도메인의 DMARC 실제 가입 메일로 확인
HTML 부분이 붙은 것처럼 보임 헤더 부재, 경계 문자열 차이 내보내기 도구가 다시 조립한 사본 원본 보기로 재확인
DNS가 덮어써짐 마법사 전후 DNS 비교 Stalwart 자동 DNS 관리 영향 판단 후 유지
관리 화면 로그인 불가 OAuth 발급자 조회 호스트 이름을 MX 이름으로 지정 /etc/hosts 우회 후 호스트 이름 변경
SRV가 옛 이름에 남음 레코드 수정 시각 호스트 이름 변경을 DNS가 따라오지 않음 API로 직접 수정
수신 45초 지연 15초 간격의 temp-error 로그 통신사 DNS가 특정 이름에 무응답 메일 컨테이너만 공개 DNS

비용은 도메인 값을 빼면 0원이다. 계정은 원하는 만큼 만들 수 있고, 메일과 캘린더와 연락처가 모두 집 디스크에 있다. 그 대가로 Google Workspace가 대신 해 주던 일이 내 몫이 됐다. 집 서버가 꺼지면 메일을 받을 수 없다. 보낸 서버들이 며칠간 다시 시도하므로 메일을 바로 잃지는 않지만 늦게 받게 된다. 발송은 SMTP2GO 무료 한도 안에서만 된다.

남은 일도 있다. Stalwart는 릴레이로 나가는 메일에 자기 DKIM 서명을 붙이지 않는다. 지금은 SMTP2GO의 duckout.dev 서명으로 DMARC를 통과하지만, 릴레이를 바꾸면 다시 확인해야 한다. MTA-STS는 정책 파일을 Cloudflare 정적 호스팅에 올리면 집 포트를 더 열지 않고도 켤 수 있다. 메일이 안정된 뒤에 할 일로 남겨 두었다.

이 구조는 회사 메일에도 그대로 옮길 수 있다. Stalwart는 도메인 여러 개와 그룹 주소, 공유 메일함을 지원한다. 다만 회사라면 서버를 집이 아니라 고정 IP를 받는 VPS에 둘 것이다. 하룻밤 동안 집 회선의 한계를 직접 겪으면서 소프트웨어 문제와 회선 문제를 구분할 수 있게 됐다.


Footnotes

  1. 되새김(2026-10-29). 측정을 위해 공유기 관리 API에 연결했다. 사람에게 관리 화면을 눌러 달라고 부탁하면 대조군 포트 두 개를 정확히 같은 시각에 열고 닫기 어렵다. 측정 전후로 포트 포워딩 목록이 비어 있음을 같은 API로 확인했다. 영구 규칙은 나중에 25번 하나만 넣었다. ↩

  2. 되새김(2026-10-29). 자체 확인이 실패한 이유는 끝까지 확정하지 못했다. 같은 리졸버 경로를 쓰던 시기여서 레코드를 만들기 전에 조회해 둔 “없음” 응답이 중간 캐시에 남았을 가능성이 크다. 발급은 통과하고 갱신만 5분씩 늦어져 더 살펴보지는 않았다. 나중에 메일 컨테이너를 공개 DNS로 옮기면서 이 문제도 사라졌을 수 있다. ↩

TAGS — 홈랩 / 메일 / 셀프호스팅 / 회고

gmail 강점기를 끝내러 왔다 - 영원히 내 소유인 메일 서버 만들기 — OG 카드