Enable easier email dev setup (#1634)

This commit is contained in:
Mihovil Ilakovac 2024-01-16 16:19:35 +01:00 committed by GitHub
parent 8e0cd05e65
commit e3c825fae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 213 additions and 102 deletions

View File

@ -8,3 +8,6 @@ export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid
{=# isMailgunProviderUsed =}
export { initMailgunEmailSender as initEmailSender } from "./providers/mailgun.js";
{=/ isMailgunProviderUsed =}
{=# isDummyProviderUsed =}
export { initDummyEmailSender as initEmailSender } from "./providers/dummy.js";
{=/ isDummyProviderUsed =}

View File

@ -1,21 +1,28 @@
import { EmailSender } from "../types.js";
import { DummyEmailProvider, EmailSender } from "../types.js";
import { getDefaultFromField } from "../helpers.js";
export function initDummyEmailSender(): EmailSender {
const yellowColor = "\x1b[33m%s\x1b[0m";
export function initDummyEmailSender(
config?: DummyEmailProvider,
): EmailSender {
const defaultFromField = getDefaultFromField();
return {
send: async (email) => {
const fromField = email.from || defaultFromField;
console.log('Test email (not sent):', {
from: {
email: fromField.email,
name: fromField.name,
},
to: email.to,
subject: email.subject,
text: email.text,
html: email.html,
});
console.log(yellowColor, '╔═══════════════════════╗');
console.log(yellowColor, '║ Dummy email sender ✉️ ║');
console.log(yellowColor, '╚═══════════════════════╝');
console.log(`From: ${fromField.name} <${fromField.email}>`);
console.log(`To: ${email.to}`);
console.log(`Subject: ${email.subject}`);
console.log(yellowColor, '═════════ Text ═════════');
console.log(email.text);
console.log(yellowColor, '═════════ HTML ═════════');
console.log(email.html);
console.log(yellowColor, '════════════════════════');
return {
success: true,
};

View File

@ -1,5 +1,5 @@
{{={= =}=}}
export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider;
export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider | DummyEmailProvider;
export type SMTPEmailProvider = {
type: "smtp";
@ -20,6 +20,10 @@ export type MailgunEmailProvider = {
domain: string;
};
export type DummyEmailProvider = {
type: "dummy";
}
export type EmailSender = {
send: (email: Email) => Promise<SentMessageInfo>;
};

View File

@ -1,9 +1,6 @@
{{={= =}=}}
import { initEmailSender } from "./core/index.js";
import waspServerConfig from '../config.js';
import { initDummyEmailSender } from "./core/providers/dummy.js";
{=# isSmtpProviderUsed =}
const emailProvider = {
type: "smtp",
@ -26,10 +23,10 @@ const emailProvider = {
domain: process.env.MAILGUN_DOMAIN,
} as const;
{=/ isMailgunProviderUsed =}
{=# isDummyProviderUsed =}
const emailProvider = {
type: "dummy",
} as const;
{=/ isDummyProviderUsed =}
const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true";
const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment;
export const emailSender = isDummyEmailSenderUsed
? initDummyEmailSender()
: initEmailSender(emailProvider);
export const emailSender = initEmailSender(emailProvider);

View File

@ -43,7 +43,6 @@ waspComplexTest/.wasp/out/server/src/dbClient.ts
waspComplexTest/.wasp/out/server/src/dbSeed/types.ts
waspComplexTest/.wasp/out/server/src/email/core/helpers.ts
waspComplexTest/.wasp/out/server/src/email/core/index.ts
waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts
waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts
waspComplexTest/.wasp/out/server/src/email/core/types.ts
waspComplexTest/.wasp/out/server/src/email/index.ts

View File

@ -286,13 +286,6 @@
],
"d524dd9ef27cd311340060411276df0e8ef22db503473f44281832338b954bb7"
],
[
[
"file",
"server/src/email/core/providers/dummy.ts"
],
"e93a7a02f50c8466f3e8e89255b98bebde598b25f9969ec117b16f07691575ae"
],
[
[
"file",
@ -305,14 +298,14 @@
"file",
"server/src/email/core/types.ts"
],
"c343f0d87b65d7563816159a88f410b65d78d897822c0bbcd723ca7752e00a20"
"0d7c19707f4e7c498a458015b1065b3f84c31e53ba73807707a05b7293473eb2"
],
[
[
"file",
"server/src/email/index.ts"
],
"c4864d5c83b96a61b1ddfaac7b52c0898f5cff04320c166c8f658b017952ee05"
"4443efa3da16d8d950bde84acd2ed6d9bc3a5d211ce3138248e3b5bad5079978"
],
[
[

View File

@ -1,24 +0,0 @@
import { EmailSender } from "../types.js";
import { getDefaultFromField } from "../helpers.js";
export function initDummyEmailSender(): EmailSender {
const defaultFromField = getDefaultFromField();
return {
send: async (email) => {
const fromField = email.from || defaultFromField;
console.log('Test email (not sent):', {
from: {
email: fromField.email,
name: fromField.name,
},
to: email.to,
subject: email.subject,
text: email.text,
html: email.html,
});
return {
success: true,
};
}
}
}

View File

@ -1,4 +1,4 @@
export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider;
export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider | DummyEmailProvider;
export type SMTPEmailProvider = {
type: "smtp";
@ -19,6 +19,10 @@ export type MailgunEmailProvider = {
domain: string;
};
export type DummyEmailProvider = {
type: "dummy";
}
export type EmailSender = {
send: (email: Email) => Promise<SentMessageInfo>;
};

View File

@ -1,16 +1,8 @@
import { initEmailSender } from "./core/index.js";
import waspServerConfig from '../config.js';
import { initDummyEmailSender } from "./core/providers/dummy.js";
const emailProvider = {
type: "sendgrid",
apiKey: process.env.SENDGRID_API_KEY,
} as const;
const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true";
const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment;
export const emailSender = isDummyEmailSenderUsed
? initDummyEmailSender()
: initEmailSender(emailProvider);
export const emailSender = initEmailSender(emailProvider);

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -15,7 +15,7 @@ data EmailSender = EmailSender
}
deriving (Show, Eq, Data)
data EmailProvider = SMTP | SendGrid | Mailgun
data EmailProvider = SMTP | SendGrid | Mailgun | Dummy
deriving (Eq, Data, Show)
data EmailFromField = EmailFromField

View File

@ -28,6 +28,7 @@ import qualified Wasp.AppSpec.App as App
import qualified Wasp.AppSpec.App.Auth as Auth
import qualified Wasp.AppSpec.App.Client as Client
import qualified Wasp.AppSpec.App.Db as AS.Db
import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender
import qualified Wasp.AppSpec.App.Wasp as Wasp
import Wasp.AppSpec.Core.Decl (takeDecls)
import qualified Wasp.AppSpec.Crud as AS.Crud
@ -70,6 +71,7 @@ validateAppSpec spec =
validateUserEntity spec,
validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec,
validateEmailSenderIsDefinedIfEmailAuthIsUsed spec,
validateDummyEmailSenderIsNotUsedInProduction spec,
validateDbIsPostgresIfPgBossUsed spec,
validateApiRoutesAreUnique spec,
validateApiNamespacePathsAreUnique spec,
@ -177,14 +179,21 @@ validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError]
validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of
Nothing -> []
Just auth ->
if not $ Auth.isEmailAuthEnabled auth
then []
else case App.emailSender app of
Nothing -> [GenericValidationError "app.emailSender must be specified when using email auth."]
Just _ -> []
if Auth.isEmailAuthEnabled auth && isNothing (App.emailSender app)
then [GenericValidationError "app.emailSender must be specified when using email auth. You can use the Dummy email sender for development purposes."]
else []
where
app = snd $ getApp spec
validateDummyEmailSenderIsNotUsedInProduction :: AppSpec -> [ValidationError]
validateDummyEmailSenderIsNotUsedInProduction spec =
if AS.isBuild spec && isDummyEmailSenderUsed
then [GenericValidationError "app.emailSender must not be set to Dummy when building for production."]
else []
where
isDummyEmailSenderUsed = (AS.EmailSender.provider <$> App.emailSender app) == Just AS.EmailSender.Dummy
app = snd $ getApp spec
validateApiRoutesAreUnique :: AppSpec -> [ValidationError]
validateApiRoutesAreUnique spec =
if null groupsOfConflictingRoutes

View File

@ -2,6 +2,7 @@ module Wasp.Generator.ServerGenerator.EmailSender.Providers
( smtp,
sendGrid,
mailgun,
dummy,
providersDirInServerSrc,
EmailSenderProvider (..),
)
@ -13,7 +14,7 @@ import qualified Wasp.Generator.ServerGenerator.Common as C
import qualified Wasp.SemanticVersion as SV
data EmailSenderProvider = EmailSenderProvider
{ npmDependency :: AS.Dependency.Dependency,
{ npmDependency :: Maybe AS.Dependency.Dependency,
setupFnFile :: Path' (Rel ProvidersDir) File',
-- We have to use explicit boolean keys in templates (e.g. "isSMTPProviderEnabled") so each
-- provider provides its own key which we pass to the template.
@ -26,7 +27,7 @@ data ProvidersDir
smtp :: EmailSenderProvider
smtp =
EmailSenderProvider
{ npmDependency = nodeMailerDependency,
{ npmDependency = Just nodeMailerDependency,
setupFnFile = [relfile|smtp.ts|],
isEnabledKey = "isSmtpProviderUsed"
}
@ -40,7 +41,7 @@ smtp =
sendGrid :: EmailSenderProvider
sendGrid =
EmailSenderProvider
{ npmDependency = sendGridDependency,
{ npmDependency = Just sendGridDependency,
setupFnFile = [relfile|sendgrid.ts|],
isEnabledKey = "isSendGridProviderUsed"
}
@ -54,7 +55,7 @@ sendGrid =
mailgun :: EmailSenderProvider
mailgun =
EmailSenderProvider
{ npmDependency = mailgunDependency,
{ npmDependency = Just mailgunDependency,
setupFnFile = [relfile|mailgun.ts|],
isEnabledKey = "isMailgunProviderUsed"
}
@ -65,5 +66,13 @@ mailgun =
mailgunDependency :: AS.Dependency.Dependency
mailgunDependency = AS.Dependency.make ("ts-mailgun", show mailgunVersionRange)
dummy :: EmailSenderProvider
dummy =
EmailSenderProvider
{ npmDependency = Nothing,
setupFnFile = [relfile|dummy.ts|],
isEnabledKey = "isDummyProviderUsed"
}
providersDirInServerSrc :: Path' (Rel C.ServerTemplatesSrcDir) (Dir ProvidersDir)
providersDirInServerSrc = [reldir|email/core/providers|]

View File

@ -39,8 +39,7 @@ genCore email =
sequence
[ genCoreIndex email,
genCoreTypes email,
genCoreHelpers email,
genFileCopy [relfile|email/core/providers/dummy.ts|]
genCoreHelpers email
]
<++> genEmailSenderProviderSetupFn email
@ -94,7 +93,7 @@ depsRequiredByEmail spec = maybeToList maybeNpmDepedency
where
maybeProvider :: Maybe Providers.EmailSenderProvider
maybeProvider = getEmailSenderProvider <$> (AS.App.emailSender . snd . getApp $ spec)
maybeNpmDepedency = Providers.npmDependency <$> maybeProvider
maybeNpmDepedency = maybeProvider >>= Providers.npmDependency
getEmailProvidersJson :: EmailSender -> Aeson.Value
getEmailProvidersJson email =
@ -109,6 +108,7 @@ getEmailSenderProvider email = case AS.EmailSender.provider email of
AS.EmailSender.SMTP -> Providers.smtp
AS.EmailSender.SendGrid -> Providers.sendGrid
AS.EmailSender.Mailgun -> Providers.mailgun
AS.EmailSender.Dummy -> Providers.dummy
genFileCopy :: Path' (Rel C.ServerTemplatesSrcDir) File' -> Generator FileDraft
genFileCopy = return . C.mkSrcTmplFd

View File

@ -220,6 +220,78 @@ spec_AppSpecValid = do
`shouldBe` [ ASV.GenericValidationError
"Entity 'User' (referenced by app.auth.userEntity) must have an ID field (specified with the '@id' attribute)"
]
describe "should validate email sender setup." $ do
let emailAuthConfig =
AS.Auth.EmailAuthConfig
{ AS.Auth.fromField =
AS.EmailSender.EmailFromField
{ AS.EmailSender.email = "dummy@info.com",
AS.EmailSender.name = Nothing
},
AS.Auth.emailVerification =
AS.Auth.EmailVerification.EmailVerificationConfig
{ AS.Auth.EmailVerification.clientRoute = AS.Core.Ref.Ref basicRouteName,
AS.Auth.EmailVerification.getEmailContentFn = Nothing
},
AS.Auth.passwordReset =
AS.Auth.PasswordReset.PasswordResetConfig
{ AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName,
AS.Auth.PasswordReset.getEmailContentFn = Nothing
},
AS.Auth.allowUnverifiedLogin = Nothing
}
let makeSpec emailSender isBuild =
basicAppSpec
{ AS.isBuild = isBuild,
AS.decls =
[ AS.Decl.makeDecl "TestApp" $
basicApp
{ AS.App.auth =
Just
AS.Auth.Auth
{ AS.Auth.methods =
AS.Auth.AuthMethods {email = Just emailAuthConfig, usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing},
AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName,
AS.Auth.externalAuthEntity = Nothing,
AS.Auth.onAuthFailedRedirectTo = "/",
AS.Auth.onAuthSucceededRedirectTo = Nothing,
AS.Auth.signup = Nothing
},
AS.App.emailSender = emailSender
},
AS.Decl.makeDecl userEntityName $
AS.Entity.makeEntity
( PslM.Body
[ PslM.ElementField $ makeIdField "id" PslM.String
]
),
basicPageDecl,
basicRouteDecl
]
}
let mailgunEmailSender =
AS.EmailSender.EmailSender
{ AS.EmailSender.provider = AS.EmailSender.Mailgun,
AS.EmailSender.defaultFrom = Nothing
}
let dummyEmailSender =
AS.EmailSender.EmailSender
{ AS.EmailSender.provider = AS.EmailSender.Dummy,
AS.EmailSender.defaultFrom = Nothing
}
it "returns an error if no email sender is set but email auth is used" $ do
ASV.validateAppSpec (makeSpec Nothing False) `shouldBe` [ASV.GenericValidationError "app.emailSender must be specified when using email auth. You can use the Dummy email sender for development purposes."]
it "returns no error if email sender is defined while using email auth" $ do
ASV.validateAppSpec (makeSpec (Just mailgunEmailSender) False) `shouldBe` []
it "returns no error if the Dummy email sender is used in development" $ do
ASV.validateAppSpec (makeSpec (Just dummyEmailSender) False) `shouldBe` []
it "returns an error if the Dummy email sender is used when building the app" $ do
ASV.validateAppSpec (makeSpec (Just dummyEmailSender) True)
`shouldBe` [ASV.GenericValidationError "app.emailSender must not be set to Dummy when building for production."]
where
makeIdField name typ =
PslM.Field

View File

@ -0,0 +1,4 @@
:::note Dummy Provider is not for production use
The `Dummy` provider is not for production use. It is only meant to be used during development. If you try building your app with the `Dummy` provider, the build will fail.
:::

View File

@ -2,14 +2,13 @@
title: Sending Emails
---
import SendingEmailsInDevelopment from '../\_sendingEmailsInDevelopment.md'
import { Required } from '@site/src/components/Tag'
import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'
import DummyProviderNote from './_dummy-provider-note.md'
# Sending Emails
With Wasp's email sending feature, you can easily integrate email functionality into your web application.
With Wasp's email-sending feature, you can easily integrate email functionality into your web application.
<Tabs groupId="js-ts">
<TabItem value="js" label="JavaScript">
@ -48,6 +47,7 @@ app Example {
Choose from one of the providers:
- `Dummy` (development only),
- `Mailgun`,
- `SendGrid`
- or the good old `SMTP`.
@ -56,11 +56,9 @@ Optionally, define the `defaultFrom` field, so you don't need to provide it when
## Sending Emails
<SendingEmailsInDevelopment />
Before jumping into details about setting up various providers, let's see how easy it is to send emails.
You import the `emailSender` that is provided by the `@wasp/email` module and call the `send` method on it.
You import the `emailSender` that is provided by the `@wasp/email/index.js` module and call the `send` method on it.
<Tabs groupId="js-ts">
<TabItem value="js" label="JavaScript">
@ -109,7 +107,42 @@ The `send` method returns an object with the status of the sent email. It varies
## Providers
For each provider, you'll need to set up env variables in the `.env.server` file at the root of your project.
We'll go over all of the available providers in the next section. For some of them, you'll need to set up some env variables. You can do that in the `.env.server` file.
### Using the Dummy Provider
<DummyProviderNote />
To speed up development, Wasp offers a `Dummy` email sender that `console.log`s the emails in the console. Since it doesn't send emails for real, it doesn't require any setup.
Set the provider to `Dummy` in your `main.wasp` file.
<Tabs groupId="js-ts">
<TabItem value="js" label="JavaScript">
```wasp title="main.wasp"
app Example {
...
emailSender: {
provider: Dummy,
}
}
```
</TabItem>
<TabItem value="ts" label="TypeScript">
```wasp title="main.wasp"
app Example {
...
emailSender: {
provider: Dummy,
}
}
```
</TabItem>
</Tabs>
### Using the SMTP Provider
@ -285,7 +318,9 @@ The `emailSender` dict has the following fields:
- `provider: Provider` <Required />
The provider you want to use. Choose from `SMTP`, `Mailgun` or `SendGrid`.
The provider you want to use. Choose from `Dummy`, `SMTP`, `Mailgun` or `SendGrid`.
<DummyProviderNote />
- `defaultFrom: dict`

View File

@ -414,9 +414,9 @@ We imported the generated Auth UI components and used them in our pages. Read mo
To support e-mail verification and password reset flows, we need an e-mail sender. Luckily, Wasp supports several email providers out of the box.
We'll use SendGrid in this guide to send our e-mails. You can use any of the supported email providers.
We'll use the `Dummy` provider to speed up the setup. It just logs the emails to the console instead of sending them. You can use any of the [supported email providers](../advanced/email#providers).
To set up SendGrid to send emails, we will add the following to our `main.wasp` file:
To set up the `Dummy` provider to send emails, add the following to the `main.wasp` file:
<Tabs groupId="js-ts">
<TabItem value="js" label="JavaScript">
@ -426,7 +426,7 @@ app myApp {
// ...
// 7. Set up the email sender
emailSender: {
provider: SendGrid,
provider: Dummy,
}
}
```
@ -438,23 +438,13 @@ app myApp {
// ...
// 7. Set up the email sender
emailSender: {
provider: SendGrid,
provider: Dummy,
}
}
```
</TabItem>
</Tabs>
... and add the following to our `.env.server` file:
```c title=".env.server"
SENDGRID_API_KEY=<your key>
```
If you are not sure how to get a SendGrid API key, read more [here](../advanced/email#getting-the-api-key).
Read more about setting up email senders in the [sending emails docs](../advanced/email).
### Conclusion
That's it! We have set up email authentication in our app. 🎉

View File

@ -104,7 +104,7 @@ module.exports = {
'advanced/deployment/manually',
],
},
'advanced/email',
'advanced/email/email',
'advanced/jobs',
'advanced/web-sockets',
'advanced/apis',