Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/website-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
-DSOURCEMETA_CORE_REGEX:BOOL=OFF
-DSOURCEMETA_CORE_IP:BOOL=OFF
-DSOURCEMETA_CORE_DNS:BOOL=OFF
-DSOURCEMETA_CORE_EMAIL:BOOL=OFF
-DSOURCEMETA_CORE_URI:BOOL=OFF
-DSOURCEMETA_CORE_URITEMPLATE:BOOL=OFF
-DSOURCEMETA_CORE_JSON:BOOL=OFF
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/website-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
-DSOURCEMETA_CORE_REGEX:BOOL=OFF
-DSOURCEMETA_CORE_IP:BOOL=OFF
-DSOURCEMETA_CORE_DNS:BOOL=OFF
-DSOURCEMETA_CORE_EMAIL:BOOL=OFF
-DSOURCEMETA_CORE_URI:BOOL=OFF
-DSOURCEMETA_CORE_URITEMPLATE:BOOL=OFF
-DSOURCEMETA_CORE_JSON:BOOL=OFF
Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ option(SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL "Use system OpenSSL for the Sou
option(SOURCEMETA_CORE_REGEX "Build the Sourcemeta Core Regex library" ON)
option(SOURCEMETA_CORE_IP "Build the Sourcemeta Core IP library" ON)
option(SOURCEMETA_CORE_DNS "Build the Sourcemeta Core DNS library" ON)
option(SOURCEMETA_CORE_EMAIL "Build the Sourcemeta Core Email library" ON)
option(SOURCEMETA_CORE_URI "Build the Sourcemeta Core URI library" ON)
option(SOURCEMETA_CORE_URITEMPLATE "Build the Sourcemeta Core URI Template library" ON)
option(SOURCEMETA_CORE_JSON "Build the Sourcemeta Core JSON library" ON)
Expand Down Expand Up @@ -125,6 +126,10 @@ if(SOURCEMETA_CORE_DNS)
add_subdirectory(src/core/dns)
endif()

if(SOURCEMETA_CORE_EMAIL)
add_subdirectory(src/core/email)
endif()

if(SOURCEMETA_CORE_URI)
add_subdirectory(src/core/uri)
endif()
Expand Down Expand Up @@ -259,6 +264,10 @@ if(SOURCEMETA_CORE_TESTS)
add_subdirectory(test/dns)
endif()

if(SOURCEMETA_CORE_EMAIL)
add_subdirectory(test/email)
endif()

if(SOURCEMETA_CORE_URI)
add_subdirectory(test/uri)
endif()
Expand Down
5 changes: 5 additions & 0 deletions config.cmake.in
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ if(NOT SOURCEMETA_CORE_COMPONENTS)
list(APPEND SOURCEMETA_CORE_COMPONENTS regex)
list(APPEND SOURCEMETA_CORE_COMPONENTS ip)
list(APPEND SOURCEMETA_CORE_COMPONENTS dns)
list(APPEND SOURCEMETA_CORE_COMPONENTS email)
list(APPEND SOURCEMETA_CORE_COMPONENTS uri)
list(APPEND SOURCEMETA_CORE_COMPONENTS uritemplate)
list(APPEND SOURCEMETA_CORE_COMPONENTS json)
Expand Down Expand Up @@ -66,6 +67,10 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS})
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake")
elseif(component STREQUAL "dns")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_dns.cmake")
elseif(component STREQUAL "email")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_dns.cmake")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_email.cmake")
elseif(component STREQUAL "uri")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake")
include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake")
Expand Down
11 changes: 11 additions & 0 deletions src/core/email/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME email
SOURCES email.cc)

if(SOURCEMETA_CORE_INSTALL)
sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME email)
endif()

target_link_libraries(sourcemeta_core_email
PRIVATE sourcemeta::core::dns)
target_link_libraries(sourcemeta_core_email
PRIVATE sourcemeta::core::ip)
208 changes: 208 additions & 0 deletions src/core/email/email.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#include <sourcemeta/core/email.h>

#include <sourcemeta/core/dns.h>
#include <sourcemeta/core/ip.h>

namespace sourcemeta::core {

// RFC 5321 §4.1.2: atext = ALPHA / DIGIT / "!" / "#" / "$" / "%" /
// "&" / "'" / "*" / "+" / "-" / "/" / "=" / "?" / "^" / "_" / "`" /
// "{" / "|" / "}" / "~"
static constexpr auto is_atext(const char character) -> bool {
switch (character) {
case '!':
case '#':
case '$':
case '%':
case '&':
case '\'':
case '*':
case '+':
case '-':
case '/':
case '=':
case '?':
case '^':
case '_':
case '`':
case '{':
case '|':
case '}':
case '~':
return true;
default:
return (character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9');
}
}

// RFC 5321 §4.1.2: qtextSMTP = %d32-33 / %d35-91 / %d93-126
static constexpr auto is_qtext_smtp(const unsigned char character) -> bool {
return (character >= 32 && character <= 33) ||
(character >= 35 && character <= 91) ||
(character >= 93 && character <= 126);
}

// RFC 5321 §4.1.2: Let-dig = ALPHA / DIGIT
static constexpr auto is_let_dig(const char character) -> bool {
return (character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9');
}

// RFC 5321 §4.1.3: dcontent = %d33-90 / %d94-126
static constexpr auto is_dcontent(const unsigned char character) -> bool {
return (character >= 33 && character <= 90) ||
(character >= 94 && character <= 126);
}

// RFC 5321 §4.1.2: Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
// RFC 5321 §4.1.3: Standardized-tag = Ldh-str
static constexpr auto is_ldh_str(const std::string_view value) -> bool {
if (value.empty() || !is_let_dig(value.back())) {
return false;
}
for (std::string_view::size_type position{0}; position + 1 < value.size();
position += 1) {
const auto character{value[position]};
if (!is_let_dig(character) && character != '-') {
return false;
}
}
return true;
}

// RFC 5234 §2.3: ABNF literal strings are case-insensitive by default
// RFC 5321 §4.1.3: IPv6-address-literal prefix is the literal "IPv6:"
static constexpr auto matches_ipv6_tag(const std::string_view value) -> bool {
return value.size() >= 5 && (value[0] == 'I' || value[0] == 'i') &&
(value[1] == 'P' || value[1] == 'p') &&
(value[2] == 'v' || value[2] == 'V') && value[3] == '6' &&
value[4] == ':';
}

// RFC 5321 §4.1.3: General-address-literal = Standardized-tag ":" 1*dcontent
static constexpr auto is_general_address_literal(const std::string_view value)
-> bool {
const auto colon_position{value.find(':')};
if (colon_position == std::string_view::npos) {
return false;
}
if (!is_ldh_str(value.substr(0, colon_position))) {
return false;
}
const auto content{value.substr(colon_position + 1)};
if (content.empty()) {
return false;
}
for (const auto character : content) {
if (!is_dcontent(static_cast<unsigned char>(character))) {
return false;
}
}
return true;
}

auto is_email(const std::string_view value) -> bool {
if (value.empty()) {
return false;
}

std::string_view::size_type position{0};

if (value[0] == '"') {
// RFC 5321 §4.1.2: Quoted-string = DQUOTE *QcontentSMTP DQUOTE
position = 1;
while (position < value.size() && value[position] != '"') {
if (value[position] == '\\') {
// RFC 5321 §4.1.2: quoted-pairSMTP = %d92 %d32-126
position += 1;
if (position >= value.size()) {
return false;
}
const auto body{static_cast<unsigned char>(value[position])};
if (body < 32 || body > 126) {
return false;
}
position += 1;
} else {
if (!is_qtext_smtp(static_cast<unsigned char>(value[position]))) {
return false;
}
position += 1;
}
}
if (position >= value.size()) {
return false;
}
// value[position] is the closing DQUOTE
position += 1;
} else {
// RFC 5321 §4.1.2: Dot-string = Atom *("." Atom), Atom = 1*atext
bool previous_was_dot{false};
bool atom_started{false};
while (position < value.size() && value[position] != '@') {
const auto character{value[position]};
if (character == '.') {
if (!atom_started || previous_was_dot) {
return false;
}
previous_was_dot = true;
atom_started = false;
} else if (is_atext(character)) {
previous_was_dot = false;
atom_started = true;
} else {
return false;
}
position += 1;
}
if (position == 0 || previous_was_dot) {
return false;
}
}

// RFC 5321 §4.5.3.1.1: Local-part octet limit is 64
if (position > 64) {
return false;
}

// RFC 5321 §4.1.2: Mailbox = Local-part "@" ( Domain / address-literal )
if (position >= value.size() || value[position] != '@') {
return false;
}

const auto domain{value.substr(position + 1)};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Add a domain-part length check before parsing address-literals; overlong bracketed domains currently bypass the 255-octet limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/email/email.cc, line 167:

<comment>Add a domain-part length check before parsing address-literals; overlong bracketed domains currently bypass the 255-octet limit.</comment>

<file context>
@@ -0,0 +1,196 @@
+    return false;
+  }
+
+  const auto domain{value.substr(position + 1)};
+
+  // RFC 5321 §4.1.3: address-literal = "[" ( IPv4 / IPv6 / General ) "]"
</file context>


// RFC 5321 §4.1.3: address-literal = "[" ( IPv4 / IPv6 / General ) "]"
if (!domain.empty() && domain.front() == '[') {
if (domain.back() != ']') {
return false;
}
// RFC 5321 §4.5.3.1.2: 255-octet cap on a domain "name or number"
if (domain.size() > 255) {
return false;
}
const auto inner{domain.substr(1, domain.size() - 2)};
// RFC 5321 §4.1.3: IPv6-address-literal = "IPv6:" IPv6-addr
if (matches_ipv6_tag(inner) && is_ipv6(inner.substr(5))) {
return true;
}
// RFC 5234 §3.2: ABNF alternatives are unordered. A failed IPv6 match
// falls through to IPv4 or General-address-literal.
// RFC 5321 §4.1.3: IPv4-address-literal = Snum 3("." Snum) has no ":",
// General-address-literal requires ":"
if (inner.find(':') == std::string_view::npos) {
return is_ipv4(inner);
}
return is_general_address_literal(inner);
}

// RFC 5321 §4.1.2 Domain matches is_hostname (RFC 1123 §2.1) by
// grammar, by 63-octet label cap (RFC 1035 §2.3.4), and by
// 255-octet total cap (RFC 5321 §4.5.3.1.2)
return is_hostname(domain);
}

} // namespace sourcemeta::core
41 changes: 41 additions & 0 deletions src/core/email/include/sourcemeta/core/email.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#ifndef SOURCEMETA_CORE_EMAIL_H_
#define SOURCEMETA_CORE_EMAIL_H_

#ifndef SOURCEMETA_CORE_EMAIL_EXPORT
#include <sourcemeta/core/email_export.h>
#endif

#include <string_view> // std::string_view

/// @defgroup email Email
/// @brief E-mail address validation per RFC 5321.
///
/// This functionality is included as follows:
///
/// ```cpp
/// #include <sourcemeta/core/email.h>
/// ```

namespace sourcemeta::core {

/// @ingroup email
/// Check whether the given string is a valid `Mailbox` per RFC 5321
/// Section 4.1.2, under the length constraints from Section 4.5.3.1.
/// For example:
///
/// ```cpp
/// #include <sourcemeta/core/email.h>
///
/// #include <cassert>
///
/// assert(sourcemeta::core::is_email("user@example.com"));
/// assert(sourcemeta::core::is_email("\"a b\"@example.com"));
/// assert(sourcemeta::core::is_email("user@[192.168.1.1]"));
/// assert(!sourcemeta::core::is_email("plain"));
/// ```
SOURCEMETA_CORE_EMAIL_EXPORT
auto is_email(const std::string_view value) -> bool;

} // namespace sourcemeta::core

#endif
5 changes: 5 additions & 0 deletions test/email/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME email
SOURCES email_test.cc)

target_link_libraries(sourcemeta_core_email_unit
PRIVATE sourcemeta::core::email)
Loading
Loading