├── .env.testing ├── .github ├── CODEOWNERS └── workflows │ └── test.yml ├── .gitignore ├── .spi.yml ├── .swift-format ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SendGrid │ ├── Application+SendGrid.swift │ ├── Exports.swift │ ├── Request+SendGrid.swift │ └── SendGrid.docc │ └── SendGrid.md └── Tests └── SendGridTests └── SendGridTests.swift /.env.testing: -------------------------------------------------------------------------------- 1 | SENDGRID_API_KEY=SG.1234567890 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @fpseverino -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | concurrency: 3 | group: ${{ github.workflow }}-${{ github.ref }} 4 | cancel-in-progress: true 5 | on: 6 | pull_request: { types: [opened, reopened, synchronize, ready_for_review] } 7 | push: { branches: [ main ] } 8 | 9 | jobs: 10 | unit-tests: 11 | uses: vapor/ci/.github/workflows/run-unit-tests.yml@main 12 | with: 13 | with_linting: true 14 | secrets: 15 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | Package.pins 6 | Package.resolved 7 | /.swiftpm 8 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [SendGrid] -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy": { 3 | "accessLevel": "private" 4 | }, 5 | "indentation": { 6 | "spaces": 4 7 | }, 8 | "indentConditionalCompilationBlocks": true, 9 | "indentSwitchCaseLabels": false, 10 | "lineBreakAroundMultilineExpressionChainComponents": false, 11 | "lineBreakBeforeControlFlowKeywords": false, 12 | "lineBreakBeforeEachArgument": false, 13 | "lineBreakBeforeEachGenericRequirement": false, 14 | "lineLength": 140, 15 | "maximumBlankLines": 1, 16 | "multiElementCollectionTrailingCommas": true, 17 | "noAssignmentInExpressions": { 18 | "allowedFunctions": [ 19 | "XCTAssertNoThrow" 20 | ] 21 | }, 22 | "prioritizeKeepingFunctionOutputTogether": false, 23 | "respectsExistingLineBreaks": true, 24 | "rules": { 25 | "AllPublicDeclarationsHaveDocumentation": false, 26 | "AlwaysUseLiteralForEmptyCollectionInit": false, 27 | "AlwaysUseLowerCamelCase": true, 28 | "AmbiguousTrailingClosureOverload": true, 29 | "BeginDocumentationCommentWithOneLineSummary": false, 30 | "DoNotUseSemicolons": true, 31 | "DontRepeatTypeInStaticProperties": true, 32 | "FileScopedDeclarationPrivacy": true, 33 | "FullyIndirectEnum": true, 34 | "GroupNumericLiterals": true, 35 | "IdentifiersMustBeASCII": true, 36 | "NeverForceUnwrap": false, 37 | "NeverUseForceTry": false, 38 | "NeverUseImplicitlyUnwrappedOptionals": false, 39 | "NoAccessLevelOnExtensionDeclaration": true, 40 | "NoAssignmentInExpressions": true, 41 | "NoBlockComments": true, 42 | "NoCasesWithOnlyFallthrough": true, 43 | "NoEmptyTrailingClosureParentheses": true, 44 | "NoLabelsInCasePatterns": true, 45 | "NoLeadingUnderscores": false, 46 | "NoParensAroundConditions": true, 47 | "NoPlaygroundLiterals": true, 48 | "NoVoidReturnOnFunctionSignature": true, 49 | "OmitExplicitReturns": false, 50 | "OneCasePerLine": true, 51 | "OneVariableDeclarationPerLine": true, 52 | "OnlyOneTrailingClosureArgument": true, 53 | "OrderedImports": true, 54 | "ReplaceForEachWithForLoop": true, 55 | "ReturnVoidInsteadOfEmptyTuple": true, 56 | "TypeNamesShouldBeCapitalized": true, 57 | "UseEarlyExits": false, 58 | "UseExplicitNilCheckInConditions": true, 59 | "UseLetInEveryBoundCaseVariable": true, 60 | "UseShorthandTypeNames": true, 61 | "UseSingleLinePropertyGetter": true, 62 | "UseSynthesizedInitializer": true, 63 | "UseTripleSlashForDocumentationComments": true, 64 | "UseWhereClausesInForLoops": false, 65 | "ValidateDocumentationComments": false 66 | }, 67 | "spacesAroundRangeFormationOperators": false, 68 | "tabWidth": 8, 69 | "version": 1 70 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vapor Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "sendgrid", 6 | platforms: [ 7 | .macOS(.v14) 8 | ], 9 | products: [ 10 | .library(name: "SendGrid", targets: ["SendGrid"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/vapor.git", from: "4.112.0"), 14 | .package(url: "https://github.com/vapor-community/sendgrid-kit.git", from: "3.0.0"), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "SendGrid", 19 | dependencies: [ 20 | .product(name: "Vapor", package: "vapor"), 21 | .product(name: "SendGridKit", package: "sendgrid-kit"), 22 | ], 23 | swiftSettings: swiftSettings 24 | ), 25 | .testTarget( 26 | name: "SendGridTests", 27 | dependencies: [ 28 | .target(name: "SendGrid"), 29 | .product(name: "VaporTesting", package: "vapor"), 30 | ], 31 | swiftSettings: swiftSettings 32 | ), 33 | ] 34 | ) 35 | 36 | var swiftSettings: [SwiftSetting] { 37 | [ 38 | .enableUpcomingFeature("ExistentialAny") 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | avatar 3 |

SendGrid

4 | 5 | Documentation 6 | 7 | Team Chat 8 | MIT License 9 | 10 | Continuous Integration 11 | 12 | 13 | 14 | 15 | 16 | Swift 6.0+ 17 | 18 |
19 |
20 | 21 | 📧 SendGrid library for the Vapor web framework, based on [SendGridKit](https://github.com/vapor-community/sendgrid-kit). 22 | 23 | Send simple emails or leverage the full capabilities of [SendGrid's V3 API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send). 24 | 25 | ### Getting Started 26 | 27 | Use the SPM string to easily include the dependendency in your `Package.swift` file 28 | 29 | ```swift 30 | .package(url: "https://github.com/vapor-community/sendgrid.git", from: "6.0.0"), 31 | ``` 32 | 33 | and add it to your target's dependencies: 34 | 35 | ```swift 36 | .product(name: "SendGrid", package: "sendgrid"), 37 | ``` 38 | 39 | ## Overview 40 | 41 | > [!WARNING] 42 | > Make sure that the `SENDGRID_API_KEY` variable is set in your environment. 43 | This can be set in the Xcode scheme, or specified in your `docker-compose.yml`, or even provided as part of a `swift run` command. 44 | A missing API key will result in a fatal error. 45 | 46 | ### Using the API 47 | 48 | You can use all of the available parameters here to build your `SendGridEmail`. 49 | 50 | Usage in a route closure would be as followed: 51 | 52 | ```swift 53 | import SendGrid 54 | 55 | let email = SendGridEmail(…) 56 | try await req.sendgrid.client.send(email: email) 57 | ``` 58 | 59 | ### Error handling 60 | 61 | If the request to the API failed for any reason a `SendGridError` is thrown, which has an `errors` property that contains an array of errors returned by the API. 62 | 63 | Simply ensure you catch errors thrown like any other throwing function. 64 | 65 | ```swift 66 | import SendGrid 67 | 68 | do { 69 | try await req.sendgrid.client.send(email: email) 70 | } catch let error as SendGridError { 71 | req.logger.error("\(error.errors)") 72 | } 73 | ``` 74 | -------------------------------------------------------------------------------- /Sources/SendGrid/Application+SendGrid.swift: -------------------------------------------------------------------------------- 1 | import NIOConcurrencyHelpers 2 | import SendGridKit 3 | import Vapor 4 | 5 | extension Application { 6 | public var sendgrid: SendGrid { 7 | .init(application: self) 8 | } 9 | 10 | public struct SendGrid: Sendable { 11 | private final class Storage: Sendable { 12 | private struct SendableBox: Sendable { 13 | var client: SendGridClient 14 | } 15 | 16 | private let sendableBox: NIOLockedValueBox 17 | 18 | var client: SendGridClient { 19 | get { 20 | self.sendableBox.withLockedValue { box in 21 | box.client 22 | } 23 | } 24 | set { 25 | self.sendableBox.withLockedValue { box in 26 | box.client = newValue 27 | } 28 | } 29 | } 30 | 31 | init(httpClient: HTTPClient, apiKey: String) { 32 | let box = SendableBox(client: .init(httpClient: httpClient, apiKey: apiKey)) 33 | self.sendableBox = .init(box) 34 | } 35 | } 36 | 37 | private struct Key: StorageKey { 38 | typealias Value = Storage 39 | } 40 | 41 | fileprivate let application: Application 42 | 43 | public init(application: Application) { 44 | self.application = application 45 | } 46 | 47 | public var client: SendGridClient { 48 | get { self.storage.client } 49 | nonmutating set { self.storage.client = newValue } 50 | } 51 | 52 | private var storage: Storage { 53 | if let existing = self.application.storage[Key.self] { 54 | return existing 55 | } else { 56 | guard let apiKey = Environment.process.SENDGRID_API_KEY else { 57 | fatalError("No SendGrid API key provided") 58 | } 59 | let new = Storage(httpClient: self.application.http.client.shared, apiKey: apiKey) 60 | self.application.storage[Key.self] = new 61 | return new 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SendGrid/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import SendGridKit 2 | -------------------------------------------------------------------------------- /Sources/SendGrid/Request+SendGrid.swift: -------------------------------------------------------------------------------- 1 | import SendGridKit 2 | import Vapor 3 | 4 | extension Request { 5 | public var sendgrid: Application.SendGrid { 6 | .init(application: self.application) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SendGrid/SendGrid.docc/SendGrid.md: -------------------------------------------------------------------------------- 1 | # ``SendGrid`` 2 | 3 | SendGrid library for the Vapor web framework, based on SendGridKit. 4 | 5 | ## Overview 6 | 7 | Send simple emails or leverage the full capabilities of SendGrid's V3 API. 8 | 9 | > Warning: Make sure that the `SENDGRID_API_KEY` variable is set in your environment. 10 | This can be set in the Xcode scheme, or specified in your `docker-compose.yml`, or even provided as part of a `swift run` command. 11 | A missing API key will result in a fatal error. 12 | 13 | ### Using the API 14 | 15 | You can use all of the available parameters here to build your `SendGridEmail`. 16 | 17 | Usage in a route closure would be as followed: 18 | 19 | ```swift 20 | import SendGrid 21 | 22 | let email = SendGridEmail(…) 23 | try await req.sendgrid.client.send(email: email) 24 | ``` 25 | 26 | ### Error handling 27 | 28 | If the request to the API failed for any reason a `SendGridError` is thrown, which has an `errors` property that contains an array of errors returned by the API. 29 | 30 | Simply ensure you catch errors thrown like any other throwing function. 31 | 32 | ```swift 33 | import SendGrid 34 | 35 | do { 36 | try await req.sendgrid.client.send(email: email) 37 | } catch let error as SendGridError { 38 | req.logger.error("\(error.errors)") 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /Tests/SendGridTests/SendGridTests.swift: -------------------------------------------------------------------------------- 1 | import SendGrid 2 | import Testing 3 | import VaporTesting 4 | 5 | @Suite("SendGrid Tests") 6 | struct SendGridTests { 7 | // TODO: Replace with `false` when you have a valid API key 8 | let credentialsAreInvalid = true 9 | 10 | // TODO: Replace from addresses and to addresses 11 | let email = SendGridEmail( 12 | personalizations: [Personalization(to: ["TO-ADDRESS"])], 13 | from: "FROM-ADDRESS", 14 | subject: "Test Email", 15 | content: ["This email was sent using SendGridKit!"] 16 | ) 17 | 18 | @Test("Access client from Application") 19 | func application() async throws { 20 | try await withApp { app in 21 | try await withKnownIssue { 22 | try await app.sendgrid.client.send(email: email) 23 | } when: { 24 | credentialsAreInvalid 25 | } 26 | } 27 | } 28 | 29 | @Test("Access client from Request") 30 | func request() async throws { 31 | try await withApp { app in 32 | app.get("test") { req async throws -> Response in 33 | try await req.sendgrid.client.send(email: email) 34 | return Response(status: .ok) 35 | } 36 | 37 | try await app.test(.GET, "test") { res async throws in 38 | let internalServerError = res.status == .internalServerError 39 | let ok = res.status == .ok 40 | #expect(credentialsAreInvalid ? internalServerError : ok) 41 | } 42 | } 43 | } 44 | 45 | @Test("Client storage in Application") 46 | func storage() async throws { 47 | try await withApp { app in 48 | do { 49 | try await app.sendgrid.client.send(email: email) 50 | } catch {} 51 | 52 | // Try sending again to ensure the client is stored and reused 53 | // You'll see if it's reused in the code coverage report 54 | do { 55 | try await app.sendgrid.client.send(email: email) 56 | } catch {} 57 | 58 | // Change the client and try sending again 59 | app.sendgrid.client = .init(httpClient: app.http.client.shared, apiKey: "new-api-key") 60 | 61 | do { 62 | try await app.sendgrid.client.send(email: email) 63 | } catch {} 64 | } 65 | } 66 | } 67 | --------------------------------------------------------------------------------