diff --git a/Package.resolved b/Package.resolved index e6850c1..5239e81 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "0a9b72369b9d87ab155ef585ef50700a34abf070", - "version" : "1.23.1" + "revision" : "2119f0d9cc1b334e25447fe43d3693c0e60e6234", + "version" : "1.24.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/IndiePitcher/indiepitcher-swift.git", "state" : { - "revision" : "6ffaacb99800a448653d7537d6e57cf27c70525c", - "version" : "1.1.0" + "revision" : "5e0a02c8c29f1b2c6cc2fcbd65c215baca14f8d2", + "version" : "1.2.3" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt.git", "state" : { - "revision" : "8ce7280a77ca45711f1b08bc1abfac7a2bb9c97b", - "version" : "5.1.0" + "revision" : "ec5a9d489a73560732c7a27ce860fe799b1413be", + "version" : "5.1.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "02a0fa600eee1bdc892013d62fc795fc623a5cc3", - "version" : "5.1.0" + "revision" : "6f745e91e2422608fe14c9a66ee3826cb661e2a6", + "version" : "5.1.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/petrpavlik/MixpanelVapor.git", "state" : { - "revision" : "7f18c3a7b270391d2ea51ea87a56eef0d60134d2", - "version" : "1.0.0" + "revision" : "09de904adffe3044f4e9e397826fcb2d3c7802a7", + "version" : "1.1.2" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Nimble.git", "state" : { - "revision" : "6416749c3c0488664fff6b42f8bf3ea8dc282ca1", - "version" : "13.6.0" + "revision" : "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", + "version" : "13.7.1" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/postgres-nio.git", "state" : { - "revision" : "cd5318a01a1efcb1e0b3c82a0ce5c9fefaf1cb2d", - "version" : "1.22.1" + "revision" : "fd0e415a705c490499f983639b04f491a2ed9d99", + "version" : "1.23.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "5c8bd186f48c16af0775972700626f0b74588278", - "version" : "1.0.2" + "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", + "version" : "1.0.3" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "21f7878f2b39d46fd8ba2b06459ccb431cdf876c", - "version" : "3.8.1" + "revision" : "ff0f781cf7c6a22d52957e50b104f5768b50c779", + "version" : "3.10.0" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", - "version" : "1.3.0" + "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", + "version" : "1.3.1" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", - "version" : "1.6.1" + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" } }, { @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "914081701062b11e3bb9e21accc379822621995e", - "version" : "2.76.1" + "revision" : "dca6594f65308c761a9c409e09fbf35f48d50d34", + "version" : "2.77.0" } }, { @@ -285,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "d7ceaf0e4d8001cd35cdc12e42cdd281e9e564e8", - "version" : "2.28.0" + "revision" : "c7e95421334b1068490b5d41314a50e70bab23d1", + "version" : "2.29.0" } }, { @@ -321,8 +321,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "24c800fb494fbee6e42bc156dc94232dc08971af", - "version" : "2.6.1" + "revision" : "f70b838872863396a25694d8b19fe58bcd0b7903", + "version" : "2.6.2" } }, { @@ -348,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "4d3bc6ce08b72a14c9879810cf0be455ca98f1fb", - "version" : "4.106.1" + "revision" : "e1002f35edf92e2a579580f2d1df92e01287c6c7", + "version" : "4.108.0" } }, { diff --git a/README.md b/README.md index 1ffbf73..7cfcfd0 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ Every SAAS needs to handle user sign up, and if your service takes off, you'll s - When cloned, create `.env` file and fill in following info to be able to run the app against a local database. - ``` FIREBASE_PROJECT_ID=your-firebase-project-id + IP_SECRET_API_KEY=your-indiepitcher-api-key ``` - This is enough to run the project locally. When deploying to production, you'll want to add the database connection keys, as well as optionally your mixpanel and sentry credentials - You can copy the `FIREBASE_PROJECT_ID` from `.env.testing` to try things out, but please do create your own firebase project. + - `IP_SECRET_API_KEY` is for sending emails. You can create one for free by visiting https://indiepitcher.com or by replacing injecting `IndiePitcherEmailService` with `MockEmailService` to disable sending emails. - Set up your local dev environment, you need to spin up a database. An easy way is by downloading [Docker](https://www.docker.com) and typing in following commands - `docker-compose build` - `docker-compose up db` starts a local database to develop against diff --git a/Sources/App/Controllers/OrganizationController.swift b/Sources/App/Controllers/OrganizationController.swift index 4367360..5567405 100644 --- a/Sources/App/Controllers/OrganizationController.swift +++ b/Sources/App/Controllers/OrganizationController.swift @@ -307,23 +307,15 @@ struct OrganizationController: RouteCollection { await req.trackAnalyticsEvent(name: "organization_member_added", params: ["organization_id": organizationId.uuidString, "member_email": profileToAdd.email, "member_role": update.role.rawValue]) - if req.application.environment != .testing { - do { - - let emailBody = """ - Hi \(profileToAdd.name?.split(separator: " ").first ?? "there"), - - This is an automated message to let you know that you've been added to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). - """ - - try await req.indiePitcher.sendEmail(data: .init(to: update.email, - subject: "You've been ivited to \(organization.name)", - body: emailBody, - bodyFormat: .markdown)) - } catch { - req.logger.error("\(error)") - } - } + let emailBody = """ + Hi \(profileToAdd.name?.split(separator: " ").first ?? "there"), + + This is an automated message to let you know that you've been added to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). + """ + + try await req.services.emailService.sendEmail(to: update.email, + subject: "You've been ivited to \(organization.name)", + markdown: emailBody) return OrganizationMemberDTO(email: update.email, role: update.role, status: .joined) @@ -354,23 +346,15 @@ struct OrganizationController: RouteCollection { await req.trackAnalyticsEvent(name: "organization_member_invitation_created", params: ["organization_id": organizationId.uuidString, "member_email": update.email, "member_role": update.role.rawValue]) - if req.application.environment != .testing { - do { - - let emailBody = """ - Hi there, - - This is an automated message to let you know that you've been invited to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). - """ - - try await req.indiePitcher.sendEmail(data: .init(to: update.email, - subject: "You've been ivited to \(organization.name)", - body: emailBody, - bodyFormat: .markdown)) - } catch { - req.logger.error("\(error)") - } - } + let emailBody = """ + Hi there, + + This is an automated message to let you know that you've been invited to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). + """ + + try await req.services.emailService.sendEmail(to: update.email, + subject: "You've been ivited to \(organization.name)", + markdown: emailBody) return OrganizationMemberDTO(email: update.email, role: update.role, status: .invited) } diff --git a/Sources/App/Controllers/ProfileController.swift b/Sources/App/Controllers/ProfileController.swift index 563ee55..1b3fba6 100644 --- a/Sources/App/Controllers/ProfileController.swift +++ b/Sources/App/Controllers/ProfileController.swift @@ -1,8 +1,5 @@ import Fluent import Vapor -import IndiePitcherSwift - -typealias MailingListPortalSessionDTO = IndiePitcherSwift.MailingListPortalSession extension Request { var profile: Profile { @@ -189,19 +186,6 @@ struct ProfileController: RouteCollection { await req.trackAnalyticsEvent(name: "profile_deleted") return .noContent } - - @Sendable - func createPortalSession(req: Request) async throws -> MailingListPortalSessionDTO { - let profile = try await req.profile - - struct Payload: Content { - var returnURL: URL - } - - let payload = try req.content.decode(Payload.self) - - return try await req.indiePitcher.createMailingListsPortalSession(contactEmail: profile.email, returnURL: payload.returnURL).data - } } private func identifyProfile(profile: Profile, req: Request, isNewProfile: Bool, refreshMixpanelOnly: Bool) async throws { @@ -225,25 +209,18 @@ private func identifyProfile(profile: Profile, req: Request, isNewProfile: Bool, await req.mixpanel.peopleSet(distinctId: profileId.uuidString, request: req, setParams: properties) - if req.application.environment != .testing { - do { - - if refreshMixpanelOnly == false { - try await req.indiePitcher.addContact(contact: .init(email: profile.email, - userId: profileId.uuidString, - avatarUrl: profile.avatarUrl, - name: profile.name, - updateIfExists: true, - subscribedToLists: isNewProfile ? ["onboarding", "product_updates"] : nil)) - } - - if isNewProfile { - try await sendWelcomeOnboardingEmail(req: req, profile: profile) - } - - } catch { - req.logger.error("\(error)") + do { + + if refreshMixpanelOnly == false { + try await req.services.emailService.syncContact(profile: profile, subscribedToLists: isNewProfile ? ["onboarding", "product_updates"] : nil) } + + if isNewProfile { + await sendWelcomeOnboardingEmail(req: req, profile: profile) + } + + } catch { + req.logger.error("\(error)") } } @@ -252,27 +229,21 @@ private func unidentifyProfile(profile: Profile, req: Request) async throws { await req.mixpanel.peopleDelete(distinctId: profileId.uuidString) } -private func sendWelcomeOnboardingEmail(req: Request, profile: Profile) async throws { - if req.application.environment != .testing { - do { - - let body = """ - Hi {{firstName|default:"there"}}, +private func sendWelcomeOnboardingEmail(req: Request, profile: Profile) async { + do { + + let body = """ + Hi {{firstName|default:"there"}}, - Thanks for signing up for Welcome to SaaS Backend Template. + Thanks for signing up for Welcome to SaaS Backend Template. -
- All the best in your startup endeavours. - """ - - try await req.indiePitcher.sendEmailToContact(data: .init(contactEmail: profile.email, - subject: "Welcome to SaaS Backend Template!", - body: body, - bodyFormat: .markdown, - list: "onboarding", - delaySeconds: 60*5)) - } catch { - req.logger.error("\(error)") - } +
+ All the best in your startup endeavours. + """ + + try await req.services.emailService.sendPersonalizedEmail(to: profile.email, subject: "Welcome to SaaS Backend Template!", markdown: body, mailingList: "onboarding", delay: 60*5) + } catch { + // don't fail a request just because this has failed + req.logger.error("\(error)") } } diff --git a/Sources/App/Services/EmailService.swift b/Sources/App/Services/EmailService.swift new file mode 100644 index 0000000..be1d12e --- /dev/null +++ b/Sources/App/Services/EmailService.swift @@ -0,0 +1,78 @@ +import Vapor +import IndiePitcherSwift + +protocol EmailService { + func sendEmail(to: String, subject: String, markdown: String) async throws + func sendPersonalizedEmail(to: String, subject: String, markdown: String, mailingList: String, delay: TimeInterval) async throws + func syncContact(profile: Profile, subscribedToLists: Set?) async throws +} + +struct IndiePitcherEmailService: EmailService { + + private let indiePitcher: IndiePitcher + + init(application: Application) { + + guard let apiKey = Environment.get("IP_SECRET_API_KEY") else { + fatalError("IP_SECRET_API_KEY env key missing. Create one for free at https://indiepitcher.com or inject the mock version `MockEmailService` in `configure.swift` to run the project without crashing.") + } + + indiePitcher = .init(client: application.http.client.shared, + apiKey: apiKey) + } + + func sendEmail(to: String, subject: String, markdown: String) async throws { + try await indiePitcher.sendEmail(data: .init(to: to, + subject: subject, + body: markdown, + bodyFormat: .markdown)) + } + + func sendPersonalizedEmail(to: String, subject: String, markdown: String, mailingList: String, delay: TimeInterval) async throws { + try await indiePitcher.sendEmailToContact(data: .init(contactEmail: to, + subject: subject, + body: markdown, + bodyFormat: .markdown, + list: mailingList, + delaySeconds: delay)) + } + + func syncContact(profile: Profile, subscribedToLists: Set?) async throws { + try await indiePitcher.addContact(contact: .init(email: profile.email, + userId: try profile.requireID().uuidString, + avatarUrl: profile.avatarUrl, + name: profile.name, + updateIfExists: true, + subscribedToLists: subscribedToLists)) + } +} + +// TODO: Log what methods are being called with what params for confirmation +struct MockEmailService: EmailService { + + func sendEmail(to: String, subject: String, markdown: String) async throws { + + } + + func sendPersonalizedEmail(to: String, subject: String, markdown: String, mailingList: String, delay: TimeInterval) async throws { + + } + + func syncContact(profile: Profile, subscribedToLists: Set?) async throws { + + } + + +} + +extension Application.Services { + var emailService: Application.Service { + .init(application: self.application) + } +} + +extension Request.Services { + var emailService: EmailService { + request.application.services.emailService.service + } +} diff --git a/Sources/App/Utils/Application+IndiePitcher.swift b/Sources/App/Utils/Application+IndiePitcher.swift deleted file mode 100644 index 3c590fb..0000000 --- a/Sources/App/Utils/Application+IndiePitcher.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Vapor -import IndiePitcherSwift - -extension Request { - var indiePitcher: IndiePitcher { - guard let apiKey = Environment.get("IP_V2_SECRET_API_KEY") else { - fatalError("IP_V2_SECRET_API_KEY env key missing") - } - - return .init(client: application.http.client.shared, apiKey: apiKey) - } -} - -extension Application { - var indiePitcher: IndiePitcher { - guard let apiKey = Environment.get("IP_V2_SECRET_API_KEY") else { - fatalError("IP_V2_SECRET_API_KEY env key missing") - } - - return .init(client: http.client.shared, apiKey: apiKey) - } -} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 152bfda..974d5e1 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -47,6 +47,19 @@ public func configure(_ app: Application) async throws { app.logger.warning("Mixpanel disabled, env variables were not provided") } } + + if app.environment == .testing { + // inject mock services + app.services.emailService.use { app in + MockEmailService() + } + } else { + // inject real services + app.services.emailService.use { app in + IndiePitcherEmailService(application: app) // requires IP_SECRET_API_KEY env value + // MockEmailService() // disable emails + } + } app.migrations.add(CreateProfile()) app.migrations.add(CreateOrganization())