최근 한 동료가 나에게 블로그 게시물 On the Futility of Email Regex Validation 을 알려주었습니다. 편의상 이 글에서는 이를 Futility 라고 부르겠습니다.
문자열이 인터넷 메시지 헤더의 RFC 5322 정의를 준수하는지 여부를 성공적으로 식별할 수 있는 정규식을 작성하는 것은 재미있는 일이지만 Futility 는 실제 프로그래머에게 유용한 가이드가 아니라는 점을 인정합니다.
이는 RFC 5322 메시지 헤더를 RFC 5321 주소 리터럴과 결합하기 때문입니다. 이는 간단히 말해서 유효한 SMTP 이메일 주소를 구성하는 것이 일반적으로 유효한 메시지 헤더를 구성하는 것과 다르다는 것을 의미합니다.
그것은 또한 독자가 표준적인 관점에서 이론적으로 가능하지만 내가 설명할 극단적인 사례에 몰두하도록 선동하기 때문이며, "야생에서" 발생할 가능성이 극히 적기 때문입니다.
이 기사에서는 이러한 두 가지 주장을 확장하고 이메일 정규식에 대한 몇 가지 가능한 사용 사례를 논의한 다음 주석이 달린 실용적인 이메일 정규식의 "요리책" 예제로 마무리할 것입니다.
이메일 전송을 위한 SMTP의 보편성은 실제로 관련 IETF RFC(5321)를 자세히 읽지 않으면 이메일 주소 형식에 대한 검사가 완료되지 않음을 의미합니다.
5322는 이메일 주소를 특별한 경우 규칙이 적용되지 않는 단순한 일반 메시지 헤더로 간주합니다. 이는 도메인 이름에서도 괄호 안의 설명이 유효함을 의미합니다.
Futility 에서 참조된 테스트 모음에는 주석, 발음 구별 부호 또는 유니코드 문자가 포함된 10개의 테스트가 포함되어 있으며 그 중 8개가 유효한 이메일 주소를 나타냅니다.
이는 RFC 5321이 이메일 주소의 도메인 이름 부분이 " SMTP 목적으로 ASCII 문자 세트에서 가져온 일련의 문자, 숫자 및 하이픈으로 구성되도록 제한되어 있다 "고 명시적으로 명시하고 있기 때문에 잘못된 것입니다.
정규식을 구성하는 과정에서 특히 과도한 문자열 길이를 결정하는 것과 관련하여 이 제약 조건이 문제를 단순화하는 정도는 아무리 강조해도 지나치지 않습니다. 예제의 주석은 아래에서 이를 강조합니다.
이는 또한 우리가 더 자세히 살펴볼 검증의 맥락에서 몇 가지 다른 실제적인 고려 사항을 의미합니다.
두 RFC에 따르면 "@" 기호 왼쪽에 있는 이메일 주소 부분의 기술 이름은 "mailbox"입니다. 두 RFC 모두 사서함 부분에 허용되는 문자에 대해 상당한 관용도를 허용합니다.
유일하게 중요한 실질적인 제약은 따옴표나 괄호가 균형을 이루어야 한다는 것인데, 이는 바닐라 정규 표현식에서 확인하기가 정말 어려운 일입니다.
그러나 실제 메일함 구현은 다시 실제 프로그래머가 사용해야 하는 척도입니다.
일반적으로 우리에게 돈을 지불하는 사람들은 청구 가능한 시간의 90%가 실제 생활에서는 전혀 존재하지 않을 수도 있는 이론적으로 극단적인 사례의 10%를 해결하는 데 사용되는 것에 눈살을 찌푸립니다.
주요 이메일 사서함 제공업체, 소비자 및 기업을 살펴보고 이들이 허용하는 이메일 주소 유형을 고려해 보겠습니다.
소비자 이메일의 경우 트위터 계정에서 유출된 5,280,739개의 이메일 주소 목록을 사용하여 몇 가지 기본 조사를 수행했습니다.
1억 1,500만 개의 트위터 계정을 기준으로 하면 전체 트위터 인구에 대해 0.055%의 오차 한계와 99% 신뢰 수준을 제공하며 이는 모든 인터넷 이메일 주소의 일반 인구를 매우 대표하는 것입니다. 내가 배운 내용은 다음과 같습니다.
그러나 이는 반올림된 100%입니다. 퀴즈를 좋아하는 분들을 위해 다음 사항도 알아냈습니다.
결과적으로 이메일 주소 사서함에 ASCII 영숫자, 점, 대시만 포함되어 있다고 가정하면 소비자 이메일에 대한 정확도가 5 9보다 향상됩니다.
비즈니스 이메일의 경우 Datanyze는 6,771,269개 회사가 91개의 이메일 호스팅 솔루션을 사용하고 있다고 보고합니다 . 그러나 파레토 분포는 유지되며 해당 사서함의 95.19%는 단 10개의 서비스 공급자에 의해 호스팅됩니다.
Google에서는 편지함을 만들 때 ASCII 문자, 숫자, 점만 허용합니다. 그러나 이메일을 받을 때는 더하기 기호를 허용합니다.
ASCII 문자, 숫자, 점만 허용됩니다.
Microsoft 365를 사용하며 ASCII 문자, 숫자, 점만 허용합니다.
문서화되지 않았습니다.
불행하게도 우리는 기업의 82%만을 확신할 수 있으며 그것이 얼마나 많은 사서함을 대표하는지 모릅니다. 그러나 우리는 Twitter 이메일 주소 중 173,467개 도메인 중 400개만이 100개 이상의 개별 이메일 편지함을 가지고 있다는 것을 알고 있습니다.
나머지 도메인의 99%는 대부분 비즈니스 이메일 주소라고 생각합니다.
서버 또는 도메인 수준의 사서함 명명 정책 측면에서 저는 이 237,592개의 이메일 주소를 99% 신뢰 수준과 0.25% 오류 한계를 가진 10억 개의 비즈니스 이메일 주소 모집단을 나타내는 것으로 간주하는 것이 합리적이라고 제안합니다. 이메일 주소 사서함에 ASCII 영숫자, 점 및 대시만 포함되어 있다고 가정하면 3 9에 가깝습니다.
다시 한 번 실용성을 최우선으로 생각하여 어떤 상황에서 유효한 이메일 주소를 프로그래밍 방식으로 식별해야 하는지 고려해 보겠습니다.
이 사용 사례에서는 신규 잠재 고객이 계정을 만들려고 합니다. 우리가 고려할 수 있는 두 가지 높은 수준의 전략이 있습니다. 첫 번째 경우에는 신규 사용자가 제공한 이메일 주소가 유효한지 확인하고 동기적으로 계정 생성을 진행합니다.
이 접근 방식을 사용하지 않는 데는 두 가지 이유가 있습니다. 첫 번째는 이메일 주소가 유효한 형식인지 확인할 수 있더라도 존재하지 않을 수 있다는 것입니다.
다른 이유는 어떤 종류의 규모에서든 동기식은 위험 신호이므로 실용적인 프로그래머는 대신 상태 비저장 웹 프런트 엔드가 양식 정보를 마이크로서비스나 API에 전달하는 실행 후 잊어버리는 모델을 고려해야 합니다. 계정 생성 프로세스 완료를 트리거하는 고유 링크를 전송하여 이메일을 비동기적으로 검증합니다.
백서를 다운로드하는 데 자주 사용되는 간단한 문의 양식의 경우, 유효한 이메일처럼 보이지만 그렇지 않은 문자열을 수락할 때 발생할 수 있는 잠재적인 단점은 이메일 주소가 실제로 존재합니다.
따라서 다시 한 번 말씀드리지만, Fire-and-forget 모델은 양식에 입력된 문자열을 프로그래밍 방식으로 검증하는 것보다 더 나은 옵션입니다.
이는 일반적으로 프로그래밍 방식의 이메일 주소 식별, 특히 정규식(구조화되지 않은 텍스트의 큰 덩어리를 익명화하거나 마이닝)에 대한 실제 사용 사례로 이어집니다.
나는 사기 탐지 데이터베이스에 리퍼러 로그를 업로드해야 하는 보안 연구원을 지원하면서 이 사용 사례를 처음 접했습니다. 추천 로그에는 회사의 벽으로 둘러싸인 정원을 떠나기 전에 익명 처리가 필요한 이메일 주소가 포함되어 있습니다.
수억 줄의 파일이었는데, 하루에 수백 개의 파일이 있었습니다. “줄”의 길이는 거의 1,000자에 가까울 수 있습니다.
루프와 표준 문자열 함수를 사용하여 줄의 문자를 반복하고 복잡한 테스트를 적용합니다(예: 줄에서 @
처음 나타나는지, [email protected]
와 같은 파일 이름의 일부인지?). 시간 복잡도가 엄청나게 컸습니다.
사실, 이 (아주 큰) 회사의 내부 개발팀은 그것이 불가능한 작업이라고 선언했습니다.
나는 다음과 같이 컴파일된 정규식을 작성했습니다.
search_pattern = re.compile("[a-zA-Z0-9\!\#\$\%\'\*\+\-\^\_\`\{\|\}\~\.]+@|\%40(?!(\w+\.)**(jpg|png))(([\w\-]+\.)+([\w\-]+)))")
그리고 그것을 다음 Python 목록 이해에 추가했습니다.
results = [(re.sub(search_pattern, "[email protected]", line)) for line in file]
얼마나 빨랐는지는 기억나지 않지만 빨랐다. 내 친구는 노트북으로 이 작업을 실행하고 몇 분 안에 완료할 수 있었습니다. 그것은 정확했다. 우리는 거짓 부정과 거짓 긍정을 모두 살펴보며 5 9로 기록했습니다.
추천 로그가 있다는 사실 덕분에 내 작업이 다소 쉬워졌습니다. 여기에는 URL "합법적" 문자만 포함될 수 있으므로 repo readme 에 문서화한 모든 충돌을 매핑할 수 있었습니다.
또한 이메일 주소 분석을 수행하고 5 9의 목표에 도달하는 데 필요한 모든 것이 ASCII 영숫자, 점 및 대시뿐이라는 확신을 가지고 배웠다면 훨씬 더 간단하고 빠르게 만들 수 있었을 것입니다.
그럼에도 불구하고 이는 실용성과 해결해야 할 실제 문제에 맞게 솔루션 범위를 지정하는 좋은 예입니다.
모든 프로그래밍 지식과 역사에서 가장 위대한 인용문 중 하나는 달성하려는 것이 무엇인지 정확히 기억하고 스스로에게 "가능한 가장 간단한 것은 무엇인가?"라고 자문하라는 Ward Cunningham의 훈계 입니다.
대량의 구조화되지 않은 텍스트에서 이메일 주소를 구문 분석(및 선택적으로 변환)하는 사용 사례에서 이 솔루션은 확실히 제가 생각할 수 있는 가장 간단한 솔루션이었습니다.
처음에 말했듯이 RFC 5322를 준수하는 정규식을 구축하는 아이디어가 재미있다는 것을 알았으므로 표준의 다양한 측면을 처리하고 정규식 정책이 어떻게 적용되는지 설명하기 위해 구성 가능한 정규식 덩어리를 보여 드리겠습니다. 마지막으로 모두 조립한 모습을 보여드리겠습니다.
이메일 주소의 구조는 다음과 같습니다.
이제 정규식을 살펴보겠습니다.
^(?<mailbox>(\[a-zA-Z0-9\\+\\!\\#\\$\\%\\&\\'\\\*\\-\\/\\=\\?\\+\\\_\\\{\\}\\|\\\~]|(?<singleDot>(?<!\\.)(?<!^)\\.(?!\\.))|(?<foldedWhiteSpace>\\s?\\&\\#13\\;\\&\\#10\\;.))\{1,64})
먼저, 문자열 시작 부분의 첫 번째 문자를 "고정"하는 ^
있습니다. 이는 유효한 이메일 외에는 아무것도 포함하지 않는 문자열을 검증하는 경우에 사용됩니다. 첫 번째 문자가 유효한지 확인합니다.
사용 사례가 더 긴 문자열에서 이메일을 찾는 것이라면 앵커를 생략하세요.
다음으로 (?<mailbox>
가 있습니다. 이는 편의상 캡처 그룹의 이름을 지정합니다. 캡처된 그룹 내부에는 대체 일치 기호 |
로 구분된 세 개의 정규식 청크가 있습니다. 이는 문자가 세 가지 표현식 중 하나와 일치할 수 있음을 의미합니다.
좋은(성능이 뛰어나고 예측 가능한) 정규식을 작성하려면 세 가지 표현식이 상호 배타적인지 확인해야 합니다. 즉, 하나와 일치하는 하위 문자열은 다른 두 개 중 어느 것과도 확실히 일치하지 않을 것입니다. 이를 위해 우리는 무서운 .*
대신 특정 문자 클래스를 사용합니다.
[a-zA-Z0-9\+\!\#\$\%\&\'\*\-\/\=\?\+\_\{\}\|\~]
첫 번째 대체 일치 항목은 대괄호로 묶인 문자 클래스로, 점, "접힌 공백", 큰따옴표 및 괄호를 제외하고 이메일 편지함에 사용할 수 있는 모든 ASCII 문자를 캡처합니다.
우리가 이를 제외하는 이유는 조건부 로만 합법적이기 때문입니다. 즉, 유효성을 검사해야 하는 사용 방법에 대한 규칙이 있다는 것입니다. 다음 2번의 대체 경기에서 이를 처리하겠습니다.
(?<singleDot>(?<!\.)(?<!^)\.(?!\.))
첫 번째 규칙은 점(마침표)에 관한 것입니다. 사서함에서 점은 두 개의 유효한 문자 문자열 사이의 구분 기호로만 허용되므로 두 개의 연속된 점은 올바르지 않습니다.
두 개의 연속 점이 있는 경우 일치를 방지하기 위해 앞에 점이 있는 경우 다음 문자(점)가 일치하지 않도록 지정하는 정규식 음수 뒤돌아보기 (?<!\.)
사용합니다.
정규식 둘러보기를 연결할 수 있습니다. 점이 사서함의 첫 번째 문자가 될 수 없다는 규칙을 시행하는 점 (?!^)
에 도달하기 전에 또 다른 부정적인 모습이 있습니다.
점 뒤에는 부정적인 look_ahead_ _(?!\.)_
있습니다 . 이는 바로 뒤에 점이 오면 점이 일치하는 것을 방지합니다.
(?<foldedWhiteSpace>\s?\&\#13\;\&\#10\;.)
메시지에 여러 줄 헤더를 허용하는 것에 대한 RFC 5322의 말도 안되는 내용입니다. 나는 이메일 주소의 역사에서 여러 줄의 메일함을 사용하여 주소를 심각하게 만든 사람은 없었다고 장담합니다(그들은 농담으로 그랬을 수도 있습니다).
하지만 저는 5322 게임을 하고 있으므로 대체 일치 항목으로 접힌 공백을 생성하는 유니코드 문자 문자열이 여기에 있습니다.
두 RFC 모두 일반적으로 불법인 문자를 묶거나 이스케이프하는 방법으로 큰따옴표를 사용할 수 있습니다.
또한 사람이 읽을 수 있도록 주석을 괄호로 묶을 수 있지만 주소를 해석할 때 메일 전송 에이전트(MTA)가 고려하지 않습니다.
두 경우 모두 균형이 맞는 경우에만 문자가 유효합니다. 즉, 열리는 문자와 닫는 문자 쌍이 있어야 합니다.
나는 기적적인 증명을 발견했다고 쓰고 싶지만, 이것은 아마도 사후에만 작동할 것입니다. 진실은 이것이 바닐라 정규식에서 사소하지 않다는 것입니다.
나는 "탐욕스러운" 정규식의 재귀적 특성이 유리하게 활용될 수 있다는 직관을 가지고 있지만 앞으로 몇 년 동안 이 문제를 해결하는 데 필요한 시간을 할애할 가능성이 낮으므로 최선의 전통에 따라 이 문제를 그대로 둡니다. 독자를 위한 연습으로.
{1,64}
실제로 중요한 것은 사서함의 최대 길이(64자)입니다.
따라서 마지막 닫는 괄호로 메일함 캡처 그룹을 닫은 후 중괄호 사이에 수량자를 사용하여 대체 항목과 최소 한 번, 최대 64번 일치해야 함을 지정합니다.
\s?(?<atSign>(?<!\-)(?<!\.)\@(?!\@))
구분 기호 청크는 특별한 경우 \s?
로 시작합니다. Futility 에 따르면 구분 기호 바로 앞에 공백을 넣을 수 있고 저는 단지 그들의 말을 그대로 받아들일 뿐입니다.
캡처 그룹의 나머지 부분은 SingleDot 과 유사한 패턴을 따릅니다. 앞에 점이나 대시가 있거나 바로 뒤에 또 다른 @
가 오면 일치하지 않습니다.
여기에는 사서함과 마찬가지로 3개의 대체 일치 항목이 있습니다. 그리고 이들 중 마지막 경기에는 또 다른 4개의 대체 경기가 중첩되어 있습니다.
(?<dns>[[:alnum:]]([[:alnum:]\-]{0,63}\.){1,24}[[:alnum:]\-]{1,63}[[:alnum:]])
이는 Futility 의 여러 테스트를 통과하지 못하지만 앞서 언급한 것처럼 최종 단어가 있는 RFC 5321을 엄격하게 준수합니다.
(?<IPv4>\[((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])
이에 대해 할 말이 너무 많지 않습니다. 이는 IPv4 주소에 대해 잘 알려져 있고 쉽게 사용할 수 있는 정규식입니다.
(?<IPv6>(?<IPv6Full>(\[IPv6(\:[0-9a-fA-F]{1,4}){8}\]))|(?<IPv6Comp1>\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,3}(\:([0-9a-fA-F]{1,4})){1,5}?\])|\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,5}(\:([0-9a-fA-F]{1,4})){1,3}?\]))|(?<IPv6Comp2>(\[IPv6\:\:(\:[0-9a-fA-F]{1,4}){1,6}\]))|(?<IPv6Comp3>(\[IPv6\:([0-9a-fA-F]{1,4}\:){1,6}\:\]))|(?<IPv6Comp4>(\[IPv6\:\:\:)\])|(?<IPv6v4Full>(\[IPv6(\:[0-9a-fA-F]{1,4}){6}\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3})(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])|(?<IPv6v4Comp1>\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,3}(\:([0-9a-fA-F]{1,4})){1,5}?(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\])|\[IPv6\:((([0-9a-fA-F]{1,4})\:){1,5}(\:([0-9a-fA-F]{1,4})){1,3}?(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp2>(\[IPv6\:\:(\:[0-9a-fA-F]{1,4}){1,5}(\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp3>(\[IPv6\:([0-9a-fA-F]{1,4}\:){1,5}\:(((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))\]))|(?<IPv6v4Comp4>(\[IPv6\:\:\:((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3})(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\]))
IPv6(및 IPv6v4) 주소에 대한 적절한 정규식을 찾을 수 없었기 때문에 RFC 5321의 Backus/Naur 표기 규칙을 주의 깊게 따라 직접 직접 작성했습니다.
IPv6 정규식의 모든 하위 그룹에 주석을 달지는 않겠지만, 쉽게 구분하고 무슨 일이 일어나고 있는지 확인할 수 있도록 모든 하위 그룹의 이름을 지정했습니다.
IUPv6Comp1 캡처 그룹에서 "왼쪽"에 탐욕스러운 일치와 "오른쪽"에 탐욕스럽지 않은 일치를 결합한 방식을 제외하면 그다지 흥미로운 것은 없습니다.
Futility의 테스트 데이터와 함께 최종 정규식을 저장하고 일부 IPv6 테스트 사례를 통해 강화하여 Regex101 에 저장했습니다. 나는 당신이 이 기사를 즐겼기를 바라며, 그것이 많은 사람들에게 유용하고 시간을 절약해 주기를 바랍니다.
AZW