Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: newsletter #80

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -17,4 +17,5 @@ LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=

# Newsletter Subscribers
MONGODB_URI=
MONGODB_URI=
MAILGUN_API_KEY=
56 changes: 36 additions & 20 deletions components/Newsletter/SubscribeForm.tsx
Original file line number Diff line number Diff line change
@@ -3,15 +3,13 @@ import toast, { Toaster } from 'react-hot-toast'
import validator from 'validator'
import style from './newsletterform.module.css'

const isDevelopment = true //TODO
const isDevelopment = false

const SubscribeForm = () => {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)

const handleSubmit = async (e) => {
e.preventDefault()

const handleSubscribe = async () => {
if (!validator.isEmail(email)) {
toast.error('Valid email is required')
return
@@ -22,44 +20,62 @@ const SubscribeForm = () => {
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})

if (response.status === 201) {
toast.success('Subscription successful')
setEmail('')
} else if (response.status === 409) {
toast.error('Email already subscribed')
} else {
toast.error('Subscription failed')
}
handleSubscribeResponse(response)
} catch (error) {
toast.error('Subscription failed')
} finally {
setIsLoading(false)
}
}

const handleSubscribeResponse = async (response) => {
if (response.status === 201) {
toast.success('Subscription successful, check your email to verify your subscription')
setEmail('')
return
}

if (response.status === 409) {
toast.error('Email already subscribed')
return
}

const data = await response.json()
if (data.reasons?.length > 0) {
toast.error(`Subscription failed: ${data.reasons.join(', ')}`)
} else if (data.didYouMean) {
toast.error(`Subscription failed. Did you mean: ${data.didYouMean}?`)
} else {
toast.error('Subscription failed')
}
}

const handleSubmit = (e) => {
e.preventDefault()
handleSubscribe()
}

return (
<div className={style.container}>
<Toaster position="bottom-center" reverseOrder={false} />
<div className={style[`form-wrapper`]}>
<h2 className={style[`form-title`]}>Subscribe to Our Newsletter</h2>
<form onSubmit={handleSubmit} className={style[`form-container`]}>
<div className={style['form-wrapper']}>
<h2 className={style['form-title']}>Subscribe to Our Newsletter</h2>
<form onSubmit={handleSubmit} className={style['form-container']}>
<input
type="email"
placeholder={isDevelopment ? 'Coming soon...' : 'Enter your email'}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={style[`email-input`]}
className={style['email-input']}
readOnly={isDevelopment}
/>
<button
type="submit"
className={style[`subscribe-button`]}
className={style['subscribe-button']}
disabled={isLoading || isDevelopment}
>
{isLoading ? 'Subscribing...' : 'Subscribe'}
Empty file.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -44,13 +44,15 @@
"clsx": "^2.1.0",
"eslint-config-next": "14.2.3",
"eslint-plugin-unicorn": "^53.0.0",
"form-data": "^4.0.0",
"framer-motion": "^11.1.9",
"geist": "^1.3.0",
"gpt3-tokenizer": "^1.1.5",
"install": "^0.13.0",
"js-yaml": "^4.1.0",
"langfuse": "3.10.0",
"lucide-react": "^0.378.0",
"mailgun.js": "^10.2.1",
"mongodb": "^6.6.1",
"mongoose": "^8.3.4",
"next": "^14.2.1",
91 changes: 87 additions & 4 deletions pages/api/subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,74 @@
import validator from 'validator'
import dbConnect from '@/utils/dbConnect'
import Subscriber from '@/utils/Subscriber'
import crypto from 'crypto'
import FormData from 'form-data'
import Mailgun from 'mailgun.js'
import { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(req, res) {
interface EmailValidationResult {
result: string
engagement: {
behavior: string
isbot: boolean
engaging: boolean
}
risk: string
address: string
did_you_mean: string | null
reason: string[]
}

const mailgun = new Mailgun(FormData)
const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY,
})

async function createMember(email: string): Promise<void> {
try {
const result = await mg.lists.members.createMember(process.env.MAILGUN_LIST, {
address: email,
subscribed: false,
})
console.log('Member created:', result)
} catch (error) {
console.error('Failed to create member:', error)
}
}

async function sendVerificationEmail(userEmail: string, link: string): Promise<void> {
try {
const result = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: '[email protected]',
to: userEmail,
subject: "And one last thing... Let's verify your email!",
html: `<p>Please verify your email by clicking the following link: <a href="${link}">here</a></p>`,
})
console.log('Email sent:', result)
} catch (error) {
console.error('Failed to send email:', error)
}
}

async function validateUserEmail(
userEmail: string,
): Promise<{ isValid: boolean; reasons?: string[]; didYouMean?: string }> {
try {
const result = (await mg.validate.get(userEmail)) as unknown as EmailValidationResult
console.log('Validation result:', result)
const isValid =
result.result === 'deliverable' && !result.engagement.isbot && result.risk === 'low'
const reasons = isValid ? undefined : result.reason
const didYouMean = result.did_you_mean
return { isValid, reasons, didYouMean }
} catch (error) {
console.error('Failed to validate email:', error)
return { isValid: false, reasons: ['Failed to validate email due to an error.'] }
}
}

export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const { method, body } = req

if (method !== 'POST') {
@@ -24,10 +90,27 @@ export default async function handler(req, res) {
return res.status(409).json({ message: 'Email already subscribed' })
}

const newSubscriber = new Subscriber({ email })
await newSubscriber.save()
const { isValid, reasons, didYouMean } = await validateUserEmail(email)

if (!isValid) {
return res.status(422).json({
message: 'Invalid email address',
reasons,
didYouMean,
})
}

await createMember(email)

const token = crypto.randomBytes(32).toString('hex')

await new Subscriber({ email, token }).save()

const verificationLink = `https://librechat.ai/api/verify?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`

await sendVerificationEmail(email, verificationLink)

return res.status(201).json({ message: 'Subscription successful' })
return res.status(201).json({ message: 'Verification email sent. Please check your inbox.' })
} catch (error) {
console.error('Error:', error)
return res.status(500).json({ message: 'Subscription failed' })
62 changes: 38 additions & 24 deletions pages/api/unsubscribe.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,51 @@
import dbConnect from '@/utils/dbConnect'
import Subscriber from '@/utils/Subscriber'
import validator from 'validator'
import Mailgun from 'mailgun.js'

const mailgun = new Mailgun(FormData)
const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY,
})

async function deleteUser(email: string): Promise<void> {
try {
const result = await mg.lists.members.destroyMember(process.env.MAILGUN_LIST, email)
console.log('Member deleted:', result)
} catch (error) {
console.error('Failed to delete member:', error)
}
}

export default async function handler(req, res) {
if (req.method === 'POST') {
const { email } = req.body
if (req.method !== 'POST') {
res.status(405).json({ message: 'Method Not Allowed' })
}

if (!validator.isEmail(email)) {
return res.status(400).json({ message: 'Invalid email format' })
}
const { email } = req.body

try {
await dbConnect()
} catch (error) {
return res.status(500).json({ message: 'Database connection failed' })
if (!email || !validator.isEmail(email)) {
return res.status(422).json({ message: 'Valid email is required' })
}

try {
await dbConnect()

const dbSubscriber = await Subscriber.findOneAndDelete({ email })

if (!dbSubscriber) {
res.status(404).json({ message: 'Subscriber not found' })
}

try {
const updatedSubscriber = await Subscriber.findOneAndUpdate(
{ email },
{ status: 'unsubscribed' },
{ new: true },
)

if (updatedSubscriber) {
res.status(200).json({ message: 'Unsubscription successful' })
} else {
res.status(404).json({ message: 'Subscriber not found' })
}
} catch (error) {
const deleteMailgunMember = deleteUser(email)

if (!deleteMailgunMember) {
res.status(500).json({ message: 'Unsubscription failed' })
}
} else {
res.status(405).json({ message: 'Method Not Allowed' })

res.status(200).json({ message: 'Unsubscription successful' })
} catch (error) {
res.status(500).json({ message: 'Unsubscription failed' })
}
}
39 changes: 39 additions & 0 deletions pages/api/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import dbConnect from '@/utils/dbConnect'
import Subscriber from '@/utils/Subscriber'

export default async function handler(req, res) {
const { method, query } = req

if (method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' })
}

const { email, token } = query

if (!email || !token) {
return res.status(400).json({ message: 'Email and token are required' })
}

try {
await dbConnect()

const subscriber = await Subscriber.findOne({ email, token })

if (!subscriber) {
return res.status(404).json({ message: 'Invalid email or token' })
}

if (!subscriber.token) {
return res.status(400).json({ message: 'Token expired' })
}

subscriber.status = 'subscribed'
subscriber.token = undefined
await subscriber.save()

return res.status(200).json({ message: 'Email verified successfully' })
} catch (error) {
console.error('Error:', error)
return res.status(500).json({ message: 'Verification failed' })
}
}
Empty file added pages/verify.mdx
Empty file.
497 changes: 292 additions & 205 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion utils/Subscriber.js
Original file line number Diff line number Diff line change
@@ -9,7 +9,16 @@ const SubscriberSchema = new mongoose.Schema({
},
status: {
type: String,
default: 'subscribed',
default: 'pending',
},
token: {
type: String,
index: { expires: '7d' },
required: false,
},
createdAt: {
type: Date,
default: Date.now,
},
})