Skip to content

Commit 980fd7e

Browse files
authored
Added smtp email support and email verification for user auth (#52)
* Add email functionality with Nodemailer configuration - Integrated Nodemailer for sending emails with a configurable SMTP transport - Added email-related environment variables to .env.example - Created email action and library for sending emails - Included TypeScript types for Nodemailer - Configured email sending with Resend as the SMTP provider * Update README with Email Configuration and Usage Guide - Added comprehensive email configuration section to README - Included environment variables for SMTP email settings - Documented email sending methods (form actions and programmatic) - Provided recommended email service providers - Enhanced documentation for email functionality in the project * Add welcome email to sign-up process - Integrated email sending functionality in the sign-up handler - Send a welcome email to new users upon successful registration - Imported sendEmail utility from the email library - Configured a simple welcome message for new sign-ups * Add bot prevention mechanism to sign-up process - Implemented a honeypot field in the sign-up form to prevent bot registrations - Added middleware validation in the auth action to reject sign-ups with the honeypot field - Introduced a hidden input field in the sign-up page to catch automated bot submissions * Implement email verification flow - Added email verification configuration in auth library - Created a new verify-email page for unverified users - Updated dashboard to redirect unverified users to email verification - Modified Verification table to include updatedAt column - Removed manual email sending from sign-up action * Update sign-out redirect to sign-in page * Add configurable email verification toggle - Added BETTER_AUTH_EMAIL_VERIFICATION environment variable - Updated .env.example with new configuration option - Modified auth library to conditionally send email verification - Updated dashboard to respect email verification configuration - Simplified README email provider recommendations * Update README tip formatting * Simplify README email usage documentation * rm email action * updated package version
1 parent 437efb1 commit 980fd7e

14 files changed

+214
-14
lines changed

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ ASTRO_DB_APP_TOKEN=""
55
# Better Auth (required) - https://www.better-auth.com/docs/installation#set-environment-variables
66
BETTER_AUTH_SECRET=""
77
BETTER_AUTH_URL="http://localhost:4321"
8+
BETTER_AUTH_EMAIL_VERIFICATION="false"
9+
10+
# Mail server configuration
11+
MAIL_HOST=smtp.resend.com
12+
MAIL_PORT=465
13+
MAIL_SECURE=true
14+
MAIL_AUTH_USER=resend
15+
MAIL_AUTH_PASS=your_resend_api_key
16+
MAIL_FROM=your_verified_sender@yourdomain.com

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A modern, type-safe web development stack using Astro, TypeScript, HTMX, Alpine.js, and more.
44

5-
> [!TIP] 💡
5+
> [!TIP]
66
> [Turso](https://tur.so/freedomstack) has a generous free tier for database hosting and management. And, when it's time to scale, use the code `FREEDOMSTACK` for a discount on paid plans.
77
88
## Get Started 🚀
@@ -39,6 +39,14 @@ ASTRO_DB_APP_TOKEN="" # Added by npm run db:setup
3939
# Better Auth (required)
4040
BETTER_AUTH_SECRET="" # Auto-generated during setup
4141
BETTER_AUTH_URL="http://localhost:4321"
42+
43+
# Email Configuration (optional) - For sending emails
44+
MAIL_HOST="" # SMTP host (e.g., smtp.resend.com)
45+
MAIL_PORT="" # SMTP port (e.g., 465)
46+
MAIL_SECURE="" # Use TLS/SSL (true/false)
47+
MAIL_AUTH_USER="" # SMTP username
48+
MAIL_AUTH_PASS="" # SMTP password or API key
49+
MAIL_FROM="" # Sender email address
4250
```
4351

4452
### 3. Have fun!
@@ -139,6 +147,42 @@ npm run host:deploy
139147

140148
---
141149

150+
## Send Emails
151+
152+
## Email Configuration 📧
153+
154+
Freedom Stack includes a pre-configured email service using Nodemailer. This allows you to:
155+
156+
- Send transactional emails
157+
- Use any SMTP provider
158+
- Handle email templates
159+
- Maintain type safety
160+
161+
### Setting up Email
162+
163+
1. Configure your environment variables as shown above
164+
165+
Send emails programmatically:
166+
167+
```typescript
168+
import { sendEmail } from "@/lib/email";
169+
170+
await sendEmail({
171+
to: "recipient@example.com",
172+
subject: "Hello!",
173+
html: "<h1>Welcome!</h1>"
174+
});
175+
```
176+
177+
### Email Providers
178+
179+
While you can use any SMTP provider, we recommend [Resend](https://resend.com) - Modern email API with generous free tier.
180+
181+
> [!TIP]
182+
> Resend offers 100 emails/day free and has excellent developer experience.
183+
184+
---
185+
142186
## Vision ❤️
143187

144188
I dream of a lightweight, simple web development stack that invokes a fun web

db/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ const Verification = defineTable({
6262
identifier: column.text(),
6363
value: column.text(),
6464
expiresAt: column.date(),
65-
createdAt: column.date()
65+
createdAt: column.date(),
66+
updatedAt: column.date()
6667
}
6768
});
6869

package-lock.json

Lines changed: 20 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "create-freedom-stack",
33
"type": "module",
4-
"version": "1.0.10",
4+
"version": "1.0.11",
55
"description": "Create a new Freedom Stack project - A modern, type-safe web development stack using Astro, TypeScript, HTMX, Alpine.js, and more",
66
"author": "Cameron Pak",
77
"license": "MIT",
@@ -66,6 +66,7 @@
6666
"@astrojs/tailwind": "^5.1.5",
6767
"@iconify-json/lucide": "^1.2.24",
6868
"@iconify-json/lucide-lab": "^1.2.3",
69+
"@types/nodemailer": "^6.4.17",
6970
"alpinejs": "^3.14.8",
7071
"astro": "^5.1.9",
7172
"astro-iconify": "^1.2.0",
@@ -77,6 +78,7 @@
7778
"htmx.org": "2.0.1",
7879
"isomorphic-dompurify": "^2.20.0",
7980
"marked": "^15.0.6",
81+
"nodemailer": "^6.10.0",
8082
"ora": "^8.1.1",
8183
"trix": "^2.1.12"
8284
},

src/actions/auth.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@ export const auth = {
5353
email: z.string().email(),
5454
password: z.string(),
5555
name: z.string(),
56-
imageUrl: z.string().optional()
56+
imageUrl: z.string().optional(),
57+
middleware: z.string().optional()
5758
}),
58-
handler: async (input, context) =>
59-
await handleAuthResponse(
59+
handler: async (input, context) => {
60+
if (input.middleware) {
61+
throw new ActionError({
62+
code: "BAD_REQUEST",
63+
message: "Bots are not allowed to sign up"
64+
});
65+
}
66+
67+
return await handleAuthResponse(
6068
() =>
6169
betterAuth.api.signUpEmail({
6270
body: { ...input, image: input.imageUrl || "" },
@@ -65,7 +73,8 @@ export const auth = {
6573
}),
6674
context,
6775
"BAD_REQUEST"
68-
)
76+
);
77+
}
6978
}),
7079

7180
signIn: defineAction({

src/env.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ interface ImportMetaEnv {
2828
readonly BETTER_AUTH_URL: string;
2929
/** https://better-auth.com/ */
3030
readonly BETTER_AUTH_SECRET: string;
31+
/** Toggle on email verification */
32+
readonly BETTER_AUTH_EMAIL_VERIFICATION: "true" | "false";
33+
/** Mail server host */
34+
readonly MAIL_HOST: string;
35+
/** Mail server port */
36+
readonly MAIL_PORT: string;
37+
/** Mail server secure setting */
38+
readonly MAIL_SECURE: string;
39+
/** Mail server auth user */
40+
readonly MAIL_AUTH_USER: string;
41+
/** Mail server auth password */
42+
readonly MAIL_AUTH_PASS: string;
43+
/** Email address to send from */
44+
readonly MAIL_FROM: string;
3145
}
3246

3347
interface ImportMeta {

src/lib/auth.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { betterAuth } from "better-auth";
22
import { Account, db, Session, User, Verification } from "astro:db";
33
import { drizzleAdapter } from "better-auth/adapters/drizzle";
4+
import { sendEmail } from "@/lib/email";
45

56
export const auth = betterAuth({
67
baseURL: import.meta.env.BETTER_AUTH_URL,
@@ -19,6 +20,18 @@ export const auth = betterAuth({
1920
},
2021
provider: "sqlite"
2122
}),
23+
emailVerification: {
24+
sendOnSignUp: import.meta.env.BETTER_AUTH_EMAIL_VERIFICATION === "true",
25+
sendVerificationEmail: async ({ user, url, token }, request) => {
26+
const updatedUrl = new URL(url);
27+
updatedUrl.searchParams.set("callbackURL", "/sign-out");
28+
await sendEmail({
29+
to: user.email,
30+
subject: "Verify your email address",
31+
html: `<a href="${updatedUrl}">Click the link to verify your email</a>`
32+
});
33+
}
34+
},
2235
emailAndPassword: {
2336
enabled: true
2437
},

src/lib/email.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Adapted from: https://developers.netlify.com/guides/send-emails-with-astro-and-resend/
3+
*/
4+
import { createTransport, type Transporter } from "nodemailer";
5+
6+
type SendEmailOptions = {
7+
/** Email address of the recipient */
8+
to: string;
9+
/** Subject line of the email */
10+
subject: string;
11+
/** Message used for the body of the email */
12+
html: string;
13+
};
14+
15+
// Singleton instance
16+
let transporter: Transporter | null = null;
17+
18+
async function getEmailTransporter(): Promise<Transporter> {
19+
// Return existing transporter if already initialized
20+
if (transporter) {
21+
return transporter;
22+
}
23+
24+
const requiredEnvVars = ["MAIL_HOST", "MAIL_PORT", "MAIL_SECURE", "MAIL_AUTH_USER", "MAIL_AUTH_PASS", "MAIL_FROM"];
25+
26+
const missingEnvVars = requiredEnvVars.filter((envVar) => !import.meta.env[envVar]);
27+
28+
if (missingEnvVars.length > 0) {
29+
throw new Error(`Missing mail configuration: ${missingEnvVars.join(", ")}`);
30+
}
31+
32+
// Create new transporter if none exists
33+
transporter = createTransport({
34+
host: import.meta.env.MAIL_HOST,
35+
port: parseInt(import.meta.env.MAIL_PORT),
36+
secure: import.meta.env.MAIL_SECURE === "true",
37+
auth: {
38+
user: import.meta.env.MAIL_AUTH_USER,
39+
pass: import.meta.env.MAIL_AUTH_PASS
40+
}
41+
});
42+
43+
return transporter;
44+
}
45+
46+
export async function sendEmail(options: SendEmailOptions): Promise<Transporter> {
47+
const emailTransporter = await getEmailTransporter();
48+
return new Promise(async (resolve, reject) => {
49+
// Build the email message
50+
const { to, subject, html } = options;
51+
const from = import.meta.env.MAIL_FROM;
52+
const message = { to, subject, html, from };
53+
54+
// Send the email
55+
emailTransporter.sendMail(message, (err, info) => {
56+
if (err) {
57+
console.error(err);
58+
reject(err);
59+
}
60+
console.log("Message sent:", info.messageId);
61+
resolve(info);
62+
});
63+
});
64+
}

src/middleware.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
1212
} else {
1313
context.locals.user = null;
1414
context.locals.session = null;
15-
if (context.url.pathname === "/sign-out") {
16-
return context.redirect("/");
17-
}
1815
}
1916

2017
return next();

src/pages/dashboard/index.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ if (!Astro.locals.session) {
1010
return Astro.redirect("/sign-in");
1111
}
1212
13-
const user = Astro.locals.user as { email: string };
13+
const user = Astro.locals.user;
1414
1515
if (!user) {
1616
return Astro.redirect("/sign-in");
17+
} else if (!user.emailVerified && import.meta.env.BETTER_AUTH_EMAIL_VERIFICATION === "true") {
18+
return Astro.redirect("/verify-email");
1719
}
1820
1921
const allPosts = await db.select().from(Posts).where(eq(Posts.author, user.email)).orderBy(desc(Posts.pubDate));

src/pages/sign-out.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ if (result?.data?.success) {
88
setAuthCookiesFromResponse(result.data.cookiesToSet, Astro.cookies);
99
}
1010
11-
return Astro.redirect("/");
11+
return Astro.redirect("/sign-in");
1212
---

src/pages/sign-up.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ if (result?.data?.success) {
6060
placeholder="email@example.com"
6161
/>
6262
</label>
63+
{/* Honeypot field to prevent bots from signing up */}
64+
<input type="hidden" name="middleware" />
6365
<label class="form-control w-full">
6466
<div class="label">
6567
<span class="label-text">Password</span>

src/pages/verify-email.astro

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
import Layout from "@/layouts/Layout.astro";
3+
import Container from "@sections/Container.astro";
4+
import Navbar from "@sections/Navbar.astro";
5+
import Footer from "@sections/Footer.astro";
6+
7+
const user = Astro.locals.user;
8+
9+
if (!user) {
10+
return Astro.redirect("/sign-in");
11+
}
12+
13+
const email = user?.email;
14+
---
15+
16+
<Layout title="Verify Email">
17+
<Navbar title="Freedom Stack" sticky links={[{ text: "Home", href: "/" }]} />
18+
<Container align="center">
19+
<div class="bg-white p-6 rounded-xl border-2 border-slate-200 flex flex-col gap-4 max-w-sm w-full">
20+
<h1>Verify Your Email</h1>
21+
<p class="text-slate-600">Please click the link in your email ({email}) to verify your account.</p>
22+
</div>
23+
</Container>
24+
<Footer />
25+
</Layout>

0 commit comments

Comments
 (0)