راستی آزمایی ایمیل (Email Confirmation) با React — راهنمای کاربردی

در این نوشته به بررسی روش تأیید ایمیل یا همان راستی آزمایی ایمیل در React میپردازیم.
روش تأیید ایمیل
شاید تأیید ایمیل و راستی آزمایی آن موضوع سادهای به نظر برسد. تأیید ایمیل دستکم از زمانی که خود ایمیل متولد شده، وجود داشته است و ما گردش کاری آن را به خوبی میدانیم. یک ایمیل را برای تأیید از کاربر دریافت میکنیم و یک لینک به آن آدرس ارسال میکنیم. سپس پایگاه داده را بهروزرسانی میکنیم تا زمانی که کاربر روی لینک کلیک کرد، ایمیل وی را تأیید کند. همان طور که میبینید همه کار در سه گام ساده اجرا میشود. اما برای این که بتوان این کار را انجام داد به یک اپلیکیشن کامل نیاز داریم و از این رو فرصت مناسبی برای یادگیری طرز کار Mongo ،Express ،React و Node و همکاری آنها با هم به نظر میآید.
کلاینت React
کلاینت خود را به وسیله Create React App (+) بوتاسترپ میکنیم. ما فرض میکنیم که شما از قبل تجربیاتی در این زمینه دارید.
برای این که بتوانید مطالب مطرح شده در این راهنما را دنبال کنید تنها به سه چیز نیاز دارید:
- یک اپلیکیشن که پیش از آن که بتواند کاری انجام دهد در وضعیت قابل کارکرد باشد.
- بازخورد یعنی چیزی که وقتی اپلیکیشن کاری انجام میدهد اتفاق میافتد.
- تأییدیه که نشان میدهد اپلیکیشن کار خود را به پایان برده است.
کلاینت ما برای این اپلیکیشن تنها چند کامپوننت دارد و میتواند با کد و کامنت بسیار کمی که در ادامه نشان میدهیم نوشته شود. در این آموزش تلاش نکردهایم از حجم کد بکاهیم تا تجربه کاربری بهتری داشته باشیم.
نکته: شما میتوانید از Bit (+) برای مدیریت کامپوننتهای با قابلیت استفاده مجدد بهره بگیرید و در زمان خود صرفهجویی کنید. مجموعهای از کامپوننتهای مفید را نگهداری کنید و از آنها در پروژههای مختلف استفاده کرده و به سادگی تغییرات را همگامسازی کنید. Bit به ساخت سریعتر اپلیکیشنها کمک زیادی میکند.
فایل App.js
اینک نوبت به کدنویسی در زبان جاوا اسکریپت رسیده است. فایل App.js اپلیکیشن ما به صورت زیر خواهد بود:
import React, { Component } from 'react' import { BrowserRouter, Route, Redirect, Switch } from 'react-router-dom' import Notifications from 'react-notify-toast' import 'react-toastify/dist/ReactToastify.css' import Landing from './components/Landing' import Confirm from './components/Confirm' import Spinner from './components/Spinner' import Footer from './components/Footer/Footer' import { API_URL } from './config' import './App.css' export default class App extends Component { // A bit of state to make sure the server is up and running before the user // can interact with the app. state = { loading: true } // When the component mounts, a simple GET request is made to 'wake up' the // server. A lot of free services like Heroku and Now.sh will put your server // to sleep if no one has used your application in a few minutes. Using a // service like uptimerobot.com to ping the server regularly can mitigate // sleepiness. componentDidMount = () => { fetch(`${API_URL}/wake-up`) .then(res => res.json()) .then(() => { this.setState({ loading: false }) }) .catch(err => console.log(err)) } // You are probaly used to seeing React 'render()' methods written like this: // // render() { // return ( // <Some jsx /> // ) // } // // Below is a version of writing a 'render()' that also works. The 'why does // it work?' is related to the 'this' keyword in JavaScript and is beyond the // scope of this post. render = () => { // The 'content' function determines what to show the user based on whether // the server is awake or not. const content = () => { // The server is still asleep, so provide a visual cue with the <Spinner /> // component to give the user that feedback. if (this.state.loading) { return <Spinner size='8x' spinning='spinning' /> } // The server is awake! React Router is used to either show the // <Landing /> component where the emails are collected or the <Confirm /> // component where the emails are confirmed. return ( <BrowserRouter> <Switch> {/* the ':id' in this route will be the unique id the database creates and is available on 'this.props' inside the <Confirm /> component at this.props.match.params.id */} <Route exact path='/confirm/:id' component={Confirm} /> <Route exact path='/' component={Landing} /> <Redirect from='*' to='/'/> </Switch> </BrowserRouter> ) } return ( // The 'container' class uses flexbox to position and center its three // children: <Notifications />, <main> and <Footer /> <div className='container fadein'> {/* <Notifications > component from 'react-notify-toast' This is the placeholder on the dom that will hold all the feedback toast messages whenever notify.show('My Message!') is called. */} <Notifications /> <main> {content()} </main> {/* For every Medium post I write I include a demo app that uses the same footer. So, I have abstracted that out to use on future posts with just a couple of props passed in. */} <Footer mediumId={'257e5d9de725'} githubRepo={'react-confirm-email'} /> </div> ) } }
فایل Landing.js
این فایل شامل کامپوننتی است که وقتی کاربر وارد اپلیکیشن میشود نمایش مییابد.
import React, { Component } from 'react' import { notify } from 'react-notify-toast' import Spinner from './Spinner' import { API_URL } from '../config' export default class Landing extends Component { // A bit of state to give the user feedback while their email address is being // added to the User model on the server. state = { sendingEmail: false } onSubmit = event => { event.preventDefault() this.setState({ sendingEmail: true}) // Super interesting to me that you can mess with the upper and lower case // of the headers on the fetch call and the world does not explode. fetch(`${API_URL}/email`, { method: 'pOSt', headers: { aCcePt: 'aPpliCaTIon/JsOn', 'cOntENt-type': 'applicAtion/JSoN' }, body: JSON.stringify({ email: this.email.value }) }) .then(res => res.json()) .then(data => { // Everything has come back successfully, time to update the state to // reenable the button and stop the <Spinner>. Also, show a toast with a // message from the server to give the user feedback and reset the form // so the user can start over if she chooses. this.setState({ sendingEmail: false}) notify.show(data.msg) this.form.reset() }) .catch(err => console.log(err)) } render = () => { // This bit of state provides user feedback in the component when something // changes. sendingEmail is flipped just before the fetch request is sent in // onSubmit and then flipped back when data has been received from the server. // How many times is the 'sendingEmail' variable used below? const { sendingEmail } = this.state return ( // A ref is put on the form so that it can be reset once the submission // process is complete. <form onSubmit={this.onSubmit} ref={form => this.form = form} > <div> <input type='email' name='email' ref={input => this.email = input} required /> {/* Putting the label after the input allows for that neat transition effect on the label when the input is focused. */} <label htmlFor='email'>email</label> </div> <div> {/* While the email is being sent from the server, provide feedback that something is happening by disabling the button and showing a <Spinner /> inside the button with a smaller 'size' prop passed in. */} <button type='submit' className='btn' disabled={sendingEmail}> {sendingEmail ? <Spinner size='lg' spinning='spinning' /> : "Let's Go!" } </button> </div> </form> ) } }
فایل Confirm.js
هنگامی که کاربر روی لینک یکتای موجود در ایمیلی که به آدرسش ارسال کردیم کلیک کند، این کامپوننت از سوی React Router (+) بارگذاری میشود.
import React, {Component} from 'react' import { Link } from 'react-router-dom' import { notify } from 'react-notify-toast' import Spinner from './Spinner' import { API_URL } from '../config' export default class Confirm extends Component { // A bit of state to give the user feedback while their email // address is being confirmed on the User model on the server. state = { confirming: true } // When the component mounts the mongo id for the user is pulled from the // params in React Router. This id is then sent to the server to confirm that // the user has clicked on the link in the email. The link in the email will // look something like this: // // http://localhost:3000/confirm/5c40d7607d259400989a9d42 // // where 5c40d...a9d42 is the unique id created by Mongo componentDidMount = () => { const { id } = this.props.match.params fetch(`${API_URL}/email/confirm/${id}`) .then(res => res.json()) .then(data => { this.setState({ confirming: false }) notify.show(data.msg) }) .catch(err => console.log(err)) } // While the email address is being confirmed on the server a spinner is // shown that gives visual feedback. Once the email has been confirmed the // spinner is stopped and turned into a button that takes the user back to the // <Landing > component so they can confirm another email address. render = () => <div className='confirm'> {this.state.confirming ? <Spinner size='8x' spinning={'spinning'} /> : <Link to='/'> <Spinner size='8x' spinning={''} /> </Link> } </div> }
فایل Spinner.js
اگر به کدهایی که در بخش قبلی مطرح کردیم، به قدر کافی دقت کرده باشید، متوجه شدهاید که ما در چهار موقعیت مختلف از کامپوننت < / Spinner> استفاده کردهایم. تنها چیزی که در این دفعات تغییر یافته است props است که به کامپوننت ارسال میشود.
Spinner.js بازخورد را در موقعیتهای زیر ارائه میکند:
- هنگامی که اپلیکیشن بارگذاری میشود.
- درون دکمه، هنگامی که فشرده میشود.
- هنگامی که لینک ایمیل کلیک میشود و سرور تأیید میکند.
- زمانی که دکمه ریاستارت کردن کل فرایند، فشرده میشود.
همه این موارد تنها در 8 خط از کد React نوشته شدهاند.
import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSync } from '@fortawesome/free-solid-svg-icons' export default props => <div className={`fadeIn ${props.spinning}`}> <FontAwesomeIcon icon={faSync} size={props.size} /> </div>
فایل Spinner.js انتهای کد کلاینت است. البته متوجه هستیم که این همه کد برای یک وظیفه ساده برای تأیید آدرس ایمیل زیاد به نظر میرسد. در واقع اگر بخش بازخورد و کامنت ها را حذف کنیم، تقریباً نیمی از حجم این کد کاسته میشود؛ اما در این حالت تجربه کاربری نیز دقیقاً نصف میشود!
نکته مهم این است که کاربر را در جریان کارهایی که در حال اجرا هستند قرار دهیم و به علاوه اپلیکیشنی داشته باشیم که پیش از آن که کاربر بخواهد کاری انجام دهد، کاملاً بارگذاری شده و آماده اجرا باشد.
گرچه ارائه بازخورد موجب میشود که حجم کد افزایش یابد، اما از سوی دیگر موجب افزایش کیفیت تجربه کاربری میشود. اینک باید کاری کنیم که کلاینت و سرور بتوانند با هم صحبت کنند.
نوشتن کد سرور
در این بخش کدهای مورد نیاز در سمت سرور را مینویسیم.
سرور
جهت راهاندازی و اجرا کردن سرور باید چندین مرحله از تنظیمات را طی کنیم:
- یک آدرس ایمیل ایجاد کند تا برای این اپلیکیشن مورد استفاده قرار دهید.
- Mongo را اجرا کنید تا مکانی برای ذخیره آدرسهای ایمیل داشته باشیم.
دریافت یک آدرس Gmail جدید
شما میتواند از این لینک (+) برای ثبت یک حساب کاربری جدید جیمیل اقدام کنید. دقت کنید که نباید از یک حساب جیمیل که برایتان مهم است استفاده کنید، چون اطلاعات رمز عبور آن در یک فایل env. روی نقاط مختلف سیستم ذخیره خواهد شد و علاوه بر آن چنان که در ادامه اشاره خواهیم کرد، امکان اجرای اپلیکیشنهای با امنیت کمتر (allow less secure apps) را در آن فعال خواهیم کرد. از این رو پیشگیری بهتر از درمان است و یک حساب کاربری جیمیل جدید میسازیم.
شما میتوانید این اپلیکیشن را با استفاده از آدرس ایمیلی از هر ارائه دهنده سرویس ایمیل مورد استفاده قرار دهید. برخی تنظیمات ممکن است در فایل sendEmail.js که در ادامه آمده است، اندکی متفاوت باشد و این تنظیمات به مشخصات ارائه دهنده ایمیل وابسته است. ما به منظور حفظ سادگی فرایند، در این راهنما از Gmail استفاده میکنیم.
پس از این که حساب جیمیل خود را ساختید، باید اطلاعات احراز هویت برای این حساب کاربری جدید را در یک فایل env. روی سرور قرار دهید. ما از فایل server/.env استفاده میکنیم و محتوای آن چنین خواهد بود:
MAIL_USER=your_new_email_address@gmail.com MAIL_PASS=your_new_password
نکته مهم: برای این که حساب جیمیل جدیدی که ساختهاید بتواند به نیابت از شما ایمیلهایی ارسال کند و بدین ترتیب اپلیکیشن ما بتواند کار کند باید امکان «Less secure app access» را فعال کنیم. مراحل این کار در این صفحه پشتیبانی گوگل (+) توضیح داده شده است.
اجرا کردن Mongo
اگر از قبل Mongo را به صورت محلی نصب کرده باشید، اینک میدانید که باید از دستور زیر استفاده کنید:
$ mongod
فایل user.model.js
در مدل Mongoose ما تنها دو چیز را ردگیری میکنیم. یک آدرس ایمیل و این که آیا تأیید شده است یا نه.
const mongoose = require('mongoose') const Schema = mongoose.Schema // Data we need to collect/confirm to have the app go. const fields = { email: { type: String }, confirmed: { type: Boolean, default: false } } // One nice, clean line to create the Schema. const userSchema = new Schema(fields) module.exports = mongoose.model('User', userSchema)
فایل server.js
سعی میکنیم که این فایل را تا حد ممکن سبک نگه داریم و صرفاً به عنوان یک پوسته برای فایلهای دیگر کار کند. بنابراین تنها چند «میانافزار» (middleware) در آن قرار میدهیم و اتصالی به پایگاه داده ایجاد میکنیم.
require('dotenv').config() const express = require('express') const mongoose = require('mongoose') const cors = require('cors') const app = express() const emailController = require('./email/email.controller') const { PORT, CLIENT_ORIGIN, DB_URL } = require('./config') // Only allow requests from our client app.use(cors({ origin: CLIENT_ORIGIN })) // Allow the app to accept JSON on req.body app.use(express.json()) // This endpoint is pinged every 5 mins by uptimerobot.com to prevent // free services like Heroku and Now.sh from letting the app go to sleep. // This endpoint is also pinged every time the client starts in the // componentDidMount of App.js. Once the app is confirmed to be up, we allow // the user to perform actions on the client. app.get('/wake-up', (req, res) => res.json('?')) // This is the endpoint that is hit from the onSubmit handler in Landing.js // The callback is shelled off to a controller file to keep this file light. app.post('/email', emailController.collectEmail) // Same as above, but this is the endpoint pinged in the componentDidMount of // Confirm.js on the client. app.get('/email/confirm/:id', emailController.confirmEmail) // Catch all to handle all other requests that come into the app. app.use('*', (req, res) => { res.status(404).json({ msg: 'Not Found' }) }) // To get rid of all those semi-annoying Mongoose deprecation warnings. const options = { useCreateIndex: true, useNewUrlParser: true, useFindAndModify: false } // Connecting the database and then starting the app. mongoose.connect(DB_URL, options, () => { app.listen(PORT, () => console.log('?')) }) // The most likely reason connecting the database would error out is because // Mongo has not been started in a separate terminal. .catch(err => console.log(err))
فایل email.controller.js
در نهایت نوبت به مغز پردازشی عملیات میرسد. فایل email.controller.js اطلاعات مختلف مربوطه را از مسیرهای گوناگون گردآوری میکند، پایگاه داده را بر مبنای این اطلاعات بهروزرسانی کرده و بازخورد را به کلاینت بازمیگرداند، به طوری که کاربر متوجه میشود چه اتفاقی افتاده است.
نکته مهم که باید در مورد این فایل توجه داشته باشیم این است که دادههای ارائه شده یعنی محتواهایی که در ایمیل ارسال میشوند یا پیامهایی که در پاسخ دریافت میشوند هر کدام به فایلهای مربوطه خود میروند. بدین ترتیب کنترلر ما تمیز و سریع باقی میماند.
const User = require('../user.model') const sendEmail = require('./email.send') const msgs = require('./email.msgs') const templates = require('./email.templates') // The callback that is invoked when the user submits the form on the client. exports.collectEmail = (req, res) => { const { email } = req.body User.findOne({ email }) .then(user => { // We have a new user! Send them a confirmation email. if (!user) { User.create({ email }) .then(newUser => sendEmail(newUser.email, templates.confirm(newUser._id))) .then(() => res.json({ msg: msgs.confirm })) .catch(err => console.log(err)) } // We have already seen this email address. But the user has not // clicked on the confirmation link. Send another confirmation email. else if (user && !user.confirmed) { sendEmail(user.email, templates.confirm(user._id)) .then(() => res.json({ msg: msgs.resend })) } // The user has already confirmed this email address else { res.json({ msg: msgs.alreadyConfirmed }) } }) .catch(err => console.log(err)) } // The callback that is invoked when the user visits the confirmation // url on the client and a fetch request is sent in componentDidMount. exports.confirmEmail = (req, res) => { const { id } = req.params User.findById(id) .then(user => { // A user with that id does not exist in the DB. Perhaps some tricky // user tried to go to a different url than the one provided in the // confirmation email. if (!user) { res.json({ msg: msgs.couldNotFind }) } // The user exists but has not been confirmed. We need to confirm this // user and let them know their email address has been confirmed. else if (user && !user.confirmed) { User.findByIdAndUpdate(id, { confirmed: true }) .then(() => res.json({ msg: msgs.confirmed })) .catch(err => console.log(err)) } // The user has already confirmed this email address. else { res.json({ msg: msgs.alreadyConfirmed }) } }) .catch(err => console.log(err)) }
فایل email.msgs.js
لازم است که کدهای پوسته غیر کارکردی مانند این را در فایلهای خاص خود قرار دهیم. بدین ترتیب فایلهای دارای کارکردهای معین، منسجم میمانند و استدلال در مورد منطق اپلیکیشن آسانتر میشود.
module.exports = { confirm: 'Email sent, please check your inbox to confirm', confirmed: 'Your email is confirmed!', resend: 'Confirmation email resent, maybe check your spam?', couldNotFind: 'Could not find you!', alreadyConfirmed: 'Your email was already confirmed' }
فایل email.send.js
Nodemailer (+) یک ماژول npm است که ایمیل را ارسال میکند.
const nodemailer = require('nodemailer') // The credentials for the email account you want to send mail from. const credentials = { host: 'smtp.gmail.com', port: 465, secure: true, auth: { // These environment variables will be pulled from the .env file user: process.env.MAIL_USER, pass: process.env.MAIL_PASS } } // Getting Nodemailer all setup with the credentials for when the 'sendEmail()' // function is called. const transporter = nodemailer.createTransport(credentials) // exporting an 'async' function here allows 'await' to be used // as the return value of this function. module.exports = async (to, content) => { // The from and to addresses for the email that is about to be sent. const contacts = { from: process.env.MAIL_USER, to } // Combining the content and contacts into a single object that can // be passed to Nodemailer. const email = Object.assign({}, content, contacts) // This file is imported into the controller as 'sendEmail'. Because // 'transporter.sendMail()' below returns a promise we can write code like this // in the contoller when we are using the sendEmail() function. // // sendEmail() // .then(() => doSomethingElse()) // // If you are running into errors getting Nodemailer working, wrap the following // line in a try/catch. Most likely is not loading the credentials properly in // the .env file or failing to allow unsafe apps in your gmail settings. await transporter.sendMail(email) }
فایل email.templates.js
ما تنها یک نوع ایمیل در این اپلیکیشن ارسال میکنیم که ایمیل تأییدیه است. در نتیجه باید این قالب ایمیل را در فایلی مانند email.controller.js یا email.send.js قرار دهیم؛ اما این کار موجب میشود که بخشی از کارکرد اپلیکیشن در آن فایلها قرار گیرد و همان طور که قبلاً اشاره کردیم email.templates.js هیچ ارتباطی با منطق اپلیکیشن ندارد. در نتیجه باید یک فایل اختصاصی برای قالب ایمیل به نام email.templates.js بسازیم. بنابراین اگر در ادامه بخواهیم یک گردش کاری برای ایمیلهای «تأیید نشده»، «تبریک تولد» یا هر چیز دیگر بسازیم، این فایل به سادگی قابل بسط خواهد بود.
const { CLIENT_ORIGIN } = require('../config') // This file is exporting an Object with a single key/value pair. // However, because this is not a part of the logic of the application // it makes sense to abstract it to another file. Plus, it is now easily // extensible if the application needs to send different email templates // (eg. unsubscribe) in the future. module.exports = { confirm: id => ({ subject: 'React Confirm Email', html: ` <a href='${CLIENT_ORIGIN}/confirm/${id}'> click to confirm email </a> `, text: `Copy and paste this link: ${CLIENT_ORIGIN}/confirm/${id}` }) }
سخن پایانی
همان طور که در این راهنما دیدیم یک گردش کاری ساده مانند گردآوری آدرس ایمیل به یک اپلیکیشن کامل نیاز دارد. با جداسازی بخشهای کلاینت و سرور در بخشهای مجزا و قابل استفاده مجدد، این امکان را فراهم میکنیم که در صورتی که لازم باشد کارکردهای مختلفی در آینده به اپلیکیشن اضافه شوند، به راحتی آن را توسعه دهیم.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزش های برنامه نویسی
- آموزش مقدماتی فریمورک React Native برای طراحی نرم افزارهای اندروید و iOS با زبان جاوا اسکریپت
- مجموعه آموزش های طراحی و برنامه نویسی وب
- آموزش React.js در کمتر از ۵ دقیقه — از صفر تا صد
- راهنمای جامع React (بخش اول) — از صفر تا صد
- واکشی (Fetch) کردن داده ها در اپلیکیشن های React — به زبان ساده
==
سلام و وقت بخیر؛
برای بازیابی نشانی ایمیل، میبایست گزینه «فراموش کردن گذرواژه» یا عبارتهای مشابه را در پرووایدرهای مختلف کلیک کنید. به این ترتیب با استفاده از روشهای مختلفی که برای راستیآزمایی ادعای مالکیت شما روی نشانی مورد نظر وجود دارد، میتوانید نشانی ایمیل خود را بازیابی نمایید.
از توجه شما متشکرم.
سلام عرض خسته نباشید خدمت شما هز چی کوشیش می کنم ایمیل من باز نمیشه چند مرحله پیش میرم اما باز نمیشه چون قبلا به گوشی دگه من بوده که دگه قابل تعمیر نمیشه و ایمیل من باز نمیشه میشود یک کاری برای من بکنید