diff --git a/Sources/AWSLambdaEvents/SES.swift b/Sources/AWSLambdaEvents/SES.swift new file mode 100644 index 00000000..4c5b3719 --- /dev/null +++ b/Sources/AWSLambdaEvents/SES.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2020 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.Date + +// https://docs.aws.amazon.com/lambda/latest/dg/services-ses.html + +public enum SES { + public struct Event: Decodable { + public struct Record: Decodable { + public let eventSource: String + public let eventVersion: String + public let ses: Message + } + + public let records: [Record] + + public enum CodingKeys: String, CodingKey { + case records = "Records" + } + } + + public struct Message: Decodable { + public let mail: Mail + public let receipt: Receipt + } + + public struct Mail: Decodable { + public let commonHeaders: CommonHeaders + public let destination: [String] + public let headers: [Header] + public let headersTruncated: Bool + public let messageId: String + public let source: String + @ISO8601WithFractionalSecondsCoding public var timestamp: Date + } + + public struct CommonHeaders: Decodable { + public let bcc: [String]? + public let cc: [String]? + @RFC5322DateTimeCoding public var date: Date + public let from: [String] + public let messageId: String + public let returnPath: String? + public let subject: String? + public let to: [String]? + } + + public struct Header: Decodable { + public let name: String + public let value: String + } + + public struct Receipt: Decodable { + public let action: Action + public let dmarcPolicy: DMARCPolicy? + public let dmarcVerdict: Verdict? + public let dkimVerdict: Verdict + public let processingTimeMillis: Int + public let recipients: [String] + public let spamVerdict: Verdict + public let spfVerdict: Verdict + @ISO8601WithFractionalSecondsCoding public var timestamp: Date + public let virusVerdict: Verdict + } + + public struct Action: Decodable { + public let functionArn: String + public let invocationType: String + public let type: String + } + + public struct Verdict: Decodable { + public let status: Status + } + + public enum DMARCPolicy: String, Decodable { + case none + case quarantine + case reject + } + + public enum Status: String, Decodable { + case pass = "PASS" + case fail = "FAIL" + case gray = "GRAY" + case processingFailed = "PROCESSING_FAILED" + } +} diff --git a/Sources/AWSLambdaEvents/Utils/DateWrappers.swift b/Sources/AWSLambdaEvents/Utils/DateWrappers.swift index 4e24946c..81ed03ec 100644 --- a/Sources/AWSLambdaEvents/Utils/DateWrappers.swift +++ b/Sources/AWSLambdaEvents/Utils/DateWrappers.swift @@ -13,7 +13,9 @@ //===----------------------------------------------------------------------===// import struct Foundation.Date +import class Foundation.DateFormatter import class Foundation.ISO8601DateFormatter +import struct Foundation.Locale @propertyWrapper public struct ISO8601Coding: Decodable { @@ -67,3 +69,30 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable { return formatter } } + +@propertyWrapper +public struct RFC5322DateTimeCoding: Decodable { + public let wrappedValue: Date + + public init(wrappedValue: Date) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + guard let date = Self.dateFormatter.date(from: dateString) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: + "Expected date to be in RFC5322 date-time format with fractional seconds, but `\(dateString)` does not forfill format") + } + self.wrappedValue = date + } + + private static let dateFormatter: DateFormatter = Self.createDateFormatter() + private static func createDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "EEE, d MMM yyy HH:mm:ss z" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } +} diff --git a/Tests/AWSLambdaEventsTests/SESTests.swift b/Tests/AWSLambdaEventsTests/SESTests.swift new file mode 100644 index 00000000..0f4b417d --- /dev/null +++ b/Tests/AWSLambdaEventsTests/SESTests.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2020 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import AWSLambdaEvents +import XCTest + +class SESTests: XCTestCase { + static let eventBody = """ + { + "Records": [ + { + "eventSource": "aws:ses", + "eventVersion": "1.0", + "ses": { + "mail": { + "commonHeaders": { + "date": "Wed, 7 Oct 2015 12:34:56 -0700", + "from": [ + "Jane Doe " + ], + "messageId": "<0123456789example.com>", + "returnPath": "janedoe@example.com", + "subject": "Test Subject", + "to": [ + "johndoe@example.com" + ] + }, + "destination": [ + "johndoe@example.com" + ], + "headers": [ + { + "name": "Return-Path", + "value": "" + }, + { + "name": "Received", + "value": "from mailer.example.com (mailer.example.com [203.0.113.1]) by inbound-smtp.eu-west-1.amazonaws.com with SMTP id o3vrnil0e2ic28trm7dfhrc2v0cnbeccl4nbp0g1 for johndoe@example.com; Wed, 07 Oct 2015 12:34:56 +0000 (UTC)" + } + ], + "headersTruncated": true, + "messageId": "5h5auqp1oa1bg49b2q8f8tmli1oju8pcma2haao1", + "source": "janedoe@example.com", + "timestamp": "1970-01-01T00:00:00.000Z" + }, + "receipt": { + "action": { + "functionArn": "arn:aws:lambda:eu-west-1:123456789012:function:Example", + "invocationType": "Event", + "type": "Lambda" + }, + "dkimVerdict": { + "status": "PASS" + }, + "processingTimeMillis": 574, + "recipients": [ + "test@swift-server.com", + "test2@swift-server.com" + ], + "spamVerdict": { + "status": "PASS" + }, + "spfVerdict": { + "status": "PROCESSING_FAILED" + }, + "timestamp": "1970-01-01T00:00:00.000Z", + "virusVerdict": { + "status": "FAIL" + } + } + } + } + ] + } + """ + + func testSimpleEventFromJSON() { + let data = Data(SESTests.eventBody.utf8) + var event: SES.Event? + XCTAssertNoThrow(event = try JSONDecoder().decode(SES.Event.self, from: data)) + + guard let record = event?.records.first else { + XCTFail("Expected to have one record") + return + } + + XCTAssertEqual(record.eventSource, "aws:ses") + XCTAssertEqual(record.eventVersion, "1.0") + XCTAssertEqual(record.ses.mail.commonHeaders.date.description, "2015-10-07 19:34:56 +0000") + XCTAssertEqual(record.ses.mail.commonHeaders.from[0], "Jane Doe ") + XCTAssertEqual(record.ses.mail.commonHeaders.messageId, "<0123456789example.com>") + XCTAssertEqual(record.ses.mail.commonHeaders.returnPath, "janedoe@example.com") + XCTAssertEqual(record.ses.mail.commonHeaders.subject, "Test Subject") + XCTAssertEqual(record.ses.mail.commonHeaders.to?[0], "johndoe@example.com") + XCTAssertEqual(record.ses.mail.destination[0], "johndoe@example.com") + XCTAssertEqual(record.ses.mail.headers[0].name, "Return-Path") + XCTAssertEqual(record.ses.mail.headers[0].value, "") + XCTAssertEqual(record.ses.mail.headers[1].name, "Received") + XCTAssertEqual(record.ses.mail.headers[1].value, "from mailer.example.com (mailer.example.com [203.0.113.1]) by inbound-smtp.eu-west-1.amazonaws.com with SMTP id o3vrnil0e2ic28trm7dfhrc2v0cnbeccl4nbp0g1 for johndoe@example.com; Wed, 07 Oct 2015 12:34:56 +0000 (UTC)") + XCTAssertEqual(record.ses.mail.headersTruncated, true) + XCTAssertEqual(record.ses.mail.messageId, "5h5auqp1oa1bg49b2q8f8tmli1oju8pcma2haao1") + XCTAssertEqual(record.ses.mail.source, "janedoe@example.com") + XCTAssertEqual(record.ses.mail.timestamp.description, "1970-01-01 00:00:00 +0000") + + XCTAssertEqual(record.ses.receipt.action.functionArn, "arn:aws:lambda:eu-west-1:123456789012:function:Example") + XCTAssertEqual(record.ses.receipt.action.invocationType, "Event") + XCTAssertEqual(record.ses.receipt.action.type, "Lambda") + XCTAssertEqual(record.ses.receipt.dkimVerdict.status, .pass) + XCTAssertEqual(record.ses.receipt.processingTimeMillis, 574) + XCTAssertEqual(record.ses.receipt.recipients[0], "test@swift-server.com") + XCTAssertEqual(record.ses.receipt.recipients[1], "test2@swift-server.com") + XCTAssertEqual(record.ses.receipt.spamVerdict.status, .pass) + XCTAssertEqual(record.ses.receipt.spfVerdict.status, .processingFailed) + XCTAssertEqual(record.ses.receipt.timestamp.description, "1970-01-01 00:00:00 +0000") + XCTAssertEqual(record.ses.receipt.virusVerdict.status, .fail) + } +} diff --git a/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift b/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift index b55e4bcb..88646479 100644 --- a/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift +++ b/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift @@ -79,4 +79,36 @@ class DateWrapperTests: XCTestCase { XCTAssertNil(context.underlyingError) } } + + func testRFC5322DateTimeCodingWrapperSuccess() { + struct TestEvent: Decodable { + @RFC5322DateTimeCoding + var date: Date + } + + let json = #"{"date":"Thu, 5 Apr 2012 23:47:37 +0200"}"# + var event: TestEvent? + XCTAssertNoThrow(event = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) + + XCTAssertEqual(event?.date.description, "2012-04-05 21:47:37 +0000") + } + + func testRFC5322DateTimeCodingWrapperFailure() { + struct TestEvent: Decodable { + @RFC5322DateTimeCoding + var date: Date + } + + let date = "Thu, 5 Apr 2012 23:47 +0200" // missing seconds + let json = #"{"date":"\#(date)"}"# + XCTAssertThrowsError(_ = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Unexpected error: \(error)"); return + } + + XCTAssertEqual(context.codingPath.compactMap { $0.stringValue }, ["date"]) + XCTAssertEqual(context.debugDescription, "Expected date to be in RFC5322 date-time format with fractional seconds, but `\(date)` does not forfill format") + XCTAssertNil(context.underlyingError) + } + } }