-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathPerfectSMTP.swift
More file actions
376 lines (349 loc) · 10.8 KB
/
PerfectSMTP.swift
File metadata and controls
376 lines (349 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
//
// SMTP.swift
// Perfect-SMTP
//
// Created by Rockford Wei on 2016-12-28.
// Copyright © 2016 PerfectlySoft. All rights reserved.
//
//===----------------------------------------------------------------------===//
//
// This source file is part of the Perfect.org open source project
//
// Copyright (c) 2016 - 2017 PerfectlySoft Inc. and the Perfect project authors
// Licensed under Apache License v2.0
//
// See http://perfect.org/licensing.html for license information
//
//===----------------------------------------------------------------------===//
//
import Foundation
import PerfectCURL
import PerfectLib
import PerfectCrypto
import PerfectMIME
/// SMTP Common Errors
public enum SMTPError:Error {
/// void subject is not allowed
case INVALID_SUBJECT
/// void sender is not allowed
case INVALID_FROM
/// void recipient is not allowed
case INVALID_RECIPIENT
/// bad memory allocation
case INVALID_BUFFER
/// void mail body is not allowed
case INVALID_CONTENT
/// unacceptable protocol
case INVALID_PROTOCOL
/// base64 failed
case INVALID_ENCRYPTION
case general(Int, String)
}
/// SMTP login structure
public struct SMTPClient {
/// smtp://smtp.mail.server or smtps://smtp.mail.server
public var url = ""
/// login name: user@mail.server
public var username = ""
/// login secret
public var password = ""
/// upgrade connection to use TLS
public var requiresTLSUpgrade = false
/// constructor
/// - parameters:
/// - url: String, smtp://somewhere or smtps://someelsewhere
/// - username: String, user@somewhere
/// - password: String
public init(url: String = "", username: String = "", password: String = "", requiresTLSUpgrade: Bool = false) {
self.url = url
self.username = username
self.password = password
self.requiresTLSUpgrade = requiresTLSUpgrade
}
}
/// email receiver format, "Full Name" <nickname@some.where>
public struct Recipient {
/// Full Name
public var name = ""
/// email address, nickname@some.where
public var address = ""
/// constructor
/// - parameters:
/// - name: full name of the email receiver / recipient
/// - address: email address, i.e., nickname@some.where
public init(name: String = "", address: String = "") {
self.name = name
self.address = address
}
}
/// string extension for express conversion from recipient, etc.
extension String {
func base64Encoded() -> String? {
if let data = self.data(using: .utf8) {
return data.base64EncodedString()
}
return nil
}
/// get RFC 5322-compliant date for email
static var rfc5322Date: String {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
let compliantDate = dateFormatter.string(from: Date())
return compliantDate
}
/// convert a recipient to standard email format: "Full Name"<nickname@some.where>
/// - parameters:
/// - recipient: the email receiver name / address structure
init(recipient: Recipient) {
// full name can be ignored
if recipient.name.isEmpty {
self = recipient.address
} else {
if let recipientNameB64 = recipient.name.base64Encoded() {
self = "=?utf-8?B?\(recipientNameB64)?= <\(recipient.address)>"
} else {
self = "\"\(recipient.name)\" <\(recipient.address)>"
}
}
}
/// convert a group of recipients into an address list, joined by comma
/// - parameters:
/// - recipients: array of recipient
init(recipients: [Recipient]) {
self = recipients.map{String(recipient: $0)}.joined(separator: ", ")
}
/// MIME mail header: To/Cc/Bcc + recipients
/// - parameters:
/// - prefix: To / Cc or Bcc
/// - recipients: mailing list
init(prefix: String, recipients: [Recipient]) {
let r = String(recipients: recipients)
self = "\(prefix): \(r)\r\n"
}
/// get the address info from a recipient, i.e, someone@somewhere -> @somewhere
var emailSuffix: String {
get {
guard let at = index(of: "@") else {
return self
}
#if swift(>=4.0)
return String(self[at..<endIndex])
#else
return self[at..<endIndex]
#endif
}
}
/// extract file name from a full path
var fileNameWithoutPath: String {
get {
let segments = self.split(separator: "/")
return String(segments[segments.count - 1])
}
}
/// extract file suffix from a file name
var suffix: String {
get {
let segments = self.split(separator: ".")
return String(segments[segments.count - 1])
}
}
}
private struct EmailBodyGen: CURLRequestBodyGenerator {
let bytes: [UInt8]
var offset = 0
var contentLength: Int? { return bytes.count }
init(_ string: String) {
bytes = Array(string.utf8)
}
mutating func next(byteCount: Int) -> [UInt8]? {
let count = bytes.count
let remaining = count - offset
guard remaining > 0 else {
return nil
}
let ret = Array(bytes[offset..<(offset + min(byteCount, remaining))])
offset += ret.count
return ret
}
}
/// SMTP mail composer
public class EMail {
/// boundary for mark different part of the mail
let boundary = "perfect-smtp-boundary"
/// login info of a valid mail
public var client: SMTPClient
/// mail receivers
public var to: [Recipient] = []
/// mail receivers
public var cc: [Recipient] = []
/// mail receivers / will not be displayed in to / cc recipients
public var bcc: [Recipient] = []
/// mail sender info
public var from: Recipient = Recipient()
/// title of the email
public var subject: String = ""
/// attachements of the mail - file name with full path
public var attachments: [String] = []
/// email content body
public var content: String = ""
// text version, to be added with a html version.
public var text: String = ""
/// an alternative name of content
public var html: String {
get { return content }
set { content = newValue }
}
public var reference: String = ""
public var connectTimeoutSeconds: Int = 15
/// for debugging purposes
public var debug = false
var progress = 0
/// constructor
/// - parameters:
/// - client: SMTP client for login info
public init(client: SMTPClient) {
self.client = client
}
/// transform an attachment into an MIME part
/// - parameters:
/// - path: local full path
/// - mimeType: i.e., text/plain for txt, etc.
/// - returns
/// MIME encoded content with boundary
@discardableResult
private func attach(path: String, mimeType: String) -> String {
// extract file name from full path
let file = path.fileNameWithoutPath
guard !file.isEmpty else {
return ""
}
do {
// get base64 encoded text
guard let data = try encode(path: path) else {
return ""
}
let disposition = "attachment"
if self.debug {
print("\(data.utf8.count) bytes attached")
}
// pack it up to an MIME part
return "--\(boundary)\r\nContent-Type: \(mimeType); name=\"\(file)\"\r\n"
+ "Content-Transfer-Encoding: base64\r\n"
+ "Content-Disposition: \(disposition); filename=\"\(file)\"\r\n\r\n\(data)\r\n"
} catch {
return ""
}
}
/// encode a file by base64 method
/// - parameters:
/// - path: full path of the file to encode
/// - returns:
/// base64 encoded text WITH A TRAILING NEWLINE
@discardableResult
private func encode(path: String) throws -> String? {
return FileManager.default.contents(atPath: path)?
.base64EncodedString(options:
.init(arrayLiteral: [.endLineWithCarriageReturn, .endLineWithLineFeed, .lineLength76Characters]))
}
private func makeBody() throws -> (String, String) {
// !FIX! quoted printable?
var body = "Date: \(String.rfc5322Date)\r\n"
progress = 0
// add the "To: " section
if to.count > 0 {
body += String(prefix: "To", recipients: to)
}
// add the "From: " section
if from.address.isEmpty {
throw SMTPError.INVALID_FROM
} else {
let f = String(recipient: from)
body += "From: \(f)\r\n"
}
// add the "Cc: " section
if cc.count > 0 {
body += String(prefix: "Cc", recipients: cc)
}
// add the "Bcc: " section
if bcc.count > 0 {
body += String(prefix: "Bcc", recipients: bcc)
}
// add the uuid of the email to avoid duplicated shipment
let uuid = UUID().uuidString
body += "Message-ID: <\(uuid).Perfect-SMTP\(from.address.emailSuffix)>\r\n"
if reference != "" {
body += "In-Reply-To: \(reference)\r\n"
body += "References: \(reference)\r\n"
}
// add the email title
if subject.isEmpty {
throw SMTPError.INVALID_SUBJECT
} else {
if let subjectB64 = subject.base64Encoded() {
body += "Subject: =?utf-8?B?\(subjectB64)?=\r\n"
} else {
body += "Subject: =?utf-8?Q?\(subject)?=\r\n"
}
}
// mark the content type
body += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
// add the html / plain text content body
guard !(content.isEmpty && text.isEmpty) else {
throw SMTPError.INVALID_CONTENT
}
let alternative = !content.isEmpty && !text.isEmpty
if alternative {
let boundary2 = boundary + "-2"
body += "--\(boundary)\r\nContent-Type: multipart/alternative; boundary=\(boundary2)\r\n\r\n"
body += "--\(boundary2)\r\nContent-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n\(text)\r\n"
body += "--\(boundary2)\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n\(content)\r\n"
body += "--\(boundary2)--\r\n"
} else {
if !text.isEmpty {
body += "--\(boundary)\r\nContent-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n\(text)\r\n"
}
if !content.isEmpty {
body += "--\(boundary)\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n\(content)\r\n"
}
}
// add the attachements
body += attachments.map { attach(path: $0, mimeType: MIMEType.forExtension($0.suffix)) }.joined(separator: "")
// end of the attachements
body += "--\(boundary)--\r\n"
return (body, uuid)
}
private func getResponse(_ body : String) throws -> CURLResponse {
let recipients = to + cc + bcc
guard recipients.count > 0 else {
throw SMTPError.INVALID_RECIPIENT
}
var options: [CURLRequest.Option] = (debug ? [.verbose] : []) + [
.mailFrom(from.address),
.userPwd("\(client.username):\(client.password)"),
.upload(EmailBodyGen(body)),
.connectTimeout(connectTimeoutSeconds)]
options.append(contentsOf: recipients.map { .mailRcpt($0.address) })
if client.url.lowercased().hasPrefix("smtps") || client.requiresTLSUpgrade {
options.append(.useSSL)
}
let request = CURLRequest(client.url, options: options)
return try request.perform()
}
/// send an email with the current settings
/// - parameters:
/// - completion: once sent, callback to the main thread with curl code, header & body string
/// - throws:
/// SMTPErrors
public func send(completion: ((Int, String, String) -> ())? = nil) throws {
let (body, uuid) = try makeBody()
let response = try getResponse(body)
let code = response.responseCode
if let c = completion {
return c(code, uuid, response.bodyString)
}
guard code > 199 && code < 300 else {
throw SMTPError.general(code, response.bodyString)
}
}
}