├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── Smtp │ ├── Application+Send.swift │ ├── Application+Smtp.swift │ ├── Errors │ ├── EmailError.swift │ ├── NIOExtrasError.swift │ ├── SmtpError.swift │ └── SmtpResponseDecoderError.swift │ ├── Handlers │ ├── DuplexMessagesHandler.swift │ ├── InboundLineBasedFrameDecoder.swift │ ├── InboundSendEmailHandler.swift │ ├── InboundSmtpResponseDecoder.swift │ ├── OutboundSmtpRequestEncoder.swift │ └── StartTlsHandler.swift │ ├── Models │ ├── Attachment.swift │ ├── Email.swift │ ├── EmailAddress.swift │ ├── HelloMethod.swift │ ├── SignInMethod.swift │ ├── SmtpRequest.swift │ └── SmtpResponse.swift │ ├── Request+Send.swift │ ├── SmtpSecure.swift │ └── SmtpServerConfiguration.swift └── Tests └── SmtpTests ├── Attachments.swift └── SmtpTests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | - name: Install swift 12 | uses: YOCKOW/Action-setup-swift@v1 13 | with: 14 | swift-version: '5.3.3' 15 | - shell: bash 16 | env: 17 | MAILTRAPUSER: ${{ secrets.MAILTRAPUSER }} 18 | run: | 19 | sed -i -e 's/#MAILTRAPUSER#/'"$MAILTRAPUSER"'/g' Tests/SmtpTests/SmtpTests.swift 20 | - shell: bash 21 | env: 22 | MAILTRAPPASS: ${{ secrets.MAILTRAPPASS }} 23 | run: | 24 | sed -i -e 's/#MAILTRAPPASS#/'"$MAILTRAPPASS"'/g' Tests/SmtpTests/SmtpTests.swift 25 | - shell: bash 26 | env: 27 | GMAILUSER: ${{ secrets.GMAILUSER }} 28 | run: | 29 | sed -i -e 's/#GMAILUSER#/'"$GMAILUSER"'/g' Tests/SmtpTests/SmtpTests.swift 30 | - shell: bash 31 | env: 32 | GMAILPASS: ${{ secrets.GMAILPASS }} 33 | run: | 34 | sed -i -e 's/#GMAILPASS#/'"$GMAILPASS"'/g' Tests/SmtpTests/SmtpTests.swift 35 | - name: Build 36 | run: swift build --enable-test-discovery 37 | # - name: Tests 38 | # run: swift test --enable-test-discovery -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,xcode,linux,vapor,swift,swiftpm,windows,visualstudio,visualstudiocode,swiftpackagemanager 3 | # Edit at https://www.gitignore.io/?templates=macos,xcode,linux,vapor,swift,swiftpm,windows,visualstudio,visualstudiocode,swiftpackagemanager 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### macOS ### 21 | # General 22 | .DS_Store 23 | .AppleDouble 24 | .LSOverride 25 | 26 | # Icon must end with two \r 27 | Icon 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Swift ### 49 | # Xcode 50 | # 51 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 52 | 53 | ## Build generated 54 | build/ 55 | DerivedData/ 56 | 57 | ## Various settings 58 | *.pbxuser 59 | !default.pbxuser 60 | *.mode1v3 61 | !default.mode1v3 62 | *.mode2v3 63 | !default.mode2v3 64 | *.perspectivev3 65 | !default.perspectivev3 66 | xcuserdata/ 67 | 68 | ## Other 69 | *.moved-aside 70 | *.xccheckout 71 | *.xcscmblueprint 72 | 73 | ## Obj-C/Swift specific 74 | *.hmap 75 | *.ipa 76 | *.dSYM.zip 77 | *.dSYM 78 | 79 | ## Playgrounds 80 | timeline.xctimeline 81 | playground.xcworkspace 82 | 83 | # Swift Package Manager 84 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 85 | Packages/ 86 | Package.pins 87 | Package.resolved 88 | .build/ 89 | # Add this line if you want to avoid checking in Xcode SPM integration. 90 | .swiftpm/xcode 91 | 92 | # CocoaPods 93 | # We recommend against adding the Pods directory to your .gitignore. However 94 | # you should judge for yourself, the pros and cons are mentioned at: 95 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 96 | # Pods/ 97 | # Add this line if you want to avoid checking in source code from the Xcode workspace 98 | # *.xcworkspace 99 | 100 | # Carthage 101 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 102 | # Carthage/Checkouts 103 | 104 | Carthage/Build 105 | 106 | # Accio dependency management 107 | Dependencies/ 108 | .accio/ 109 | 110 | # fastlane 111 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 112 | # screenshots whenever they are needed. 113 | # For more information about the recommended setup visit: 114 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 115 | 116 | fastlane/report.xml 117 | fastlane/Preview.html 118 | fastlane/screenshots/**/*.png 119 | fastlane/test_output 120 | 121 | # Code Injection 122 | # After new code Injection tools there's a generated folder /iOSInjectionProject 123 | # https://github.com/johnno1962/injectionforxcode 124 | 125 | iOSInjectionProject/ 126 | 127 | ### SwiftPackageManager ### 128 | Packages 129 | xcuserdata 130 | *.xcodeproj 131 | 132 | 133 | ### SwiftPM ### 134 | 135 | 136 | ### Vapor ### 137 | Config/secrets 138 | 139 | ### Vapor Patch ### 140 | 141 | 142 | ### VisualStudioCode ### 143 | .vscode/* 144 | !.vscode/settings.json 145 | !.vscode/tasks.json 146 | !.vscode/launch.json 147 | !.vscode/extensions.json 148 | 149 | ### VisualStudioCode Patch ### 150 | # Ignore all local history of files 151 | .history 152 | 153 | ### Windows ### 154 | # Windows thumbnail cache files 155 | Thumbs.db 156 | Thumbs.db:encryptable 157 | ehthumbs.db 158 | ehthumbs_vista.db 159 | 160 | # Dump file 161 | *.stackdump 162 | 163 | # Folder config file 164 | [Dd]esktop.ini 165 | 166 | # Recycle Bin used on file shares 167 | $RECYCLE.BIN/ 168 | 169 | # Windows Installer files 170 | *.cab 171 | *.msi 172 | *.msix 173 | *.msm 174 | *.msp 175 | 176 | # Windows shortcuts 177 | *.lnk 178 | 179 | ### Xcode ### 180 | # Xcode 181 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 182 | 183 | ## User settings 184 | 185 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 186 | 187 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 188 | 189 | ## Xcode Patch 190 | *.xcodeproj/* 191 | !*.xcodeproj/project.pbxproj 192 | !*.xcodeproj/xcshareddata/ 193 | !*.xcworkspace/contents.xcworkspacedata 194 | /*.gcno 195 | 196 | ### Xcode Patch ### 197 | **/xcshareddata/WorkspaceSettings.xcsettings 198 | 199 | ### VisualStudio ### 200 | ## Ignore Visual Studio temporary files, build results, and 201 | ## files generated by popular Visual Studio add-ons. 202 | ## 203 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 204 | 205 | # User-specific files 206 | *.rsuser 207 | *.suo 208 | *.user 209 | *.userosscache 210 | *.sln.docstates 211 | 212 | # User-specific files (MonoDevelop/Xamarin Studio) 213 | *.userprefs 214 | 215 | # Mono auto generated files 216 | mono_crash.* 217 | 218 | # Build results 219 | [Dd]ebug/ 220 | [Dd]ebugPublic/ 221 | [Rr]elease/ 222 | [Rr]eleases/ 223 | x64/ 224 | x86/ 225 | [Aa][Rr][Mm]/ 226 | [Aa][Rr][Mm]64/ 227 | bld/ 228 | [Bb]in/ 229 | [Oo]bj/ 230 | [Ll]og/ 231 | 232 | # Visual Studio 2015/2017 cache/options directory 233 | .vs/ 234 | # Uncomment if you have tasks that create the project's static files in wwwroot 235 | #wwwroot/ 236 | 237 | # Visual Studio 2017 auto generated files 238 | Generated\ Files/ 239 | 240 | # MSTest test Results 241 | [Tt]est[Rr]esult*/ 242 | [Bb]uild[Ll]og.* 243 | 244 | # NUnit 245 | *.VisualState.xml 246 | TestResult.xml 247 | nunit-*.xml 248 | 249 | # Build Results of an ATL Project 250 | [Dd]ebugPS/ 251 | [Rr]eleasePS/ 252 | dlldata.c 253 | 254 | # Benchmark Results 255 | BenchmarkDotNet.Artifacts/ 256 | 257 | # .NET Core 258 | project.lock.json 259 | project.fragment.lock.json 260 | artifacts/ 261 | 262 | # StyleCop 263 | StyleCopReport.xml 264 | 265 | # Files built by Visual Studio 266 | *_i.c 267 | *_p.c 268 | *_h.h 269 | *.ilk 270 | *.obj 271 | *.iobj 272 | *.pch 273 | *.pdb 274 | *.ipdb 275 | *.pgc 276 | *.pgd 277 | *.rsp 278 | *.sbr 279 | *.tlb 280 | *.tli 281 | *.tlh 282 | *.tmp 283 | *.tmp_proj 284 | *_wpftmp.csproj 285 | *.log 286 | *.vspscc 287 | *.vssscc 288 | .builds 289 | *.pidb 290 | *.svclog 291 | *.scc 292 | 293 | # Chutzpah Test files 294 | _Chutzpah* 295 | 296 | # Visual C++ cache files 297 | ipch/ 298 | *.aps 299 | *.ncb 300 | *.opendb 301 | *.opensdf 302 | *.sdf 303 | *.cachefile 304 | *.VC.db 305 | *.VC.VC.opendb 306 | 307 | # Visual Studio profiler 308 | *.psess 309 | *.vsp 310 | *.vspx 311 | *.sap 312 | 313 | # Visual Studio Trace Files 314 | *.e2e 315 | 316 | # TFS 2012 Local Workspace 317 | $tf/ 318 | 319 | # Guidance Automation Toolkit 320 | *.gpState 321 | 322 | # ReSharper is a .NET coding add-in 323 | _ReSharper*/ 324 | *.[Rr]e[Ss]harper 325 | *.DotSettings.user 326 | 327 | # JustCode is a .NET coding add-in 328 | .JustCode 329 | 330 | # TeamCity is a build add-in 331 | _TeamCity* 332 | 333 | # DotCover is a Code Coverage Tool 334 | *.dotCover 335 | 336 | # AxoCover is a Code Coverage Tool 337 | .axoCover/* 338 | !.axoCover/settings.json 339 | 340 | # Visual Studio code coverage results 341 | *.coverage 342 | *.coveragexml 343 | 344 | # NCrunch 345 | _NCrunch_* 346 | .*crunch*.local.xml 347 | nCrunchTemp_* 348 | 349 | # MightyMoose 350 | *.mm.* 351 | AutoTest.Net/ 352 | 353 | # Web workbench (sass) 354 | .sass-cache/ 355 | 356 | # Installshield output folder 357 | [Ee]xpress/ 358 | 359 | # DocProject is a documentation generator add-in 360 | DocProject/buildhelp/ 361 | DocProject/Help/*.HxT 362 | DocProject/Help/*.HxC 363 | DocProject/Help/*.hhc 364 | DocProject/Help/*.hhk 365 | DocProject/Help/*.hhp 366 | DocProject/Help/Html2 367 | DocProject/Help/html 368 | 369 | # Click-Once directory 370 | publish/ 371 | 372 | # Publish Web Output 373 | *.[Pp]ublish.xml 374 | *.azurePubxml 375 | # Note: Comment the next line if you want to checkin your web deploy settings, 376 | # but database connection strings (with potential passwords) will be unencrypted 377 | *.pubxml 378 | *.publishproj 379 | 380 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 381 | # checkin your Azure Web App publish settings, but sensitive information contained 382 | # in these scripts will be unencrypted 383 | PublishScripts/ 384 | 385 | # NuGet Packages 386 | *.nupkg 387 | # NuGet Symbol Packages 388 | *.snupkg 389 | # The packages folder can be ignored because of Package Restore 390 | **/[Pp]ackages/* 391 | # except build/, which is used as an MSBuild target. 392 | !**/[Pp]ackages/build/ 393 | # Uncomment if necessary however generally it will be regenerated when needed 394 | #!**/[Pp]ackages/repositories.config 395 | # NuGet v3's project.json files produces more ignorable files 396 | *.nuget.props 397 | *.nuget.targets 398 | 399 | # Microsoft Azure Build Output 400 | csx/ 401 | *.build.csdef 402 | 403 | # Microsoft Azure Emulator 404 | ecf/ 405 | rcf/ 406 | 407 | # Windows Store app package directories and files 408 | AppPackages/ 409 | BundleArtifacts/ 410 | Package.StoreAssociation.xml 411 | _pkginfo.txt 412 | *.appx 413 | *.appxbundle 414 | *.appxupload 415 | 416 | # Visual Studio cache files 417 | # files ending in .cache can be ignored 418 | *.[Cc]ache 419 | # but keep track of directories ending in .cache 420 | !?*.[Cc]ache/ 421 | 422 | # Others 423 | ClientBin/ 424 | ~$* 425 | *.dbmdl 426 | *.dbproj.schemaview 427 | *.jfm 428 | *.pfx 429 | *.publishsettings 430 | orleans.codegen.cs 431 | 432 | # Including strong name files can present a security risk 433 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 434 | #*.snk 435 | 436 | # Since there are multiple workflows, uncomment next line to ignore bower_components 437 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 438 | #bower_components/ 439 | 440 | # RIA/Silverlight projects 441 | Generated_Code/ 442 | 443 | # Backup & report files from converting an old project file 444 | # to a newer Visual Studio version. Backup files are not needed, 445 | # because we have git ;-) 446 | _UpgradeReport_Files/ 447 | Backup*/ 448 | UpgradeLog*.XML 449 | UpgradeLog*.htm 450 | ServiceFabricBackup/ 451 | *.rptproj.bak 452 | 453 | # SQL Server files 454 | *.mdf 455 | *.ldf 456 | *.ndf 457 | 458 | # Business Intelligence projects 459 | *.rdl.data 460 | *.bim.layout 461 | *.bim_*.settings 462 | *.rptproj.rsuser 463 | *- [Bb]ackup.rdl 464 | *- [Bb]ackup ([0-9]).rdl 465 | *- [Bb]ackup ([0-9][0-9]).rdl 466 | 467 | # Microsoft Fakes 468 | FakesAssemblies/ 469 | 470 | # GhostDoc plugin setting file 471 | *.GhostDoc.xml 472 | 473 | # Node.js Tools for Visual Studio 474 | .ntvs_analysis.dat 475 | node_modules/ 476 | 477 | # Visual Studio 6 build log 478 | *.plg 479 | 480 | # Visual Studio 6 workspace options file 481 | *.opt 482 | 483 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 484 | *.vbw 485 | 486 | # Visual Studio LightSwitch build output 487 | **/*.HTMLClient/GeneratedArtifacts 488 | **/*.DesktopClient/GeneratedArtifacts 489 | **/*.DesktopClient/ModelManifest.xml 490 | **/*.Server/GeneratedArtifacts 491 | **/*.Server/ModelManifest.xml 492 | _Pvt_Extensions 493 | 494 | # Paket dependency manager 495 | .paket/paket.exe 496 | paket-files/ 497 | 498 | # FAKE - F# Make 499 | .fake/ 500 | 501 | # CodeRush personal settings 502 | .cr/personal 503 | 504 | # Python Tools for Visual Studio (PTVS) 505 | __pycache__/ 506 | *.pyc 507 | 508 | # Cake - Uncomment if you are using it 509 | # tools/** 510 | # !tools/packages.config 511 | 512 | # Tabs Studio 513 | *.tss 514 | 515 | # Telerik's JustMock configuration file 516 | *.jmconfig 517 | 518 | # BizTalk build output 519 | *.btp.cs 520 | *.btm.cs 521 | *.odx.cs 522 | *.xsd.cs 523 | 524 | # OpenCover UI analysis results 525 | OpenCover/ 526 | 527 | # Azure Stream Analytics local run output 528 | ASALocalRun/ 529 | 530 | # MSBuild Binary and Structured Log 531 | *.binlog 532 | 533 | # NVidia Nsight GPU debugger configuration file 534 | *.nvuser 535 | 536 | # MFractors (Xamarin productivity tool) working folder 537 | .mfractor/ 538 | 539 | # Local History for Visual Studio 540 | .localhistory/ 541 | 542 | # BeatPulse healthcheck temp database 543 | healthchecksdb 544 | 545 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 546 | MigrationBackup/ 547 | 548 | # End of https://www.gitignore.io/api/macos,xcode,linux,vapor,swift,swiftpm,windows,visualstudio,visualstudiocode,swiftpackagemanager 549 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Marcin Czachurski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Smtp", 8 | platforms: [ 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library(name: "Smtp", targets: ["Smtp"]) 14 | ], 15 | dependencies: [ 16 | // 💧 A server-side Swift web framework. 17 | .package(url: "https://github.com/vapor/vapor.git", .upToNextMajor(from: "4.0.1")), 18 | 19 | // Event-driven network application framework for high performance protocol servers & clients, non-blocking. 20 | .package(url: "https://github.com/apple/swift-nio.git", .upToNextMajor(from: "2.16.0")), 21 | 22 | // Bindings to OpenSSL-compatible libraries for TLS support in SwiftNIO 23 | .package(url: "https://github.com/apple/swift-nio-ssl.git", .upToNextMajor(from: "2.7.1")) 24 | ], 25 | targets: [ 26 | .target(name: "Smtp", dependencies: [ 27 | .product(name: "NIO", package: "swift-nio"), 28 | .product(name: "NIOSSL", package: "swift-nio-ssl"), 29 | .product(name: "Vapor", package: "vapor") 30 | ]), 31 | .testTarget(name: "SmtpTests", dependencies: [ 32 | .product(name: "NIO", package: "swift-nio"), 33 | .product(name: "NIOSSL", package: "swift-nio-ssl"), 34 | .product(name: "Vapor", package: "vapor"), 35 | .target(name: "Smtp") 36 | ]) 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Smtp 2 | 3 | ![Build Status](https://github.com/Mikroservices/Smtp/workflows/Build/badge.svg) 4 | [![Swift 5.2](https://img.shields.io/badge/Swift-5.2-orange.svg?style=flat)](ttps://developer.apple.com/swift/) 5 | [![Vapor 4](https://img.shields.io/badge/vapor-4.0-blue.svg?style=flat)](https://vapor.codes) 6 | [![Swift Package Manager](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://swift.org/package-manager/) 7 | [![Platforms OS X | Linux](https://img.shields.io/badge/Platforms-OS%20X%20%7C%20Linux%20-lightgray.svg?style=flat)](https://developer.apple.com/swift/) 8 | 9 | :email: SMTP protocol support for the Vapor web framework. 10 | 11 | This framework has dependencies only to `Vapor` and `SwiftNIO` packages. 12 | `SwiftNIO` support was inspired by Apple examples: [Swift NIO examples](https://github.com/apple/swift-nio-examples). 13 | 14 | Features: 15 | 16 | - [x] Vapor provider/service 17 | - [x] async/await support 18 | - [x] SwiftNIO Support 19 | - [x] Text/HTML 20 | - [x] Attachments 21 | - [x] SSL/TLS (when connection starts) 22 | - [x] STARTTLS support 23 | - [x] Multiple recipients & CC 24 | - [x] Reply to 25 | - [x] BCC fields 26 | - [ ] Multiple emails sent at the same time (one SMTP connection) 27 | 28 | ## Getting started 29 | 30 | You need to add library to `Package.swift` file: 31 | 32 | - add package to dependencies: 33 | ```swift 34 | .package(url: "https://github.com/Mikroservices/Smtp.git", from: "3.0.0") 35 | ``` 36 | 37 | - and add product to your target: 38 | ```swift 39 | .target(name: "App", dependencies: [ 40 | .product(name: "Vapor", package: "vapor"), 41 | .product(name: "Smtp", package: "Smtp") 42 | ]) 43 | ``` 44 | 45 | **Set the SMTP server configuration (e.g. in `main.swift` file)** 46 | 47 | ```swift 48 | import Smtp 49 | 50 | var env = try Environment.detect() 51 | try LoggingSystem.bootstrap(from: &env) 52 | 53 | let app = Application(env) 54 | defer { app.shutdown() } 55 | 56 | app.smtp.configuration.host = "smtp.server" 57 | app.smtp.configuration.signInMethod = .credentials(username: "johndoe", password: "passw0rd") 58 | app.smtp.configuration.secure = .ssl 59 | 60 | try configure(app) 61 | try app.run() 62 | ``` 63 | 64 | **Using SMTP client (EventLoopFuture)** 65 | 66 | ```swift 67 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 68 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 69 | subject: "The subject (text)", 70 | body: "This is email body.") 71 | 72 | request.smtp.send(email).map { result in 73 | switch result { 74 | case .success: 75 | print("Email has been sent") 76 | case .failure(let error): 77 | print("Email has not been sent: \(error)") 78 | } 79 | } 80 | ``` 81 | 82 | Also you can send emails directly via `application` class. 83 | 84 | ```swift 85 | app.smtp.send(email).map { result in 86 | ... 87 | } 88 | ``` 89 | 90 | **Using SMPT client (async/await)** 91 | 92 | You have to set macOS 12 as a target in `Package.swift` file (and tool version 5.5). 93 | 94 | ```swift 95 | platforms: [ 96 | .macOS(.v12) 97 | ], 98 | ``` 99 | 100 | Then you can use async/await alternatives of SMTP methods. 101 | 102 | ```swift 103 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 104 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 105 | subject: "The subject (text)", 106 | body: "This is email body.") 107 | 108 | try await request.smtp.send(email) 109 | ``` 110 | 111 | Also you can send emails directly via `application` class. 112 | 113 | ```swift 114 | try await app.smtp.send(email) 115 | ``` 116 | 117 | 118 | ## Troubleshoots 119 | 120 | You can use `logHandler` to handle and print all messages send/retrieved from email server. 121 | 122 | ```swift 123 | request.smtp.send(email) { message in 124 | print(message) 125 | }.map { result in 126 | ... 127 | } 128 | ``` 129 | 130 | ## Developing 131 | 132 | After cloning the repository you can open it in Xcode. 133 | 134 | ```bash 135 | $ git clone https://github.com/Mikroservices/Smtp.git 136 | $ cd Smtp 137 | $ open Package.swift 138 | ``` 139 | You can build and run tests directly in Xcode. 140 | 141 | ## Testing 142 | 143 | Unit (integration) tests requires correct email credentials. Credentials are not check-in to the repository. 144 | If you want to run unit tests you have to use your [mailtrap](https://mailtrap.io) account and/or other email provider credentials. 145 | 146 | All you need to do is replacing the configuration section in `Tests/SmtpTests/SmtpTests.swift` file. 147 | 148 | ## License 149 | 150 | This project is licensed under the terms of the MIT license. 151 | -------------------------------------------------------------------------------- /Sources/Smtp/Application+Send.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import NIO 9 | import NIOSSL 10 | import Vapor 11 | 12 | /// This is simple implementation of SMTP client service. 13 | /// The implementation was based on Apple SwiftNIO. 14 | /// 15 | /// # Usage 16 | /// 17 | /// **Set the SMTP server configuration (main.swift).** 18 | /// 19 | ///```swift 20 | /// import Smtp 21 | /// 22 | /// var env = try Environment.detect() 23 | /// try LoggingSystem.bootstrap(from: &env) 24 | /// 25 | /// let app = Application(env) 26 | /// defer { app.shutdown() } 27 | /// 28 | /// app.smtp.configuration.host = "smtp.server" 29 | /// app.smtp.configuration.username = "johndoe" 30 | /// app.smtp.configuration.password = "passw0rd" 31 | /// app.smtp.configuration.secure = .ssl 32 | /// 33 | /// try configure(app) 34 | /// try app.run() 35 | ///``` 36 | /// 37 | /// **Using SMTP client** 38 | /// 39 | ///```swift 40 | /// let email = Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 41 | /// to: [EmailAddress("ben.doe@testxx.com")], 42 | /// subject: "The subject (text)", 43 | /// body: "This is email body.") 44 | /// 45 | /// request.send(email).map { result in 46 | /// switch result { 47 | /// case .success: 48 | /// print("Email has been sent") 49 | /// case .failure(let error): 50 | /// print("Email has not been sent: \(error)") 51 | /// } 52 | /// } 53 | ///``` 54 | /// 55 | /// Channel pipeline: 56 | /// 57 | /// ``` 58 | /// +-------------------------------------------------------------------+ 59 | /// | | 60 | /// | [ Socket.read ] [ Socket.write ] | 61 | /// | | | | 62 | /// +--------------+----------------------------------+-----------------+ 63 | /// | /|\ 64 | /// \|/ | 65 | /// +-----+----------------------------------+-----+ 66 | /// | OpenSSLClientHandler (enabled/disabled) | 67 | /// +-----+----------------------------------+-----+ 68 | /// | /|\ 69 | /// \|/ | 70 | /// +-----+----------------------------------+-----+ 71 | /// | DuplexMessagesHandler | 72 | /// +-----+----------------------------------+-----+ 73 | /// | /|\ 74 | /// \|/ | 75 | /// +-----+--------------------------+ | 76 | /// | InboundLineBasedFrameDecoder | | 77 | /// +-----+--------------------------+ | 78 | /// | | 79 | /// \|/ | 80 | /// +-----+--------------------------+ | 81 | /// | InboundSmtpResponseDecoder | | 82 | /// +-----+--------------------------+ | 83 | /// | | 84 | /// | | 85 | /// | +--------------------------+-----+ 86 | /// | | OutboundSmtpRequestEncoder | 87 | /// | +--------------------------+-----+ 88 | /// | /|\ 89 | /// \|/ | 90 | /// +-----+----------------------------------+-----+ 91 | /// | StartTlsHandler | 92 | /// +-----+----------------------------------+-----+ 93 | /// | /|\ 94 | /// | | 95 | /// \|/ | [write] 96 | /// +-----+--------------------------+ | 97 | /// | InboundSendEmailHandler +-------+ 98 | /// +--------------------------------+ 99 | ///``` 100 | /// `OpenSSLClientHandler` is enabled only when `.ssl` secure is defined. For `.none` that 101 | /// handler is not added to the pipeline. 102 | /// 103 | /// `StartTlsHandler` is responsible for establishing SSL encryption after `STARTTLS` 104 | /// command (this handler adds dynamically `OpenSSLClientHandler` to the pipeline if 105 | /// server supports that encryption. 106 | public extension Application.Smtp { 107 | /// Sending an email. 108 | /// 109 | /// - parameters: 110 | /// - email: Email which will be send. 111 | /// - eventLoop: Event lopp which will be used to send email (if nil then event loop from application will be created). 112 | /// - logHandler: Callback which can be used for logging/printing of sending status messages. 113 | /// - returns: An `EventLoopFuture>` with information about sent email. 114 | func send(_ email: Email, eventLoop: EventLoop? = nil, logHandler: ((String) -> Void)? = nil) -> EventLoopFuture> { 115 | 116 | let smtpEventLoop = eventLoop ?? self.application.eventLoopGroup 117 | let emailSentPromise: EventLoopPromise = smtpEventLoop.next().makePromise() 118 | 119 | let configuration = self.application.smtp.configuration 120 | 121 | // Client configuration 122 | let bootstrap = ClientBootstrap(group: self.application.eventLoopGroup.next()) 123 | .connectTimeout(configuration.connectTimeout) 124 | .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) 125 | .channelInitializer { channel in 126 | 127 | let secureChannelFuture = configuration.secure.configureChannel(on: channel, hostname: configuration.hostname) 128 | return secureChannelFuture.flatMap { 129 | 130 | let defaultHandlers: [ChannelHandler] = [ 131 | DuplexMessagesHandler(handler: logHandler), 132 | ByteToMessageHandler(InboundLineBasedFrameDecoder()), 133 | InboundSmtpResponseDecoder(), 134 | MessageToByteHandler(OutboundSmtpRequestEncoder()), 135 | StartTlsHandler(configuration: configuration, allDonePromise: emailSentPromise), 136 | InboundSendEmailHandler(configuration: configuration, 137 | email: email, 138 | allDonePromise: emailSentPromise) 139 | ] 140 | 141 | return channel.pipeline.addHandlers(defaultHandlers, position: .last) 142 | } 143 | } 144 | 145 | // Connect and send email. 146 | let connection = bootstrap.connect(host: configuration.hostname, port: configuration.port) 147 | 148 | connection.cascadeFailure(to: emailSentPromise) 149 | 150 | return emailSentPromise.futureResult.map { () -> Result in 151 | connection.whenSuccess { $0.close(promise: nil) } 152 | return Result.success(true) 153 | }.flatMapError { error -> EventLoopFuture> in 154 | return smtpEventLoop.future(Result.failure(error)) 155 | } 156 | } 157 | } 158 | 159 | #if compiler(>=5.5) && canImport(_Concurrency) 160 | 161 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 162 | public extension Application.Smtp { 163 | func send(_ email: Email, eventLoop: EventLoop? = nil, logHandler: ((String) -> Void)? = nil) async throws { 164 | let result = try await self.send(email, eventLoop: eventLoop, logHandler: logHandler).get() 165 | 166 | switch result { 167 | case .success(_): 168 | break 169 | case .failure(let error): 170 | throw error 171 | } 172 | } 173 | } 174 | 175 | #endif 176 | -------------------------------------------------------------------------------- /Sources/Smtp/Application+Smtp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Vapor 8 | 9 | extension Application { 10 | public var smtp: Smtp { 11 | .init(application: self) 12 | } 13 | 14 | public struct Smtp { 15 | let application: Application 16 | 17 | struct ConfigurationKey: StorageKey { 18 | typealias Value = SmtpServerConfiguration 19 | } 20 | 21 | public var configuration: SmtpServerConfiguration { 22 | get { 23 | self.application.storage[ConfigurationKey.self] ?? .init() 24 | } 25 | nonmutating set { 26 | self.application.storage[ConfigurationKey.self] = newValue 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Smtp/Errors/EmailError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | enum EmailError: Error { 8 | case recipientNotSpecified 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Smtp/Errors/NIOExtrasError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | 9 | internal protocol NIOExtrasError: Equatable, Error { } 10 | 11 | /// Errors that are raised in NIOExtras. 12 | internal enum NIOExtrasErrors { 13 | 14 | /// Error indicating that after an operation some unused bytes are left. 15 | public struct LeftOverBytesError: NIOExtrasError { 16 | public let leftOverBytes: ByteBuffer 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Smtp/Errors/SmtpError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | struct SmtpError: Error { 10 | let message: String 11 | 12 | init(_ message: String) { 13 | self.message = message 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Smtp/Errors/SmtpResponseDecoderError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | enum SmtpResponseDecoderError: Error { 8 | case malformedMessage 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/DuplexMessagesHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | 9 | internal final class DuplexMessagesHandler: ChannelDuplexHandler { 10 | typealias InboundIn = ByteBuffer 11 | typealias InboundOut = ByteBuffer 12 | typealias OutboundIn = ByteBuffer 13 | typealias OutboundOut = ByteBuffer 14 | 15 | private let handler: ((String) -> Void)? 16 | 17 | init(handler: ((String) -> Void)? = nil) { 18 | self.handler = handler 19 | } 20 | 21 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 22 | 23 | if let handler = self.handler { 24 | let buffer = self.unwrapInboundIn(data) 25 | handler("==> \(String(decoding: buffer.readableBytesView, as: UTF8.self))") 26 | } 27 | 28 | context.fireChannelRead(data) 29 | } 30 | 31 | func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { 32 | 33 | if let handler = self.handler { 34 | let buffer = self.unwrapOutboundIn(data) 35 | handler("<== \(String(decoding: buffer.readableBytesView, as: UTF8.self))") 36 | } 37 | 38 | context.write(data, promise: promise) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/InboundLineBasedFrameDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | 9 | /// A decoder that splits incoming `ByteBuffer`s around line end 10 | /// character(s) (`'\n'` or `'\r\n'`). 11 | /// 12 | /// Let's, for example, consider the following received buffer: 13 | /// 14 | /// +----+-------+------------+ 15 | /// | AB | C\nDE | F\r\nGHI\n | 16 | /// +----+-------+------------+ 17 | /// 18 | /// A instance of `InboundLineBasedFrameDecoder` will split this buffer 19 | /// as follows: 20 | /// 21 | /// +-----+-----+-----+ 22 | /// | ABC | DEF | GHI | 23 | /// +-----+-----+-----+ 24 | /// 25 | internal class InboundLineBasedFrameDecoder: ByteToMessageDecoder { 26 | 27 | public typealias InboundIn = ByteBuffer 28 | public typealias InboundOut = ByteBuffer 29 | public var cumulationBuffer: ByteBuffer? 30 | // keep track of the last scan offset from the buffer's reader index (if we didn't find the delimiter) 31 | private var lastScanOffset = 0 32 | private var handledLeftovers = false 33 | 34 | public init() { } 35 | 36 | public func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) -> DecodingState { 37 | if let frame = self.findNextFrame(buffer: &buffer) { 38 | context.fireChannelRead(wrapInboundOut(frame)) 39 | return .continue 40 | } else { 41 | return .needMoreData 42 | } 43 | } 44 | 45 | private func findNextFrame(buffer: inout ByteBuffer) -> ByteBuffer? { 46 | let view = buffer.readableBytesView.dropFirst(self.lastScanOffset) 47 | 48 | // look for the delimiter 49 | if let delimiterIndex = view.firstIndex(of: 0x0A) { // '\n' 50 | let length = delimiterIndex - buffer.readerIndex 51 | let dropCarriageReturn = delimiterIndex > view.startIndex && view[delimiterIndex - 1] == 0x0D // '\r' 52 | let buff = buffer.readSlice(length: dropCarriageReturn ? length - 1 : length) 53 | 54 | // drop the delimiter (and trailing carriage return if appicable) 55 | buffer.moveReaderIndex(forwardBy: dropCarriageReturn ? 2 : 1) 56 | 57 | // reset the last scan start index since we found a line 58 | self.lastScanOffset = 0 59 | 60 | return buff 61 | } 62 | 63 | // next scan we start where we stopped 64 | self.lastScanOffset = buffer.readableBytes 65 | return nil 66 | } 67 | 68 | public func handlerRemoved(context: ChannelHandlerContext) { 69 | self.handleLeftOverBytes(context: context) 70 | } 71 | 72 | public func channelInactive(context: ChannelHandlerContext) { 73 | self.handleLeftOverBytes(context: context) 74 | } 75 | 76 | private func handleLeftOverBytes(context: ChannelHandlerContext) { 77 | if let buffer = self.cumulationBuffer, buffer.readableBytes > 0 && !self.handledLeftovers { 78 | self.handledLeftovers = true 79 | context.fireErrorCaught(NIOExtrasErrors.LeftOverBytesError(leftOverBytes: buffer)) 80 | } 81 | } 82 | } 83 | 84 | #if !swift(>=4.2) 85 | private extension ByteBufferView { 86 | func firstIndex(of element: UInt8) -> Int? { 87 | return self.index(of: element) 88 | } 89 | } 90 | #endif 91 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/InboundSendEmailHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | import NIOSSL 9 | 10 | internal final class InboundSendEmailHandler: ChannelInboundHandler { 11 | typealias InboundIn = SmtpResponse 12 | typealias OutboundOut = SmtpRequest 13 | 14 | enum Expect { 15 | case initialMessageFromServer 16 | case okAfterHello 17 | case okAfterStartTls 18 | case okAfterStartTlsHello 19 | case okAfterAuthBegin 20 | case okAfterUsername 21 | case okAfterPassword 22 | case okAfterMailFrom 23 | case okAfterRecipient 24 | case okAfterDataCommand 25 | case okAfterMailData 26 | case okAfterQuit 27 | case nothing 28 | 29 | case error 30 | } 31 | 32 | private var currentlyWaitingFor = Expect.initialMessageFromServer 33 | private var email: Email 34 | private let serverConfiguration: SmtpServerConfiguration 35 | private let allDonePromise: EventLoopPromise 36 | private var recipients: [EmailAddress] = [] 37 | 38 | init(configuration: SmtpServerConfiguration, email: Email, allDonePromise: EventLoopPromise) { 39 | self.email = email 40 | self.allDonePromise = allDonePromise 41 | self.serverConfiguration = configuration 42 | 43 | if let to = self.email.to { 44 | self.recipients += to 45 | } 46 | 47 | if let cc = self.email.cc { 48 | self.recipients += cc 49 | } 50 | 51 | if let bcc = self.email.bcc { 52 | self.recipients += bcc 53 | } 54 | } 55 | 56 | func send(context: ChannelHandlerContext, command: SmtpRequest) { 57 | context.writeAndFlush(self.wrapOutboundOut(command)).cascadeFailure(to: self.allDonePromise) 58 | } 59 | 60 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 61 | let result = self.unwrapInboundIn(data) 62 | switch result { 63 | case .error(let message): 64 | self.allDonePromise.fail(SmtpError(message)) 65 | return 66 | case .ok: 67 | () // cool 68 | } 69 | 70 | switch self.currentlyWaitingFor { 71 | case .initialMessageFromServer: 72 | self.send(context: context, 73 | command: .sayHello(serverName: self.serverConfiguration.hostname, 74 | helloMethod: self.serverConfiguration.helloMethod 75 | ) 76 | ) 77 | self.currentlyWaitingFor = .okAfterHello 78 | case .okAfterHello: 79 | 80 | if self.shouldInitializeTls() { 81 | self.send(context: context, command: .startTls) 82 | self.currentlyWaitingFor = .okAfterStartTls 83 | } else { 84 | switch self.serverConfiguration.signInMethod { 85 | case .credentials(_, _): 86 | self.send(context: context, command: .beginAuthentication) 87 | self.currentlyWaitingFor = .okAfterAuthBegin 88 | case .anonymous: 89 | self.send(context: context, command: .mailFrom(self.email.from.address)) 90 | self.currentlyWaitingFor = .okAfterMailFrom 91 | } 92 | } 93 | 94 | case .okAfterStartTls: 95 | self.send(context: context, command: .sayHelloAfterTls(serverName: self.serverConfiguration.hostname, helloMethod: self.serverConfiguration.helloMethod)) 96 | self.currentlyWaitingFor = .okAfterStartTlsHello 97 | case .okAfterStartTlsHello: 98 | self.send(context: context, command: .beginAuthentication) 99 | self.currentlyWaitingFor = .okAfterAuthBegin 100 | case .okAfterAuthBegin: 101 | 102 | switch self.serverConfiguration.signInMethod { 103 | case .credentials(let username, _): 104 | self.send(context: context, command: .authUser(username)) 105 | self.currentlyWaitingFor = .okAfterUsername 106 | case .anonymous: 107 | self.allDonePromise.fail(SmtpError("After auth begin executed for anonymous sign in method")) 108 | break; 109 | } 110 | 111 | case .okAfterUsername: 112 | switch self.serverConfiguration.signInMethod { 113 | case .credentials(_, let password): 114 | self.send(context: context, command: .authPassword(password)) 115 | self.currentlyWaitingFor = .okAfterPassword 116 | case .anonymous: 117 | self.allDonePromise.fail(SmtpError("After user name executed for anonymous sign in method")) 118 | break; 119 | } 120 | 121 | case .okAfterPassword: 122 | self.send(context: context, command: .mailFrom(self.email.from.address)) 123 | self.currentlyWaitingFor = .okAfterMailFrom 124 | case .okAfterMailFrom: 125 | if let recipient = self.recipients.popLast() { 126 | self.send(context: context, command: .recipient(recipient.address)) 127 | } else { 128 | fallthrough 129 | } 130 | case .okAfterRecipient: 131 | self.send(context: context, command: .data) 132 | self.currentlyWaitingFor = .okAfterDataCommand 133 | case .okAfterDataCommand: 134 | self.send(context: context, command: .transferData(email)) 135 | self.currentlyWaitingFor = .okAfterMailData 136 | case .okAfterMailData: 137 | self.send(context: context, command: .quit) 138 | self.currentlyWaitingFor = .okAfterQuit 139 | case .okAfterQuit: 140 | self.allDonePromise.succeed(()) 141 | self.currentlyWaitingFor = .nothing 142 | case .nothing: 143 | () // ignoring more data whilst quit (it's odd though) 144 | case .error: 145 | self.allDonePromise.fail(SmtpError("Communication error state")) 146 | } 147 | } 148 | 149 | private func shouldInitializeTls() -> Bool { 150 | return self.serverConfiguration.secure == .startTls || self.serverConfiguration.secure == .startTlsWhenAvailable 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/InboundSmtpResponseDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | 9 | internal final class InboundSmtpResponseDecoder: ChannelInboundHandler { 10 | typealias InboundIn = ByteBuffer 11 | typealias InboundOut = SmtpResponse 12 | 13 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 14 | var response = self.unwrapInboundIn(data) 15 | 16 | if let firstFourBytes = response.readString(length: 4), let code = Int(firstFourBytes.dropLast()) { 17 | let remainder = response.readString(length: response.readableBytes) ?? "" 18 | 19 | let firstCharacter = firstFourBytes.first! 20 | let fourthCharacter = firstFourBytes.last! 21 | 22 | switch (firstCharacter, fourthCharacter) { 23 | case ("2", " "), 24 | ("3", " "): 25 | let parsedMessage = SmtpResponse.ok(code, remainder) 26 | context.fireChannelRead(self.wrapInboundOut(parsedMessage)) 27 | case (_, "-"): 28 | () // intermediate message, ignore 29 | default: 30 | context.fireChannelRead(self.wrapInboundOut(.error(firstFourBytes+remainder))) 31 | } 32 | } else { 33 | context.fireErrorCaught(SmtpResponseDecoderError.malformedMessage) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/OutboundSmtpRequestEncoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | import NIOFoundationCompat 9 | import Foundation 10 | 11 | internal final class OutboundSmtpRequestEncoder: MessageToByteEncoder { 12 | typealias OutboundIn = SmtpRequest 13 | 14 | func encode(data: SmtpRequest, out: inout ByteBuffer) { 15 | switch data { 16 | case .sayHello(serverName: let server, helloMethod: let helloMethod): 17 | out.writeString("\(helloMethod.rawValue) \(server)") 18 | case .startTls: 19 | out.writeString("STARTTLS") 20 | case .sayHelloAfterTls(serverName: let server, helloMethod: let helloMethod): 21 | out.writeString("\(helloMethod.rawValue) \(server)") 22 | case .mailFrom(let from): 23 | out.writeString("MAIL FROM:<\(from)>") 24 | case .recipient(let rcpt): 25 | out.writeString("RCPT TO:<\(rcpt)>") 26 | case .data: 27 | out.writeString("DATA") 28 | case .transferData(let email): 29 | email.write(to: &out) 30 | case .quit: 31 | out.writeString("QUIT") 32 | case .beginAuthentication: 33 | out.writeString("AUTH LOGIN") 34 | case .authUser(let user): 35 | let userData = Data(user.utf8) 36 | out.writeBytes(userData.base64EncodedData()) 37 | case .authPassword(let password): 38 | let passwordData = Data(password.utf8) 39 | out.writeBytes(passwordData.base64EncodedData()) 40 | } 41 | 42 | out.writeString("\r\n") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Smtp/Handlers/StartTlsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | import NIOSSL 9 | 10 | internal final class StartTlsHandler: ChannelDuplexHandler, RemovableChannelHandler { 11 | typealias InboundIn = SmtpResponse 12 | typealias InboundOut = SmtpResponse 13 | typealias OutboundIn = SmtpRequest 14 | typealias OutboundOut = SmtpRequest 15 | 16 | private let serverConfiguration: SmtpServerConfiguration 17 | private let allDonePromise: EventLoopPromise 18 | private var waitingForStartTlsResponse = false 19 | 20 | init(configuration: SmtpServerConfiguration, allDonePromise: EventLoopPromise) { 21 | self.serverConfiguration = configuration 22 | self.allDonePromise = allDonePromise 23 | } 24 | 25 | func channelRead(context: ChannelHandlerContext, data: NIOAny) { 26 | 27 | if self.startTlsDisabled() { 28 | context.fireChannelRead(data) 29 | return 30 | } 31 | 32 | if waitingForStartTlsResponse { 33 | self.waitingForStartTlsResponse = false 34 | 35 | let result = self.unwrapInboundIn(data) 36 | switch result { 37 | case .error(let message): 38 | if self.serverConfiguration.secure == .startTls { 39 | // Fail only if tls is required. 40 | self.allDonePromise.fail(SmtpError(message)) 41 | return 42 | } 43 | 44 | // Tls is not required, we can continue without encryption. 45 | let startTlsResult = self.wrapInboundOut(.ok(200, "STARTTLS is not supported")) 46 | context.fireChannelRead(startTlsResult) 47 | return 48 | case .ok: 49 | self.initializeTlsHandler(context: context, data: data) 50 | } 51 | } else { 52 | context.fireChannelRead(data) 53 | } 54 | } 55 | 56 | func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { 57 | 58 | if self.startTlsDisabled() { 59 | context.write(data, promise: promise) 60 | return 61 | } 62 | 63 | let command = self.unwrapOutboundIn(data) 64 | switch command { 65 | case .startTls: 66 | self.waitingForStartTlsResponse = true 67 | default: 68 | break 69 | } 70 | 71 | 72 | context.write(data, promise: promise) 73 | } 74 | 75 | private func initializeTlsHandler(context: ChannelHandlerContext, data: NIOAny) { 76 | do { 77 | let sslContext = try NIOSSLContext(configuration: .makeClientConfiguration()) 78 | let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: self.serverConfiguration.hostname) 79 | _ = context.channel.pipeline.addHandler(sslHandler, name: "NIOSSLClientHandler", position: .first) 80 | 81 | context.fireChannelRead(data) 82 | _ = context.channel.pipeline.removeHandler(self) 83 | } catch let error { 84 | self.allDonePromise.fail(error) 85 | } 86 | } 87 | 88 | private func startTlsDisabled() -> Bool { 89 | return self.serverConfiguration.secure != .startTls && self.serverConfiguration.secure != .startTlsWhenAvailable 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/Attachment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct Attachment { 10 | public let name: String 11 | public let contentType: String 12 | public let data: Data 13 | public let contentId: String? 14 | 15 | public init(name: String, contentType: String, data: Data, contentId: String? = nil) { 16 | self.name = name 17 | self.contentType = contentType 18 | self.data = data 19 | self.contentId = contentId 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/Email.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import NIO 9 | 10 | public struct Email { 11 | public let from: EmailAddress 12 | public let to: [EmailAddress]? 13 | public let cc: [EmailAddress]? 14 | public let bcc: [EmailAddress]? 15 | public let subject: String 16 | public let body: String 17 | public let plain: String? 18 | public let isBodyHtml: Bool 19 | public let replyTo: EmailAddress? 20 | public let reference : String? 21 | public let dateFormatted: String 22 | public let uuid : String 23 | 24 | internal var attachments: [Attachment] = [] 25 | 26 | public init(from: EmailAddress, 27 | to: [EmailAddress]? = nil, 28 | cc: [EmailAddress]? = nil, 29 | bcc: [EmailAddress]? = nil, 30 | subject: String, 31 | body: String, 32 | plain: String? = nil, 33 | isBodyHtml: Bool = false, 34 | replyTo: EmailAddress? = nil, 35 | reference : String? = nil 36 | ) throws { 37 | if (to?.isEmpty ?? true) == true && (cc?.isEmpty ?? true) == true && (bcc?.isEmpty ?? true) == true { 38 | throw EmailError.recipientNotSpecified 39 | } 40 | 41 | self.from = from 42 | self.to = to 43 | self.cc = cc 44 | self.bcc = bcc 45 | self.subject = subject 46 | self.plain = plain 47 | 48 | self.isBodyHtml = isBodyHtml 49 | self.replyTo = replyTo 50 | self.reference = reference 51 | 52 | // Body have to contains POSIX new lines. 53 | self.body = body 54 | .replacingOccurrences(of: "\r\n", with: "\n") 55 | .replacingOccurrences(of: "\n", with: "\r\n") 56 | 57 | let date = Date() 58 | let dateFormatter = DateFormatter() 59 | dateFormatter.locale = Locale(identifier: "en_US") 60 | dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" 61 | 62 | self.dateFormatted = dateFormatter.string(from: date) 63 | self.uuid = "<\(date.timeIntervalSince1970)\(self.from.address.drop { $0 != "@" })>" 64 | } 65 | 66 | public mutating func addAttachment(_ attachment: Attachment) { 67 | self.attachments.append(attachment) 68 | } 69 | } 70 | 71 | extension Email { 72 | internal func write(to out: inout ByteBuffer) { 73 | 74 | out.writeString("From: \(self.formatMIME(emailAddress: self.from))\r\n") 75 | 76 | if let to = self.to { 77 | let toAddresses = to.map { self.formatMIME(emailAddress: $0) }.joined(separator: ", ") 78 | out.writeString("To: \(toAddresses)\r\n") 79 | } 80 | 81 | if let cc = self.cc { 82 | let ccAddresses = cc.map { self.formatMIME(emailAddress: $0) }.joined(separator: ", ") 83 | out.writeString("Cc: \(ccAddresses)\r\n") 84 | } 85 | 86 | if let replyTo = self.replyTo { 87 | out.writeString("Reply-to: \(self.formatMIME(emailAddress:replyTo))\r\n") 88 | } 89 | 90 | out.writeString("Subject: \(self.subject)\r\n") 91 | out.writeString("Date: \(self.dateFormatted)\r\n") 92 | out.writeString("Message-ID: \(self.uuid)\r\n") 93 | 94 | if let reference = self.reference { 95 | out.writeString("In-Reply-To: \(reference)\r\n") 96 | out.writeString("References: \(reference)\r\n") 97 | } 98 | 99 | let boundary = self.boundary() 100 | if self.attachments.count > 0 { 101 | out.writeString("Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n") 102 | out.writeString("Mime-Version: 1.0\r\n\r\n") 103 | } else if self.isBodyHtml && self.plain != nil { 104 | out.writeString("Content-type: multipart/alternative; boundary=\"\(boundary)\"\r\n") 105 | out.writeString("Mime-Version: 1.0\r\n\r\n") 106 | } else if self.isBodyHtml { 107 | out.writeString("Content-Type: text/html; charset=\"UTF-8\"\r\n") 108 | out.writeString("Mime-Version: 1.0\r\n\r\n") 109 | } else { 110 | out.writeString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") 111 | out.writeString("Mime-Version: 1.0\r\n\r\n") 112 | } 113 | 114 | if self.attachments.count > 0 || (self.isBodyHtml && self.plain != nil) { 115 | 116 | if self.isBodyHtml { 117 | if let text = self.plain { 118 | out.writeString("--\(boundary)\r\n") 119 | out.writeString("Content-Type: text/plain; charset=\"UTF-8\"\r\n\r\n") 120 | out.writeString("\(text)\r\n") 121 | } 122 | 123 | out.writeString("--\(boundary)\r\n") 124 | out.writeString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n") 125 | out.writeString("\(self.body)\r\n") 126 | out.writeString("--\(boundary)\(self.attachments.count == 0 ? "--" : "")\r\n") 127 | } else { 128 | out.writeString("--\(boundary)\r\n") 129 | out.writeString("Content-Type: text/plain; charset=\"UTF-8\"\r\n\r\n") 130 | out.writeString("\(self.body)\r\n") 131 | out.writeString("--\(boundary)\(self.attachments.count == 0 ? "--" : "")\r\n") 132 | } 133 | 134 | for (index, attachment) in self.attachments.enumerated() { 135 | out.writeString("Content-type: \(attachment.contentType)\r\n") 136 | out.writeString("Content-Transfer-Encoding: base64\r\n") 137 | 138 | if let contentId = attachment.contentId { 139 | out.writeString("Content-ID: <\(contentId)>\r\n") 140 | } 141 | 142 | out.writeString("Content-Disposition: attachment; filename=\"\(attachment.name)\"\r\n\r\n") 143 | out.writeString("\(attachment.data.base64EncodedString())\r\n") 144 | 145 | if index == self.attachments.count - 1 { 146 | out.writeString("--\(boundary)--\r\n") 147 | } else { 148 | out.writeString("--\(boundary)\r\n") 149 | } 150 | } 151 | 152 | } else { 153 | out.writeString("\(self.body)\r\n") 154 | } 155 | 156 | out.writeString(".") 157 | } 158 | 159 | private func boundary() -> String { 160 | return UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() 161 | } 162 | 163 | func formatMIME(emailAddress: EmailAddress) -> String { 164 | if let name = emailAddress.name { 165 | return "\(name) <\(emailAddress.address)>" 166 | } else { 167 | return emailAddress.address 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/EmailAddress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | public struct EmailAddress { 8 | public let address: String 9 | public let name: String? 10 | 11 | public init(address: String, name: String? = nil) { 12 | self.address = address 13 | self.name = name 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/HelloMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | public enum HelloMethod: String { 8 | case helo = "HELO" 9 | case ehlo = "EHLO" 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/SignInMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | public enum SignInMethod { 8 | case anonymous 9 | case credentials(username: String, password: String) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/SmtpRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | internal enum SmtpRequest { 8 | case sayHello(serverName: String, helloMethod: HelloMethod) 9 | case startTls 10 | case sayHelloAfterTls(serverName: String, helloMethod: HelloMethod) 11 | case beginAuthentication 12 | case authUser(String) 13 | case authPassword(String) 14 | case mailFrom(String) 15 | case recipient(String) 16 | case data 17 | case transferData(Email) 18 | case quit 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Smtp/Models/SmtpResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | internal enum SmtpResponse { 8 | case ok(Int, String) 9 | case error(String) 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Smtp/Request+Send.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import NIO 9 | import NIOSSL 10 | import Vapor 11 | 12 | public extension Request { 13 | var smtp: Smtp { 14 | .init(request: self) 15 | } 16 | 17 | struct Smtp { 18 | let request: Request 19 | 20 | public func send(_ email: Email, logHandler: ((String) -> Void)? = nil) -> EventLoopFuture> { 21 | return self.request.application.smtp.send(email, eventLoop: self.request.eventLoop, logHandler: logHandler) 22 | } 23 | } 24 | } 25 | 26 | #if compiler(>=5.5) && canImport(_Concurrency) 27 | 28 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 29 | public extension Request.Smtp { 30 | func send(_ email: Email, logHandler: ((String) -> Void)? = nil) async throws { 31 | return try await self.request.application.smtp.send(email, eventLoop: self.request.eventLoop, logHandler: logHandler) 32 | } 33 | } 34 | 35 | #endif 36 | -------------------------------------------------------------------------------- /Sources/Smtp/SmtpSecure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Vapor 8 | import NIO 9 | import NIOSSL 10 | 11 | public enum SmtpSecureChannel { 12 | 13 | /// Communication without any encryption (even password is send as a plain text). 14 | case none 15 | 16 | /// The connection should use SSL or TLS encryption immediately. 17 | case ssl 18 | 19 | /// Elevates the connection to use TLS encryption immediately after 20 | /// reading the greeting and capabilities of the server. If the server 21 | /// does not support the STARTTLS extension, then the connection will 22 | /// fail and error will be thrown. 23 | case startTls 24 | 25 | /// Elevates the connection to use TLS encryption immediately after 26 | /// reading the greeting and capabilities of the server, but only if 27 | /// the server supports the STARTTLS extension. 28 | case startTlsWhenAvailable 29 | 30 | internal func configureChannel(on channel: Channel, hostname: String) -> EventLoopFuture { 31 | switch self { 32 | case .ssl: 33 | do { 34 | let sslContext = try NIOSSLContext(configuration: .makeClientConfiguration()) 35 | let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: hostname) 36 | return channel.pipeline.addHandler(sslHandler) 37 | } catch { 38 | return channel.eventLoop.makeSucceededFuture(()) 39 | } 40 | default: 41 | return channel.eventLoop.makeSucceededFuture(()) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Smtp/SmtpServerConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import NIO 8 | import Vapor 9 | 10 | public struct SmtpServerConfiguration { 11 | public var hostname: String 12 | public var port: Int 13 | public var secure: SmtpSecureChannel 14 | public var connectTimeout:TimeAmount 15 | public var helloMethod: HelloMethod 16 | public var signInMethod: SignInMethod 17 | 18 | public init(hostname: String = "", 19 | port: Int = 465, 20 | signInMethod: SignInMethod = .anonymous, 21 | secure: SmtpSecureChannel = .none, 22 | connectTimeout: TimeAmount = TimeAmount.seconds(10), 23 | helloMethod: HelloMethod = .helo 24 | ) { 25 | self.hostname = hostname 26 | self.port = port 27 | self.secure = secure 28 | self.connectTimeout = connectTimeout 29 | self.helloMethod = helloMethod 30 | self.signInMethod = signInMethod 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SmtpTests/Attachments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | class Attachments { 10 | 11 | public static func text() -> Data { 12 | return Data("To jest plik tekstowt".utf8) 13 | } 14 | 15 | public static func image() -> Data { 16 | return Data(base64Encoded: "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")! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SmtpTests/SmtpTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import XCTest 8 | import NIO 9 | import Vapor 10 | @testable import Smtp 11 | 12 | final class SmtpTests: XCTestCase { 13 | 14 | let smtpConfiguration = SmtpServerConfiguration(hostname: "smtp.mailtrap.io", 15 | port: 465, 16 | signInMethod: .credentials(username: "#MAILTRAPUSER#", password: "#MAILTRAPPASS#"), 17 | secure: .none) 18 | 19 | let sslSmtpConfiguration = SmtpServerConfiguration(hostname: "smtp.gmail.com", 20 | port: 465, 21 | signInMethod: .credentials(username: "#GMAILUSER#", password: "#GMAILPASS#"), 22 | secure: .ssl) 23 | 24 | let tslSmtpConfiguration = SmtpServerConfiguration(hostname: "smtp.gmail.com", 25 | port: 587, 26 | signInMethod: .credentials(username: "#GMAILUSER#", password: "#GMAILPASS#"), 27 | secure: .startTls) 28 | 29 | let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) 30 | 31 | func testSendTextMessage() throws { 32 | let application = Application() 33 | defer { 34 | application.shutdown() 35 | } 36 | 37 | application.smtp.configuration = smtpConfiguration 38 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 39 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 40 | subject: "The subject (text) - \(timestamp)", 41 | body: "This is email body.") 42 | 43 | let request = Request(application: application, on: application.eventLoopGroup.next()) 44 | try request.smtp.send(email) { message in 45 | print(message) 46 | }.flatMapThrowing { result in 47 | XCTAssertTrue(try result.get()) 48 | }.wait() 49 | 50 | sleep(3) 51 | } 52 | 53 | func testSendTextMessageViaApplication() throws { 54 | let application = Application() 55 | defer { 56 | application.shutdown() 57 | } 58 | 59 | application.smtp.configuration = smtpConfiguration 60 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 61 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 62 | subject: "The subject (text) - \(timestamp)", 63 | body: "This is email body.") 64 | 65 | try application.smtp.send(email) { message in 66 | print(message) 67 | }.flatMapThrowing { result in 68 | XCTAssertTrue(try result.get()) 69 | }.wait() 70 | 71 | sleep(3) 72 | } 73 | 74 | func testSendTextMessageWithoutNames() throws { 75 | let application = Application() 76 | defer { 77 | application.shutdown() 78 | } 79 | 80 | application.smtp.configuration = smtpConfiguration 81 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com"), 82 | to: [EmailAddress(address: "ben.doe@testxx.com")], 83 | subject: "The subject (without names) - \(timestamp)", 84 | body: "This is email body.") 85 | 86 | let request = Request(application: application, on: application.eventLoopGroup.next()) 87 | try request.smtp.send(email) { message in 88 | print(message) 89 | }.flatMapThrowing { result in 90 | XCTAssertTrue(try result.get()) 91 | }.wait() 92 | 93 | sleep(3) 94 | } 95 | 96 | func testSendHtmlMessage() throws { 97 | let application = Application() 98 | defer { 99 | application.shutdown() 100 | } 101 | 102 | application.smtp.configuration = smtpConfiguration 103 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 104 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 105 | subject: "The subject (html) - \(timestamp)", 106 | body: "

This is email content!

", 107 | isBodyHtml: true) 108 | 109 | let request = Request(application: application, on: application.eventLoopGroup.next()) 110 | try request.smtp.send(email) { message in 111 | print(message) 112 | }.flatMapThrowing { result in 113 | XCTAssertTrue(try result.get()) 114 | }.wait() 115 | 116 | sleep(3) 117 | } 118 | 119 | func testSendTextMessageWithAttachments() throws { 120 | let application = Application() 121 | defer { 122 | application.shutdown() 123 | } 124 | 125 | application.smtp.configuration = smtpConfiguration 126 | var email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 127 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 128 | subject: "The subject (text) - \(timestamp)", 129 | body: "This is email body.") 130 | 131 | email.addAttachment(Attachment(name: "plik1.txt", contentType: "text/plain", data: Attachments.text())) 132 | email.addAttachment(Attachment(name: "image.png", contentType: "image/png", data: Attachments.image())) 133 | 134 | let request = Request(application: application, on: application.eventLoopGroup.next()) 135 | try request.smtp.send(email) { message in 136 | print(message) 137 | }.flatMapThrowing { result in 138 | XCTAssertTrue(try result.get()) 139 | }.wait() 140 | 141 | sleep(3) 142 | } 143 | 144 | func testSendHtmlMessageWithAttachments() throws { 145 | let application = Application() 146 | defer { 147 | application.shutdown() 148 | } 149 | 150 | application.smtp.configuration = smtpConfiguration 151 | var email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 152 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 153 | subject: "The subject (html) - \(timestamp)", 154 | body: "

This is email content!

", 155 | isBodyHtml: true) 156 | 157 | email.addAttachment(Attachment(name: "plik1.txt", contentType: "text/plain", data: Attachments.text())) 158 | email.addAttachment(Attachment(name: "image.png", contentType: "image/png", data: Attachments.image())) 159 | 160 | let request = Request(application: application, on: application.eventLoopGroup.next()) 161 | try request.smtp.send(email) { message in 162 | print(message) 163 | }.flatMapThrowing { result in 164 | XCTAssertTrue(try result.get()) 165 | }.wait() 166 | 167 | sleep(3) 168 | } 169 | 170 | func testSendTextMessageToMultipleRecipients() throws { 171 | let application = Application() 172 | defer { 173 | application.shutdown() 174 | } 175 | 176 | application.smtp.configuration = smtpConfiguration 177 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 178 | to: [ 179 | EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe"), 180 | EmailAddress(address: "anton.doe@testxx.com", name: "Anton Doe") 181 | ], 182 | subject: "The subject (multiple to) - \(timestamp)", 183 | body: "This is email body.") 184 | 185 | let request = Request(application: application, on: application.eventLoopGroup.next()) 186 | try request.smtp.send(email) { message in 187 | print(message) 188 | }.flatMapThrowing { result in 189 | XCTAssertTrue(try result.get()) 190 | }.wait() 191 | 192 | sleep(3) 193 | } 194 | 195 | func testSendTextMessageWithCC() throws { 196 | let application = Application() 197 | defer { 198 | application.shutdown() 199 | } 200 | 201 | application.smtp.configuration = smtpConfiguration 202 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 203 | to: [ 204 | EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe"), 205 | EmailAddress(address: "anton.doe@testxx.com", name: "Anton Doe") 206 | ], 207 | cc: [ 208 | EmailAddress(address: "tom.doe@testxx.com", name: "Tom Doe"), 209 | EmailAddress(address: "rob.doe@testxx.com", name: "Rob Doe") 210 | ], 211 | subject: "The subject (multiple cc) - \(timestamp)", 212 | body: "This is email body.") 213 | 214 | let request = Request(application: application, on: application.eventLoopGroup.next()) 215 | try request.smtp.send(email) { message in 216 | print(message) 217 | }.flatMapThrowing { result in 218 | XCTAssertTrue(try result.get()) 219 | }.wait() 220 | 221 | sleep(3) 222 | } 223 | 224 | func testSendTextMessageWithReplyTo() throws { 225 | let application = Application() 226 | defer { 227 | application.shutdown() 228 | } 229 | 230 | application.smtp.configuration = smtpConfiguration 231 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 232 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 233 | subject: "The subject (reply-to) - \(timestamp)", 234 | body: "This is email body.", 235 | replyTo: EmailAddress(address: "noreply@testxx.com")) 236 | 237 | let request = Request(application: application, on: application.eventLoopGroup.next()) 238 | try request.smtp.send(email) { message in 239 | print(message) 240 | }.flatMapThrowing { result in 241 | XCTAssertTrue(try result.get()) 242 | }.wait() 243 | 244 | sleep(3) 245 | } 246 | 247 | func testSendTextMessageOverSSL() throws { 248 | let application = Application() 249 | defer { 250 | application.shutdown() 251 | } 252 | 253 | application.smtp.configuration = sslSmtpConfiguration 254 | var email = try! Email(from: EmailAddress(address: "smtp.mikroservice@gmail.com", name: "John Doe"), 255 | to: [EmailAddress(address: "smtp.mikroservice@outlook.com", name: "Ben Doe")], 256 | subject: "The subject (over SSL) - \(timestamp)", 257 | body: "This is email body.") 258 | 259 | email.addAttachment(Attachment(name: "plik1.txt", contentType: "text/plain", data: Attachments.text())) 260 | email.addAttachment(Attachment(name: "image.png", contentType: "image/png", data: Attachments.image())) 261 | 262 | let request = Request(application: application, on: application.eventLoopGroup.next()) 263 | try request.smtp.send(email) { message in 264 | print(message) 265 | }.flatMapThrowing { result in 266 | XCTAssertTrue(try result.get()) 267 | }.wait() 268 | } 269 | 270 | func testSendTextMessageOverTSL() throws { 271 | let application = Application() 272 | defer { 273 | application.shutdown() 274 | } 275 | 276 | application.smtp.configuration = tslSmtpConfiguration 277 | var email = try! Email(from: EmailAddress(address: "smtp.mikroservice@gmail.com", name: "John Doe"), 278 | to: [EmailAddress(address: "smtp.mikroservice@outlook.com", name: "Ben Doe")], 279 | subject: "The subject (over TSL) - \(timestamp)", 280 | body: "This is email body.") 281 | 282 | email.addAttachment(Attachment(name: "plik1.txt", contentType: "text/plain", data: Attachments.text())) 283 | email.addAttachment(Attachment(name: "image.png", contentType: "image/png", data: Attachments.image())) 284 | 285 | let request = Request(application: application, on: application.eventLoopGroup.next()) 286 | try request.smtp.send(email) { message in 287 | print(message) 288 | }.flatMapThrowing { result in 289 | XCTAssertTrue(try result.get()) 290 | }.wait() 291 | } 292 | 293 | func testSendBccTextMessage() throws { 294 | let application = Application() 295 | defer { 296 | application.shutdown() 297 | } 298 | 299 | application.smtp.configuration = smtpConfiguration 300 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 301 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 302 | cc: [EmailAddress(address: "july.doe@testxx.com", name: "July Doe"), EmailAddress(address: "viki.doe@testxx.com", name: "Viki Doe")], 303 | bcc:[EmailAddress(address: "hidden1@testxx.com", name: "Hidden One"), EmailAddress(address: "hidden2@testxx.com", name: "Hidden Two")], 304 | subject: "The subject (bcc) - \(timestamp)", 305 | body: "This is email body.") 306 | 307 | let request = Request(application: application, on: application.eventLoopGroup.next()) 308 | try request.smtp.send(email) { message in 309 | print(message) 310 | }.flatMapThrowing { result in 311 | XCTAssertTrue(try result.get()) 312 | }.wait() 313 | 314 | sleep(3) 315 | } 316 | 317 | func testSendInReplyToTextMessage() throws { 318 | let application = Application() 319 | defer { 320 | application.shutdown() 321 | } 322 | 323 | application.smtp.configuration = smtpConfiguration 324 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 325 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 326 | subject: "The subject (reference) - \(timestamp)", 327 | body: "This is email body.", 328 | reference: "<53455345@testxx.com>" 329 | ) 330 | 331 | let request = Request(application: application, on: application.eventLoopGroup.next()) 332 | try request.smtp.send(email) { message in 333 | print(message) 334 | }.flatMapThrowing { result in 335 | XCTAssertTrue(try result.get()) 336 | }.wait() 337 | 338 | sleep(3) 339 | } 340 | 341 | func testSendOnlyBccTextMessage() throws { 342 | let application = Application() 343 | defer { 344 | application.shutdown() 345 | } 346 | 347 | application.smtp.configuration = smtpConfiguration 348 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 349 | bcc:[EmailAddress(address: "hidden1@testxx.com", name: "Hidden One"), EmailAddress(address: "hidden2@testxx.com", name: "Hidden Two")], 350 | subject: "The subject (only bcc) - \(timestamp)", 351 | body: "This is email body.") 352 | 353 | let request = Request(application: application, on: application.eventLoopGroup.next()) 354 | try request.smtp.send(email) { message in 355 | print(message) 356 | }.flatMapThrowing { result in 357 | XCTAssertTrue(try result.get()) 358 | }.wait() 359 | 360 | sleep(3) 361 | } 362 | 363 | func testEmailWithoutRecipientsCannotBeInitialized() throws { 364 | XCTAssertThrowsError( 365 | try Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 366 | subject: "The subject (reference) - \(timestamp)", 367 | body: "This is email body.", 368 | reference: "<53455345@testxx.com>" 369 | ) 370 | ) { error in 371 | XCTAssertEqual(error as! EmailError, EmailError.recipientNotSpecified) 372 | } 373 | } 374 | 375 | #if compiler(>=5.5) && canImport(_Concurrency) 376 | 377 | @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) 378 | func testSendTextMessageWithAwaitFunction() async { 379 | let application = Application() 380 | defer { 381 | application.shutdown() 382 | } 383 | 384 | application.smtp.configuration = smtpConfiguration 385 | let email = try! Email(from: EmailAddress(address: "john.doe@testxx.com", name: "John Doe"), 386 | to: [EmailAddress(address: "ben.doe@testxx.com", name: "Ben Doe")], 387 | subject: "The subject (text) - \(timestamp)", 388 | body: "This is email body.") 389 | 390 | let request = Request(application: application, on: application.eventLoopGroup.next()) 391 | do { 392 | try await request.smtp.send(email) 393 | } 394 | catch { 395 | XCTFail("Error during send email") 396 | } 397 | 398 | sleep(3) 399 | } 400 | 401 | #endif 402 | } 403 | --------------------------------------------------------------------------------