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())