diff --git a/src/vmime/address.cpp b/src/vmime/address.cpp index 5703a389..619d5315 100644 --- a/src/vmime/address.cpp +++ b/src/vmime/address.cpp @@ -112,27 +112,39 @@ shared_ptr
address::parseNext( case '"': - quoted = !quoted; + if (commentLevel == 0) { + quoted = !quoted; + } + break; case '<': - inRouteAddr = true; + if (!quoted && commentLevel == 0) { + inRouteAddr = true; + } + break; case '>': - inRouteAddr = false; + if (!quoted && commentLevel == 0) { + inRouteAddr = false; + } + break; case '(': - ++commentLevel; + if (!quoted) { + ++commentLevel; + } + break; case ')': - if (commentLevel > 0) { + if (!quoted && commentLevel > 0) { --commentLevel; } diff --git a/src/vmime/addressList.cpp b/src/vmime/addressList.cpp index 75f62245..6f7645d8 100644 --- a/src/vmime/addressList.cpp +++ b/src/vmime/addressList.cpp @@ -26,6 +26,7 @@ #include "vmime/exception.hpp" #include "vmime/mailboxList.hpp" #include "vmime/mailboxGroup.hpp" +#include "vmime/utility/outputStreamStringAdapter.hpp" namespace vmime { @@ -87,21 +88,43 @@ void addressList::generateImpl( size_t pos = curLinePos; - generationContext tmpCtx(ctx); - tmpCtx.setMaxLineLength(tmpCtx.getMaxLineLength() - 2); + // Leave room for the comma after all addresses but the last one + generationContext commaCtx(ctx); - if (!m_list.empty()) { + if (ctx.getMaxLineLength() != lineLengthLimits::infinite && + ctx.getMaxLineLength() > NEW_LINE_SEQUENCE_LENGTH + 1) { - for (std::vector >::const_iterator i = m_list.begin() ; ; ) { + commaCtx.setMaxLineLength(ctx.getMaxLineLength() - 1); + } - (*i)->generate(ctx, os, pos, &pos); + for (std::vector >::const_iterator i = m_list.begin() ; + i != m_list.end() ; ++i) { - if (++i == m_list.end()) { - break; - } + const generationContext& addrCtx = (i + 1 == m_list.end()) ? ctx : commaCtx; + + if (i == m_list.begin()) { + (*i)->generate(addrCtx, os, pos, &pos); + continue; + } + + string tmp; + utility::outputStreamStringAdapter tmpStream(tmp); + size_t tmpPos = 0; + + (*i)->generate(addrCtx, tmpStream, pos + 2, &tmpPos); + + // Unless the address fits entirely after ", ", it starts a new line, + // with the fold after the comma + if (pos > NEW_LINE_SEQUENCE_LENGTH && + (tmp.find(CRLF) != string::npos || tmpPos > addrCtx.getMaxLineLength())) { + + os << "," << NEW_LINE_SEQUENCE; + (*i)->generate(addrCtx, os, NEW_LINE_SEQUENCE_LENGTH, &pos); + + } else { - os << ", "; - pos += 2; + os << ", " << tmp; + pos = tmpPos; } } diff --git a/src/vmime/emailAddress.cpp b/src/vmime/emailAddress.cpp index e6873803..7003e125 100644 --- a/src/vmime/emailAddress.cpp +++ b/src/vmime/emailAddress.cpp @@ -22,6 +22,7 @@ // #include "vmime/emailAddress.hpp" +#include "vmime/wordEncoder.hpp" #include "vmime/platform.hpp" @@ -587,9 +588,38 @@ void emailAddress::generateImpl( } else { - // Local part + // Local part. It may not be folded, nor contain an encoded-word + // (RFC 2047 §5), unless it cannot be written otherwise. vmime::utility::outputStreamStringAdapter os(localPart); - m_localName.generate(ctx, os, 0, NULL, text::QUOTE_IF_NEEDED, NULL); + const string& buffer = m_localName.getBuffer(); + + // Not is7bit(), which is also false for "=?" and a trailing '='. + // ESC (ISO-2022-JP), CR and LF are not printable. + bool printable = true; + + for (size_t i = 0 ; printable && i < buffer.length() ; ++i) { + printable = parserHelpers::isPrint(buffer[i]); + } + + if (printable) { + + generationContext localCtx(ctx); + localCtx.setMaxLineLength(lineLengthLimits::infinite); + + m_localName.generate(localCtx, os, 0, NULL, + text::FORCE_NO_ENCODING | text::QUOTE_IF_NEEDED, NULL); + + } else { + + wordEncoder enc(buffer, m_localName.getCharset()); + const string lang = m_localName.getLanguage(); + + os << "=?" << m_localName.getCharset().getName() + << (lang.empty() ? "" : "*" + lang) + << (enc.getEncoding() == wordEncoder::ENCODING_B64 ? "?B?" : "?Q?") + << enc.getNextChunk(lineLengthLimits::infinite) + << "?="; + } // Domain part as IDNA domainPart = domainNameToIDNA(m_domainName.getConvertedText(vmime::charsets::UTF_8)); diff --git a/src/vmime/mailbox.cpp b/src/vmime/mailbox.cpp index 0ede48fc..52def32a 100644 --- a/src/vmime/mailbox.cpp +++ b/src/vmime/mailbox.cpp @@ -335,11 +335,14 @@ void mailbox::generateImpl( size_t pos = curLinePos; - // No display name is specified, only email address. - if (curLinePos + generatedEmail.length() > ctx.getMaxLineLength()) { + // No display name is specified, only email address. Fold before + // it only if it then fits, as it cannot be folded itself. + if (curLinePos + generatedEmail.length() > ctx.getMaxLineLength() && + curLinePos > NEW_LINE_SEQUENCE_LENGTH && + NEW_LINE_SEQUENCE_LENGTH + generatedEmail.length() <= ctx.getMaxLineLength()) { os << NEW_LINE_SEQUENCE; - pos = NEW_LINE_SEQUENCE.length(); + pos = NEW_LINE_SEQUENCE_LENGTH; } os << generatedEmail; @@ -378,14 +381,20 @@ void mailbox::generateImpl( text::QUOTE_IF_POSSIBLE | (forceEncode ? text::FORCE_ENCODING : 0) ); + // The fold, or a space, separates the name from the address if (pos + generatedEmail.length() + 3 > ctx.getMaxLineLength()) { os << NEW_LINE_SEQUENCE; - pos = NEW_LINE_SEQUENCE.length(); + pos = NEW_LINE_SEQUENCE_LENGTH; + + } else { + + os << " "; + ++pos; } - os << " <" << generatedEmail << ">"; - pos += 2 + generatedEmail.length() + 1; + os << "<" << generatedEmail << ">"; + pos += 1 + generatedEmail.length() + 1; if (newLinePos) { *newLinePos = pos; diff --git a/src/vmime/mailboxGroup.cpp b/src/vmime/mailboxGroup.cpp index ff9c74ff..8bf94095 100644 --- a/src/vmime/mailboxGroup.cpp +++ b/src/vmime/mailboxGroup.cpp @@ -24,6 +24,7 @@ #include "vmime/mailboxGroup.hpp" #include "vmime/parserHelpers.hpp" #include "vmime/exception.hpp" +#include "vmime/utility/outputStreamStringAdapter.hpp" namespace vmime { @@ -161,9 +162,6 @@ void mailboxGroup::generateImpl( size_t pos = curLinePos; - generationContext tmpCtx(ctx); - tmpCtx.setMaxLineLength(ctx.getMaxLineLength() - 2); - m_name.encodeAndFold( ctx, os, pos, &pos, forceEncode ? text::FORCE_ENCODING : 0 @@ -172,21 +170,42 @@ void mailboxGroup::generateImpl( os << ":"; ++pos; + // Leave room for the ',' or ';' after each mailbox + generationContext mboxCtx(ctx); + + if (ctx.getMaxLineLength() != lineLengthLimits::infinite && + ctx.getMaxLineLength() > NEW_LINE_SEQUENCE_LENGTH + 1) { + + mboxCtx.setMaxLineLength(ctx.getMaxLineLength() - 1); + } + for (std::vector >::const_iterator it = m_list.begin() ; it != m_list.end() ; ++it) { if (it != m_list.begin()) { + os << ","; + ++pos; + } + + string tmp; + utility::outputStreamStringAdapter tmpStream(tmp); + size_t tmpPos = 0; + + (*it)->generate(mboxCtx, tmpStream, pos + 1, &tmpPos); - os << ", "; - pos += 2; + // Unless the mailbox fits entirely after a space, it starts a new + // line, with the fold in place of the space + if (pos > NEW_LINE_SEQUENCE_LENGTH && + (tmp.find(CRLF) != string::npos || tmpPos > mboxCtx.getMaxLineLength())) { + + os << NEW_LINE_SEQUENCE; + (*it)->generate(mboxCtx, os, NEW_LINE_SEQUENCE_LENGTH, &pos); } else { - os << " "; - ++pos; + os << " " << tmp; + pos = tmpPos; } - - (*it)->generate(tmpCtx, os, pos, &pos); } os << ";"; diff --git a/src/vmime/messageIdSequence.cpp b/src/vmime/messageIdSequence.cpp index e1d324a1..a816bb39 100644 --- a/src/vmime/messageIdSequence.cpp +++ b/src/vmime/messageIdSequence.cpp @@ -23,6 +23,7 @@ #include "vmime/messageIdSequence.hpp" #include "vmime/exception.hpp" +#include "vmime/utility/outputStreamStringAdapter.hpp" #include @@ -123,19 +124,33 @@ void messageIdSequence::generateImpl( if (!m_list.empty()) { - generationContext tmpCtx(ctx); - tmpCtx.setMaxLineLength(ctx.getMaxLineLength() - 2); + m_list.front()->generate(ctx, os, pos, &pos); - for (std::vector >::const_iterator it = m_list.begin() ; ; ) { + // The following ids are folded here, in place of the separator + generationContext idCtx(ctx); + idCtx.setWrapMessageId(false); - (*it)->generate(ctx, os, pos, &pos); + for (std::vector >::const_iterator it = m_list.begin() + 1 ; + it != m_list.end() ; ++it) { - if (++it == m_list.end()) { - break; + string id; + utility::outputStreamStringAdapter idStream(id); + + (*it)->generate(idCtx, idStream, 0, NULL); + + if (ctx.getWrapMessageId() && pos + 1 + id.length() > ctx.getMaxLineLength()) { + + os << NEW_LINE_SEQUENCE; + pos = NEW_LINE_SEQUENCE_LENGTH; + + } else { + + os << " "; + ++pos; } - os << " "; - pos++; + os << id; + pos += id.length(); } } diff --git a/src/vmime/word.cpp b/src/vmime/word.cpp index 9d7ea735..7cfa732b 100644 --- a/src/vmime/word.cpp +++ b/src/vmime/word.cpp @@ -442,6 +442,166 @@ void word::generateImpl( } +enum QuoteResult { + QUOTE_NOT_DONE, // nothing written, fold the text unquoted + QUOTE_DONE, + QUOTE_NEEDS_ENCODING // nothing written, a run does not fit on a line +}; + + +static bool isWSP(const char c) { + + return c == ' ' || c == '\t'; +} + + +// Whether "buffer" can only be written as a quoted-string: it contains +// specials (RFC 5322 §3.2.3), or white-space which would be read +// as a single space outside quotes (§3.2.2) +static bool phraseNeedsQuoting(const string& buffer) { + + if (utility::stringUtils::needQuoting(buffer, "()<>[]:;@\\,.\"\t")) { + return true; + } + + return !buffer.empty() && + (buffer[0] == ' ' || buffer[buffer.length() - 1] == ' ' || + buffer.find(" ") != string::npos); +} + + +enum FoldPoint { + FOLD_NONE, + FOLD_SPACE, // before a single space + FOLD_RUN_END, // before the last space of a white-space run + FOLD_TAB, // before a tab, which a lenient parser reads as a space + FOLD_BACKSLASH // after '\', which a lenient parser takes as continuation +}; + + +// Where a fold may be put before buffer[pos], "prev" being the character +// before buffer[0]. Only at the end of a run, which lenient parsers +// collapse, and the kind tells how much a fold there is preferred. +static FoldPoint getFoldPoint(const string& buffer, const size_t pos, const char prev) { + + const char before = (pos == 0 ? prev : buffer[pos - 1]); + + if (!isWSP(buffer[pos]) || + (pos + 1 < buffer.length() && isWSP(buffer[pos + 1])) || + (pos == 0 && isWSP(before))) { + + return FOLD_NONE; + } + + if (before == '\\') { + return FOLD_BACKSLASH; + } else if (buffer[pos] == '\t') { + return FOLD_TAB; + } + + return isWSP(before) ? FOLD_RUN_END : FOLD_SPACE; +} + + +// Position after "pos" where a quoted-string may be folded, or the end +static size_t nextFoldPoint(const string& quoted, size_t pos) { + + for (++pos ; pos < quoted.length() ; ++pos) { + + if (getFoldPoint(quoted, pos, 'x') == FOLD_SPACE) { + break; + } + } + + return std::min(pos, quoted.length()); +} + + +// Write "buffer" as a quoted-string (RFC 5322 §3.2.4). If it does +// not fit on the current line, fold before it, or inside it before +// white-space, where unfolding restores the text exactly. +static QuoteResult generateQuotedPhrase( + const string& buffer, + const generationContext& ctx, + utility::outputStream& os, + size_t& curLineLength, + const int flags, + word::generatorState* state +) { + + const size_t maxLineLength = ctx.getMaxLineLength(); + const string quoted = utility::stringUtils::quote(buffer, "\\\"", "\\"); + + if (curLineLength + 2 /* 2 x " */ + quoted.length() > maxLineLength) { + + if (!phraseNeedsQuoting(buffer)) { + return QUOTE_NOT_DONE; + } + + // Lengths of the first and the longest run, with the quotes + size_t firstRun = 0, maxRun = 0; + + for (size_t i = 0, next ; i < quoted.length() ; i = next) { + + next = nextFoldPoint(quoted, i); + + const size_t len = next - i + (i == 0 ? 1 : 0) + (next == quoted.length() ? 1 : 0); + + if (i == 0) { + firstRun = len; + } + + maxRun = std::max(maxRun, len); + } + + // Same rule as for unquoted text + if ((flags & text::FORCE_NO_ENCODING) == 0 && maxRun >= maxLineLength - 3) { + return QUOTE_NEEDS_ENCODING; + } + + // Start a new line if not even the first run fits on this one, or if + // the quoted string then fits entirely, but leave a fold before the + // first word to the enclosing component, and prefer not to fold + // after a '\' + if (curLineLength > NEW_LINE_SEQUENCE_LENGTH && + (curLineLength + firstRun > maxLineLength || + (!state->isFirstWord && !state->lastCharIsBackslash && + NEW_LINE_SEQUENCE_LENGTH + 2 + quoted.length() <= maxLineLength))) { + + os << NEW_LINE_SEQUENCE; + curLineLength = NEW_LINE_SEQUENCE_LENGTH; + } + } + + os << '"'; + ++curLineLength; + + for (size_t i = 0, next ; i < quoted.length() ; i = next) { + + next = nextFoldPoint(quoted, i); + + const size_t len = next - i + (next == quoted.length() ? 1 : 0); + + if (i != 0 && curLineLength + len > maxLineLength) { + os << CRLF; + curLineLength = 0; + } + + os << string(quoted, i, next - i); + curLineLength += next - i; + } + + os << '"'; + ++curLineLength; + + state->prevWordIsEncoded = false; + state->lastCharIsSpace = false; + state->lastCharIsBackslash = false; + + return QUOTE_DONE; +} + + void word::generate( const generationContext& ctx, utility::outputStream& os, @@ -459,6 +619,16 @@ void word::generate( state = &defaultGeneratorState; } + // Nothing to write, keep the state of the previous word + if (m_buffer.empty() && (flags & (text::QUOTE_IF_POSSIBLE | text::QUOTE_IF_NEEDED)) == 0) { + + if (newLinePos) { + *newLinePos = curLinePos; + } + + return; + } + // Find out if encoding is forced or required by contents + charset bool encodingNeeded = false; @@ -470,6 +640,8 @@ void word::generate( encodingNeeded = wordEncoder::isEncodingNeeded(ctx, m_buffer, m_charset, m_lang); } + QuoteResult quoteResult = QUOTE_NOT_DONE; + // If text does not need to be encoded, quote the buffer (no folding is performed). if (!encodingNeeded && (flags & text::QUOTE_IF_NEEDED) && @@ -480,18 +652,22 @@ void word::generate( os << '"' << quoted << '"'; curLineLength += 1 + quoted.length() + 1; - // If possible and requested (with flag), quote the buffer (no folding is performed). + state->prevWordIsEncoded = false; + state->lastCharIsBackslash = false; + + // If possible and requested (with flag), quote the buffer. // Quoting is possible if and only if: // - the buffer does not need to be encoded - // - the buffer does not contain quoting character (") - // - there is enough remaining space on the current line to hold the whole buffer + // - it fits on the current line, or it cannot be written unquoted } else if (!encodingNeeded && (flags & text::QUOTE_IF_POSSIBLE) && - m_buffer.find('"') == string::npos && - (curLineLength + 2 /* 2 x " */ + m_buffer.length()) < ctx.getMaxLineLength()) { + (quoteResult = generateQuotedPhrase(m_buffer, ctx, os, curLineLength, + flags, state)) != QUOTE_NOT_DONE) { - os << '"' << m_buffer << '"'; - curLineLength += 2 + m_buffer.length(); + if (quoteResult == QUOTE_NEEDS_ENCODING) { + generate(ctx, os, curLinePos, newLinePos, flags | text::FORCE_ENCODING, state); + return; + } // We will fold lines without encoding them. } else if (!encodingNeeded) { @@ -514,15 +690,18 @@ void word::generate( // * a maximum line length of N bytes // * a buffer containing N+1 bytes, with no whitespace // - // Look in the buffer for any run (ie. whitespace-separated sequence) which + // Look in the buffer for any run (ie. sequence between two fold points) which // is longer than the maximum line length. If there is one, then force encoding, // so that no generated line is longer than the maximum line length. + const char prevChar = state->lastCharIsBackslash ? '\\' + : (state->lastCharIsSpace ? ' ' : 'x'); + size_t maxRunLength = 0; size_t curRunLength = 0; - for (string::const_iterator p = buffer.begin(), end = buffer.end() ; p != end ; ++p) { + for (size_t i = 0 ; i < buffer.length() ; ++i) { - if (parserHelpers::isSpace(*p)) { + if (getFoldPoint(buffer, i, prevChar) != FOLD_NONE) { maxRunLength = std::max(maxRunLength, curRunLength); curRunLength = 0; @@ -542,8 +721,21 @@ void word::generate( return; } + // Separate from previous word, unless the text starts with white-space + bool sepPending = + !state->isFirstWord && + (state->prevWordIsEncoded || ctx.getInternationalizedEmailSupport()) && + !state->lastCharIsSpace && + !buffer.empty() && !parserHelpers::isSpace(buffer[0]); + + if (sepPending) { + ++curLineLength; + } + // Output runs, and fold line when a whitespace is encountered string::const_iterator lastWSpos = buffer.end(); // last white-space position + string::const_iterator lastRunEnd = buffer.end(), lastTab = buffer.end(); + string::const_iterator lastBackslash = buffer.end(); string::const_iterator curLineStart = buffer.begin(); // current line start string::const_iterator p = buffer.begin(); @@ -557,14 +749,38 @@ void word::generate( for ( ; p != end ; ++p, ++curLineLength) { // Exceeded maximum line length, but we have found a white-space - // where we can cut the line... - if (curLineLength >= ctx.getMaxLineLength() && lastWSpos != end) { - break; + // where we can cut the line: a single space, else the end of a + // run, else a tab, else after a '\' + if (curLineLength >= ctx.getMaxLineLength()) { + + if (lastWSpos == end) { + lastWSpos = lastRunEnd; + } + + if (lastWSpos == end) { + lastWSpos = lastTab; + } + + if (lastWSpos == end) { + lastWSpos = lastBackslash; + } + + if (lastWSpos != end) { + break; + } } - if (*p == ' ' || *p == '\t') { - // Remember the position of this white-space character - lastWSpos = p; + if (newLine && p == curLineStart) { + continue; // a fold was just put here + } + + switch (getFoldPoint(buffer, p - buffer.begin(), prevChar)) { + + case FOLD_SPACE: lastWSpos = p; break; + case FOLD_RUN_END: lastRunEnd = p; break; + case FOLD_TAB: lastTab = p; break; + case FOLD_BACKSLASH: lastBackslash = p; break; + case FOLD_NONE: break; } } @@ -600,17 +816,15 @@ void word::generate( } p = curLineStart; - lastWSpos = end; + lastWSpos = lastRunEnd = lastTab = lastBackslash = end; newLine = true; + sepPending = false; } else { - if (!state->isFirstWord && - (state->prevWordIsEncoded || ctx.getInternationalizedEmailSupport()) && - !state->lastCharIsSpace && - !parserHelpers::isSpace(*curLineStart)) { - + if (sepPending) { os << " "; // Separate from previous word + sepPending = false; } os << string(curLineStart, p); @@ -639,7 +853,7 @@ void word::generate( } curLineStart = p; - lastWSpos = end; + lastWSpos = lastRunEnd = lastTab = lastBackslash = end; newLine = true; } } @@ -650,11 +864,9 @@ void word::generate( // characters _after_ the last white-space; so we cut the line at this // last white-space. - if (curLineLength != NEW_LINE_SEQUENCE_LENGTH && - !state->isFirstWord && - state->prevWordIsEncoded) { - + if (sepPending) { os << " "; // Separate from previous word + sepPending = false; } os << string(curLineStart, lastWSpos); @@ -665,6 +877,8 @@ void word::generate( state->lastCharIsSpace = false; } + curLineStart = lastWSpos + 1; + if (flags & text::NO_NEW_LINE_SEQUENCE) { os << CRLF; @@ -672,6 +886,14 @@ void word::generate( state->lastCharIsSpace = true; + } else if (*lastWSpos == '\t') { + + os << CRLF; // the tab starts the next line + curLineLength = 0; + curLineStart = lastWSpos; + + state->lastCharIsSpace = true; + } else { os << NEW_LINE_SEQUENCE; @@ -680,14 +902,15 @@ void word::generate( state->lastCharIsSpace = true; } - curLineStart = lastWSpos + 1; - - p = lastWSpos + 1; - lastWSpos = end; + p = curLineStart; + lastWSpos = lastRunEnd = lastTab = lastBackslash = end; newLine = true; } } + state->prevWordIsEncoded = false; + state->lastCharIsBackslash = (!buffer.empty() && buffer[buffer.length() - 1] == '\\'); + /* RFC #2047: 4. Encodings @@ -732,6 +955,9 @@ void word::generate( const size_t maxLineLength2 = (maxLineLength3 < minWordLength + 1) ? maxLineLength3 + minWordLength + 1 : maxLineLength3; + // RFC 2047 §2: an encoded-word may not be more than 75 characters long + const size_t maxEncodedWordLength = 75; + // Checks whether remaining space on this line is usable. If too few // characters can be encoded, start a new line. bool startNewLine = true; @@ -753,31 +979,47 @@ void word::generate( } } + // White-space after an unencoded word is displayed (RFC 2047 section + // 6.2), so the fold or space added here stands for a leading space + // or tab, which is then written as is + const bool leadingWSP = + !state->isFirstWord && !state->prevWordIsEncoded && isWSP(m_buffer[0]); + const char sep = leadingWSP ? m_buffer[0] : ' '; + + bool sepAdded = false; + if (startNewLine) { - os << NEW_LINE_SEQUENCE; + os << CRLF << sep; curLineLength = NEW_LINE_SEQUENCE_LENGTH; state->lastCharIsSpace = true; + sepAdded = true; } // Encode and fold input buffer if (!startNewLine && !state->isFirstWord && !state->lastCharIsSpace) { - os << " "; // Separate from previous word - if (!state->prevWordIsEncoded && m_buffer[0] == ' ') - wordEnc.getNextChunk(1); - + os << sep; // Separate from previous word ++curLineLength; state->lastCharIsSpace = true; + sepAdded = true; + } + + if (sepAdded && leadingWSP) { + wordEnc.getNextChunk(1); } + const bool infiniteLength = (ctx.getMaxLineLength() == lineLengthLimits::infinite); + for (unsigned int i = 0 ; ; ++i) { // Compute the number of encoded chars that will fit on this line - const size_t fit = maxLineLength2 - minWordLength - - (i == 0 ? curLineLength : NEW_LINE_SEQUENCE_LENGTH); + const size_t used = (i == 0 ? curLineLength : NEW_LINE_SEQUENCE_LENGTH); + const size_t lineRoom = (maxLineLength2 > used) ? maxLineLength2 - used : 0; + const size_t wordRoom = std::min(lineRoom, maxEncodedWordLength); + const size_t fit = (wordRoom > minWordLength) ? wordRoom - minWordLength : 1; // Get the next encoded chunk const string chunk = wordEnc.getNextChunk(fit); @@ -786,8 +1028,13 @@ void word::generate( break; } - // Start a new encoded word - if (i != 0) { + // Start a new encoded word, on the same line if there is no limit + if (i != 0 && infiniteLength) { + + os << " "; + ++curLineLength; + + } else if (i != 0) { os << NEW_LINE_SEQUENCE; curLineLength = NEW_LINE_SEQUENCE_LENGTH; @@ -804,6 +1051,7 @@ void word::generate( state->prevWordIsEncoded = true; state->lastCharIsSpace = false; + state->lastCharIsBackslash = false; } } diff --git a/src/vmime/word.hpp b/src/vmime/word.hpp index e20fa908..57a5f6bc 100644 --- a/src/vmime/word.hpp +++ b/src/vmime/word.hpp @@ -169,13 +169,15 @@ class VMIME_EXPORT word : public headerFieldValue { generatorState() : isFirstWord(true), prevWordIsEncoded(false), - lastCharIsSpace(false) { + lastCharIsSpace(false), + lastCharIsBackslash(false) { } bool isFirstWord; bool prevWordIsEncoded; bool lastCharIsSpace; + bool lastCharIsBackslash; }; class parserState { diff --git a/src/vmime/wordEncoder.cpp b/src/vmime/wordEncoder.cpp index 404a57b4..92b42ed1 100644 --- a/src/vmime/wordEncoder.cpp +++ b/src/vmime/wordEncoder.cpp @@ -147,7 +147,7 @@ const string wordEncoder::getNextChunk(const size_t maxLength) { // bytes to encode knowing the maximum number of encoded chars. In // Base64 encoding, 3 bytes of input provide 4 bytes of output. const size_t inputCount = - std::min(remaining, (maxLength > 1) ? ((maxLength - 1) * 3) / 4 : 1); + std::min(remaining, std::max(static_cast (1), (maxLength / 4) * 3)); // Encode chunk utility::inputStreamStringAdapter in(m_buffer, m_pos, m_pos + inputCount); @@ -163,12 +163,17 @@ const string wordEncoder::getNextChunk(const size_t maxLength) { size_t inputCount = 0; size_t outputCount = 0; - while ((inputCount == 0 || outputCount < maxLength) && (inputCount < remaining)) { + while (inputCount < remaining) { const unsigned char c = m_buffer[m_pos + inputCount]; + const size_t len = utility::encoder::qpEncoder::RFC2047_getEncodedLength(c); + + if (inputCount != 0 && outputCount + len > maxLength) { + break; + } inputCount++; - outputCount += utility::encoder::qpEncoder::RFC2047_getEncodedLength(c); + outputCount += len; } // Encode chunk @@ -187,7 +192,7 @@ const string wordEncoder::getNextChunk(const size_t maxLength) { size_t outputCount = 0; string encodeBuffer; - while ((inputCount == 0 || outputCount < maxLength) && (inputCount < remaining)) { + while (inputCount < remaining) { // Get the next UTF8 character const size_t inputCharLength = @@ -202,25 +207,29 @@ const string wordEncoder::getNextChunk(const size_t maxLength) { string encodeBytes; conv->convert(inputChar, encodeBytes); - encodeBuffer += encodeBytes; - // Compute number of output bytes + size_t newOutputCount = outputCount; + if (m_encoding == ENCODING_B64) { - outputCount = std::max( - static_cast (4), - (encodeBuffer.length() * 4) / 3 - ); + newOutputCount = ((encodeBuffer.length() + encodeBytes.length() + 2) / 3) * 4; } else { // ENCODING_QP for (size_t i = 0, n = encodeBytes.length() ; i < n ; ++i) { const unsigned char c = encodeBytes[i]; - outputCount += utility::encoder::qpEncoder::RFC2047_getEncodedLength(c); + newOutputCount += utility::encoder::qpEncoder::RFC2047_getEncodedLength(c); } } + // Always take at least one character, so that we make progress + if (inputCount != 0 && newOutputCount > maxLength) { + break; + } + + encodeBuffer += encodeBytes; + outputCount = newOutputCount; inputCount += inputCharLength; } diff --git a/tests/parser/emailAddressTest.cpp b/tests/parser/emailAddressTest.cpp index c755150f..f695a226 100644 --- a/tests/parser/emailAddressTest.cpp +++ b/tests/parser/emailAddressTest.cpp @@ -39,6 +39,8 @@ VMIME_TEST_SUITE_BEGIN(emailAddressTest) VMIME_TEST(testParseCommentInDomainPart) VMIME_TEST(testParseRFC2047EncodedLocalPart) VMIME_TEST(testGenerateSpecialChars) + VMIME_TEST(testGenerateLongLocalPart) + VMIME_TEST(testGenerateASCIILocalPart) VMIME_TEST_LIST_END @@ -257,4 +259,56 @@ VMIME_TEST_SUITE_BEGIN(emailAddressTest) ); } + void testGenerateLongLocalPart() { + + // Neither encoded nor folded + const std::string local(80, 'a'); + vmime::emailAddress e1(local, "example.com"); + + VASSERT_EQ("1", local + "@example.com", e1.generate(78, 4)); + VASSERT_EQ("2", local + "@example.com", e1.generate()); + + vmime::emailAddress back; + back.parse(e1.generate(78, 4)); + VASSERT_EQ("3", local, back.getLocalName().getBuffer()); + + // Non-ASCII without SMTPUTF8: one encoded-word, not folded + std::string local2; + + for (int i = 0 ; i < 40 ; ++i) { + local2 += "\xc3\xa4"; + } + + const std::string out = vmime::emailAddress(local2, "example.com").generate(78, 4); + + VASSERT_EQ("4", std::string::npos, out.find_first_of(" \r\n")); + + back.parse(out); + VASSERT_EQ("5", local2, back.getLocalName().getConvertedText(vmime::charsets::UTF_8)); + } + + void testGenerateASCIILocalPart() { + + // Plain ASCII is not encoded, even with '=' (eg. VERP) or "=?" + const char* locals[] = { "bounce-dGVzdA==", "abc=", "a=?b" }; + + for (const char* local : locals) { + + vmime::emailAddress e(local, "example.com"); + VASSERT_EQ(local, std::string(local) + "@example.com", e.generate()); + } + + vmime::emailAddress back; + back.parse(vmime::emailAddress("bounce-dGVzdA==", "example.com").generate()); + VASSERT_EQ("parse", "bounce-dGVzdA==", back.getLocalName().getBuffer()); + + // 7-bit, but with escape sequences: still encoded + const vmime::word jp("\x1b$B$3$s$K$A$O\x1b(B", vmime::charset("iso-2022-jp")); + const std::string out = + vmime::emailAddress(jp, vmime::word("example.com", vmime::charsets::US_ASCII)).generate(); + + VASSERT("iso-2022-jp: " + out, out.compare(0, 16, "=?iso-2022-jp?B?") == 0); + VASSERT_EQ("no ESC", std::string::npos, out.find('\x1b')); + } + VMIME_TEST_SUITE_END diff --git a/tests/parser/mailboxGroupTest.cpp b/tests/parser/mailboxGroupTest.cpp index 00a7baad..5a8c781f 100644 --- a/tests/parser/mailboxGroupTest.cpp +++ b/tests/parser/mailboxGroupTest.cpp @@ -34,6 +34,7 @@ VMIME_TEST_SUITE_BEGIN(mailboxGroupTest) VMIME_TEST(testEncodedEmptyGroup) VMIME_TEST(testGroupInGroup) VMIME_TEST(testBrokenGroup) + VMIME_TEST(testGenerateFolding) VMIME_TEST_LIST_END @@ -132,4 +133,37 @@ VMIME_TEST_SUITE_BEGIN(mailboxGroupTest) VASSERT_EQ("count", 0, mgrp.getMailboxCount()); } + void testGenerateFolding() { + + using namespace vmime; + + mailboxGroup grp(text("Team", charsets::UTF_8)); + grp.appendMailbox(make_shared (text("Alice Allison", charsets::UTF_8), emailAddress("alice@example.com"))); + grp.appendMailbox(make_shared (text("Bob Builder", charsets::UTF_8), emailAddress("bob@example.com"))); + grp.appendMailbox(make_shared (text("Doe, John", charsets::UTF_8), emailAddress("john@example.com"))); + grp.appendMailbox(make_shared (emailAddress("first.person@example.com"))); + + // Whole mailboxes move to the next line, the fold replaces the + // space after the comma + VASSERT_EQ( + "1", + "Team: \"Alice Allison\" ,\r\n" + " \"Bob Builder\" , \"Doe, John\" ,\r\n" + " first.person@example.com;", + grp.generate(78, 4) + ); + + mailboxGroup back; + back.parse(grp.generate(78, 4)); + VASSERT_EQ("2", 4, back.getMailboxCount()); + VASSERT_EQ("3", "Doe, John", back.getMailboxAt(2)->getName().getWholeBuffer()); + + VASSERT_EQ( + "4", + "Team: \"Alice Allison\" , \"Bob Builder\" , " + "\"Doe, John\" , first.person@example.com;", + grp.generate() + ); + } + VMIME_TEST_SUITE_END diff --git a/tests/parser/mailboxListTest.cpp b/tests/parser/mailboxListTest.cpp index 2036863d..407ee89d 100644 --- a/tests/parser/mailboxListTest.cpp +++ b/tests/parser/mailboxListTest.cpp @@ -29,6 +29,8 @@ VMIME_TEST_SUITE_BEGIN(mailboxListTest) VMIME_TEST_LIST_BEGIN VMIME_TEST(testParseGroup) VMIME_TEST(testBrokenGroup) + VMIME_TEST(testGenerateFolding) + VMIME_TEST(testParseQuotedSpecials) VMIME_TEST_LIST_END @@ -59,4 +61,63 @@ VMIME_TEST_SUITE_BEGIN(mailboxListTest) VASSERT_EQ("count", 0, mboxList.getMailboxCount()); } + void testGenerateFolding() { + + using namespace vmime; + + mailboxList ml; + ml.appendMailbox(make_shared (text("Alice Allison", charsets::UTF_8), emailAddress("alice@example.com"))); + ml.appendMailbox(make_shared (text("Bob Builder", charsets::UTF_8), emailAddress("bob@example.com"))); + ml.appendMailbox(make_shared (text("Doe, John", charsets::UTF_8), emailAddress("john@example.com"))); + ml.appendMailbox(make_shared (text("O'Brien; Pat", charsets::UTF_8), emailAddress("pat@example.com"))); + ml.appendMailbox(make_shared (text("Carol Smith", charsets::UTF_8), emailAddress("carol@example.com"))); + ml.appendMailbox(make_shared (text("J\xc3\xbcrgen M\xc3\xbcller", charsets::UTF_8), emailAddress("juergen@example.com"))); + ml.appendMailbox(make_shared (emailAddress("first.person@example.com"))); + + // Whole mailboxes move to the next line, the fold replaces the + // space after the comma + VASSERT_EQ( + "1", + "\"Alice Allison\" , \"Bob Builder\" ,\r\n" + " \"Doe, John\" , \"O'Brien; Pat\" ,\r\n" + " \"Carol Smith\" ,\r\n" + " =?utf-8?Q?J=C3=BCrgen_M=C3=BCller?= ,\r\n" + " first.person@example.com", + ml.generate(78, 4) + ); + + mailboxList back; + back.parse(ml.generate(78, 4)); + VASSERT_EQ("2", 7, back.getMailboxCount()); + VASSERT_EQ("3", "Doe, John", back.getMailboxAt(2)->getName().getWholeBuffer()); + + VASSERT_EQ( + "4", + "\"Alice Allison\" , \"Bob Builder\" , " + "\"Doe, John\" , \"O'Brien; Pat\" , " + "\"Carol Smith\" , " + "=?utf-8?Q?J=C3=BCrgen_M=C3=BCller?= , " + "first.person@example.com", + ml.generate() + ); + } + + void testParseQuotedSpecials() { + + // '(' inside a quoted-string does not start a comment + vmime::mailboxList ml1; + ml1.parse("\"F\" , \"a(b\" , \"Z\" "); + + VASSERT_EQ("1", 3, ml1.getMailboxCount()); + VASSERT_EQ("2", "a(b", ml1.getMailboxAt(1)->getName().getWholeBuffer()); + VASSERT_EQ("3", "r@x.com", ml1.getMailboxAt(1)->getEmail().generate()); + + // '"' inside a comment does not start a quoted-string + vmime::mailboxList ml2; + ml2.parse("(a\"b) , "); + + VASSERT_EQ("4", 2, ml2.getMailboxCount()); + VASSERT_EQ("5", "s@x.com", ml2.getMailboxAt(1)->getEmail().generate()); + } + VMIME_TEST_SUITE_END diff --git a/tests/parser/mailboxTest.cpp b/tests/parser/mailboxTest.cpp index a186a5a7..a4154a5b 100644 --- a/tests/parser/mailboxTest.cpp +++ b/tests/parser/mailboxTest.cpp @@ -34,6 +34,10 @@ VMIME_TEST_SUITE_BEGIN(mailboxTest) VMIME_TEST(testExcessiveQuoting) VMIME_TEST(testSpacing) VMIME_TEST(testInternationalizedEmail) + VMIME_TEST(testQuotedNameSpecials) + VMIME_TEST(testQuotedNameFolding) + VMIME_TEST(testFoldPoints) + VMIME_TEST(testFoldBeforeAddress) VMIME_TEST_LIST_END @@ -216,6 +220,127 @@ VMIME_TEST_SUITE_BEGIN(mailboxTest) VASSERT_EQ("3", "\"J\xc3\xa4ger\" ", oss.str()); } + void testQuotedNameSpecials() { + + using namespace vmime; + + // '"' and '\' are escaped inside the quoted-string + mailbox m1(text("Meier, Hans \"Hansi\"", charsets::UTF_8), emailAddress("x@example.com")); + VASSERT_EQ("1", "\"Meier, Hans \\\"Hansi\\\"\" ", m1.generate()); + + mailbox m2(text("IT \\ Support", charsets::UTF_8), emailAddress("x@example.com")); + VASSERT_EQ("2", "\"IT \\\\ Support\" ", m2.generate()); + + mailbox back; + back.parse(m1.generate()); + VASSERT_EQ("3", "Meier, Hans \"Hansi\"", back.getName().getWholeBuffer()); + back.parse(m2.generate()); + VASSERT_EQ("4", "IT \\ Support", back.getName().getWholeBuffer()); + } + + void testQuotedNameFolding() { + + using namespace vmime; + + generationContext ctx; + ctx.setMaxLineLength(78); + + // Fits exactly + std::string out; + utility::outputStreamStringAdapter os(out); + word("Doe, John", charsets::US_ASCII).generate(ctx, os, 67, NULL, text::QUOTE_IF_POSSIBLE, NULL); + VASSERT_EQ("1", "\"Doe, John\"", out); + + // Does not fit: the quotes are kept, the fold goes inside + mailbox m1(text("Doe, John", charsets::UTF_8), emailAddress("john@example.com")); + VASSERT_EQ("2", "\"Doe,\r\n John\" ", m1.generate(78, 68)); + + mailbox back; + back.parse(m1.generate(78, 68)); + VASSERT_EQ("3", "Doe, John", back.getName().getWholeBuffer()); + + // Longer than a line + const std::string name = + "Very Long Department Name, With Commas, And More Commas, Until It Is Longer Than A Line"; + mailbox m2(text(name, charsets::UTF_8), emailAddress("lc@example.com")); + VASSERT_EQ( + "4", + "\"Very Long Department Name, With Commas, And More Commas, Until It Is\r\n" + " Longer Than A Line\" ", + m2.generate(78, 4) + ); + + back.parse(m2.generate(78, 4)); + VASSERT_EQ("5", name, back.getName().getWholeBuffer()); + + // A double space is only kept inside quotes + mailbox m3(text("John Doe Smith", charsets::UTF_8), emailAddress("js@example.com")); + VASSERT_EQ("6", "\"John Doe\r\n Smith\" ", m3.generate(78, 66)); + + // Atoms may still be folded without quotes + mailbox m4(text("Bob Builder", charsets::UTF_8), emailAddress("bob@example.com")); + VASSERT_EQ("7", "Bob\r\n Builder ", m4.generate(78, 72)); + } + + void testFoldPoints() { + + using namespace vmime; + + // No fold after '\' or inside a white-space run, which a lenient + // parser would not unfold to the same text + const char* names[] = { + "IT \\ Support Department of the Central Administration Office", + "Doe, John Central Office Team North and South and East and West", + }; + + generationContext ctx; + ctx.setMaxLineLength(78); + + for (const char* name : names) { + + for (size_t col = 4 ; col < 78 ; ++col) { + + std::string out; + utility::outputStreamStringAdapter os(out); + text(name, charsets::UTF_8).encodeAndFold(ctx, os, col, NULL, text::QUOTE_IF_POSSIBLE); + + VASSERT(out, out.find("\\\r\n") == std::string::npos); + VASSERT(out, out.find(" \r\n") == std::string::npos); + VASSERT(out, out.find("\r\n ") == std::string::npos); + + mailbox back; + back.parse(out + " "); + VASSERT_EQ(out, name, back.getName().getWholeBuffer()); + } + } + } + + void testFoldBeforeAddress() { + + using namespace vmime; + + // The fold replaces the space before "<" + mailbox m1(text("Carol Smith-Jones", charsets::UTF_8), emailAddress("carol.smith-jones@example.com")); + VASSERT_EQ("1", "\"Carol Smith-Jones\"\r\n ", m1.generate(78, 30)); + + // A bare address is folded only if it then fits + generationContext ctx; + ctx.setMaxLineLength(78); + + std::string out; + utility::outputStreamStringAdapter os(out); + size_t pos = 0; + + mailbox m2(emailAddress("first.person@example.com")); + m2.generate(ctx, os, 60, &pos); + VASSERT_EQ("2", "\r\n first.person@example.com", out); + VASSERT_EQ("3", 25, pos); + + const std::string addr = std::string(70, 'x') + "@example.com"; + mailbox m3((emailAddress(addr))); + VASSERT_EQ("4", addr, m3.generate(78, 4)); + } + void testAPI() { auto take_string_directly = [](vmime::string &&) {}; diff --git a/tests/parser/messageIdSequenceTest.cpp b/tests/parser/messageIdSequenceTest.cpp index 0237108d..ded6e275 100644 --- a/tests/parser/messageIdSequenceTest.cpp +++ b/tests/parser/messageIdSequenceTest.cpp @@ -29,6 +29,7 @@ VMIME_TEST_SUITE_BEGIN(messageIdSequenceTest) VMIME_TEST_LIST_BEGIN VMIME_TEST(testParse) VMIME_TEST(testGenerate) + VMIME_TEST(testGenerateFolding) VMIME_TEST_LIST_END @@ -75,4 +76,35 @@ VMIME_TEST_SUITE_BEGIN(messageIdSequenceTest) VASSERT_EQ("2", " ", s2.generate()); } + void testGenerateFolding() { + + vmime::messageIdSequence s; + s.appendMessageId(vmime::make_shared ("first.id.1234567890", "example.com")); + s.appendMessageId(vmime::make_shared ("second.id.1234567890", "example.com")); + s.appendMessageId(vmime::make_shared ("third.id.1234567890", "example.com")); + + // No white-space before the fold + VASSERT_EQ( + "1", + " \r\n" + " ", + s.generate(78, 5) + ); + + vmime::generationContext ctx; + ctx.setMaxLineLength(78); + ctx.setWrapMessageId(false); + + std::string out; + vmime::utility::outputStreamStringAdapter os(out); + s.generate(ctx, os, 5); + + VASSERT_EQ( + "2", + " " + " ", + out + ); + } + VMIME_TEST_SUITE_END diff --git a/tests/parser/parameterTest.cpp b/tests/parser/parameterTest.cpp index 68a987ad..08640dd2 100644 --- a/tests/parser/parameterTest.cpp +++ b/tests/parser/parameterTest.cpp @@ -405,20 +405,20 @@ VMIME_TEST_SUITE_BEGIN(parameterTest) VASSERT_EQ( "3.7bit-only", "F: X; \r\n " - "param1=\"=?utf-8?B?zrTPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+DzrTPg860?=\r\n " - "=?utf-8?B?zrEgzrTPg86xzrTPg860z4POsc60z4POsc60zrHPg860zrHPgyDOtM+DzrHOtM6x?=\r\n " - "=?utf-8?B?z4POtM+DzrEgzrTPg86xzrTOsc+DzrTPg86xIM60zrHPg860zrHPg860zrHPgyDOtA==?=\r\n " - "=?utf-8?B?zrHPg86xz4fPhs60zrTPg86xIDIwMDguZG9j?=\"", + "param1=\"=?utf-8?B?zrTPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+DzrQ=?=\r\n " + "=?utf-8?B?z4POtM6xIM60z4POsc60z4POtM+DzrHOtM+DzrHOtM6xz4POtM6xz4MgzrQ=?=\r\n " + "=?utf-8?B?z4POsc60zrHPg860z4POsSDOtM+DzrHOtM6xz4POtM+DzrEgzrTOsc+DzrQ=?=\r\n " + "=?utf-8?B?zrHPg860zrHPgyDOtM6xz4POsc+Hz4bOtM60z4POsSAyMDA4LmRvYw==?=\"", p3.generate(vmime::generationContext::PARAMETER_VALUE_RFC2047_ONLY, 80) // max line length = 80 ); VASSERT_EQ( "3.both", "F: X; \r\n " - "param1=\"=?utf-8?B?zrTPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+DzrTPg860?=\r\n " - "=?utf-8?B?zrEgzrTPg86xzrTPg860z4POsc60z4POsc60zrHPg860zrHPgyDOtM+DzrHOtM6x?=\r\n " - "=?utf-8?B?z4POtM+DzrEgzrTPg86xzrTOsc+DzrTPg86xIM60zrHPg860zrHPg860zrHPgyDOtA==?=\r\n " - "=?utf-8?B?zrHPg86xz4fPhs60zrTPg86xIDIwMDguZG9j?=\";\r\n " + "param1=\"=?utf-8?B?zrTPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+Dz4PPg8+DzrQ=?=\r\n " + "=?utf-8?B?z4POtM6xIM60z4POsc60z4POtM+DzrHOtM+DzrHOtM6xz4POtM6xz4MgzrQ=?=\r\n " + "=?utf-8?B?z4POsc60zrHPg860z4POsSDOtM+DzrHOtM6xz4POtM+DzrEgzrTOsc+DzrQ=?=\r\n " + "=?utf-8?B?zrHPg860zrHPgyDOtM6xz4POsc+Hz4bOtM60z4POsSAyMDA4LmRvYw==?=\";\r\n " "param1*0*=utf-8''%CE%B4%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83;\r\n " "param1*1*=%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83%CF%83;\r\n " "param1*2*=%CE%B4%CF%83%CE%B4%CE%B1%20%CE%B4%CF%83%CE%B1%CE%B4%CF%83%CE%B4%CF;\r\n " diff --git a/tests/parser/textTest.cpp b/tests/parser/textTest.cpp index 13e498e1..5284d554 100644 --- a/tests/parser/textTest.cpp +++ b/tests/parser/textTest.cpp @@ -41,14 +41,19 @@ VMIME_TEST_SUITE_BEGIN(textTest) VMIME_TEST(testWordGenerateSpace) VMIME_TEST(testWordGenerateSpace2) VMIME_TEST(testWordGenerateMultiBytes) + VMIME_TEST(testEncodedWordLength) VMIME_TEST(testWordGenerateQuote) VMIME_TEST(testWordGenerateSpecialCharsets) VMIME_TEST(testWordGenerateSpecials) VMIME_TEST(testWhitespace) VMIME_TEST(testWhitespaceMBox) + VMIME_TEST(testWhitespaceAfterPlainWord) + VMIME_TEST(testWhitespaceAroundFold) VMIME_TEST(testFoldingAscii) + VMIME_TEST(testFoldPoints) + VMIME_TEST(testFoldWhitespaceRuns) VMIME_TEST(testForcedNonEncoding) VMIME_TEST(testBugFix20110511) @@ -84,6 +89,36 @@ VMIME_TEST_SUITE_BEGIN(textTest) return res; } + // RFC 2047: encoded-words of at most 75 chars, lines of at most 76 + static void checkEncodedWordLength( + const std::string& id, + const std::string& str, + const size_t maxLineLength + ) { + + for (size_t start = 0 ; ; ) { + + const size_t end = str.find("\r\n", start); + const std::string line = str.substr(start, end == std::string::npos ? end : end - start); + + VASSERT(id + ": line: " + line, line.length() <= maxLineLength); + + for (size_t p = 0 ; (p = line.find("=?", p)) != std::string::npos ; ) { + + const size_t e = line.find("?=", line.find('?', line.find('?', p + 2) + 1) + 1); + + VASSERT(id + ": word: " + line, e != std::string::npos && e + 2 - p <= 75); + p = e + 2; + } + + if (end == std::string::npos) { + break; + } + + start = end + 2; + } + } + void testConstructors() { @@ -417,15 +452,60 @@ VMIME_TEST_SUITE_BEGIN(textTest) ) ); + // "aaa=C3" fits, but not the whole character VASSERT_EQ( "2", + "=?utf-8?Q?aaa?==?utf-8?Q?=C3=A9?==?utf-8?Q?zzz?=", + cleanGeneratedWords( + vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(19) + ) + ); + + VASSERT_EQ( + "3", "=?utf-8?Q?aaa=C3=A9?==?utf-8?Q?zzz?=", cleanGeneratedWords( - vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(17) + vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(22) ) ); } + void testEncodedWordLength() { + + std::string in; + + for (int i = 0 ; i < 60 ; ++i) { + in += "\xc3\xbc\xf0\x9f\x98\x80" "ab"; + } + + const vmime::word w(in, vmime::charset("utf-8")); + const size_t lengths[] = { 50, 76, 78, 100, vmime::lineLengthLimits::infinite }; + + for (const size_t maxLen : lengths) { + + for (size_t col = 0 ; col < 10 ; ++col) { + + const std::string id = std::to_string(maxLen) + "/" + std::to_string(col); + const std::string out = w.generate(maxLen, col); + + if (maxLen == vmime::lineLengthLimits::infinite) { + + checkEncodedWordLength(id, out, maxLen); + VASSERT_EQ(id + ": no fold", std::string::npos, out.find('\r')); + + } else { + + checkEncodedWordLength(id, std::string(col, 'x') + out, std::min(maxLen, size_t(76))); + } + + vmime::text back; + back.parse(out); + + VASSERT_EQ(id, in, back.getWholeBuffer()); + } + } + } + void testWordGenerateQuote() { std::string str; @@ -536,6 +616,83 @@ VMIME_TEST_SUITE_BEGIN(textTest) VASSERT_EQ("parse.email", "me@vmime.org", mbox.getEmail()); } + void testWhitespaceAfterPlainWord() { + + // The space before an encoded word which follows an unencoded + // word is not encoded a second time + VASSERT_EQ( + "1", + "=?utf-8?B?R3LDvMOfZQ==?= aus =?utf-8?Q?K=C3=B6ln?=", + vmime::text("Gr\xc3\xbc\xc3\x9f" "e aus K\xc3\xb6ln", vmime::charsets::UTF_8).generate() + ); + + const std::string subject = + "Re: AW: Angebot, Lieferung und Montage im Werk S\xc3\xbc" "d, " + "Termin n\xc3\xa4" "chste Woche bitte best\xc3\xa4tigen"; + + vmime::text back; + back.parse(vmime::text(subject, vmime::charsets::UTF_8).generate()); + + VASSERT_EQ("2", subject, back.getWholeBuffer()); + + // Neither is a leading tab, nor is a space added + vmime::text t; + t.appendWord(vmime::make_shared ("foo@", vmime::charset("us-ascii"))); + t.appendWord(vmime::make_shared ("\t\xc3\xa4", vmime::charset("utf-8"))); + + VASSERT_EQ("3", "foo@\t=?utf-8?B?w6Q=?=", t.generate()); + + back.parse(t.generate()); + VASSERT_EQ("4", "foo@\t\xc3\xa4", back.getConvertedText(vmime::charsets::UTF_8)); + } + + void testWhitespaceAroundFold() { + + // The fold before an encoded word stands for its leading space + vmime::text t1; + t1.appendWord(vmime::make_shared ("Termin", vmime::charset("us-ascii"))); + t1.appendWord(vmime::make_shared (" n\xc3\xa4" "chste", vmime::charset("utf-8"))); + + VASSERT_EQ("1", "Termin\r\n =?utf-8?Q?n=C3=A4chste?=", t1.generate(78, 52)); + + // An empty word writes nothing, the space stays encoded + vmime::text e1, e2; + e1.parse("=?utf-8?Q?=C3=A4?= =?us-ascii?Q?" "?= =?utf-8?Q?_=C3=B6?="); + e2.parse(e1.generate()); + + VASSERT_EQ("empty", "\xc3\xa4 \xc3\xb6", e2.getConvertedText(vmime::charsets::UTF_8)); + + const std::string subject = + "Re: AW: Angebot, Lieferung und Montage der neuen Anlage im Werk Nord und S\xc3\xbc" "d, " + "Termin n\xc3\xa4" "chste Woche bitte best\xc3\xa4tigen"; + + vmime::text back; + back.parse(vmime::text(subject, vmime::charsets::UTF_8).generate(78, 9)); + + VASSERT_EQ("2", subject, back.getWholeBuffer()); + + // No separator in addition to the leading space of an unencoded word + const vmime::text t2( + "\xc3\x9c" "berschrift one two three four five six seven eight nine ten" + " eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen", + vmime::charsets::UTF_8 + ); + + VASSERT_EQ( + "3", + "=?utf-8?Q?=C3=9Cberschrift?= one two three four five six seven eight\r\n" + " nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen", + t2.generate(78, 9) + ); + + // The separator after an encoded word counts for the line length + vmime::text t3; + t3.appendWord(vmime::make_shared ("\xc3\xa4", vmime::charset("utf-8"))); + t3.appendWord(vmime::make_shared (std::string(60, 'b') + " c", vmime::charset("us-ascii"))); + + VASSERT_EQ("4", "=?utf-8?B?w6Q=?= " + std::string(60, 'b') + "\r\n c", t3.generate(78, 0)); + } + void testFoldingAscii() { // In this test, no encoding is needed, but line should be folded anyway @@ -550,6 +707,148 @@ VMIME_TEST_SUITE_BEGIN(textTest) ); } + void testFoldPoints() { + + // No fold after '\' if there is another fold point, nor inside a + // white-space run + const std::string in = + "Copy C:\\ Temp and D:\\ Data to the backup share on the file server," + " then check the logs"; + + for (size_t col = 0 ; col < 78 ; ++col) { + + const std::string out = vmime::text(in, vmime::charsets::UTF_8).generate(78, col); + + VASSERT(out, out.find("\\\r\n") == std::string::npos); + VASSERT(out, out.find(" \r\n") == std::string::npos); + VASSERT(out, out.find("\r\n ") == std::string::npos); + + vmime::text back; + back.parse(out); + VASSERT_EQ(out, in, back.getWholeBuffer()); + } + + // Neither before the next word, unless "Copy C:\ Temp" does not fit + vmime::text t; + t.appendWord(vmime::make_shared ("Copy C:\\", vmime::charset("us-ascii"))); + t.appendWord(vmime::make_shared (" Temp to the backup share", vmime::charset("us-ascii"))); + + for (size_t col = 0 ; col < 78 ; ++col) { + + const std::string out = t.generate(78, col); + + if (col + 13 < 78) { + VASSERT(out, out.find("\\\r\n") == std::string::npos); + } + + // Only a first line without a fold point may be longer + for (size_t start = 0, offset = col ; ; offset = 0) { + + const size_t eol = out.find("\r\n", start); + const std::string line = out.substr(start, eol == std::string::npos ? eol : eol - start); + + VASSERT(out, offset + line.length() <= 78 || (start == 0 && line.find(' ') == std::string::npos)); + + if (eol == std::string::npos) { + break; + } + + start = eol + 2; + } + } + + // Before an encoded-word, rather than going past 76 chars (RFC 2047) + vmime::text t2; + t2.appendWord(vmime::make_shared ( + std::string(50, 'a') + " C:\\", vmime::charset("us-ascii"))); + t2.appendWord(vmime::make_shared ( + " \xc3\xa4nderungen \xc3\xa4nderungen", vmime::charsets::UTF_8)); + + VASSERT_EQ( + "encoded", + std::string(50, 'a') + " C:\\\r\n =?utf-8?Q?=C3=A4nderungen_=C3=A4nderungen?=", + t2.generate(78, 20) + ); + } + + void testFoldWhitespaceRuns() { + + // Without a single space, fold before the last space of a run + VASSERT_EQ( + "1", + "Invoice 12345 from ACME Corp due 2026-09-30 please pay \r\n" + " promptly thanks a lot", + vmime::text( + "Invoice 12345 from ACME Corp due 2026-09-30 please pay" + " promptly thanks a lot", vmime::charset("us-ascii") + ).generate(78, 9) + ); + + // Without a space, fold before a tab, which then starts the next line + VASSERT_EQ( + "2", + "Col1\tCol2\tCol3\tCol4\tCol5\tCol6\tCol7\tCol8\tCol9\tCol10\tCol11\tCol12\tCol13\r\n" + "\tCol14", + vmime::text( + "Col1\tCol2\tCol3\tCol4\tCol5\tCol6\tCol7\tCol8\tCol9\tCol10" + "\tCol11\tCol12\tCol13\tCol14", vmime::charset("us-ascii") + ).generate(78, 9) + ); + + // A first word which does not fit on the line is not encoded + const std::string url = + "https://www.example.com/a/rather/long/path/to/the/page.html?id=123456789"; + + VASSERT("does not fit", 9 + url.length() > 78 && url.length() < 75); + VASSERT_EQ("3", url, vmime::text(url, vmime::charset("us-ascii")).generate(78, 9)); + + // Unencoded, fold before the last space of a run, or before a tab + std::string spaces = "tok", tabs = "tok"; + + for (int i = 1 ; spaces.length() < 1200 ; ++i) { + + spaces += " tok" + std::to_string(i); + tabs += "\ttok" + std::to_string(i); + } + + const std::string ins[] = { spaces, tabs }; + + for (int i = 0 ; i < 2 ; ++i) { + + vmime::generationContext ctx; + ctx.setMaxLineLength(78); + + std::string out; + vmime::utility::outputStreamStringAdapter os(out); + + vmime::text(ins[i], vmime::charset("us-ascii")) + .encodeAndFold(ctx, os, 10, NULL, vmime::text::FORCE_NO_ENCODING); + + std::string unfolded; + size_t lineLength = 10; + + for (size_t j = 0 ; j < out.length() ; ++j) { + + if (out[j] == '\r' && out[j + 1] == '\n') { + + VASSERT(out, lineLength <= 78); + VASSERT(out, out.compare(j + 2, 2, " ") != 0); + + lineLength = 0; + ++j; + + } else { + + unfolded += out[j]; + ++lineLength; + } + } + + VASSERT(out, lineLength <= 78); + VASSERT_EQ("unfolded", ins[i], unfolded); + } + } + void testForcedNonEncoding() { // Testing long unbreakable and unencodable header diff --git a/tests/parser/wordEncoderTest.cpp b/tests/parser/wordEncoderTest.cpp index 0c827bff..cde0996f 100644 --- a/tests/parser/wordEncoderTest.cpp +++ b/tests/parser/wordEncoderTest.cpp @@ -31,6 +31,8 @@ VMIME_TEST_SUITE_BEGIN(wordEncoderTest) VMIME_TEST_LIST_BEGIN VMIME_TEST(testGetNextChunk) VMIME_TEST(testGetNextChunk_integral) + VMIME_TEST(testGetNextChunk_maxLength) + VMIME_TEST(testGetNextChunk_simple) VMIME_TEST(testIsEncodingNeeded_ascii) VMIME_TEST(testIsEncodingNeeded_withLanguage) VMIME_TEST(testIsEncodingNeeded_specialChars) @@ -64,8 +66,42 @@ VMIME_TEST_SUITE_BEGIN(wordEncoderTest) vmime::wordEncoder::ENCODING_AUTO ); - VASSERT_EQ("1", "buffer=C3=A0", we.getNextChunk(7)); - VASSERT_EQ("2", "plop", we.getNextChunk(10)); + VASSERT_EQ("1", "buffer", we.getNextChunk(7)); + VASSERT_EQ("2", "=C3=A0", we.getNextChunk(1)); + VASSERT_EQ("3", "plop", we.getNextChunk(10)); + } + + void testGetNextChunk_maxLength() { + + vmime::wordEncoder we( + "\xc3\xa4\xc3\xa4\xc3\xa4\xc3\xa4\xc3\xa4", + vmime::charset("utf-8"), + vmime::wordEncoder::ENCODING_B64 + ); + + VASSERT_EQ("1", "w6TDpMOk", we.getNextChunk(10)); + VASSERT_EQ("2", "w6TDpA==", we.getNextChunk(10)); + } + + void testGetNextChunk_simple() { + + // Unknown charset: bytes are encoded as they are + vmime::wordEncoder we1( + "abcdefghij", + vmime::charset("x-unknown-charset"), + vmime::wordEncoder::ENCODING_B64 + ); + + VASSERT_EQ("1", "YWJj", we1.getNextChunk(7)); + + vmime::wordEncoder we2( + "abcdef\xffghi", + vmime::charset("x-unknown-charset"), + vmime::wordEncoder::ENCODING_QP + ); + + VASSERT_EQ("2", "abcdef", we2.getNextChunk(7)); + VASSERT_EQ("3", "=FFghi", we2.getNextChunk(7)); } void testIsEncodingNeeded_ascii() {