Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modify Mail to allow folding of long header fields #123

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
60 changes: 56 additions & 4 deletions Sources/SwiftSMTP/Mail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
**/

import Foundation
import LoggerAPI

/// Represents an email that can be sent through an `SMTP` instance.
public struct Mail {
Expand Down Expand Up @@ -122,27 +123,78 @@ public struct Mail {
dictionary["MESSAGE-ID"] = id
dictionary["DATE"] = Date().smtpFormatted
dictionary["FROM"] = from.mime
dictionary["TO"] = to.map { $0.mime }.joined(separator: ", ")
dictionary["TO"] = foldHeaderValue(key: "TO", value: to.map { $0.mime }.joined(separator: ", "))

if !cc.isEmpty {
dictionary["CC"] = cc.map { $0.mime }.joined(separator: ", ")
dictionary["CC"] = foldHeaderValue(key: "CC", value: cc.map { $0.mime }.joined(separator: ", "))
sbeitzel marked this conversation as resolved.
Show resolved Hide resolved
}

dictionary["SUBJECT"] = subject.mimeEncoded ?? ""
dictionary["SUBJECT"] = foldHeaderValue(key: "SUBJECT", value: (subject.mimeEncoded ?? ""))
dictionary["MIME-VERSION"] = "1.0 (Swift-SMTP)"

for (key, value) in additionalHeaders {
let keyUppercased = key.uppercased()
if keyUppercased != "CONTENT-TYPE" &&
keyUppercased != "CONTENT-DISPOSITION" &&
keyUppercased != "CONTENT-TRANSFER-ENCODING" {
dictionary[keyUppercased] = value
dictionary[keyUppercased] = foldHeaderValue(key: key, value: value)
}
}

return dictionary
}

private func foldHeaderValue(key: String, value: String) -> String {
let suggestedLineLength = 78
let maximumLineLength = 998

let initialHeader = "\(key): \(value)"
if initialHeader.count <= suggestedLineLength {
return value
}
// if we're here, it means that RFC 5322 SUGGESTS that we fold this header
var foldedHeader = ""
var register = "\(key): "
sbeitzel marked this conversation as resolved.
Show resolved Hide resolved
var linePosition = 0
let foldableCharacters = CharacterSet(charactersIn: " ,")
for char in value {
// append the character to the register
register.append(char)
// this test is to detect the end of a token, mid-stream
if let _ = String(char).rangeOfCharacter(from: foldableCharacters) {
if linePosition > 1 && (register.count + linePosition > suggestedLineLength) {
// We already have stuff on the line and the register is too long
// to continue on the current line. So we fold and start a new line.
foldedHeader.append("\r\n ")
linePosition = 1
}
// now, register contains a complete token, so we put it up to the line
linePosition += register.count
if linePosition > maximumLineLength {
Log.error("Header line length exceeds the specified maximum (998 chars) - see RFC 5322 section 2.2.3")
}
foldedHeader.append(register)
register = ""
}
}
// We have the last of the value characters in register, so we put them up
// to the line. We still want to apply the same logic as that inside the loop
// though, and apply folding if it's appropriate.
if linePosition > 1 && (register.count + linePosition > suggestedLineLength) {
foldedHeader.append("\r\n ")
linePosition = 1
}
if (register.count + linePosition) > maximumLineLength {
Log.error("Header line length exceeds the specified maximum (998 chars) - see RFC 5322 section 2.2.3")
}
foldedHeader.append(register)

// Here is where we remove "\(key): " from the beginning of the folded header,
// so we only return the value.
let valueIndex = foldedHeader.index(foldedHeader.startIndex, offsetBy: key.count + 2)
return String(foldedHeader.suffix(from: valueIndex))
}

var headersString: String {
return headersDictionary.map { (key, value) in
return "\(key): \(value)"
Expand Down
138 changes: 138 additions & 0 deletions Tests/SwiftSMTPTests/TestHeaderFolding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Copyright Kitura 2021-2022
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

@testable import SwiftSMTP
import LoggerAPI
import XCTest

internal class TestLogger: Logger {
var messages = [String]()

func log(_ type: LoggerMessageType, msg: String, functionName: String, lineNum: Int, fileName: String) {
let message = "[\(type.description.uppercased())] \(msg)"
messages.append(message)
}

func isLogging(_ level: LoggerMessageType) -> Bool { true }
}

class TestHeaderFolding: XCTestCase {
let spaciousSubject = "Sample subject with extra whitespace inside"
var mailMessage: Mail {
Mail(from: Mail.User(name: "Test User", email: "tester@dumbster.local"),
to: manyRecipients,
cc: [absurdlyLongEmail],
subject: spaciousSubject,
text: "Just some message",
additionalHeaders: ["X-OBNOXIOUS": egregiousLine,
"X-SHORT": spaciousSubject])
}

let manyRecipients: [Mail.User] = Array.init(repeating: Mail.User(email: "some_recipient@dumbster.local"),
count: 30)
let absurdlyLongEmail = Mail.User(name: "Unfortunate Joe",
email: "unfortunate_joe_1234567890_123456789_123456783_123456784_123456785_123456786@dumbster.local")

// this is a base64 encoding of Package.swift
let egregiousLine = "Ly8gc3dpZnQtdG9vbHMtdmVyc2lvbjo1LjAKCmltcG9ydCBQYWNrYWdlRGVzY3JpcHRpb24KCmxldCBwYWNrYWdlID0gUGFja2FnZSgKICAgIG5hbWU6ICJTd2lmdFNNVFAiLAogICAgcHJvZHVjdHM6IFsKICAgICAgICAubGlicmFyeSgKICAgICAgICAgICAgbmFtZTogIlN3aWZ0U01UUCIsCiAgICAgICAgICAgIHRhcmdldHM6IFsiU3dpZnRTTVRQIl0pLAogICAgICAgIF0sCiAgICBkZXBlbmRlbmNpZXM6IFsKICAgICAgICAucGFja2FnZSh1cmw6ICJodHRwczovL2dpdGh1Yi5jb20vS2l0dXJhL0JsdWVTb2NrZXQuZ2l0IiwgZnJvbTogIjIuMC4yIiksCiAgICAgICAgLnBhY2thZ2UodXJsOiAiaHR0cHM6Ly9naXRodWIuY29tL0tpdHVyYS9CbHVlU1NMU2VydmljZS5naXQiLCBmcm9tOiAiMi4wLjEiKSwKICAgICAgICAucGFja2FnZSh1cmw6ICJodHRwczovL2dpdGh1Yi5jb20vS2l0dXJhL0JsdWVDcnlwdG9yLmdpdCIsIGZyb206ICIyLjAuMSIpLAogICAgICAgIC5wYWNrYWdlKHVybDogImh0dHBzOi8vZ2l0aHViLmNvbS9LaXR1cmEvTG9nZ2VyQVBJLmdpdCIsIGZyb206ICIxLjkuMjAwIiksCiAgICAgICAgXSwKICAgIHRhcmdldHM6IFsKICAgICAgICAudGFyZ2V0KAogICAgICAgICAgICBuYW1lOiAiU3dpZnRTTVRQIiwKICAgICAgICAgICAgZGVwZW5kZW5jaWVzOiBbIlNvY2tldCIsICJTU0xTZXJ2aWNlIiwgIkNyeXB0b3IiLCAiTG9nZ2VyQVBJIl0pLAogICAgICAgIC50ZXN0VGFyZ2V0KAogICAgICAgICAgICBuYW1lOiAiU3dpZnRTTVRQVGVzdHMiLAogICAgICAgICAgICBkZXBlbmRlbmNpZXM6IFsiU3dpZnRTTVRQIl0pLAogICAgICAgIF0KKQo="

var previousLogger: Logger?
var currentLogger: TestLogger?

override func setUpWithError() throws {
previousLogger = Log.logger
currentLogger = TestLogger()
Log.logger = currentLogger
}

override func tearDownWithError() throws {
Log.logger = previousLogger
currentLogger = nil
}

// Here we test two things: 1) a short line will not be folded,
// and 2) a line containing multiple consecutive whitespace characters
// will not be changed.
func testShortHeaderUnchanged() throws {
let allHeaders = mailMessage.headersString.split(separator: "\r\n")
let unmodified = "X-SHORT: \(spaciousSubject)"
for header in allHeaders where header.hasPrefix("X-SHORT") {
XCTAssertEqual(String(header), unmodified)
}
}

// Whatever else is true of the folded header, the folding process should
// not ever produce a line which consists solely of whitespace.
func testNoWSOnlyLines() throws {
let allHeaders = mailMessage.headersString.split(separator: "\r\n")
for header in allHeaders {
XCTAssert(!header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}

// This test looks at folding a long name plus email address, which won't
// contain a comma. The fold should happen at the space between the name
// and the email.
func testAbsurdEmail() throws {
let allHeaders = mailMessage.headersString.split(separator: "\r\n")
var foundCC = false
var testEncountered = false
for header in allHeaders {
if foundCC {
// we are now at the first line *after* the CC: line
XCTAssertEqual(header, " <unfortunate_joe_1234567890_123456789_123456783_123456784_123456785_123456786@dumbster.local>")
testEncountered = true
break
} else if header.hasPrefix("CC:") {
foundCC = true
XCTAssertEqual(header, "CC: \("Unfortunate Joe".mimeEncoded!) ")
}
}
XCTAssertTrue(testEncountered, "The CC line did not get folded!")
}

// This test looks at what happens when a header value does not
// contain any spaces at all, and is thus unfoldable, while still
// being longer than the recommended length.
func testUnfoldableHeader() throws {
let allHeaders = mailMessage.headersString.split(separator: "\r\n")
for header in allHeaders where header.hasPrefix("X-OBNOXIOUS") {
XCTAssertEqual(header, "X-OBNOXIOUS: \(egregiousLine)")
}
// Incidentally, the line is longer than the RFC mandated maximum length.
// Currently, the library does not throw an error in this case, but
// the folding code should at least log a message about it, so that
// consumers of the library can have some help figuring out why their
// mail message was rejected by a remote SMTP server.
guard let currentLogger = currentLogger else {
throw XCTSkip("There is no logger installed!")
}
XCTAssert(!currentLogger.messages
.filter({ $0.hasPrefix("[ERROR] Header line length") })
.isEmpty)
}

// This test looks at a long list of email addresses. There are plenty
// of commas and whitespaces, so folding is possible. It should happen
// at the whitespace, and not in the middle of an address.
func testFoldOnWhitespace() throws {
let allHeaders = mailMessage
.headersString.split(separator: "\r\n")
for header in allHeaders where header.hasPrefix("TO: ") {
XCTAssert(header.hasSuffix("some_recipient@dumbster.local, "))
}
}
}