Skip to content

Commit ae834f1

Browse files
authoredJul 5, 2022
feat(providers): allow styling e-mail through theme option (#4841)
* 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
1 parent 4d4c276 commit ae834f1

File tree

9 files changed

+127
-169
lines changed

9 files changed

+127
-169
lines changed
 

‎apps/dev/pages/api/auth/[...nextauth].ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ import BoxyHQSAMLProvider from "next-auth/providers/boxyhq-saml"
4646
// })
4747
// const adapter = FaunaAdapter(client)
4848
export const authOptions: NextAuthOptions = {
49-
// adapter,
49+
// adapter: {
50+
// getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
51+
// createVerificationToken: (token) => token,
52+
// } as any,
5053
providers: [
5154
// E-mail
5255
// Start fake e-mail server with `npm run start:email`

‎docs/docs/configuration/options.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,14 @@ Changes the color scheme theme of [pages](/configuration/pages) as well as allow
366366

367367
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.
368368

369+
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.
370+
369371
```js
370372
theme: {
371373
colorScheme: "auto", // "auto" | "dark" | "light"
372374
brandColor: "", // Hex color code
373-
logo: "" // Absolute URL to image
375+
logo: "", // Absolute URL to image
376+
buttonText: "" // Hex color code
374377
}
375378
```
376379

‎docs/docs/providers/email.md

+49-42
Original file line numberDiff line numberDiff line change
@@ -124,67 +124,74 @@ providers: [
124124
The following code shows the complete source for the built-in `sendVerificationRequest()` method:
125125

126126
```js
127-
import nodemailer from "nodemailer"
127+
import { createTransport } from "nodemailer"
128128

129-
async function sendVerificationRequest({
130-
identifier: email,
131-
url,
132-
provider: { server, from },
133-
}) {
129+
async function sendVerificationRequest(params) {
130+
const { identifier, url, provider, theme } = params
134131
const { host } = new URL(url)
135-
const transport = nodemailer.createTransport(server)
136-
await transport.sendMail({
137-
to: email,
138-
from,
132+
// NOTE: You are not required to use `nodemailer`, use whatever you want.
133+
const transport = createTransport(provider.server)
134+
const result = await transport.sendMail({
135+
to: identifier,
136+
from: provider.from,
139137
subject: `Sign in to ${host}`,
140138
text: text({ url, host }),
141-
html: html({ url, host, email }),
139+
html: html({ url, host, theme }),
142140
})
141+
const failed = result.rejected.concat(result.pending).filter(Boolean)
142+
if (failed.length) {
143+
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
144+
}
143145
}
144146

145-
// Email HTML body
146-
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
147-
// Insert invisible space into domains and email address to prevent both the
148-
// email address and the domain from being turned into a hyperlink by email
149-
// clients like Outlook and Apple mail, as this is confusing because it seems
150-
// like they are supposed to click on their email address to sign in.
151-
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
152-
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
153-
154-
// Some simple styling options
155-
const backgroundColor = "#f9f9f9"
156-
const textColor = "#444444"
157-
const mainBackgroundColor = "#ffffff"
158-
const buttonBackgroundColor = "#346df1"
159-
const buttonBorderColor = "#346df1"
160-
const buttonTextColor = "#ffffff"
147+
/**
148+
* Email HTML body
149+
* Insert invisible space into domains from being turned into a hyperlink by email
150+
* clients like Outlook and Apple mail, as this is confusing because it seems
151+
* like they are supposed to click on it to sign in.
152+
*
153+
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
154+
*/
155+
function html(params: { url: string; host: string; theme: Theme }) {
156+
const { url, host, theme } = params
157+
158+
const escapedHost = host.replace(/\./g, "&#8203;.")
159+
160+
const brandColor = theme.brandColor || "#346df1"
161+
const color = {
162+
background: "#f9f9f9",
163+
text: "#444",
164+
mainBackground: "#fff",
165+
buttonBackground: brandColor,
166+
buttonBorder: brandColor,
167+
buttonText: theme.buttonText || "#fff",
168+
}
161169

162170
return `
163-
<body style="background: ${backgroundColor};">
164-
<table width="100%" border="0" cellspacing="0" cellpadding="0">
171+
<body style="background: ${color.background};">
172+
<table width="100%" border="0" cellspacing="20" cellpadding="0"
173+
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
165174
<tr>
166-
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
167-
<strong>${escapedHost}</strong>
168-
</td>
169-
</tr>
170-
</table>
171-
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
172-
<tr>
173-
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
174-
Sign in as <strong>${escapedEmail}</strong>
175+
<td align="center"
176+
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
177+
Sign in to <strong>${escapedHost}</strong>
175178
</td>
176179
</tr>
177180
<tr>
178181
<td align="center" style="padding: 20px 0;">
179182
<table border="0" cellspacing="0" cellpadding="0">
180183
<tr>
181-
<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>
184+
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
185+
target="_blank"
186+
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
187+
in</a></td>
182188
</tr>
183189
</table>
184190
</td>
185191
</tr>
186192
<tr>
187-
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
193+
<td align="center"
194+
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
188195
If you did not request this email you can safely ignore it.
189196
</td>
190197
</tr>
@@ -193,8 +200,8 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
193200
`
194201
}
195202

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

‎packages/next-auth/src/core/init.ts

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export async function init({
6262
colorScheme: "auto",
6363
logo: "",
6464
brandColor: "",
65+
buttonText: "",
6566
},
6667
// Custom options override defaults
6768
...userOptions,

‎packages/next-auth/src/core/lib/email/signin.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default async function email(
1010
identifier: string,
1111
options: InternalOptions<"email">
1212
) {
13-
const { url, adapter, provider, logger, callbackUrl } = options
13+
const { url, adapter, provider, logger, callbackUrl, theme } = options
1414

1515
// Generate token
1616
const token =
@@ -42,6 +42,7 @@ export default async function email(
4242
expires,
4343
url: _url,
4444
provider,
45+
theme,
4546
})
4647
} catch (error) {
4748
logger.error("SEND_VERIFICATION_EMAIL_ERROR", {

‎packages/next-auth/src/core/routes/signin.ts

+1-10
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,10 @@ export default async function signin(params: {
3737
* it solves. We treat email addresses as all lower case. If anyone
3838
* complains about this we can make strict RFC 2821 compliance an option.
3939
*/
40-
let email = body?.email?.toLowerCase()
40+
const email = body?.email?.toLowerCase()
4141

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

44-
email = email
45-
.split(",")[0]
46-
.trim()
47-
.replaceAll("&", "&amp;")
48-
.replaceAll("<", "&lt;")
49-
.replaceAll(">", "&gt;")
50-
.replaceAll('"', "&quot;")
51-
.replaceAll("'", "&#x27;")
52-
5344
// Verified in `assertConfig`
5445
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5546
const { getUserByEmail } = adapter!

‎packages/next-auth/src/core/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export interface Theme {
217217
colorScheme: "auto" | "dark" | "light"
218218
logo?: string
219219
brandColor?: string
220+
buttonText?: string
220221
}
221222

222223
/**

‎packages/next-auth/src/providers/email.ts

+65-55
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import { createTransport } from "nodemailer"
33
import type { CommonProviderOptions } from "."
44
import type { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
55
import type { Awaitable } from ".."
6+
import type { Theme } from "../core/types"
7+
8+
export interface SendVerificationRequestParams {
9+
identifier: string
10+
url: string
11+
expires: Date
12+
provider: EmailConfig
13+
token: string
14+
theme: Theme
15+
}
616

717
export interface EmailConfig extends CommonProviderOptions {
818
type: "email"
@@ -16,13 +26,10 @@ export interface EmailConfig extends CommonProviderOptions {
1626
* @default 86400
1727
*/
1828
maxAge?: number
19-
sendVerificationRequest: (params: {
20-
identifier: string
21-
url: string
22-
expires: Date
23-
provider: EmailConfig
24-
token: string
25-
}) => Awaitable<void>
29+
/** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */
30+
sendVerificationRequest: (
31+
params: SendVerificationRequestParams
32+
) => Awaitable<void>
2633
/**
2734
* By default, we are generating a random verification token.
2835
* You can make it predictable or modify it as you like with this method.
@@ -56,78 +63,81 @@ export default function Email(options: EmailUserConfig): EmailConfig {
5663
type: "email",
5764
name: "Email",
5865
// Server can be an SMTP connection string or a nodemailer config object
59-
server: {
60-
host: "localhost",
61-
port: 25,
62-
auth: {
63-
user: "",
64-
pass: "",
65-
},
66-
},
66+
server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
6767
from: "NextAuth <no-reply@example.com>",
6868
maxAge: 24 * 60 * 60,
69-
async sendVerificationRequest({
70-
identifier: email,
71-
url,
72-
provider: { server, from },
73-
}) {
69+
async sendVerificationRequest(params) {
70+
const { identifier, url, provider, theme } = params
7471
const { host } = new URL(url)
75-
const transport = createTransport(server)
76-
await transport.sendMail({
77-
to: email,
78-
from,
72+
const transport = createTransport(provider.server)
73+
const result = await transport.sendMail({
74+
to: identifier,
75+
from: provider.from,
7976
subject: `Sign in to ${host}`,
8077
text: text({ url, host }),
81-
html: html({ url, host, email }),
78+
html: html({ url, host, theme }),
8279
})
80+
const failed = result.rejected.concat(result.pending).filter(Boolean)
81+
if (failed.length) {
82+
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
83+
}
8384
},
8485
options,
8586
}
8687
}
8788

88-
// Email HTML body
89-
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
90-
// Insert invisible space into domains and email address to prevent both the
91-
// email address and the domain from being turned into a hyperlink by email
92-
// clients like Outlook and Apple mail, as this is confusing because it seems
93-
// like they are supposed to click on their email address to sign in.
94-
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
95-
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
89+
/**
90+
* Email HTML body
91+
* Insert invisible space into domains from being turned into a hyperlink by email
92+
* clients like Outlook and Apple mail, as this is confusing because it seems
93+
* like they are supposed to click on it to sign in.
94+
*
95+
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
96+
*/
97+
function html(params: { url: string; host: string; theme: Theme }) {
98+
const { url, host, theme } = params
99+
100+
const escapedHost = host.replace(/\./g, "&#8203;.")
101+
102+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
103+
const brandColor = theme.brandColor || "#346df1"
104+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
105+
const buttonText = theme.buttonText || "#fff"
96106

97-
// Some simple styling options
98-
const backgroundColor = "#f9f9f9"
99-
const textColor = "#444444"
100-
const mainBackgroundColor = "#ffffff"
101-
const buttonBackgroundColor = "#346df1"
102-
const buttonBorderColor = "#346df1"
103-
const buttonTextColor = "#ffffff"
107+
const color = {
108+
background: "#f9f9f9",
109+
text: "#444",
110+
mainBackground: "#fff",
111+
buttonBackground: brandColor,
112+
buttonBorder: brandColor,
113+
buttonText,
114+
}
104115

105116
return `
106-
<body style="background: ${backgroundColor};">
107-
<table width="100%" border="0" cellspacing="0" cellpadding="0">
108-
<tr>
109-
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
110-
<strong>${escapedHost}</strong>
111-
</td>
112-
</tr>
113-
</table>
114-
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
117+
<body style="background: ${color.background};">
118+
<table width="100%" border="0" cellspacing="20" cellpadding="0"
119+
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
115120
<tr>
116-
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
117-
Sign in as <strong>${escapedEmail}</strong>
121+
<td align="center"
122+
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
123+
Sign in to <strong>${escapedHost}</strong>
118124
</td>
119125
</tr>
120126
<tr>
121127
<td align="center" style="padding: 20px 0;">
122128
<table border="0" cellspacing="0" cellpadding="0">
123129
<tr>
124-
<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>
130+
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
131+
target="_blank"
132+
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
133+
in</a></td>
125134
</tr>
126135
</table>
127136
</td>
128137
</tr>
129138
<tr>
130-
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
139+
<td align="center"
140+
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
131141
If you did not request this email you can safely ignore it.
132142
</td>
133143
</tr>
@@ -136,7 +146,7 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
136146
`
137147
}
138148

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

‎packages/next-auth/tests/email.test.ts

-59
This file was deleted.

1 commit comments

Comments
 (1)
Please sign in to comment.