diff --git a/convex/auth.ts b/convex/auth.ts index e513458..64ad900 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -8,6 +8,7 @@ import { DataModel } from './_generated/dataModel'; import { query } from './_generated/server'; import authConfig from './auth.config'; import authSchema from './betterAuth/schema'; +import { sendEmail } from './lib/resend'; const siteUrl = process.env.SITE_URL!; @@ -31,7 +32,24 @@ export const createAuthOptions = ( maxPasswordLength: 128, autoSignIn: true, enabled: true, - requireEmailVerification: false, + requireEmailVerification: true, + sendResetPassword: async ({ user, url }) => { + void sendEmail({ + to: user.email, + subject: 'Reset your password', + html: `

Click here to reset your password.

`, + }); + }, + }, + emailVerification: { + sendOnSignUp: true, + sendVerificationEmail: async ({ user, url }) => { + void sendEmail({ + to: user.email, + subject: 'Verify your email', + html: `

Click here to verify your email address.

`, + }); + }, }, plugins: [convex({ authConfig }), haveIBeenPwned()], }; diff --git a/convex/lib/resend.ts b/convex/lib/resend.ts new file mode 100644 index 0000000..15d3d01 --- /dev/null +++ b/convex/lib/resend.ts @@ -0,0 +1,31 @@ +const RESEND_API_KEY = process.env.RESEND_API_KEY!; +const RESEND_FROM_EMAIL = process.env.RESEND_FROM_EMAIL!; + +interface SendEmailOptions { + to: string; + subject: string; + html: string; +} + +export async function sendEmail({ to, subject, html }: SendEmailOptions) { + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: RESEND_FROM_EMAIL, + to, + subject, + html, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to send email: ${error}`); + } + + return response.json(); +} diff --git a/package.json b/package.json index 547bf97..9b586a5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.72.0", + "resend": "^6.12.2", "shadcn": "^4.1.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c3cc7c..1dc41a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: react-hook-form: specifier: ^7.72.0 version: 7.72.0(react@19.2.4) + resend: + specifier: ^6.12.2 + version: 6.12.2 shadcn: specifier: ^4.1.1 version: 4.1.1(@types/node@20.19.37)(typescript@5.9.3) @@ -1798,6 +1801,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3170,6 +3176,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4167,6 +4176,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -4353,6 +4365,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.12.2: + resolution: {integrity: sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4523,6 +4544,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4625,6 +4649,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.90.0: + resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4812,6 +4839,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6652,6 +6683,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -7995,6 +8028,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -8925,6 +8960,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -9174,6 +9211,11 @@ snapshots: require-from-string@2.0.2: {} + resend@6.12.2: + dependencies: + postal-mime: 2.7.4 + svix: 1.90.0 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -9438,6 +9480,11 @@ snapshots: stable-hash@0.0.5: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -9552,6 +9599,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.90.0: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -9772,6 +9824,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + validate-npm-package-name@7.0.2: {} vary@1.1.2: {}