본문으로 건너뛰기
블로그로 돌아가기

Outlook OAuth 오디세이: IMAP은 망가졌다, Graph 만세

8분 분량

이 글은 GitHub 이슈 하나 — "Microsoft 계정 로그인이 안 됩니다" — 가 어떻게 우리 Azure AD 애플리케이션을 직접 등록하고, Rust로 Microsoft Graph REST API 전송 계층을 통째로 만들고, 메일이 마침내 뜨기까지 연쇄적으로 터진 일곱 가지 문제를 디버깅하는 일로 번졌는지에 대한 이야기입니다. 설정 하나 고치면 될 일이 MailVault가 Microsoft와 대화하는 방식을 바닥부터 다시 만드는 일이 되었습니다.

버그 신고

두 사용자가 서로 다른 각도에서 같은 것을 신고했습니다. 기업용 M365 사용자는 로그인하려 할 때 AADSTS900971: No reply address provided를 보았습니다. 개인 Outlook.com 사용자는 로그인 화면은 지나갔지만 앱이 IMAP으로 연결하려 할 때 AUTHENTICATE failed에 부딪혔습니다. 증상은 같지만 — "로그인이 안 된다" — 근본 원인은 완전히 다른 둘이었습니다.

문제 1: 빌려 쓴 클라이언트 ID

MailVault의 초기 Microsoft OAuth2 흐름은 Thunderbird의 널리 알려진 공개 클라이언트 ID를 썼습니다. 흔한 방식입니다 — Thunderbird가 Microsoft와 Google에 OAuth2 앱을 등록해 두어 다른 메일 클라이언트가 그 위에 얹혀 갈 수 있습니다. 문제가 생기기 전까지는 훌륭하게 작동합니다.

기업용 M365 테넌트에서는 Thunderbird 앱에 우리 localhost:19876/callback 리디렉션 URI가 등록되어 있지 않습니다. Azure AD는 정확히 일치할 것을 요구하는데, Thunderbird의 앱에는 Thunderbird 자신의 리디렉션 URI만 올라가 있습니다. 해결: MailVault 자체 Azure AD 앱을 우리 리디렉션 URI 그대로 등록하고, 공개 클라이언트(시크릿 없음, PKCE만)로 설정해 조직 계정과 개인 Microsoft 계정을 모두 지원하게 했습니다.

문제 2: Microsoft가 IMAP을 망가뜨렸다

개인 Outlook.com 문제는 더 나빴습니다. 2024년 12월부터 Microsoft에는 개인 계정의 IMAP OAuth2에 영향을 주는 서버 쪽 퇴행이 있습니다. OAuth2 흐름이 성공적으로 끝난 뒤에도 XOAUTH2 SASL 악수가 "User is authenticated but not connected."로 실패합니다. 이것은 클라이언트 버그가 아니라 — Microsoft의 IMAP 서버가 멀쩡한 토큰을 거부하는 것입니다. Microsoft 지원 포럼의 해당 글은 몇 달째 열려 있고 고쳐질 기미가 없습니다.

개인 계정에서는 앱 비밀번호가 여전히 작동하지만, 그러면 OAuth2를 쓰는 의미가 사라집니다. 전혀 다른 접근이 필요했습니다.

최후의 수단: Microsoft Graph API

IMAP이 망가졌다면 IMAP을 쓰지 않으면 됩니다. Microsoft Graph는 IMAP을 전혀 거치지 않고 메일함에 바로 접근하게 해 주는 REST API입니다. outlook.office365.com:993에 연결해 IMAP 프로토콜로 말하는 대신, graph.microsoft.com/v1.0/me/messages에 Bearer 토큰을 실어 HTTPS 요청을 보냅니다. 같은 OAuth2 토큰, 완전히 다른 전송입니다.

Graph 전송 계층을 만든다는 것은 앱의 거의 모든 층을 건드린다는 뜻이었습니다:

Rust 백엔드 — list_folders(), list_messages(), get_message(), delete_message(), set_read_status(), get_mime_content()를 구현한 GraphClient 구조체가 든 새 graph.rs 모듈입니다. Graph의 JSON 응답을 담을 자료 구조(GraphMessage, GraphMailFolder 등)와, Graph 메시지를 기존 EmailHeader 구조체로 옮기는 변환 메서드도 함께 넣었습니다. 이 모두를 프런트엔드에 열어 주는 새 Tauri 명령 여섯 개도 만들었습니다.

OAuth2 범위 분기 — IMAP 계정에는 IMAP.AccessAsUser.All과 SMTP.Send 범위가 필요합니다. Graph 계정에는 Mail.ReadWrite와 Mail.Send가 필요합니다. 범위 선택은 사용자가 "Sign in with Microsoft"를 누르는 순간, 그 메일 도메인이 알려진 개인용 Microsoft 도메인(outlook.com, hotmail.com, live.com과 지역별 변형 12개)인지에 따라 이뤄집니다.

프런트엔드 저장소 — Zustand 저장소의 모든 메일 작업에 — 헤더 불러오기, 메일 선택, 읽음/안 읽음 표시, 삭제, 검색, 미리 가져오기까지 — if (isGraphAccount(account)) 분기가 필요했습니다. Graph 메시지는 정수 UID 대신 속을 알 수 없는 문자열 ID를 쓰므로, Graph 메시지에 인공적인 연번 UID를 붙였다가 API를 호출할 때 되돌려 주는 매핑 계층(_graphIdMap)을 둡니다.

배경 파이프라인 — 배경 헤더 로딩과 내용 캐싱을 맡는 AccountPipeline 클래스는 두 단계 모두에 Graph 전용 경로가 필요했습니다. 헤더 로딩은 IMAP 페이지 가져오기 대신 graphListMessages를 통한 건너뛰기 기반 페이지 나누기를 씁니다. 내용 캐싱은 IMAP FETCH 대신 graphCacheMime으로 전체 MIME을 내려받습니다(원본 .eml을 가져와 Maildir에 저장하고 파싱하는 Rust 명령 하나입니다).

일곱 번의 실패

구현을 마치고 실제 Outlook.com 계정으로 배포해 시험을 시작했습니다. 그 뒤로는 수정 하나가 다음 문제를 드러내는 디버깅 마라톤이 이어졌습니다.

실패 1: "Invalid client secret provided" — CI 빌드가 MAILVAULT_MS_CLIENT_SECRET을 환경 변수로 설정하고 있었습니다. 코드는 그것을 곧이곧대로 Azure에 보냈고, Azure는 우리 앱이 공개 클라이언트(시크릿 사용 불가)로 등록되어 있다는 이유로 거부했습니다. 해결: Microsoft에 대해서는 client_secret: None을 고정값으로 두었습니다 — 공개 PKCE 클라이언트는 어떤 경우에도 시크릿을 보내서는 안 됩니다.

실패 2: 연결 테스트에서 "AUTHENTICATE failed" — 시크릿 문제를 고치자 OAuth2 흐름은 정상적으로 끝났습니다. 그런데 앱이 곧바로 IMAP으로 연결을 테스트했습니다 — 바로 그 망가진 프로토콜로 말입니다. addAccount 함수에는 Graph 분기 없이 IMAP 연결 테스트가 고정되어 있었습니다. 해결: Graph 계정은 IMAP 세션을 여는 대신 graphListFolders를 호출해 테스트하도록 했습니다.

실패 3: "Could not load email content" — 헤더는 Graph로 잘 불러왔지만 메일을 눌러 읽으려 하면 실패했습니다. 채팅 보기와 스레드 보기 양쪽에서 메일 본문을 점진적으로 불러오는 useChatBodyLoader 훅은 IMAP의 fetchEmailLight로 본문을 가져오는 방법밖에 몰랐습니다. Graph 계정에서는 매핑된 Graph 메시지 ID로 graphGetMessage를 써야 했습니다. 해결: Graph ID를 찾아 Graph API로 가져오는 분기를 추가했습니다.

실패 4: 메일이 한 번 열린 뒤 사라짐 — 처음에는 메일이 잘 열렸지만 다른 곳에 갔다가 돌아오면 스레드가 비어 있었습니다. Graph 분기는 메일 본문을 메모리 LRU 캐시에만 담고 .eml 파일을 디스크에 쓰지 않았습니다. LRU 캐시가 그것을 밀어내면 본문은 사라졌습니다. 해결: 빠른 graphGetMessage JSON 응답으로 메일을 표시한 뒤, 배경에서 graphCacheMime을 호출해 전체 MIME을 내려받고 .eml을 Maildir에 저장하도록 했습니다.

실패 5: 메일을 눌러도 스피너가 끝나지 않음 — 실패 4를 처음 고칠 때는 graphCacheMime을 주된 가져오기 방법으로 썼습니다(MIME 내려받기, 디스크에 저장, 파싱, 반환). 이 방식은 무언가를 보여 주기 전에 첨부파일까지 포함한 원본 메일 전체를 내려받습니다. 첨부파일이 붙은 5 MB짜리 메일이라면 몇 초 동안 스피너만 돕니다. 해결: 즉시 표시할 때는 (HTML 본문만 담긴 빠른 JSON인) graphGetMessage를 쓰고, 디스크 보존은 배경에서 graphCacheMime으로 처리했습니다. 두 단계로 나눈 로딩입니다. 표시는 즉시, 저장은 뒤늦게.

실패 6: 스레드 보기에서 접었다 펴야 내용이 보임 — 스레드 대화 보기의 메일은 직접 접었다 다시 펴기 전까지 "Could not load email content"를 표시했습니다. React 렌더링 경쟁 상태였습니다. useChatBodyLoader 훅은 불러온 본문을 비어 있는 상태로 시작하는 useRef(new Map())에 담았습니다. ThreadEmailItem 컴포넌트는 렌더 도중 이 맵을 동기적으로 읽었지만, 맵을 채우는 useEffect는 첫 렌더 뒤에 실행되었습니다. 본문이 준비되었을 때 컴포넌트는 이미 "Could not load."를 렌더한 뒤였습니다. 접었다 펴면 다시 렌더되면서 그제야 채워진 맵을 찾았습니다. 해결: 메모리 캐시를 확인해, useEffect 안이 아니라 훅 본문에서 동기적으로 본문 맵을 미리 채웠습니다. 캐시에 없는 본문은 여전히 비동기 useEffect가 가져오지만, 캐시에 있는 것은 첫 렌더에서 바로 쓸 수 있습니다.

실패 7: 배경 동기화가 여전히 IMAP을 두드림 — refreshAllAccounts 함수는 타이머에 맞춰 모든 계정을 새로 고칩니다. 활성 상태가 아닌 계정에 대해서는 조건 없이 IMAP으로 헤더를 가져왔고, 그래서 Graph 계정에서 연결 오류와 로그 폭주가 났습니다. 다른 코드 경로는 전부 고쳤는데 이 하나를 놓친 것입니다. 해결: isGraphAccount 분기를 추가해 배경 동기화에 graphListFolders + graphListMessages를 쓰도록 했습니다.

무엇을 내보냈는가

최종 결과는 이렇습니다. 개인용 Microsoft 계정(outlook.com, hotmail.com, live.com과 지역 변형)은 로그인 과정에서 자동으로 감지되어 Graph API 전송 경로로 넘어갑니다. 사용자는 "Microsoft로 로그인"을 누르고 브라우저에서 인증하면 앱이 그대로 동작합니다. IMAP은 개입하지 않고, "User is authenticated but not connected" 오류도 없습니다.

기업용 M365 계정은 리디렉션 URI가 제대로 등록된 MailVault 자체 Azure AD 앱을 씁니다. OAuth2 정책이 엄격한 조직이라면 계정 설정의 "고급" 항목에서 클라이언트 ID와 테넌트 ID를 직접 지정할 수 있습니다.

Graph 계정은 메일 읽기, 삭제, 읽음/읽지 않음 표시를 지원합니다. 발송은 여전히 SMTP를 거칩니다(OAuth2 토큰과 문제없이 작동합니다). 메일 본문은 즉시 표시를 위해 빠른 JSON으로 가져오고, 오프라인 접근과 첨부파일 지원을 위해 전체 MIME은 백그라운드에서 내려받습니다.

배운 것

가장 큰 교훈은 이것입니다. 메일 클라이언트에 새 전송 방식을 추가한다는 것은 코드 경로를 하나 늘리는 일이 아니라 — 모든 코드 경로를 갈라놓는 일입니다. 네트워크를 건드리는 모든 함수가 어느 전송 방식을 쓸지 알아야 합니다. 모든 백그라운드 작업, 모든 미리 가져오기, 모든 동기화 타이머, 모든 연결 테스트가 그렇습니다. 하나만 빠뜨려도 새 전송 방식을 쓰는 사용자에게만 나타나는 버그가 생깁니다.

두 번째 교훈은, 두 단계 로딩이 복잡함을 감수할 값어치가 있다는 것입니다. 무엇이든 보여 주기 전에 전체 MIME 메시지를 내려받으면 견디기 힘든 지연이 생깁니다. JSON API로 빠른 미리보기를 먼저 보여 주고 전체 내용을 백그라운드에서 천천히 저장하면, 사용자는 즉각적인 반응을 얻으면서도 오프라인 보관본이 차곡차곡 쌓입니다.

세 번째. React의 useRef + useEffect 패턴은 첫 렌더에서 바로 있어야 하는 데이터에는 함정입니다. 컴포넌트가 렌더 도중 ref를 읽는데 그 ref를 이펙트에서 채운다면, 첫 렌더는 언제나 낡은 데이터를 봅니다. 채우는 로직을 이펙트가 아니라 훅 본문에서 동기적으로 실행하도록 옮기면 렌더를 더 늘리지 않고도 해결됩니다.

어떤 "로그인이 안 된다" 버그는 설정 하나를 고치면 끝납니다. 어떤 것은 완전히 새로운 API 전송 계층을 요구합니다. 어느 쪽일지는 고를 수 없습니다.