Skip to content

Commit

Permalink
feat(providers): allow styling e-mail through theme option (#4841)
Browse files Browse the repository at this point in the history
* fix(core): move email handling

* fix: don' use `replaceAll`

* feat(providers): re-use `theme` for e-mail

* docs: mention `theme` option for email

* fix: don't render user e-mail in the email HTML body

* docs: add missing comma

* refactor: fix lint

* refactor: fix lint
  • Loading branch information
balazsorban44 committed Jul 5, 2022
1 parent 4d4c276 commit ae834f1
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 169 deletions.
5 changes: 4 additions & 1 deletion apps/dev/pages/api/auth/[...nextauth].ts
Expand Up @@ -46,7 +46,10 @@ import BoxyHQSAMLProvider from "next-auth/providers/boxyhq-saml"
// })
// const adapter = FaunaAdapter(client)
export const authOptions: NextAuthOptions = {
// adapter,
// adapter: {
// getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
// createVerificationToken: (token) => token,
// } as any,
providers: [
// E-mail
// Start fake e-mail server with `npm run start:email`
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/configuration/options.md
Expand Up @@ -366,11 +366,14 @@ Changes the color scheme theme of [pages](/configuration/pages) as well as allow

In addition, you can define a logo URL in `theme.logo` which will be rendered above the main card in the default signin/signout/error/verify-request pages, as well as a `theme.brandColor` which will affect the accent color of these pages.

The sign-in button's background color will match the `brandColor` and defaults to `"#346df1"`. The text color is `#fff` by default, but if your brand color gives a weak contrast, correct it with the `buttonText` color option.

```js
theme: {
colorScheme: "auto", // "auto" | "dark" | "light"
brandColor: "", // Hex color code
logo: "" // Absolute URL to image
logo: "", // Absolute URL to image
buttonText: "" // Hex color code
}
```

Expand Down
91 changes: 49 additions & 42 deletions docs/docs/providers/email.md
Expand Up @@ -124,67 +124,74 @@ providers: [
The following code shows the complete source for the built-in `sendVerificationRequest()` method:

```js
import nodemailer from "nodemailer"
import { createTransport } from "nodemailer"

async function sendVerificationRequest({
identifier: email,
url,
provider: { server, from },
}) {
async function sendVerificationRequest(params) {
const { identifier, url, provider, theme } = params
const { host } = new URL(url)
const transport = nodemailer.createTransport(server)
await transport.sendMail({
to: email,
from,
// NOTE: You are not required to use `nodemailer`, use whatever you want.
const transport = createTransport(provider.server)
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${host}`,
text: text({ url, host }),
html: html({ url, host, email }),
html: html({ url, host, theme }),
})
const failed = result.rejected.concat(result.pending).filter(Boolean)
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
}
}

// Email HTML body
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click on their email address to sign in.
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`

// Some simple styling options
const backgroundColor = "#f9f9f9"
const textColor = "#444444"
const mainBackgroundColor = "#ffffff"
const buttonBackgroundColor = "#346df1"
const buttonBorderColor = "#346df1"
const buttonTextColor = "#ffffff"
/**
* Email HTML body
* Insert invisible space into domains from being turned into a hyperlink by email
* clients like Outlook and Apple mail, as this is confusing because it seems
* like they are supposed to click on it to sign in.
*
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/
function html(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params

const escapedHost = host.replace(/\./g, "&#8203;.")

const brandColor = theme.brandColor || "#346df1"
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || "#fff",
}

return `
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${escapedHost}</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in as <strong>${escapedEmail}</strong>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
Expand All @@ -193,8 +200,8 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
`
}

// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }: Record<"url" | "host", string>) {
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`
}
```
Expand Down
1 change: 1 addition & 0 deletions packages/next-auth/src/core/init.ts
Expand Up @@ -62,6 +62,7 @@ export async function init({
colorScheme: "auto",
logo: "",
brandColor: "",
buttonText: "",
},
// Custom options override defaults
...userOptions,
Expand Down
3 changes: 2 additions & 1 deletion packages/next-auth/src/core/lib/email/signin.ts
Expand Up @@ -10,7 +10,7 @@ export default async function email(
identifier: string,
options: InternalOptions<"email">
) {
const { url, adapter, provider, logger, callbackUrl } = options
const { url, adapter, provider, logger, callbackUrl, theme } = options

// Generate token
const token =
Expand Down Expand Up @@ -42,6 +42,7 @@ export default async function email(
expires,
url: _url,
provider,
theme,
})
} catch (error) {
logger.error("SEND_VERIFICATION_EMAIL_ERROR", {
Expand Down
11 changes: 1 addition & 10 deletions packages/next-auth/src/core/routes/signin.ts
Expand Up @@ -37,19 +37,10 @@ export default async function signin(params: {
* it solves. We treat email addresses as all lower case. If anyone
* complains about this we can make strict RFC 2821 compliance an option.
*/
let email = body?.email?.toLowerCase()
const email = body?.email?.toLowerCase()

if (!email) return { redirect: `${url}/error?error=EmailSignin` }

email = email
.split(",")[0]
.trim()
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#x27;")

// Verified in `assertConfig`
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { getUserByEmail } = adapter!
Expand Down
1 change: 1 addition & 0 deletions packages/next-auth/src/core/types.ts
Expand Up @@ -217,6 +217,7 @@ export interface Theme {
colorScheme: "auto" | "dark" | "light"
logo?: string
brandColor?: string
buttonText?: string
}

/**
Expand Down
120 changes: 65 additions & 55 deletions packages/next-auth/src/providers/email.ts
Expand Up @@ -3,6 +3,16 @@ import { createTransport } from "nodemailer"
import type { CommonProviderOptions } from "."
import type { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
import type { Awaitable } from ".."
import type { Theme } from "../core/types"

export interface SendVerificationRequestParams {
identifier: string
url: string
expires: Date
provider: EmailConfig
token: string
theme: Theme
}

export interface EmailConfig extends CommonProviderOptions {
type: "email"
Expand All @@ -16,13 +26,10 @@ export interface EmailConfig extends CommonProviderOptions {
* @default 86400
*/
maxAge?: number
sendVerificationRequest: (params: {
identifier: string
url: string
expires: Date
provider: EmailConfig
token: string
}) => Awaitable<void>
/** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */
sendVerificationRequest: (
params: SendVerificationRequestParams
) => Awaitable<void>
/**
* By default, we are generating a random verification token.
* You can make it predictable or modify it as you like with this method.
Expand Down Expand Up @@ -56,78 +63,81 @@ export default function Email(options: EmailUserConfig): EmailConfig {
type: "email",
name: "Email",
// Server can be an SMTP connection string or a nodemailer config object
server: {
host: "localhost",
port: 25,
auth: {
user: "",
pass: "",
},
},
server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
from: "NextAuth <no-reply@example.com>",
maxAge: 24 * 60 * 60,
async sendVerificationRequest({
identifier: email,
url,
provider: { server, from },
}) {
async sendVerificationRequest(params) {
const { identifier, url, provider, theme } = params
const { host } = new URL(url)
const transport = createTransport(server)
await transport.sendMail({
to: email,
from,
const transport = createTransport(provider.server)
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${host}`,
text: text({ url, host }),
html: html({ url, host, email }),
html: html({ url, host, theme }),
})
const failed = result.rejected.concat(result.pending).filter(Boolean)
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
}
},
options,
}
}

// Email HTML body
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click on their email address to sign in.
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
/**
* Email HTML body
* Insert invisible space into domains from being turned into a hyperlink by email
* clients like Outlook and Apple mail, as this is confusing because it seems
* like they are supposed to click on it to sign in.
*
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/
function html(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params

const escapedHost = host.replace(/\./g, "&#8203;.")

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const brandColor = theme.brandColor || "#346df1"
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const buttonText = theme.buttonText || "#fff"

// Some simple styling options
const backgroundColor = "#f9f9f9"
const textColor = "#444444"
const mainBackgroundColor = "#ffffff"
const buttonBackgroundColor = "#346df1"
const buttonBorderColor = "#346df1"
const buttonTextColor = "#ffffff"
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText,
}

return `
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${escapedHost}</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in as <strong>${escapedEmail}</strong>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
Expand All @@ -136,7 +146,7 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
`
}

// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }: Record<"url" | "host", string>) {
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`
}

1 comment on commit ae834f1

@vercel
Copy link

@vercel vercel bot commented on ae834f1 Jul 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.