Adds support for e-mail sending (#1050)

This commit is contained in:
Mihovil Ilakovac 2023-03-24 12:42:22 +01:00 committed by GitHub
parent b5967d395b
commit 6dbeedca60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 986 additions and 41 deletions

View File

@ -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 <strong>world</strong>'
})
```
### `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.

View File

@ -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,

View File

@ -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 <test@test.com>"
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 =}

View File

@ -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 =}

View File

@ -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,
};
}
}
}

View File

@ -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);
},
};
}

View File

@ -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,
});
},
};
}

View File

@ -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,
});
},
};
}

View File

@ -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<SentMessageInfo>;
};
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;
}

View File

@ -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);

View File

@ -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

View File

@ -102,7 +102,7 @@
"file",
"server/src/config.js"
],
"db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942"
"85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[

View File

@ -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,

View File

@ -109,7 +109,7 @@
"file",
"server/src/config.js"
],
"db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942"
"85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -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"}]}}
{"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"}]}}

View File

@ -1,3 +1,4 @@
GOOGLE_CLIENT_ID="google_client_id"
GOOGLE_CLIENT_SECRET="google_client_secret"
DATABASE_URL="mock-database-url"
SENDGRID_API_KEY="sendgrid_api_key"

View File

@ -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",

View File

@ -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,

View File

@ -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 <test@test.com>"
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",
}
}

View File

@ -0,0 +1 @@
export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid.js";

View File

@ -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,
};
}
}
}

View File

@ -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,
});
},
};
}

View File

@ -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<SentMessageInfo>;
};
export type SentMessageInfo = any;
export type Email = {
from?: EmailFromField;
to: string;
subject: string;
text: string;
html: string;
};
export type EmailFromField = {
name?: string;
email: string;
}

View File

@ -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);

View File

@ -1,3 +1,4 @@
GOOGLE_CLIENT_ID="google_client_id"
GOOGLE_CLIENT_SECRET="google_client_secret"
DATABASE_URL="mock-database-url"
SENDGRID_API_KEY="sendgrid_api_key"

View File

@ -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")

View File

@ -109,7 +109,7 @@
"file",
"server/src/config.js"
],
"db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942"
"85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[

View File

@ -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,

View File

@ -109,7 +109,7 @@
"file",
"server/src/config.js"
],
"db65648bfa899b556f499abfde8a4cc6360b01dce88d9318456a8920e6a7d942"
"85e22f3e8e87902ed0b58a3e529d9c2167b553383f33fbb4261a5031a3c5ba27"
],
[
[

View File

@ -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,

View File

@ -5,29 +5,37 @@ import {
CreateTask,
DeleteCompletedTasks,
ToggleAllTasks,
UpdateTaskIsDone
UpdateTaskIsDone,
} from '@wasp/actions/types'
export const createTask: CreateTask<Pick<Task, 'description'>> = async (task, context) => {
export const createTask: CreateTask<Pick<Task, 'description'>> = 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<Pick<Task, 'id' | 'isDone'>> = async ({ id, isDone }, context) => {
export const updateTaskIsDone: UpdateTaskIsDone<
Pick<Task, 'id' | 'isDone'>
> = async ({ id, isDone }, context) => {
if (!context.user) {
throw new HttpError(401)
}
@ -37,20 +45,24 @@ export const updateTaskIsDone: UpdateTaskIsDone<Pick<Task, 'id' | 'isDone'>> = 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) {

View File

@ -34,7 +34,7 @@ app todoApp {
},
db: {
system: PostgreSQL
}
},
}
entity User {=psl

View File

@ -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 -}

View File

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

View File

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

View File

@ -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

View File

@ -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|]

View File

@ -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

View File

@ -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"
}
}
}
)
]

View File

@ -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

View File

@ -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/|],

View File

@ -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

View File

@ -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.
:::

View File

@ -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: <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
<SendingEmailsInDevelopment />
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 <strong>world</strong>'
})
```
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=
```

View File

@ -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
<SendingEmailsInDevelopment />
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 <strong>world</strong>'
})
```

3
web/package-lock.json generated
View File

@ -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": {

View File

@ -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": [

View File

@ -58,6 +58,7 @@ module.exports = {
'integrations/css-frameworks',
'deploying',
'typescript',
'guides/sending-emails'
],
},