From b3ee26f474aca1641f02eea5bcd6b939b38d1a83 Mon Sep 17 00:00:00 2001 From: Asiel Cabrera Date: Sat, 30 May 2026 14:01:21 -0400 Subject: [PATCH 1/2] fix: replace yourusername with devswiftzone in docs --- MIGRATION_GUIDE.md | 8 ++++---- README.md | 4 ++-- Sources/ResendKit/ResendKit.docc/ResendKit.md | 2 +- Sources/ResendVapor/ResendVapor.docc/ResendVapor.md | 2 +- VAPOR_GUIDE.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index a485500..159fc8f 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -17,7 +17,7 @@ Version 2.0 is a complete rewrite with: **Before (v1.x):** ```swift -.package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") +.package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") // In target dependencies .product(name: "Resend", package: "Resend") @@ -25,7 +25,7 @@ Version 2.0 is a complete rewrite with: **After (v2.0):** ```swift -.package(url: "https://github.com/yourusername/Resend.git", from: "2.0.0") +.package(url: "https://github.com/devswiftzone/Resend.git", from: "2.0.0") // For iOS/macOS apps .product(name: "Resend", package: "Resend") @@ -273,7 +273,7 @@ let cancelled = try await resend.email.cancel(id: "email_id") ```swift // Update version -.package(url: "https://github.com/yourusername/Resend.git", from: "2.0.0") +.package(url: "https://github.com/devswiftzone/Resend.git", from: "2.0.0") // For Vapor apps, change import .product(name: "ResendVapor", package: "Resend") // was "Resend" @@ -409,7 +409,7 @@ public func configure(_ app: Application) throws { If you encounter issues, you can rollback to v1.x: ```swift -.package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") +.package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") ``` However, note that v1.x is no longer maintained and has limited functionality. diff --git a/README.md b/README.md index 35e2793..02c7cbe 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Add the package to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") + .package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") ] ``` @@ -411,7 +411,7 @@ This project is licensed under the MIT License — see the LICENSE file for deta - [Resend Website](https://resend.com) - [Resend API Documentation](https://resend.com/docs) -- [GitHub Repository](https://github.com/yourusername/Resend) +- [GitHub Repository](https://github.com/devswiftzone/Resend) ## Acknowledgments diff --git a/Sources/ResendKit/ResendKit.docc/ResendKit.md b/Sources/ResendKit/ResendKit.docc/ResendKit.md index 22606e4..7548191 100644 --- a/Sources/ResendKit/ResendKit.docc/ResendKit.md +++ b/Sources/ResendKit/ResendKit.docc/ResendKit.md @@ -14,7 +14,7 @@ Add ResendKit to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") + .package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") ] ``` diff --git a/Sources/ResendVapor/ResendVapor.docc/ResendVapor.md b/Sources/ResendVapor/ResendVapor.docc/ResendVapor.md index 4bcc102..72b60f1 100644 --- a/Sources/ResendVapor/ResendVapor.docc/ResendVapor.md +++ b/Sources/ResendVapor/ResendVapor.docc/ResendVapor.md @@ -14,7 +14,7 @@ Add ResendVapor to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") + .package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") ] ``` diff --git a/VAPOR_GUIDE.md b/VAPOR_GUIDE.md index 51c340f..9ba7d75 100644 --- a/VAPOR_GUIDE.md +++ b/VAPOR_GUIDE.md @@ -9,7 +9,7 @@ Add ResendVapor to your Vapor project's `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/vapor/vapor.git", from: "4.66.1"), - .package(url: "https://github.com/yourusername/Resend.git", from: "1.0.0") + .package(url: "https://github.com/devswiftzone/Resend.git", from: "1.0.0") ], targets: [ .target( From bbdebcbd430d764fe911c165bb9e45108307c32a Mon Sep 17 00:00:00 2001 From: Asiel Cabrera Date: Sat, 30 May 2026 14:57:11 -0400 Subject: [PATCH 2/2] fix: align models with Resend API, add email list/audience update, fix webhook signature parser - EmailAttachment: removed disposition->path mapping, added optional path/type fields - ResendContact.email: changed to String? (create response omits email) - ResendRetrieveError.statusCode: use camelCase (matches API response) - WebhookSignature: support v1= (Svix), v1, (legacy), and mixed token formats - Add Sendable conformances to all protocols, HTTPRequest/Response, HTTPMethod - Add EmailClient.list() and listAll() with cursor pagination - Add AudienceClient.update(id:name:) for PATCH audiences - Add ResendVaporTests target to Package.swift - Add example project with env-var config and real UUID usage - Fix test data: use statusCode instead of status_code - Add client.webhooks access test --- Package.swift | 16 +- .../ResendCore/Models/EmailAttachment.swift | 43 ++- Sources/ResendCore/Models/ResendContact.swift | 4 +- .../Models/ResendRetrieveError.swift | 2 +- .../Protocols/HTTPClientProtocol.swift | 6 +- .../Protocols/ResendClientProtocol.swift | 28 +- .../ResendKit/Clients/AudienceClient.swift | 16 + Sources/ResendKit/Clients/EmailClient.swift | 24 ++ Sources/ResendKit/ResendClient.swift | 2 +- Sources/ResendKit/WebhookSignature.swift | 18 +- .../Clients/EmailClientTests.swift | 2 +- .../Clients/ResendClientTests.swift | 1 + .../Integration/IntegrationTests.swift | 2 +- Tests/ResendTests/Mocks/TestData.swift | 2 +- .../Models/ParameterizedModelTests.swift | 45 ++- .../ResendTests/Models/ResendEmailTests.swift | 3 +- .../ApplicationResendTests.swift | 70 ++++ example/Package.resolved | 267 ++++++++++++++ example/Package.swift | 20 ++ example/Sources/main.swift | 327 ++++++++++++++++++ 20 files changed, 835 insertions(+), 63 deletions(-) create mode 100644 Tests/ResendVaporTests/ApplicationResendTests.swift create mode 100644 example/Package.resolved create mode 100644 example/Package.swift create mode 100644 example/Sources/main.swift diff --git a/Package.swift b/Package.swift index 8f85d24..a426961 100644 --- a/Package.swift +++ b/Package.swift @@ -79,7 +79,21 @@ let package = Package( .product(name: "Crypto", package: "swift-crypto"), ], swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency") + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + + // Vapor integration tests (requires Vapor dependency) + .testTarget( + name: "ResendVaporTests", + dependencies: [ + "ResendVapor", + "ResendCore", + "ResendKit", + .product(name: "Vapor", package: "vapor"), + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), ] ), ] diff --git a/Sources/ResendCore/Models/EmailAttachment.swift b/Sources/ResendCore/Models/EmailAttachment.swift index 9fd9b9d..3f72117 100644 --- a/Sources/ResendCore/Models/EmailAttachment.swift +++ b/Sources/ResendCore/Models/EmailAttachment.swift @@ -9,43 +9,48 @@ import Foundation /// A file attachment for an email message. /// -/// Attachments are included as Base64-encoded content in the email payload. -/// The maximum total attachment size is 40MB. +/// Attachments can be provided as Base64-encoded content or as a URL path +/// for the Resend API to fetch. The maximum total attachment size is 40MB. /// -/// ## Example +/// ## Examples /// /// ```swift +/// // Base64-encoded content /// let attachment = EmailAttachment( /// content: "base64EncodedString", -/// filename: "document.pdf", -/// disposition: "attachment" +/// filename: "document.pdf" +/// ) +/// +/// // URL-based attachment +/// let attachment = EmailAttachment( +/// filename: "report.pdf", +/// path: "https://example.com/report.pdf" /// ) /// ``` public struct EmailAttachment: Codable, Sendable { /// The Base64 encoded content of the attachment - public var content: String + public var content: String? /// The filename of the attachment - public var filename: String + public var filename: String? - /// Content-disposition: "attachment" (download) or "inline" (display in email body) - public var disposition: String + /// URL for the Resend API to fetch the attachment from + public var path: String? + + /// MIME type of the attachment (e.g., "application/pdf") + public var type: String? public init( - content: String, - filename: String, - disposition: String = "attachment" + content: String? = nil, + filename: String? = nil, + path: String? = nil, + type: String? = nil ) { self.content = content self.filename = filename - self.disposition = disposition - } - - private enum CodingKeys: String, CodingKey { - case content - case filename - case disposition = "path" + self.path = path + self.type = type } } diff --git a/Sources/ResendCore/Models/ResendContact.swift b/Sources/ResendCore/Models/ResendContact.swift index 668746f..82b847b 100644 --- a/Sources/ResendCore/Models/ResendContact.swift +++ b/Sources/ResendCore/Models/ResendContact.swift @@ -16,7 +16,7 @@ public struct ResendContact: Codable, Sendable { public var id: String /// Email address of the contact - public var email: String + public var email: String? /// First name of the contact public var firstName: String? @@ -33,7 +33,7 @@ public struct ResendContact: Codable, Sendable { public init( object: String? = nil, id: String, - email: String, + email: String?, firstName: String? = nil, lastName: String? = nil, createdAt: String? = nil, diff --git a/Sources/ResendCore/Models/ResendRetrieveError.swift b/Sources/ResendCore/Models/ResendRetrieveError.swift index 019f309..e72e09e 100644 --- a/Sources/ResendCore/Models/ResendRetrieveError.swift +++ b/Sources/ResendCore/Models/ResendRetrieveError.swift @@ -28,7 +28,7 @@ public struct ResendRetrieveError: Codable, Sendable, Error { } private enum CodingKeys: String, CodingKey { - case statusCode = "status_code" + case statusCode case message case name } diff --git a/Sources/ResendCore/Protocols/HTTPClientProtocol.swift b/Sources/ResendCore/Protocols/HTTPClientProtocol.swift index 08a558a..3f622aa 100644 --- a/Sources/ResendCore/Protocols/HTTPClientProtocol.swift +++ b/Sources/ResendCore/Protocols/HTTPClientProtocol.swift @@ -19,7 +19,7 @@ public protocol HTTPClientProtocol: Sendable { } /// Represents an HTTP request to be sent to the Resend API. -public struct HTTPRequest { +public struct HTTPRequest: Sendable { /// The full URL for the request public let url: String @@ -46,7 +46,7 @@ public struct HTTPRequest { } /// Represents an HTTP response from the Resend API. -public struct HTTPResponse { +public struct HTTPResponse: Sendable { /// The HTTP status code public let statusCode: Int @@ -68,7 +68,7 @@ public struct HTTPResponse { } /// HTTP methods used by the Resend API. -public enum HTTPMethod: String { +public enum HTTPMethod: String, Sendable { case GET case POST case PATCH diff --git a/Sources/ResendCore/Protocols/ResendClientProtocol.swift b/Sources/ResendCore/Protocols/ResendClientProtocol.swift index e7e463a..7a07343 100644 --- a/Sources/ResendCore/Protocols/ResendClientProtocol.swift +++ b/Sources/ResendCore/Protocols/ResendClientProtocol.swift @@ -11,7 +11,7 @@ import Foundation /// /// Conforming types provide access to all Resend API operations through /// specialized sub-clients for each resource type. -public protocol ResendClientProtocol { +public protocol ResendClientProtocol: Sendable { /// Email operations (send, retrieve, schedule, cancel) var email: EmailClientProtocol { get } @@ -36,8 +36,8 @@ public protocol ResendClientProtocol { /// Protocol for email operations. /// -/// Provides methods for sending, retrieving, scheduling, and canceling emails. -public protocol EmailClientProtocol { +/// Provides methods for sending, retrieving, listing, scheduling, and canceling emails. +public protocol EmailClientProtocol: Sendable { /// Send an email func send(email: ResendEmail) async throws -> ResendEmailResponse /// Retrieve a sent email by ID @@ -48,12 +48,16 @@ public protocol EmailClientProtocol { func cancel(id: String) async throws -> ResendEmailResponse /// Send a batch of emails in a single API call func sendBatch(emails: [ResendEmail]) async throws -> ResendBatchResponse + /// List sent emails with cursor-based pagination + func list(limit: Int?, after: String?, before: String?) async throws -> ResendListResponse + /// List all sent emails using automatic cursor pagination + func listAll(limit: Int?) -> PaginatedSequence } /// Protocol for domain operations. /// /// Provides methods for creating, verifying, and managing sending domains. -public protocol DomainClientProtocol { +public protocol DomainClientProtocol: Sendable { /// Create a new domain for sending emails func create(name: String, region: String?, customReturnPath: String?) async throws -> ResendDomain /// Retrieve a domain by ID @@ -72,8 +76,8 @@ public protocol DomainClientProtocol { /// Protocol for API key operations. /// -/// Provides methods for creating, listing, and deleting API keys. -public protocol APIKeyClientProtocol { +/// Provides methods for creating, retrieving, listing, and deleting API keys. +public protocol APIKeyClientProtocol: Sendable { /// Create a new API key func create(name: String, permission: String?, domainId: String?) async throws -> ResendAPIKey /// List API keys with cursor-based pagination @@ -86,8 +90,8 @@ public protocol APIKeyClientProtocol { /// Protocol for audience operations. /// -/// Provides methods for creating and managing audience groups for broadcast campaigns. -public protocol AudienceClientProtocol { +/// Provides methods for creating, updating, and managing audience groups for broadcast campaigns. +public protocol AudienceClientProtocol: Sendable { /// Create a new audience func create(name: String) async throws -> ResendAudience /// Retrieve an audience by ID @@ -96,6 +100,8 @@ public protocol AudienceClientProtocol { func list(limit: Int?, after: String?, before: String?) async throws -> ResendListResponse /// List all audiences using automatic cursor pagination func listAll(limit: Int?) -> PaginatedSequence + /// Update an audience's name + func update(id: String, name: String) async throws -> ResendAudience /// Delete an audience func delete(id: String) async throws -> ResendDeleteResponse } @@ -103,7 +109,7 @@ public protocol AudienceClientProtocol { /// Protocol for contact operations. /// /// Provides methods for managing contacts within an audience. -public protocol ContactClientProtocol { +public protocol ContactClientProtocol: Sendable { /// Create a new contact in an audience func create(audienceId: String, email: String, firstName: String?, lastName: String?, unsubscribed: Bool?) async throws -> ResendContact /// Retrieve a contact by audience ID and contact identifier @@ -122,7 +128,7 @@ public protocol ContactClientProtocol { /// /// Provides methods for creating and managing webhook endpoints /// that receive event notifications from Resend. -public protocol WebhookClientProtocol { +public protocol WebhookClientProtocol: Sendable { /// Create a new webhook endpoint func create(endpoint: String, events: [String]) async throws -> ResendWebhook /// Retrieve a webhook by ID @@ -140,7 +146,7 @@ public protocol WebhookClientProtocol { /// Protocol for broadcast operations. /// /// Provides methods for creating and sending broadcast email campaigns to audiences. -public protocol BroadcastClientProtocol { +public protocol BroadcastClientProtocol: Sendable { /// Create a new broadcast campaign func create(audienceId: String, from: String, subject: String, replyTo: [String]?, html: String?, text: String?, name: String?) async throws -> ResendBroadcast /// Retrieve a broadcast by ID diff --git a/Sources/ResendKit/Clients/AudienceClient.swift b/Sources/ResendKit/Clients/AudienceClient.swift index 8bcd448..bac5292 100644 --- a/Sources/ResendKit/Clients/AudienceClient.swift +++ b/Sources/ResendKit/Clients/AudienceClient.swift @@ -12,6 +12,10 @@ private struct CreateAudienceRequest: Encodable { let name: String } +private struct UpdateAudienceRequest: Encodable { + let name: String +} + final class AudienceClient: AudienceClientProtocol { private let apiKey: String private let httpClient: HTTPClientProtocol @@ -69,6 +73,18 @@ final class AudienceClient: AudienceClientProtocol { return try await httpClient.executeAndDecode(request, decoder: ResendClient.decoder) } + public func update(id: String, name: String) async throws -> ResendAudience { + let body = try ResendClient.encoder.encode(UpdateAudienceRequest(name: name)) + let request = ResendClient.buildRequest( + apiKey: apiKey, + baseURL: baseURL, + method: .PATCH, + path: "audiences/\(id)", + body: body + ) + return try await httpClient.executeAndDecode(request, decoder: ResendClient.decoder) + } + public func delete(id: String) async throws -> ResendDeleteResponse { let request = ResendClient.buildRequest( apiKey: apiKey, diff --git a/Sources/ResendKit/Clients/EmailClient.swift b/Sources/ResendKit/Clients/EmailClient.swift index 020e775..07ff471 100644 --- a/Sources/ResendKit/Clients/EmailClient.swift +++ b/Sources/ResendKit/Clients/EmailClient.swift @@ -78,4 +78,28 @@ final class EmailClient: EmailClientProtocol { ) return try await httpClient.executeAndDecode(request, decoder: ResendClient.decoder) } + + public func list(limit: Int?, after: String?, before: String?) async throws -> ResendListResponse { + var query: [URLQueryItem] = [] + if let limit = limit { query.append(URLQueryItem(name: "limit", value: String(limit))) } + if let after = after { query.append(URLQueryItem(name: "after", value: after)) } + if let before = before { query.append(URLQueryItem(name: "before", value: before)) } + + let request = ResendClient.buildRequest( + apiKey: apiKey, + baseURL: baseURL, + method: .GET, + path: "emails", + query: query + ) + return try await httpClient.executeAndDecode(request, decoder: ResendClient.decoder) + } + + public func listAll(limit: Int?) -> PaginatedSequence { + PaginatedSequence { cursor in + let response = try await self.list(limit: limit, after: cursor, before: nil) + let nextCursor = response.data.last?.id + return (response.data, response.hasMore, nextCursor) + } + } } diff --git a/Sources/ResendKit/ResendClient.swift b/Sources/ResendKit/ResendClient.swift index 93702d1..dbd3b34 100644 --- a/Sources/ResendKit/ResendClient.swift +++ b/Sources/ResendKit/ResendClient.swift @@ -40,7 +40,7 @@ import ResendCore /// let domain = try await resend.domains.create(name: "example.com", region: nil, customReturnPath: nil) /// let domains = try await resend.domains.list(limit: 10, after: nil, before: nil) /// ``` -public final class ResendClient: ResendClientProtocol { +public final class ResendClient: ResendClientProtocol, Sendable { private let apiKey: String private let httpClient: HTTPClientProtocol diff --git a/Sources/ResendKit/WebhookSignature.swift b/Sources/ResendKit/WebhookSignature.swift index 2b3f032..c70adf2 100644 --- a/Sources/ResendKit/WebhookSignature.swift +++ b/Sources/ResendKit/WebhookSignature.swift @@ -86,10 +86,20 @@ public enum WebhookSignature { let expectedSignatures = signatureHeader .split(separator: " ") - .compactMap { piece -> String? in - let parts = piece.split(separator: ",", maxSplits: 1) - guard parts.count == 2, parts[0] == "v1" else { return nil } - return String(parts[1]) + .flatMap { piece -> [String] in + let token = String(piece) + if token.hasPrefix("v1=") { + return [String(token.dropFirst(3))] + } + if token.hasPrefix("v1,") { + return [String(token.dropFirst(3))] + } + return token.split(separator: ",").compactMap { part -> String? in + let s = String(part) + if s.hasPrefix("v1=") { return String(s.dropFirst(3)) } + if s.hasPrefix("v1,") { return String(s.dropFirst(3)) } + return nil + } } guard !expectedSignatures.isEmpty else { diff --git a/Tests/ResendTests/Clients/EmailClientTests.swift b/Tests/ResendTests/Clients/EmailClientTests.swift index 626ba5d..0a5a742 100644 --- a/Tests/ResendTests/Clients/EmailClientTests.swift +++ b/Tests/ResendTests/Clients/EmailClientTests.swift @@ -143,7 +143,7 @@ struct EmailClientTests { let mockHTTPClient = MockHTTPClient() let errorJSON = """ { - "status_code": 404, + "statusCode": 404, "message": "Email not found", "name": "not_found" } diff --git a/Tests/ResendTests/Clients/ResendClientTests.swift b/Tests/ResendTests/Clients/ResendClientTests.swift index 6e296d6..2edae70 100644 --- a/Tests/ResendTests/Clients/ResendClientTests.swift +++ b/Tests/ResendTests/Clients/ResendClientTests.swift @@ -24,6 +24,7 @@ struct ResendClientTests { _ = client.audiences _ = client.contacts _ = client.broadcasts + _ = client.webhooks } @Test("Client initialization with custom HTTP client") diff --git a/Tests/ResendTests/Integration/IntegrationTests.swift b/Tests/ResendTests/Integration/IntegrationTests.swift index 1b626ac..4846d88 100644 --- a/Tests/ResendTests/Integration/IntegrationTests.swift +++ b/Tests/ResendTests/Integration/IntegrationTests.swift @@ -98,7 +98,7 @@ struct IntegrationTests { let errorJSON = """ { - "status_code": 401, + "statusCode": 401, "message": "Invalid API key", "name": "unauthorized" } diff --git a/Tests/ResendTests/Mocks/TestData.swift b/Tests/ResendTests/Mocks/TestData.swift index b6c0e4e..d391de6 100644 --- a/Tests/ResendTests/Mocks/TestData.swift +++ b/Tests/ResendTests/Mocks/TestData.swift @@ -183,7 +183,7 @@ enum TestData { // MARK: - Error Test Data static let errorJSON = """ { - "status_code": 400, + "statusCode": 400, "message": "Invalid email address", "name": "validation_error" } diff --git a/Tests/ResendTests/Models/ParameterizedModelTests.swift b/Tests/ResendTests/Models/ParameterizedModelTests.swift index 95d1a08..bddd741 100644 --- a/Tests/ResendTests/Models/ParameterizedModelTests.swift +++ b/Tests/ResendTests/Models/ParameterizedModelTests.swift @@ -7,39 +7,44 @@ import Foundation @Suite("EmailAttachment Tests") struct EmailAttachmentTests { - @Test("Basic initialization") + @Test("Basic initialization with content") func testBasic() { let attachment = EmailAttachment(content: "base64", filename: "doc.pdf") #expect(attachment.content == "base64") #expect(attachment.filename == "doc.pdf") - #expect(attachment.disposition == "attachment") + #expect(attachment.path == nil) + #expect(attachment.type == nil) } - @Test("Custom disposition") - func testCustomDisposition() { - let attachment = EmailAttachment(content: "base64", filename: "img.png", disposition: "inline") - #expect(attachment.disposition == "inline") + @Test("URL path attachment") + func testURLPath() { + let attachment = EmailAttachment(filename: "report.pdf", path: "https://example.com/report.pdf") + #expect(attachment.content == nil) + #expect(attachment.path == "https://example.com/report.pdf") } - @Test("Encoding uses path key for disposition") + @Test("Full initialization with type") + func testFull() { + let attachment = EmailAttachment(content: "base64", filename: "doc.pdf", path: nil, type: "application/pdf") + #expect(attachment.type == "application/pdf") + } + + @Test("Encoding to JSON") func testEncoding() throws { - let attachment = EmailAttachment(content: "base64", filename: "doc.pdf", disposition: "attachment") + let attachment = EmailAttachment(content: "base64", filename: "doc.pdf") let data = try JSONEncoder().encode(attachment) let json = try JSONSerialization.jsonObject(with: data) as? [String: String] #expect(json?["content"] == "base64") #expect(json?["filename"] == "doc.pdf") - #expect(json?["path"] == "attachment") - #expect(json?["disposition"] == nil) } @Test("Decoding from JSON") func testDecoding() throws { - let json = #"{"content":"base64","filename":"doc.pdf","path":"attachment"}"# + let json = #"{"content":"base64","filename":"doc.pdf"}"# let data = json.data(using: .utf8)! let attachment = try JSONDecoder().decode(EmailAttachment.self, from: data) #expect(attachment.content == "base64") #expect(attachment.filename == "doc.pdf") - #expect(attachment.disposition == "attachment") } @Test("Empty content string") @@ -52,17 +57,25 @@ struct EmailAttachmentTests { func testLongFilename() { let long = String(repeating: "a", count: 500) let attachment = EmailAttachment(content: "base64", filename: long) - #expect(attachment.filename.count == 500) + #expect(attachment.filename?.count == 500) } @Test("Encode/decode roundtrip") func testRoundtrip() throws { - let original = EmailAttachment(content: "dGVzdA==", filename: "test.txt", disposition: "inline") + let original = EmailAttachment(content: "dGVzdA==", filename: "test.txt") let data = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode(EmailAttachment.self, from: data) #expect(decoded.content == original.content) #expect(decoded.filename == original.filename) - #expect(decoded.disposition == original.disposition) + } + + @Test("Encode/decode path-based roundtrip") + func testPathRoundtrip() throws { + let original = EmailAttachment(filename: "report.pdf", path: "https://example.com/r.pdf") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(EmailAttachment.self, from: data) + #expect(decoded.path == original.path) + #expect(decoded.filename == original.filename) } } @@ -526,7 +539,7 @@ struct ResendRetrieveErrorTests { @Test("Decoding with snake_case keys") func testDecoding() throws { let json = """ - {"status_code": 403, "message": "Forbidden", "name": "permission_error"} + {"statusCode": 403, "message": "Forbidden", "name": "permission_error"} """ let data = json.data(using: .utf8)! let error = try JSONDecoder().decode(ResendRetrieveError.self, from: data) diff --git a/Tests/ResendTests/Models/ResendEmailTests.swift b/Tests/ResendTests/Models/ResendEmailTests.swift index 0e6f9bd..7123849 100644 --- a/Tests/ResendTests/Models/ResendEmailTests.swift +++ b/Tests/ResendTests/Models/ResendEmailTests.swift @@ -37,8 +37,7 @@ struct ResendEmailTests { func testFullInitialization() { let attachment = EmailAttachment( content: "base64content", - filename: "test.pdf", - disposition: "attachment" + filename: "test.pdf" ) let tag = EmailTag(name: "category", value: "test") diff --git a/Tests/ResendVaporTests/ApplicationResendTests.swift b/Tests/ResendVaporTests/ApplicationResendTests.swift new file mode 100644 index 0000000..66dc618 --- /dev/null +++ b/Tests/ResendVaporTests/ApplicationResendTests.swift @@ -0,0 +1,70 @@ +import Testing +import Vapor +@testable import ResendVapor +@testable import ResendCore +@testable import ResendKit + +@Suite("Application+Resend Tests") +struct ApplicationResendTests { + + @Test("Initialize with API key stores client") + func testInitializeWithApiKey() throws { + let app = Application(.testing) + defer { app.shutdown() } + + app.resend.initialize(apiKey: "test_key") + + #expect(app.resend.client is ResendClient) + } + + @Test("Client is accessible after initialization") + func testClientAfterInitialization() throws { + let app = Application(.testing) + defer { app.shutdown() } + + app.resend.initialize(apiKey: "test_key") + + _ = app.resend.client.email + _ = app.resend.client.domains + _ = app.resend.client.apiKeys + _ = app.resend.client.audiences + _ = app.resend.client.contacts + _ = app.resend.client.broadcasts + _ = app.resend.client.webhooks + } + + @Test("Client is accessible via Request") + func testRequestAccess() throws { + let app = Application(.testing) + defer { app.shutdown() } + + app.resend.initialize(apiKey: "test_key") + + let request = Request( + application: app, + method: .GET, + url: URI(path: "/test"), + on: app.eventLoopGroup.next() + ) + + _ = request.resend + } + + @Test("VaporHTTPClient wraps Vapor Client") + func testVaporHTTPClientInitialization() throws { + let app = Application(.testing) + defer { app.shutdown() } + + let vaporClient = VaporHTTPClient(client: app.client) + _ = vaporClient + } + + @Test("VaporHTTPClient conforms to HTTPClientProtocol") + func testVaporHTTPClientConformance() throws { + let app = Application(.testing) + defer { app.shutdown() } + + let vaporClient: HTTPClientProtocol = VaporHTTPClient(client: app.client) + _ = vaporClient + } +} diff --git a/example/Package.resolved b/example/Package.resolved new file mode 100644 index 0000000..02fa78b --- /dev/null +++ b/example/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "a385d5359ad8a7cfec15a14b4875d08cc595bfde7648eb40e766f0b724e70a21", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version" : "1.13.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" + } + } + ], + "version" : 3 +} diff --git a/example/Package.swift b/example/Package.swift new file mode 100644 index 0000000..e97a4af --- /dev/null +++ b/example/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ResendExample", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(path: "../") + ], + targets: [ + .executableTarget( + name: "ResendExample", + dependencies: [ + .product(name: "Resend", package: "Resend") + ] + ) + ] +) diff --git a/example/Sources/main.swift b/example/Sources/main.swift new file mode 100644 index 0000000..bcb639c --- /dev/null +++ b/example/Sources/main.swift @@ -0,0 +1,327 @@ +import Foundation +import Logging +import Crypto +import Resend + +// Configuración desde variables de entorno +guard let apiKey = ProcessInfo.processInfo.environment["RESEND_API_KEY"], !apiKey.isEmpty else { + fatalError("RESEND_API_KEY environment variable is required") +} +let fromEmail = ProcessInfo.processInfo.environment["FROM_EMAIL"] ?? "hello@swiftzone.dev" +let toEmail = ProcessInfo.processInfo.environment["TO_EMAIL"] ?? "cabrerasiel@gmail.com" +let domainName = ProcessInfo.processInfo.environment["DOMAIN"] ?? "swiftzone.dev" + +let resend = ResendClient( + apiKey: apiKey, + retry: RetryConfiguration(maxRetries: 3), + logger: Logger(label: "com.resend.example") +) + +// MARK: - 1. Send a Simple Email +print("\n─── 1. Send Email ───") +var lastEmailId: String? +do { + let email = ResendEmail( + from: fromEmail, + to: [toEmail], + subject: "Hello from Resend Swift SDK", + html: "Welcome!", + text: "Welcome to Resend!" + ) + let r = try await resend.email.send(email: email) + lastEmailId = r.id + print(" Sent: \(r.id)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 2. Send with Attachment +print("\n─── 2. Send with Attachment ───") +do { + let attachment = EmailAttachment( + content: Data("Hello, world!".utf8).base64EncodedString(), + filename: "hello.txt" + ) + let email = ResendEmail( + from: fromEmail, + to: [toEmail], + subject: "With Attachment", + html: "

See attached file.

", + attachments: [attachment] + ) + let r = try await resend.email.send(email: email) + print(" Sent with attachment: \(r.id)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 3. Send with Tags and Headers +print("\n─── 3. Send with Tags and Headers ───") +do { + let email = ResendEmail( + from: fromEmail, + to: [toEmail], + subject: "Tracked Email", + html: "

Track this email.

", + headers: ["X-Custom": "value"], + tags: [ + EmailTag(name: "category", value: "welcome"), + EmailTag(name: "user_id", value: "42") + ] + ) + let r = try await resend.email.send(email: email) + print(" Sent with tags: \(r.id)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 4. Retrieve an Email +print("\n─── 4. Retrieve Email ───") +if let id = lastEmailId { + do { + let email = try await resend.email.retrieve(id: id) + print(" Subject: \(email.subject), From: \(email.from)") + } catch { + print(" Error: \(error)") + } +} else { + print(" Skip: no sent email ID available") +} + +// MARK: - 5. List Emails +print("\n─── 5. List Emails ───") +do { + let list = try await resend.email.list(limit: 10, after: nil, before: nil) + print(" Page items: \(list.data.count), hasMore: \(list.hasMore)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 6. Auto Pagination +print("\n─── 6. Auto Pagination ───") +do { + for try await email in resend.email.listAll(limit: 5) { + print(" - \(email.id ?? "?"): \(email.subject)") + } +} catch { + print(" Error: \(error)") +} + +// MARK: - 7. Batch Send +print("\n─── 7. Batch Send ───") +do { + let emails = [ + ResendEmail(from: fromEmail, to: [toEmail], subject: "Hi A", html: "

Hi

"), + ResendEmail(from: fromEmail, to: [toEmail], subject: "Hi B", html: "

Hi

") + ] + let r = try await resend.email.sendBatch(emails: emails) + print(" Sent \(r.data.count) emails") + if let errors = r.errors { + for e in errors { print(" Error at \(e.index): \(e.message)") } + } +} catch { + print(" Error: \(error)") +} + +// MARK: - 8. Update Scheduled Email +print("\n─── 8. Update Scheduled Email ───") +if let id = lastEmailId { + do { + let r = try await resend.email.update(id: id, scheduledAt: "2026-06-01T10:00:00Z") + print(" Updated: \(r.id)") + } catch { + print(" Error: \(error)") + } +} else { + print(" Skip: no sent email ID available") +} + +// MARK: - 9. Cancel Scheduled Email +print("\n─── 9. Cancel Scheduled Email ───") +if let id = lastEmailId { + do { + let r = try await resend.email.cancel(id: id) + print(" Canceled: \(r.id)") + } catch { + print(" Error: \(error)") + } +} else { + print(" Skip: no sent email ID available") +} + +// MARK: - 10. Domain Management +print("\n─── 10. Domain Management ───") +do { + // Create domain (needs DNS verification in dashboard) + let d = try await resend.domains.create(name: domainName, region: "us-east-1", customReturnPath: nil) + print(" Created: \(d.id)") + + let g = try await resend.domains.get(id: d.id) + print(" Status: \(g.status ?? "unknown")") + + let v = try await resend.domains.verify(id: d.id) + print(" Verified: \(v.status ?? "unknown")") + + if let records = d.records { + print(" DNS records to configure:") + for record in records { + print(" \(record.record): \(record.name) \(record.type) → \(record.value)") + } + } + + _ = try await resend.domains.update(id: d.id, clickTracking: true, openTracking: true, tls: "enforce") + print(" Tracking updated") + + let del = try await resend.domains.delete(id: d.id) + print(" Deleted: \(del.deleted)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 11. List All Domains +print("\n─── 11. List All Domains ───") +do { + for try await domain in resend.domains.listAll(limit: 10) { + print(" \(domain.name) [\(domain.status ?? "?")]") + } +} catch { + print(" Error: \(error)") +} + +// MARK: - 12. API Key Management +print("\n─── 12. API Key Management ───") +do { + let k = try await resend.apiKeys.create(name: "Example Key", permission: "full_access", domainId: nil) + print(" Created: \(k.id), Token: \(k.token)") + + let del = try await resend.apiKeys.delete(id: k.id) + print(" Deleted: \(del.deleted)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 13. Audience + Contact Management +print("\n─── 13. Audience + Contact Management ───") +do { + let audience = try await resend.audiences.create(name: "Newsletter") + print(" Audience: \(audience.id)") + + let contact = try await resend.contacts.create( + audienceId: audience.id, + email: toEmail, + firstName: "User", + lastName: "Test", + unsubscribed: false + ) + print(" Contact: \(contact.id)") + + let updated = try await resend.contacts.update( + audienceId: audience.id, + identifier: toEmail, + firstName: "Updated", + lastName: nil, + unsubscribed: nil + ) + print(" Updated: \(updated.firstName ?? "")") + + let renamed = try await resend.audiences.update(id: audience.id, name: "Premium Newsletter") + print(" Renamed: \(renamed.name)") + + let delContact = try await resend.contacts.delete(audienceId: audience.id, identifier: toEmail) + print(" Contact deleted: \(delContact.deleted)") + + let delAudience = try await resend.audiences.delete(id: audience.id) + print(" Audience deleted: \(delAudience.deleted)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 14. Broadcast Campaign +print("\n─── 14. Broadcast Campaign ───") +do { + let audience = try await resend.audiences.create(name: "Campaign") + let broadcast = try await resend.broadcasts.create( + audienceId: audience.id, + from: fromEmail, + subject: "Monthly Newsletter", + replyTo: [fromEmail], + html: "

Newsletter

", + text: "Newsletter text", + name: "March Newsletter" + ) + print(" Broadcast: \(broadcast.id)") + + let updated = try await resend.broadcasts.update( + id: broadcast.id, + audienceId: nil, from: nil, + subject: "Updated Newsletter", + replyTo: nil, html: nil, text: nil, name: nil + ) + print(" Updated subject: \(updated.subject ?? "?")") + + let sent = try await resend.broadcasts.send(id: broadcast.id, scheduledAt: nil) + print(" Sent: \(sent.id)") + + let del = try await resend.broadcasts.delete(id: broadcast.id) + print(" Deleted: \(del.deleted)") + + _ = try await resend.audiences.delete(id: audience.id) +} catch { + print(" Error: \(error)") +} + +// MARK: - 15. Webhook Management +print("\n─── 15. Webhook Management ───") +do { + let wh = try await resend.webhooks.create( + endpoint: "https://myapp.com/webhooks/resend", + events: ["email.sent", "email.delivered", "email.bounced"] + ) + print(" Created: \(wh.id)") + + let g = try await resend.webhooks.get(id: wh.id) + print(" Endpoint: \(g.endpoint ?? "")") + + let u = try await resend.webhooks.update(id: wh.id, endpoint: nil, events: ["email.sent"], disabled: false) + print(" Events: \(u.events ?? [])") + + let del = try await resend.webhooks.delete(id: wh.id) + print(" Deleted: \(del.deleted)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 16. Webhook Signature Verification +print("\n─── 16. Webhook Signature Verification ───") +do { + let rawKey = "test-secret-key-12345" + let base64Secret = "whsec_" + Data(rawKey.utf8).base64EncodedString() + let whId = "wh_123" + let ts = String(Int(Date().timeIntervalSince1970)) + let payload = #"{"type":"email.sent","data":{"id":"email_123"}}"# + + let signedContent = "\(whId).\(ts).\(payload)" + let key = SymmetricKey(data: Data(rawKey.utf8)) + let code = HMAC.authenticationCode(for: Data(signedContent.utf8), using: key) + let base64sig = Data(code).base64EncodedString() + let header = "v1,\(base64sig)" + + let valid = try WebhookSignature.verify( + payload: payload, + id: whId, + timestamp: ts, + signatureHeader: header, + secret: base64Secret + ) + print(" Signature valid: \(valid)") +} catch { + print(" Error: \(error)") +} + +// MARK: - 17. Custom Configuration +print("\n─── 17. Custom Configuration ───") +let custom = RetryConfiguration(maxRetries: 5, baseDelay: 0.5, maxDelay: 30, enableJitter: true) +let euClient = ResendClient(apiKey: apiKey, retry: custom, baseURL: "https://api.eu.resend.com") +_ = euClient + +print("\n─── Example Complete ───")