├── .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 |
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 |
--------------------------------------------------------------------------------