From 6dbeedca60edb55315be68477d67e34ae7b1cb5c Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 24 Mar 2023 12:42:22 +0100 Subject: [PATCH] Adds support for e-mail sending (#1050) --- waspc/ChangeLog.md | 28 ++++ .../Generator/templates/server/src/config.js | 1 + .../server/src/email/core/helpers.ts | 37 +++++ .../templates/server/src/email/core/index.ts | 10 ++ .../server/src/email/core/providers/dummy.ts | 24 +++ .../src/email/core/providers/mailgun.ts | 21 +++ .../src/email/core/providers/sendgrid.ts | 27 ++++ .../server/src/email/core/providers/smtp.ts | 28 ++++ .../templates/server/src/email/core/types.ts | 45 ++++++ .../templates/server/src/email/index.ts | 35 +++++ waspc/e2e-test/Tests/WaspComplexTest.hs | 33 +++- .../waspBuild/.wasp/build/.waspchecksums | 2 +- .../.wasp/build/server/src/config.js | 1 + .../waspCompile/.wasp/out/.waspchecksums | 2 +- .../.wasp/out/server/src/config.js | 1 + .../waspComplexTest-golden/files.manifest | 6 + .../waspComplexTest/.env.server | 1 + .../waspComplexTest/.wasp/out/.waspchecksums | 50 +++++- .../installedFullStackNpmDependencies.json | 2 +- .../waspComplexTest/.wasp/out/server/.env | 3 +- .../.wasp/out/server/package.json | 1 + .../.wasp/out/server/src/config.js | 1 + .../out/server/src/email/core/helpers.ts | 24 +++ .../.wasp/out/server/src/email/core/index.ts | 1 + .../server/src/email/core/providers/dummy.ts | 24 +++ .../src/email/core/providers/sendgrid.ts | 27 ++++ .../.wasp/out/server/src/email/core/types.ts | 39 +++++ .../.wasp/out/server/src/email/index.ts | 16 ++ .../waspComplexTest/.wasp/out/web-app/.env | 3 +- .../waspComplexTest/main.wasp | 8 + .../waspJob/.wasp/out/.waspchecksums | 2 +- .../waspJob/.wasp/out/server/src/config.js | 1 + .../waspMigrate/.wasp/out/.waspchecksums | 2 +- .../.wasp/out/server/src/config.js | 1 + waspc/examples/todoApp/src/server/actions.ts | 38 +++-- waspc/examples/todoApp/todoApp.wasp | 4 +- waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs | 3 + waspc/src/Wasp/AppSpec/App.hs | 2 + waspc/src/Wasp/AppSpec/App/EmailSender.hs | 25 +++ waspc/src/Wasp/Generator/ServerGenerator.hs | 5 +- .../ServerGenerator/EmailSender/Providers.hs | 69 ++++++++ .../Generator/ServerGenerator/EmailSenderG.hs | 114 ++++++++++++++ waspc/test/AnalyzerTest.hs | 21 ++- waspc/test/AppSpec/ValidTest.hs | 3 +- waspc/test/Generator/WebAppGeneratorTest.hs | 3 +- waspc/waspc.cabal | 3 + web/docs/_sendingEmailsInDevelopment.md | 7 + web/docs/guides/sending-emails.md | 147 ++++++++++++++++++ web/docs/language/features.md | 69 ++++++++ web/package-lock.json | 3 +- web/package.json | 3 +- web/sidebars.js | 1 + 52 files changed, 986 insertions(+), 41 deletions(-) create mode 100644 waspc/data/Generator/templates/server/src/email/core/helpers.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/index.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts create mode 100644 waspc/data/Generator/templates/server/src/email/core/types.ts create mode 100644 waspc/data/Generator/templates/server/src/email/index.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts create mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts create mode 100644 waspc/src/Wasp/AppSpec/App/EmailSender.hs create mode 100644 waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs create mode 100644 waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs create mode 100644 web/docs/_sendingEmailsInDevelopment.md create mode 100644 web/docs/guides/sending-emails.md diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 88a0aeaa7..bd4f36884 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -21,6 +21,34 @@ export const fooBar : FooBar = (req, res, context) => { } ``` +### Adds support for sending e-mails + +Wasp now supports sending e-mails! You can use the `emailSender` app property to configure the e-mail provider and optionally the `defaultFrom` address. Then, you can use the `send` function in your backend code to send e-mails. + +```ts +// main.wasp +app MyApp { + emailSender: { + provider: SendGrid, + defaultFrom: { + name: "My App", + email: "myapp@domain.com" + }, + }, +} + +// server/actions.ts +import { emailSender } from '@wasp/email/index.js' + +// In some action handler... +const info = await emailSender.send({ + to: 'user@domain.com', + subject: 'Saying hello', + text: 'Hello world', + html: 'Hello world' +}) +``` + ### `wasp start db` -> Wasp can now run your dev database for you with a single command Moving from SQLite to PostgreSQL with Wasp can feel like increase in complexity, because suddenly you have to care about running your PostgreSQL database, providing connection URL for it via env var, and if you checkout somebody's else Wasp project, or your old Wasp project that you have no memory of any more, you also have to figure all that out. diff --git a/waspc/data/Generator/templates/server/src/config.js b/waspc/data/Generator/templates/server/src/config.js index c3f0c01ff..073492259 100644 --- a/waspc/data/Generator/templates/server/src/config.js +++ b/waspc/data/Generator/templates/server/src/config.js @@ -13,6 +13,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.{= databaseUrlEnvVarName =}, frontendUrl: undefined, diff --git a/waspc/data/Generator/templates/server/src/email/core/helpers.ts b/waspc/data/Generator/templates/server/src/email/core/helpers.ts new file mode 100644 index 000000000..b1c2e911d --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/helpers.ts @@ -0,0 +1,37 @@ +{{={= =}=}} +import { EmailFromField } from "./types"; + +// Formats an email address and an optional name into a string that can be used +// as the "from" field in an email. +// { email: "test@test.com, name: "Test" } -> "Test " +export function formatFromField({ + email, + name, +}: { + email: string; + name?: string; +}): string { + if (name) { + return `${name} <${email}>`; + } + return email; +} + +{=# isDefaultFromFieldDefined =} +export function getDefaultFromField(): EmailFromField { + return { + email: "{= defaultFromField.email =}", + {=# defaultFromField.isNameDefined =} + name: "{= defaultFromField.name =}", + {=/ defaultFromField.isNameDefined =} + } +} +{=/ isDefaultFromFieldDefined =} +{=^ isDefaultFromFieldDefined =} +export function getDefaultFromField(): EmailFromField { + return { + email: "", + name: "", + }; +} +{=/ isDefaultFromFieldDefined =} diff --git a/waspc/data/Generator/templates/server/src/email/core/index.ts b/waspc/data/Generator/templates/server/src/email/core/index.ts new file mode 100644 index 000000000..9844ed396 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/index.ts @@ -0,0 +1,10 @@ +{{={= =}=}} +{=# isSmtpProviderUsed =} +export { initSmtpEmailSender as initEmailSender } from "./providers/smtp.js"; +{=/ isSmtpProviderUsed =} +{=# isSendGridProviderUsed =} +export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid.js"; +{=/ isSendGridProviderUsed =} +{=# isMailgunProviderUsed =} +export { initMailgunEmailSender as initEmailSender } from "./providers/mailgun.js"; +{=/ isMailgunProviderUsed =} diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts new file mode 100644 index 000000000..b4b3ef045 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts @@ -0,0 +1,24 @@ +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, + }; + } + } +} diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts b/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts new file mode 100644 index 000000000..19a8098d5 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/providers/mailgun.ts @@ -0,0 +1,21 @@ +import { NodeMailgun } from "ts-mailgun"; +import { getDefaultFromField } from "../helpers.js"; +import type { MailgunEmailProvider, EmailSender } from "../types.js"; + +export function initMailgunEmailSender( + config: MailgunEmailProvider +): EmailSender { + const mailer = new NodeMailgun(config.apiKey, config.domain); + + const defaultFromField = getDefaultFromField(); + + return { + async send(email) { + const fromField = email.from || defaultFromField; + mailer.fromEmail = fromField.email; + mailer.fromTitle = fromField.name; + mailer.init(); + return mailer.send(email.to, email.subject, email.html); + }, + }; +} diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts b/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts new file mode 100644 index 000000000..3ce8d39cd --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/providers/sendgrid.ts @@ -0,0 +1,27 @@ +import SendGrid from "@sendgrid/mail"; +import { getDefaultFromField } from "../helpers.js"; +import type { SendGridProvider, EmailSender } from "../types.js"; + +export function initSendGridEmailSender( + provider: SendGridProvider +): EmailSender { + SendGrid.setApiKey(provider.apiKey); + + const defaultFromField = getDefaultFromField(); + + return { + async send(email) { + const fromField = email.from || defaultFromField; + return SendGrid.send({ + from: { + email: fromField.email, + name: fromField.name, + }, + to: email.to, + subject: email.subject, + text: email.text, + html: email.html, + }); + }, + }; +} diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts b/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts new file mode 100644 index 000000000..b09fbc5ec --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/providers/smtp.ts @@ -0,0 +1,28 @@ +import { createTransport } from "nodemailer"; +import { formatFromField, getDefaultFromField } from "../helpers.js"; +import type { SMTPEmailProvider, EmailSender } from "../types.js"; + +export function initSmtpEmailSender(config: SMTPEmailProvider): EmailSender { + const transporter = createTransport({ + host: config.host, + port: config.port, + auth: { + user: config.username, + pass: config.password, + }, + }); + + const defaultFromField = getDefaultFromField(); + + return { + async send(email) { + return transporter.sendMail({ + from: formatFromField(email.from || defaultFromField), + to: email.to, + subject: email.subject, + text: email.text, + html: email.html, + }); + }, + }; +} diff --git a/waspc/data/Generator/templates/server/src/email/core/types.ts b/waspc/data/Generator/templates/server/src/email/core/types.ts new file mode 100644 index 000000000..a86b26b3b --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/core/types.ts @@ -0,0 +1,45 @@ +{{={= =}=}} +export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider; + +export type SMTPEmailProvider = { + type: "smtp"; + host: string; + port: number; + username: string; + password: string; +}; + +export type SendGridProvider = { + type: "sendgrid"; + apiKey: string; +}; + +export type MailgunEmailProvider = { + type: "mailgun"; + apiKey: string; + domain: string; +}; + +export type EmailSender = { + send: (email: Email) => Promise; +}; + +export type SentMessageInfo = any; + +export type Email = { + {=# isDefaultFromFieldDefined =} + from?: EmailFromField; + {=/ isDefaultFromFieldDefined =} + {=^ isDefaultFromFieldDefined =} + from: EmailFromField; + {=/ isDefaultFromFieldDefined =} + to: string; + subject: string; + text: string; + html: string; +}; + +export type EmailFromField = { + name?: string; + email: string; +} diff --git a/waspc/data/Generator/templates/server/src/email/index.ts b/waspc/data/Generator/templates/server/src/email/index.ts new file mode 100644 index 000000000..a4467c43f --- /dev/null +++ b/waspc/data/Generator/templates/server/src/email/index.ts @@ -0,0 +1,35 @@ +{{={= =}=}} +import { initEmailSender } from "./core/index.js"; + +import waspServerConfig from '../config.js'; +import { initDummyEmailSender } from "./core/providers/dummy.js"; + +{=# isSmtpProviderUsed =} +const emailProvider = { + type: "smtp", + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT, 10), + username: process.env.SMTP_USERNAME, + password: process.env.SMTP_PASSWORD, +} as const; +{=/ isSmtpProviderUsed =} +{=# isSendGridProviderUsed =} +const emailProvider = { + type: "sendgrid", + apiKey: process.env.SENDGRID_API_KEY, +} as const; +{=/ isSendGridProviderUsed =} +{=# isMailgunProviderUsed =} +const emailProvider = { + type: "mailgun", + apiKey: process.env.MAILGUN_API_KEY, + domain: process.env.MAILGUN_DOMAIN, +} as const; +{=/ isMailgunProviderUsed =} + +const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true"; +const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment; + +export const emailSender = isDummyEmailSenderUsed + ? initDummyEmailSender() + : initEmailSender(emailProvider); \ No newline at end of file diff --git a/waspc/e2e-test/Tests/WaspComplexTest.hs b/waspc/e2e-test/Tests/WaspComplexTest.hs index 10246efc5..198982549 100644 --- a/waspc/e2e-test/Tests/WaspComplexTest.hs +++ b/waspc/e2e-test/Tests/WaspComplexTest.hs @@ -24,6 +24,7 @@ waspComplexTest = do ] <++> addServerEnvFile <++> addDependencies + <++> addEmailSender <++> addClientSetup <++> addServerSetup <++> addGoogleAuth @@ -129,7 +130,8 @@ addServerEnvFile = do -- on the location where the tests are being run. -- Therefore, we make sure to set custom database url here, to avoid .env -- changing between different machines / setups. - databaseUrlEnvVarName <> "=" <> "mock-database-url" + databaseUrlEnvVarName <> "=" <> "mock-database-url", + "SENDGRID_API_KEY=sendgrid_api_key" ] addGoogleAuth :: ShellCommandBuilder [ShellCommand] @@ -233,12 +235,6 @@ addDependencies = do " ]," ] -insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand -insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion - where - lineNumberInWaspFileAfterVersion :: Int - lineNumberInWaspFileAfterVersion = 5 - addApi :: ShellCommandBuilder [ShellCommand] addApi = do sequence @@ -271,3 +267,26 @@ addApi = do " res.json({ msg: 'Hello, stranger!' })", "}" ] + +addEmailSender :: ShellCommandBuilder [ShellCommand] +addEmailSender = do + sequence + [ insertCodeIntoWaspFileAfterVersion emailSender + ] + where + emailSender = + unlines + [ " emailSender: {", + " provider: SendGrid,", + " defaultFrom: {", + " name: \"Hello\",", + " email: \"hello@itsme.com\"", + " },", + " }," + ] + +insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand +insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion + where + lineNumberInWaspFileAfterVersion :: Int + lineNumberInWaspFileAfterVersion = 5 diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index c5d599ddf..dfad0ee3a 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -102,7 +102,7 @@ "file", "server/src/config.js" ], - "db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942" + "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js index cd6de5aa0..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js @@ -12,6 +12,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, frontendUrl: undefined, diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index cbc32cb4b..7db217747 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -109,7 +109,7 @@ "file", "server/src/config.js" ], - "db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942" + "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js index cd6de5aa0..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js @@ -12,6 +12,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, frontendUrl: undefined, diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest index c27f1d0a1..8568635f7 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest @@ -34,6 +34,12 @@ waspComplexTest/.wasp/out/server/src/core/HttpError.js waspComplexTest/.wasp/out/server/src/core/auth.js waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js waspComplexTest/.wasp/out/server/src/dbClient.js +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 waspComplexTest/.wasp/out/server/src/entities/index.ts waspComplexTest/.wasp/out/server/src/ext-src/actions/bar.js waspComplexTest/.wasp/out/server/src/ext-src/apis.ts diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server index 535e1f35f..3009791f3 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.env.server @@ -1,4 +1,5 @@ GOOGLE_CLIENT_ID=google_client_id GOOGLE_CLIENT_SECRET=google_client_secret DATABASE_URL=mock-database-url +SENDGRID_API_KEY=sendgrid_api_key diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums index c4cb92678..bcf87d5bf 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "server/.env" ], - "368bf0f88f43e6cc3083d535f348905e51a1d3747abb479749dc250253a0c042" + "d9649485a674ce3c6b8d668a1253cde3bd8695cb3d2d3a6b7c573a3e2805c7ec" ], [ [ @@ -60,7 +60,7 @@ "file", "server/package.json" ], - "94c2733af39f289c2691be23909fd6a1d8b3dcbb19144ca66ba5a48e2a26727e" + "9ebd5e39b263bee5baf7a60d86850051050f665b9a2e0274a8e80b47b93ae90d" ], [ [ @@ -186,7 +186,7 @@ "file", "server/src/config.js" ], - "feb61b839f6ce3c1f49b279fd08275479308089a6f12188b312b64bdf571e6c5" + "b5e2d31201460b018e781532b7bb6348cf3f7eb45851a81711a18584c0f4f5ac" ], [ [ @@ -223,6 +223,48 @@ ], "5fb53eff5e5eae318e281a8fc1cc433b69688d93c3c82d72415f710a6e75e2af" ], + [ + [ + "file", + "server/src/email/core/helpers.ts" + ], + "b59fd329fc96e47f2839ba0b06ea5e42935e4959d228676be9c13ddb30d9e02c" + ], + [ + [ + "file", + "server/src/email/core/index.ts" + ], + "d524dd9ef27cd311340060411276df0e8ef22db503473f44281832338b954bb7" + ], + [ + [ + "file", + "server/src/email/core/providers/dummy.ts" + ], + "e93a7a02f50c8466f3e8e89255b98bebde598b25f9969ec117b16f07691575ae" + ], + [ + [ + "file", + "server/src/email/core/providers/sendgrid.ts" + ], + "b1a16455eede9723f6ae43c03e628b4129fd96f7300f004f052f1eab31d9b95b" + ], + [ + [ + "file", + "server/src/email/core/types.ts" + ], + "c343f0d87b65d7563816159a88f410b65d78d897822c0bbcd723ca7752e00a20" + ], + [ + [ + "file", + "server/src/email/index.ts" + ], + "c4864d5c83b96a61b1ddfaac7b52c0898f5cff04320c166c8f658b017952ee05" + ], [ [ "file", @@ -417,7 +459,7 @@ "file", "web-app/.env" ], - "368bf0f88f43e6cc3083d535f348905e51a1d3747abb479749dc250253a0c042" + "d9649485a674ce3c6b8d668a1253cde3bd8695cb3d2d3a6b7c573a3e2805c7ec" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json index d409f7b30..2ef3ba50b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env index 664959825..31fd552be 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/.env @@ -1,3 +1,4 @@ GOOGLE_CLIENT_ID="google_client_id" GOOGLE_CLIENT_SECRET="google_client_secret" -DATABASE_URL="mock-database-url" \ No newline at end of file +DATABASE_URL="mock-database-url" +SENDGRID_API_KEY="sendgrid_api_key" \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json index f7d4a695a..1d1295975 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@prisma/client": "4.5.0", + "@sendgrid/mail": "^7.7.0", "cookie-parser": "~1.4.6", "cors": "^2.8.5", "dotenv": "16.0.2", diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js index 05df10bfc..5c6590be2 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/config.js @@ -12,6 +12,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, frontendUrl: undefined, diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts new file mode 100644 index 000000000..3a0cdcd45 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/helpers.ts @@ -0,0 +1,24 @@ +import { EmailFromField } from "./types"; + +// Formats an email address and an optional name into a string that can be used +// as the "from" field in an email. +// { email: "test@test.com, name: "Test" } -> "Test " +export function formatFromField({ + email, + name, +}: { + email: string; + name?: string; +}): string { + if (name) { + return `${name} <${email}>`; + } + return email; +} + +export function getDefaultFromField(): EmailFromField { + return { + email: "hello@itsme.com", + name: "Hello", + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts new file mode 100644 index 000000000..f3706274c --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/index.ts @@ -0,0 +1 @@ +export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid.js"; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts new file mode 100644 index 000000000..b4b3ef045 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts @@ -0,0 +1,24 @@ +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, + }; + } + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts new file mode 100644 index 000000000..3ce8d39cd --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts @@ -0,0 +1,27 @@ +import SendGrid from "@sendgrid/mail"; +import { getDefaultFromField } from "../helpers.js"; +import type { SendGridProvider, EmailSender } from "../types.js"; + +export function initSendGridEmailSender( + provider: SendGridProvider +): EmailSender { + SendGrid.setApiKey(provider.apiKey); + + const defaultFromField = getDefaultFromField(); + + return { + async send(email) { + const fromField = email.from || defaultFromField; + return SendGrid.send({ + from: { + email: fromField.email, + name: fromField.name, + }, + to: email.to, + subject: email.subject, + text: email.text, + html: email.html, + }); + }, + }; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts new file mode 100644 index 000000000..9a2440038 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts @@ -0,0 +1,39 @@ +export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider; + +export type SMTPEmailProvider = { + type: "smtp"; + host: string; + port: number; + username: string; + password: string; +}; + +export type SendGridProvider = { + type: "sendgrid"; + apiKey: string; +}; + +export type MailgunEmailProvider = { + type: "mailgun"; + apiKey: string; + domain: string; +}; + +export type EmailSender = { + send: (email: Email) => Promise; +}; + +export type SentMessageInfo = any; + +export type Email = { + from?: EmailFromField; + to: string; + subject: string; + text: string; + html: string; +}; + +export type EmailFromField = { + name?: string; + email: string; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts new file mode 100644 index 000000000..47f4e8e64 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts @@ -0,0 +1,16 @@ +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); \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env index 664959825..31fd552be 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/.env @@ -1,3 +1,4 @@ GOOGLE_CLIENT_ID="google_client_id" GOOGLE_CLIENT_SECRET="google_client_secret" -DATABASE_URL="mock-database-url" \ No newline at end of file +DATABASE_URL="mock-database-url" +SENDGRID_API_KEY="sendgrid_api_key" \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp index 8a29c310f..5cf5c8921 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp @@ -21,6 +21,14 @@ app waspComplexTest { rootComponent: import App from "@client/App.jsx" }, + emailSender: { + provider: SendGrid, + defaultFrom: { + name: "Hello", + email: "hello@itsme.com" + }, + }, + dependencies: [ ("redux", "^4.0.5"), ("react-redux", "^7.1.3") diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 4dba4b67c..dec092e16 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -109,7 +109,7 @@ "file", "server/src/config.js" ], - "db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942" + "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js index cd6de5aa0..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js @@ -12,6 +12,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, frontendUrl: undefined, diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index 50e78be50..8fb8b8806 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -109,7 +109,7 @@ "file", "server/src/config.js" ], - "db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942" + "85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js index cd6de5aa0..9230b7dc5 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js @@ -12,6 +12,7 @@ const env = process.env.NODE_ENV || 'development' const config = { all: { env, + isDevelopment: env === 'development', port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, frontendUrl: undefined, diff --git a/waspc/examples/todoApp/src/server/actions.ts b/waspc/examples/todoApp/src/server/actions.ts index c1bf68342..c6520c909 100644 --- a/waspc/examples/todoApp/src/server/actions.ts +++ b/waspc/examples/todoApp/src/server/actions.ts @@ -5,29 +5,37 @@ import { CreateTask, DeleteCompletedTasks, ToggleAllTasks, - UpdateTaskIsDone + UpdateTaskIsDone, } from '@wasp/actions/types' -export const createTask: CreateTask> = async (task, context) => { +export const createTask: CreateTask> = async ( + task, + context +) => { if (!context.user) { throw new HttpError(401) } const Task = context.entities.Task - console.log('New task created! Btw, current value of someResource is: ' + getSomeResource()) + console.log( + 'New task created! Btw, current value of someResource is: ' + + getSomeResource() + ) return Task.create({ data: { description: task.description, user: { - connect: { id: context.user.id } - } - } + connect: { id: context.user.id }, + }, + }, }) } -export const updateTaskIsDone: UpdateTaskIsDone> = async ({ id, isDone }, context) => { +export const updateTaskIsDone: UpdateTaskIsDone< + Pick +> = async ({ id, isDone }, context) => { if (!context.user) { throw new HttpError(401) } @@ -37,20 +45,24 @@ export const updateTaskIsDone: UpdateTaskIsDone> = a // await sleep(3000); const Task = context.entities.Task - return Task.updateMany({ + const updateResult = await Task.updateMany({ where: { id, user: { id: context.user.id } }, - data: { isDone } + data: { isDone }, }) + return updateResult } -export const deleteCompletedTasks: DeleteCompletedTasks = async (_args, context) => { +export const deleteCompletedTasks: DeleteCompletedTasks = async ( + _args, + context +) => { if (!context.user) { throw new HttpError(401) } const Task = context.entities.Task await Task.deleteMany({ - where: { isDone: true, user: { id: context.user.id } } + where: { isDone: true, user: { id: context.user.id } }, }) } @@ -62,8 +74,8 @@ export const toggleAllTasks: ToggleAllTasks = async (_args, context) => { const whereIsDone = (isDone: boolean) => ({ isDone, user: { id: context.user.id }, - }); - const Task = context.entities.Task; + }) + const Task = context.entities.Task const notDoneTasksCount = await Task.count({ where: whereIsDone(false) }) if (notDoneTasksCount > 0) { diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index 855e3938d..f2dc4933c 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -34,7 +34,7 @@ app todoApp { }, db: { system: PostgreSQL - } + }, } entity User {=psl @@ -167,4 +167,4 @@ job mySpecialScheduledJob { pgBoss: {=json { "retryLimit": 2 } json=} } } -} +} \ No newline at end of file diff --git a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs index af9db2b82..d8c18ec21 100644 --- a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs +++ b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs @@ -14,12 +14,14 @@ import Wasp.AppSpec.Action (Action) import Wasp.AppSpec.Api (Api, HttpMethod) import Wasp.AppSpec.App (App) import Wasp.AppSpec.App.Db (DbSystem) +import Wasp.AppSpec.App.EmailSender (EmailProvider) import Wasp.AppSpec.Entity (Entity) import Wasp.AppSpec.Job (Job, JobExecutor) import Wasp.AppSpec.Page (Page) import Wasp.AppSpec.Query (Query) import Wasp.AppSpec.Route (Route) +makeEnumType ''EmailProvider makeEnumType ''DbSystem makeDeclType ''App makeDeclType ''Page @@ -48,5 +50,6 @@ stdTypes = TD.addDeclType @Job $ TD.addEnumType @HttpMethod $ TD.addDeclType @Api $ + TD.addEnumType @EmailProvider $ TD.empty {- ORMOLU_ENABLE -} diff --git a/waspc/src/Wasp/AppSpec/App.hs b/waspc/src/Wasp/AppSpec/App.hs index 4d20f6225..da202d329 100644 --- a/waspc/src/Wasp/AppSpec/App.hs +++ b/waspc/src/Wasp/AppSpec/App.hs @@ -7,6 +7,7 @@ import Wasp.AppSpec.App.Auth (Auth) import Wasp.AppSpec.App.Client (Client) import Wasp.AppSpec.App.Db (Db) import Wasp.AppSpec.App.Dependency (Dependency) +import Wasp.AppSpec.App.EmailSender (EmailSender) import Wasp.AppSpec.App.Server (Server) import Wasp.AppSpec.App.Wasp (Wasp) import Wasp.AppSpec.Core.Decl (IsDecl) @@ -19,6 +20,7 @@ data App = App server :: Maybe Server, client :: Maybe Client, db :: Maybe Db, + emailSender :: Maybe EmailSender, dependencies :: Maybe [Dependency] } deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/EmailSender.hs b/waspc/src/Wasp/AppSpec/App/EmailSender.hs new file mode 100644 index 000000000..ee10a4090 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/App/EmailSender.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.App.EmailSender + ( EmailSender (..), + EmailProvider (..), + EmailFromField (..), + ) +where + +import Data.Data (Data) + +data EmailSender = EmailSender + { provider :: EmailProvider, + defaultFrom :: Maybe EmailFromField + } + deriving (Show, Eq, Data) + +data EmailProvider = SMTP | SendGrid | Mailgun + deriving (Eq, Data, Show) + +data EmailFromField = EmailFromField + { name :: Maybe String, + email :: String + } + deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index d01b4a7ba..b469c0a75 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -56,6 +56,7 @@ import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport) import Wasp.Generator.ServerGenerator.AuthG (genAuth) import qualified Wasp.Generator.ServerGenerator.Common as C import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile) +import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender) import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy) import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobExecutors, genJobs) import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) @@ -152,7 +153,8 @@ npmDepsForWasp spec = ("lodash.merge", "^4.6.2") ] ++ depsRequiredByPassport spec - ++ depsRequiredByJobs spec, + ++ depsRequiredByJobs spec + ++ depsRequiredByEmail spec, N.waspDevDependencies = AS.Dependency.fromList [ ("nodemon", "^2.0.19"), @@ -200,6 +202,7 @@ genSrcDir spec = <++> genOperationsRoutes spec <++> genOperations spec <++> genAuth spec + <++> genEmailSender spec where genFileCopy = return . C.mkSrcTmplFd diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs new file mode 100644 index 000000000..afcff7682 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs @@ -0,0 +1,69 @@ +module Wasp.Generator.ServerGenerator.EmailSender.Providers + ( smtp, + sendGrid, + mailgun, + providersDirInServerSrc, + EmailSenderProvider (..), + ) +where + +import StrongPath (Dir, File', Path', Rel, reldir, relfile) +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.SemanticVersion as SV + +data EmailSenderProvider = EmailSenderProvider + { npmDependency :: 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. + isEnabledKey :: String + } + deriving (Show, Eq) + +data ProvidersDir + +smtp :: EmailSenderProvider +smtp = + EmailSenderProvider + { npmDependency = nodeMailerDependency, + setupFnFile = [relfile|smtp.ts|], + isEnabledKey = "isSmtpProviderUsed" + } + where + nodeMailerVersionRange :: SV.Range + nodeMailerVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 6 9 1)] + + nodeMailerDependency :: AS.Dependency.Dependency + nodeMailerDependency = AS.Dependency.make ("nodemailer", show nodeMailerVersionRange) + +sendGrid :: EmailSenderProvider +sendGrid = + EmailSenderProvider + { npmDependency = sendGridDependency, + setupFnFile = [relfile|sendgrid.ts|], + isEnabledKey = "isSendGridProviderUsed" + } + where + sendGridVersionRange :: SV.Range + sendGridVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 7 7 0)] + + sendGridDependency :: AS.Dependency.Dependency + sendGridDependency = AS.Dependency.make ("@sendgrid/mail", show sendGridVersionRange) + +mailgun :: EmailSenderProvider +mailgun = + EmailSenderProvider + { npmDependency = mailgunDependency, + setupFnFile = [relfile|mailgun.ts|], + isEnabledKey = "isMailgunProviderUsed" + } + where + mailgunVersionRange :: SV.Range + mailgunVersionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 0 5 1)] + + mailgunDependency :: AS.Dependency.Dependency + mailgunDependency = AS.Dependency.make ("ts-mailgun", show mailgunVersionRange) + +providersDirInServerSrc :: Path' (Rel C.ServerTemplatesSrcDir) (Dir ProvidersDir) +providersDirInServerSrc = [reldir|email/core/providers|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs new file mode 100644 index 000000000..a5d12e175 --- /dev/null +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs @@ -0,0 +1,114 @@ +module Wasp.Generator.ServerGenerator.EmailSenderG where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.Maybe (fromMaybe, isJust, maybeToList) +import qualified Data.Text +import StrongPath (File', Path', Rel, relfile, ()) +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import Wasp.AppSpec.App.EmailSender (EmailSender) +import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender +import Wasp.AppSpec.Valid (getApp) +import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.ServerGenerator.Common as C +import qualified Wasp.Generator.ServerGenerator.EmailSender.Providers as Providers +import Wasp.Util ((<++>)) + +genEmailSender :: AppSpec -> Generator [FileDraft] +genEmailSender spec = case maybeEmailSender of + Just emailSender -> + sequence + [ genIndex emailSender + ] + <++> genCore emailSender + Nothing -> return [] + where + maybeEmailSender = AS.App.emailSender $ snd $ getApp spec + +genIndex :: EmailSender -> Generator FileDraft +genIndex email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/index.ts|] + tmplData = getEmailProvidersJson email + +genCore :: EmailSender -> Generator [FileDraft] +genCore email = + sequence + [ genCoreIndex email, + genCoreTypes email, + genCoreHelpers email, + copyTmplFile [relfile|email/core/providers/dummy.ts|] + ] + <++> genEmailSenderProviderSetupFn email + +genCoreIndex :: EmailSender -> Generator FileDraft +genCoreIndex email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/index.ts|] + tmplData = getEmailProvidersJson email + +genCoreTypes :: EmailSender -> Generator FileDraft +genCoreTypes email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/types.ts|] + tmplData = + object ["isDefaultFromFieldDefined" .= isDefaultFromFieldDefined] + isDefaultFromFieldDefined = isJust defaultFromField + defaultFromField = AS.EmailSender.defaultFrom email + +genCoreHelpers :: EmailSender -> Generator FileDraft +genCoreHelpers email = return $ C.mkTmplFdWithData tmplPath (Just tmplData) + where + tmplPath = [relfile|src/email/core/helpers.ts|] + tmplData = + object + [ "defaultFromField" + .= object + [ "email" .= fromMaybe "" maybeEmail, + "name" .= fromMaybe "" maybeName, + "isNameDefined" .= isJust maybeName + ], + "isDefaultFromFieldDefined" .= isDefaultFromFieldDefined + ] + isDefaultFromFieldDefined = isJust defaultFromField + maybeEmail = AS.EmailSender.email <$> defaultFromField + maybeName = defaultFromField >>= AS.EmailSender.name + defaultFromField = AS.EmailSender.defaultFrom email + +genEmailSenderProviderSetupFn :: EmailSender -> Generator [FileDraft] +genEmailSenderProviderSetupFn email = + sequence + [ copyTmplFile tmplPath + ] + where + provider :: Providers.EmailSenderProvider + provider = getEmailSenderProvider email + + tmplPath = Providers.providersDirInServerSrc Providers.setupFnFile provider + +depsRequiredByEmail :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredByEmail spec = maybeToList maybeNpmDepedency + where + maybeProvider :: Maybe Providers.EmailSenderProvider + maybeProvider = getEmailSenderProvider <$> (AS.App.emailSender . snd . getApp $ spec) + maybeNpmDepedency = Providers.npmDependency <$> maybeProvider + +getEmailProvidersJson :: EmailSender -> Aeson.Value +getEmailProvidersJson email = + object [isEnabledKey .= True] + where + provider :: Providers.EmailSenderProvider + provider = getEmailSenderProvider email + isEnabledKey = Data.Text.pack $ Providers.isEnabledKey provider + +getEmailSenderProvider :: EmailSender -> Providers.EmailSenderProvider +getEmailSenderProvider email = case AS.EmailSender.provider email of + AS.EmailSender.SMTP -> Providers.smtp + AS.EmailSender.SendGrid -> Providers.sendGrid + AS.EmailSender.Mailgun -> Providers.mailgun + +copyTmplFile :: Path' (Rel C.ServerTemplatesSrcDir) File' -> Generator FileDraft +copyTmplFile = return . C.mkSrcTmplFd diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index dacf555a9..a2d6b2f85 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -18,6 +18,7 @@ import qualified Wasp.AppSpec.App.Auth as Auth import qualified Wasp.AppSpec.App.Client as Client import qualified Wasp.AppSpec.App.Db as Db import qualified Wasp.AppSpec.App.Dependency as Dependency +import qualified Wasp.AppSpec.App.EmailSender as EmailSender import qualified Wasp.AppSpec.App.Server as Server import qualified Wasp.AppSpec.App.Wasp as Wasp import Wasp.AppSpec.Core.Ref (Ref (..)) @@ -62,6 +63,13 @@ spec_Analyzer = do " },", " db: {", " system: PostgreSQL", + " },", + " emailSender: {", + " provider: SendGrid,", + " defaultFrom: {", + " email: \"test@test.com\",", + " name: \"Test\"", + " }", " }", "}", "", @@ -154,7 +162,18 @@ spec_Analyzer = do Just $ ExtImport (ExtImportField "App") (fromJust $ SP.parseRelFileP "App.jsx") }, - App.db = Just Db.Db {Db.system = Just Db.PostgreSQL} + App.db = Just Db.Db {Db.system = Just Db.PostgreSQL}, + App.emailSender = + Just + EmailSender.EmailSender + { EmailSender.provider = EmailSender.SendGrid, + EmailSender.defaultFrom = + Just + EmailSender.EmailFromField + { EmailSender.email = "test@test.com", + EmailSender.name = Just "Test" + } + } } ) ] diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index 23d65c343..01b5c2358 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -185,7 +185,8 @@ spec_AppSpecValid = do AS.App.client = Nothing, AS.App.auth = Nothing, AS.App.dependencies = Nothing, - AS.App.head = Nothing + AS.App.head = Nothing, + AS.App.emailSender = Nothing } basicAppDecl = AS.Decl.makeDecl "TestApp" basicApp diff --git a/waspc/test/Generator/WebAppGeneratorTest.hs b/waspc/test/Generator/WebAppGeneratorTest.hs index efd4e8aed..8b8f4285a 100644 --- a/waspc/test/Generator/WebAppGeneratorTest.hs +++ b/waspc/test/Generator/WebAppGeneratorTest.hs @@ -40,7 +40,8 @@ spec_WebAppGenerator = do AS.App.client = Nothing, AS.App.auth = Nothing, AS.App.dependencies = Nothing, - AS.App.head = Nothing + AS.App.head = Nothing, + AS.App.emailSender = Nothing } ], AS.waspProjectDir = systemSPRoot SP. [SP.reldir|test/|], diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index fb48fb3e9..4fc07d6a6 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -189,6 +189,7 @@ library Wasp.AppSpec.App.Auth Wasp.AppSpec.App.Client Wasp.AppSpec.App.Db + Wasp.AppSpec.App.EmailSender Wasp.AppSpec.App.Dependency Wasp.AppSpec.App.Server Wasp.AppSpec.App.Wasp @@ -252,6 +253,8 @@ library Wasp.Generator.ServerGenerator.AuthG Wasp.Generator.ServerGenerator.Auth.OAuthAuthG Wasp.Generator.ServerGenerator.Auth.LocalAuthG + Wasp.Generator.ServerGenerator.EmailSenderG + Wasp.Generator.ServerGenerator.EmailSender.Providers Wasp.Generator.ServerGenerator.Common Wasp.Generator.ServerGenerator.ConfigG Wasp.Generator.ServerGenerator.ExternalCodeGenerator diff --git a/web/docs/_sendingEmailsInDevelopment.md b/web/docs/_sendingEmailsInDevelopment.md new file mode 100644 index 000000000..afb830fcf --- /dev/null +++ b/web/docs/_sendingEmailsInDevelopment.md @@ -0,0 +1,7 @@ +:::info Sending emails while developing + +When you run your app in development mode, the e-mails are not actually sent. Instead, they are logged to the console. + +In order to enable sending e-mails in development mode, you need to set the `SEND_EMAILS_IN_DEVELOPMENT` env variable to `true` in your `.env.server` file. + +::: diff --git a/web/docs/guides/sending-emails.md b/web/docs/guides/sending-emails.md new file mode 100644 index 000000000..eb7002c45 --- /dev/null +++ b/web/docs/guides/sending-emails.md @@ -0,0 +1,147 @@ +--- +title: Sending Emails +--- + +import SendingEmailsInDevelopment from '../_sendingEmailsInDevelopment.md' + +# Sending Emails + +With Wasp's email-sending feature, you can easily integrate email functionality into your web application. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + +Choose from one of the providers: +- `Mailgun`, +- `SendGrid` +- or the good old `SMTP`. + +Optionally, define the `defaultFrom` field, so you don't need to provide it whenever sending an e-mail. + +## Sending e-mails + + + +Before jumping into details about setting up various providers, let's see how easy it is to send e-mails. + +You import the `emailSender` that is provided by the `@wasp/email` module and call the `send` method on it. + +```ts title="src/actions/sendEmail.js" +import { emailSender } from '@wasp/email/index.js' + +// In some action handler... +const info = await emailSender.send({ + from: { + name: 'John Doe', + email: 'john@doe.com', + }, + to: 'user@domain.com', + subject: 'Saying hello', + text: 'Hello world', + html: 'Hello world' +}) +``` + +Let's see what the `send` method accepts: + +- `from` - the sender's details. + - `name` - the name of the sender + - `email` - the e-mail address of the sender + - If you set up `defaultFrom` field in the `main.wasp`, this field is optional. +- `to` - the recipient's e-mail address +- `subject` - the subject of the e-mail +- `text` - the text version of the e-mail +- `html` - the HTML version of the e-mail + +The `send` method returns an object with the status of the sent e-mail. It varies depending on the provider you use. + +## Providers + +For each provider, you'll need to set up env variables in the `.env.server` file at the root of your project. + +## Using the SMTP provider + +First, set the provider to `SMTP` in your `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: SMTP, + } +} +``` + +Then, add the following env variables to your `.env.server` file. + +```properties title=".env.server" +SMTP_HOST= +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_PORT= +``` + +Many transactional email providers (e.g. Mailgun, SendGrid but also others) can also use SMTP, so you can use them as well. + +## Using the Mailgun provider + +Set the provider to `Mailgun` in the `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: Mailgun, + } +} +``` + +Then, get the Mailgun API key and domain and add them to your `.env.server` file. + +### Getting the API key and domain + +1. Go to [Mailgun](https://www.mailgun.com/) and create an account. +2. Go to [API Keys](https://app.mailgun.com/app/account/security/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. +4. Go to [Domains](https://app.mailgun.com/app/domains) and create a new domain. +5. Copy the domain and add it to your `.env.server` file. + +```properties title=".env.server" +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +``` + +## Using the SendGrid provider + +Set the provider field to `SendGrid` in your `main.wasp` file. + +```js title="main.wasp" +app Example { + ... + emailSender: { + provider: SendGrid, + } +} +``` + +Then, get the SendGrid API key and add it to your `.env.server` file. + +### Getting the API key + +1. Go to [SendGrid](https://sendgrid.com/) and create an account. +2. Go to [API Keys](https://app.sendgrid.com/settings/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. + +```properties title=".env.server" +SENDGRID_API_KEY= +``` diff --git a/web/docs/language/features.md b/web/docs/language/features.md index 6bc9d291c..33d85aa75 100644 --- a/web/docs/language/features.md +++ b/web/docs/language/features.md @@ -4,6 +4,7 @@ title: Features import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +import SendingEmailsInDevelopment from '../_sendingEmailsInDevelopment.md' ## App @@ -58,6 +59,10 @@ Check [`app.db`](/docs/language/features#database-configuration) for more detail List of dependencies (external libraries). Check [`app.dependencies`](/docs/language/features#dependencies) for more details. +#### `emailSender: dict` (optional) +Email sender configuration. +Check [`app.emailSender`](/docs/language/features#email-sender) for more details. + ## Page `page` declaration is the top-level layout abstraction. Your app can have multiple pages. @@ -1766,8 +1771,72 @@ The easiest way to provide the needed `DATABASE_URL` environment variable is by ### Migrating from SQLite to PostgreSQL To run Wasp app in production, you will need to switch from `SQLite` to `PostgreSQL`. + 1. Set `app.db.system` to `PostgreSQL`. 3. Delete old migrations, since they are SQLite migrations and can't be used with PostgreSQL: `rm -r migrations/`. 3. Run `wasp start db` to start your new db running (or check instructions above if you prefer using your custom db). Leave it running, since we need it for the next step. 4. In a different terminal, run `wasp db migrate-dev` to apply new changes and create new, initial migration. 5. That is it, you are all done! + +## Email sender + +#### `provider: EmailProvider` (required) + +We support multiple different providers for sending e-mails: `SMTP`, `SendGrid` and `Mailgun`. + +### SMTP + +SMTP e-mail sender uses your SMTP server to send e-mails. + +Read [our guide](/docs/guides/sending-emails#using-the-smtp-provider) for setting up SMTP for more details. + + +### SendGrid + +SendGrid is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. + +Check out [our guide](/docs/guides/sending-emails#using-the-sendgrid-provider) for setting up Sendgrid for more details. + +### Mailgun + +Mailgun is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. + +Check out [our guide](/docs/guides/sending-emails#using-the-mailgun-provider) for setting up Mailgun for more details. + +#### `defaultSender: EmailFromField` (optional) + +You can optionally provide a default sender info that will be used when you don't provide it explicitly when sending an e-mail. + +```c +app MyApp { + title: "My app", + // ... + emailSender: { + provider: SMTP, + defaultFrom: { + name: "Hello", + email: "hello@itsme.com" + }, + }, +} +``` + +After you set up the email sender, you can use it in your code to send e-mails. For example, you can send an e-mail when a user signs up, or when a user resets their password. + +### Sending e-mails + + + +To send an e-mail, you can use the `emailSender` that is provided by the `@wasp/email` module. + +```ts title="src/actions/sendEmail.js" +import { emailSender } from '@wasp/email/index.js' + +// In some action handler... +const info = await emailSender.send({ + to: 'user@domain.com', + subject: 'Saying hello', + text: 'Hello world', + html: 'Hello world' +}) +``` \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index d9e055bf2..b85260ee0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,8 +33,7 @@ "@docusaurus/module-type-aliases": "2.2.0" }, "engines": { - "node": "^18.12.0", - "npm": "^8.19.2" + "node": "^18.12.0" } }, "node_modules/@algolia/autocomplete-core": { diff --git a/web/package.json b/web/package.json index 7ab7c11af..7f125efc4 100644 --- a/web/package.json +++ b/web/package.json @@ -39,8 +39,7 @@ "@docusaurus/module-type-aliases": "2.2.0" }, "engines": { - "node": "^18.12.0", - "npm": "^8.19.2" + "node": "^18.12.0" }, "browserslist": { "production": [ diff --git a/web/sidebars.js b/web/sidebars.js index 34a26e67f..32806df1f 100644 --- a/web/sidebars.js +++ b/web/sidebars.js @@ -58,6 +58,7 @@ module.exports = { 'integrations/css-frameworks', 'deploying', 'typescript', + 'guides/sending-emails' ], },