Email은 CRLF 쓰세요..

겔로그·2024년 12월 31일

평화롭던 어느날 Email 서비스에 이상한 일이 발생하기 시작했습니다.

기존에 정상 발송되던 메일들이 soft bounce로 수신 서버에서 일시 차단되는 일이 발생했습니다. 이 글은 문제 원인이 무엇이었고, 어떻게 해결했는지 정리한 글입니다.

사건 발단 - 고객 기능 개선 요청

사건의 시작은 어느 고객사의 기능 개선 요청사항이었습니다.

Email 발송간 발송 제목에 표기한 이모지가 깨진다는 문의였고 빨리 개선해달라는 요청이었습니다. 갑자기 잘 되던 서비스에서 왜 이모지가 깨졌을까요?

MIME(Multipurpose Internet Mail Extensions) - Folding

Email에서는 MIME를 표준으로 사용해 메세지를 구성합니다. MIME를 통해 Email에서는 사진, 비디오 등 다양한 컨텐츠를 변환하여 메세지로 발송할 수 있습니다.

MIME 메세지를 구성하는 과정에서 RFC 822 - 3.1.1 Long Header Fields에서 folding이라는 개념이 나오는데요. folding은 다음과 같습니다.

Each header field can be viewed as a single, logical line of
ASCII characters, comprising a field-name and a field-body.
For convenience, the field-body portion of this conceptual
entity can be split into a multiple-line representation; this
is called "folding". The general rule is that wherever there
may be linear-white-space (NOT simply LWSP-chars), a CRLF
immediately followed by AT LEAST one LWSP-char may instead be
inserted.

헤더의 내용이 긴 문자열일 경우, 이를 multiple-line으로 구성해야 하며 이 과정을 folding이라고 합니다.

javax.mail library

Java 진영에서 이메일을 쉽게 발송할 수 있게 구현해놓은 라이브러리가 있는데 javax.mail 라이브러리입니다. (이 외에도 많습니다 ㅎㅎ)

javax.mail 라이브러리에는 이메일 발송을 위한 메세지 클래스 MIMEMessage 클래스가 존재하며 서비스에서는 JDK8과 javax.mail 1.4 버전을 사용하고 있습니다.

현재 발생하는 문제인 부분을 확인하고자 folding 로직을 확인해보겠습니다.

MimeMessage.java

    public void setSubject(String subject, String charset) throws MessagingException {
        if (subject == null) {
            this.removeHeader("Subject");
        } else {
            try {
                this.setHeader("Subject", MimeUtility.fold(9, MimeUtility.encodeText(subject, charset, (String)null)));
            } catch (UnsupportedEncodingException var4) {
                UnsupportedEncodingException uex = var4;
                throw new MessagingException("Encoding error", uex);
            }
        }

    }

확인해보니 MimeUtility.fold라는 메소드를 사용하고 있네요. 내부 로직을 추가로 확인해보겠습니다.

MimeUtility.java

    public static String fold(int used, String s) {
        if (!foldText) {
            return s;
        } else {
            int end;
            char c;
            for(end = s.length() - 1; end >= 0; --end) {
                c = s.charAt(end);
                if (c != ' ' && c != '\t' && c != '\r' && c != '\n') {
                    break;
                }
            }

            if (end != s.length() - 1) {
                s = s.substring(0, end + 1);
            }

            if (used + s.length() <= 76) {
                return makesafe(s);
            } else {
                StringBuilder sb = new StringBuilder(s.length() + 4);

                for(char lastc = 0; used + s.length() > 76; used = 1) {
                    int lastspace = -1;

                    for(int i = 0; i < s.length() && (lastspace == -1 || used + i <= 76); ++i) {
                        c = s.charAt(i);
                        if ((c == ' ' || c == '\t') && lastc != ' ' && lastc != '\t') {
                            lastspace = i;
                        }

                        lastc = c;
                    }

                    if (lastspace == -1) {
                        sb.append(s);
                        s = "";
                        int used = false;
                        break;
                    }

                    sb.append(s.substring(0, lastspace));
                    sb.append("\r\n");
                    lastc = s.charAt(lastspace);
                    sb.append(lastc);
                    s = s.substring(lastspace + 1);
                }

                sb.append(s);
                return makesafe(sb);
            }
        }
    }

여기서 봐야할 부분은 charAt와 76이라는 값을 중점적으로 봐야합니다. 먼저 charAt을 봐볼까요?

charAt은 surrogate값을 반환한다고 정의되어 있습니다.

surrogate

서로게이트(Surrogate)는 유니코드(Unicode)에서 UTF-16 인코딩 방식을 사용하여 16비트(2바이트)로 표현할 수 없는 문자를 나타내기 위해 사용하는 특별한 코드 단위입니다. 서로게이트는 고위 서러게이트(high surrogate)저위 서러게이트(low surrogate)의 쌍으로 구성되어 하나의 유니코드 문자(코드 포인트)를 나타냅니다.

주요 특징

고위 서러게이트와 저위 서러게이트는 항상 쌍으로 동작하며, 이 둘을 결합해 하나의 코드 포인트를 나타냅니다.
단독으로 고립된 고위 서러게이트 또는 저위 서러게이트는 유효하지 않은 문자로 간주됩니다.

다시 원인으로 돌아가보자

😂 (0xD83D 0xDE02)를 사용할 경우 내부적으로 인코딩할 때 서로게이트 쌍을 인지하지 못하고 인코딩을 수행합니다. 이후 folding간 76자를 초과할 경우 multiple-line으로 동작하기 때문에 개행 처리되며 이 과정에서 서로게이트 쌍이 분리되어 각각의 문자로 인식돼 ?로 처리될 가능성이 존재하게 됩니다.

문제 해결

문제 해결은 76자가 초과될 경우 folding 처리하는 부분에서 서로게이트 쌍이 깨지지 않도록 로직을 구성하는 것입니다.

    public List<String> splitString(String input, int maxBytes, int usedBytes) {
        if (input == null || input.isEmpty()) {
            return new ArrayList<>(); // 빈 문자열에 대해 빈 리스트 반환
        }

        List<String> result = new ArrayList<>();
        int offset = 0;

        while (offset < input.length()) {
            // 헤더 필드 이름의 길이를 반영하여 자를 위치 계산
            int nextOffset = findSafeSplitPoint(input, offset, maxBytes - usedBytes);
            result.add(input.substring(offset, nextOffset));
            offset = nextOffset;

            // 이후 줄부터는 줄바꿈 오버헤드(1바이트 공백)만 고려
            usedBytes = 1; // 공백 추가로 인해 이후 줄은 1바이트 차감
        }

        return result;
    }
    
        private int findSafeSplitPoint(String input, int start, int maxBytes) {
        int end = start;
        int currentBytes = 0;

        while (end < input.length() && currentBytes <= maxBytes) {
            int nextCodePoint = input.codePointAt(end);
            int charLength = Character.charCount(nextCodePoint);
            int byteLength = String.valueOf(Character.toChars(nextCodePoint))
                .getBytes(StandardCharsets.UTF_8).length;

            if (currentBytes + byteLength > maxBytes) {
                break;
            }

            currentBytes += byteLength;
            end += charLength;
        }

        return end;
    }

해당 방식은 folding 처리를 자체적으로 구현한 로직입니다. 인코딩 전 정의한 바이트를 먼저 쪼개 인코딩한 이후 개행 처리를 수행합니다. 이모지의 크기까지 고려하여 데이터를 분리한 뒤 인코딩을 수행하기 때문에 이모지가 깨지는 현상이 발생하지 않습니다.

문제 해결! 그런데...

문제를 해결한 이후 여유롭게 지낸지 1주일이 지났는데 갑자기 soft bounce(일시 차단)현상이 대다수 고객사에서 발생하는 문제가 생겼습니다.

처음에는 단순한 수신처 이슈로 생각해 무시하였으나, 지속적으로 발생해 서비스 발송 속도 자체가 급격하게 줄어들어 원인을 추가 확인하였습니다.

추가 원인 분석 - DKIM softfail

Received-SPF: pass (google.com: domain of no_reply@test.com designates x.x.x.x as permitted sender) client-ip=x.x.x.x;
Authentication-Results: mx.google.com;
       dkim=fail header.i=@test.com header.s=header header.b=test;
       spf=pass (google.com: domain of no_reply@test.net designates x.x.x.x as permitted sender) smtp.mailfrom=no_reply@test.net;
       dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=test.net
Received: from [x.x.x.x] (HELO test.svr.net)
 ([x.x.x.x]) by smtp.com with SMTP id 325982395829;
 Thu, 05 Dec 2024 15:39:18 +0900
Date: Thu, 5 Dec 2024 15:39:18 +0900 (KST)
From: no_reply@test.net
To: receiver@gmail.com
Message-ID: <2024@message>
Subject: =?utf-8?B?7ZWY7J207YG0656Y7IqkIOq0keqzoOyEsSDsoJXrs7Qg7IiY?=
 =?utf-8?B?7IugIOuPmeydmCDtmITtmakg7JWI64K0?=
MIME-Version: 1.0
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Precedence: bulk
X-Toast-Seq: 0

발송 실패 원문을 확인하니 dkim=fail 응답을 주고 있는 것을 알게 되었습니다. 다른 수신처로 보내보니 signature failed라는 상세 메세지도 확인할 수 있었습니다.

그럼 왜 dkim에서 에러가 발생했을까요?

DKIM이란?

도메인키 식별 메일(DKIM)은 스팸 발송자 및 기타 악의적인 당사자가 합법적인 도메인을 가장하는 것을 방지하는 데 도움이 되는 이메일 인증 방법입니다.

이메일 공급자는 공개 키와 개인 키를 생성합니다. 이메일 공급자는 도메인 소유자에게 공개 키를 제공하고, 도메인 소유자는 공개적으로 사용 가능한 DNS 레코드인 DKIM 레코드에 공개 키를 저장합니다.

해당 도메인에서 보낸 모든 이메일에는 개인 키로 서명된 데이터 섹션이 포함된 DKIM 헤더가 포함되어 있으며, 이를 "디지털 서명"이라고 합니다. 이메일 서버에서는 DKIM DNS 레코드를 확인하고, 공개 키를 가져오며, 공개 키를 사용하여 디지털 서명을 확인할 수 있습니다.

DKIM에 대해 간단하게 설명하면 발신자가 발송간 개인 키로 서명한 DKIM 헤더가 존재하는데 해당 값은 MIME Message에 포함된 값을 서명한 값이므로 발신 원문이 위변조 됐을 경우 해당 값을 통해 위변조 여부를 쉽게 알 수 있습니다.

DKIM sign

DKIM 사이닝 또한 java 라이브러리를 통해 쉽게 사용 가능합니다.

    protected static void updateSignature(Signature signature, boolean relaxed, CharSequence header, String fv) throws SignatureException {
        if (relaxed) {
            signature.update(header.toString().toLowerCase().getBytes());
            signature.update(":".getBytes());
            String headerValue = fv.substring(fv.indexOf(58) + 1);
            headerValue = headerValue.replaceAll("\r\n[\t ]", " ");
            headerValue = headerValue.replaceAll("[\t ]+", " ");
            headerValue = headerValue.trim();
            signature.update(headerValue.getBytes());
        } else {
            signature.update(fv.getBytes());
        }

    }

다음과 같이 헤더의 값을 변환한 뒤 dkim signature를 생성하는데요. 여기서 replaceAll의 문자를 주의해서 봐주세요.

문제의 코드

folding 처리하는 로직을 MIMEUtility 클래스가 아닌 자체 구현하면서 한가지 오류를 범하였습니다.

    // 제목 인코딩 문제로 인해 자체 구현
    public String encodeText(String text) {
        return splitString(text, 45, 9) // 45바이트 단위로 안전하게 자름
            .stream()
            .map(it -> Base64.getEncoder().encodeToString(it.getBytes(StandardCharsets.UTF_8)))
            .map(it -> "=?utf-8?B?" + it + "?=")
            .collect(Collectors.joining("\n "));
    }

과연 원인이 뭐였을까요?

문제 진짜 해결!

결론적으론 맨 마지막 joining하는 부분이 \n이 되어 발생한 이슈였습니다.

사실 앞서 공유드린 RFC 822 문서에 포함된 내용을 자세히 보지않았는데 확인해보니 MIME 에서는 \n이 아닌 CRLF만 허용처리 되는 것이었습니다!.

dkim signature를 생성할 때 정규화하는 과정에서 \r\n[\t ] 문자에 대해 공백으로 변환하는 작업을 수행하고 있습니다.

수신처에서는 역으로 dkim 모드(relax, strict)에 따라 해당 공백을 다시 CRLF로 변환하는데요.

이 때 수신처에서 재변환한 값에는 CRLF로 처리된 반면 실제 본문은 \n으로 들어가기 때문에 원문이 서로 달라 실패처리가 됩니다.

이 부분은 \n에서 \r\n으로 변환하여 최종적으로 정상 처리되는 것을 확인할 수 있었습니다.

결론

개발자는 모든 로직을 개발할 필요없이 많은 사람들이 사용하는 라이브러리를 활용합니다.
하지만 라이브러리의 모든 코드가 정상적으로 동작하지 않을 수 있다는 점을 주의하고 본인이 사용할 로직이 어떻게 동작하는지 확인해보는 것을 권장드립니다.

이번 기회로 인해 dkim 동작방식과 MIME에 대해 좀 더 깊게 알 수 있어 좋은 경험이었습니다.

감사합니다.

profile
Gelog 나쁜 것만 드려요~

0개의 댓글