├── .gitattributes ├── .gitignore ├── README.md ├── client ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── api │ └── index.js │ ├── components │ ├── AuthPage │ │ ├── AuthPage.js │ │ ├── Form │ │ │ ├── FormLogin.js │ │ │ ├── FormRegister.js │ │ │ └── styles │ │ │ │ └── Form.module.css │ │ ├── images │ │ │ └── gmail.svg │ │ └── styles │ │ │ └── AuthPage.module.css │ └── EmailPage │ │ ├── ComposeMail │ │ ├── ComposeMail.js │ │ └── styles │ │ │ └── ComposeMail.module.css │ │ ├── EmailCategory │ │ ├── EmailCategory.js │ │ ├── EmailListItem │ │ │ ├── EmailListItem.js │ │ │ └── styles │ │ │ │ └── EmailListItem.module.css │ │ └── styles │ │ │ └── EmailCategory.module.css │ │ ├── EmailOptions │ │ ├── EmailOptions.js │ │ └── styles │ │ │ └── EmailOptions.module.css │ │ ├── EmailPage.js │ │ ├── EmailView │ │ ├── EmailView.js │ │ └── styles │ │ │ └── EmailView.module.css │ │ ├── Header │ │ ├── AccountControls │ │ │ ├── AccountControls.js │ │ │ └── styles │ │ │ │ └── AccountControls.module.css │ │ ├── EditImageModal │ │ │ ├── EditImageModal.js │ │ │ └── styles │ │ │ │ └── EditImageModal.module.css │ │ ├── Header.js │ │ ├── images │ │ │ └── gmail-logo.png │ │ └── styles │ │ │ └── Header.module.css │ │ ├── Sidebar │ │ ├── Sidebar.js │ │ ├── SidebarOption │ │ │ ├── SidebarOption.js │ │ │ └── styles │ │ │ │ └── SidebarOption.module.css │ │ └── styles │ │ │ └── Sidebar.module.css │ │ └── styles │ │ └── EmailPage.module.css │ ├── index.js │ ├── redux │ ├── actions │ │ ├── accountActions.js │ │ ├── clearErrors.js │ │ └── emailActions.js │ ├── constants │ │ └── index.js │ ├── reducers │ │ ├── emailReducer.js │ │ └── userReducer.js │ └── store.js │ └── styles │ ├── App.css │ └── reset.css ├── preview_login.png ├── preview_mailbox.png └── server ├── .env.example ├── .gitignore ├── api ├── controllers │ ├── account.js │ └── email.js ├── middleware │ ├── authToken.js │ └── validations.js ├── models │ ├── Account.js │ └── Email.js └── routes │ ├── account.js │ └── email.js ├── index.js ├── package-lock.json └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERN Gmail clone 2 | 3 | - **M** = [MongoDB](https://www.mongodb.com) 4 | - **E** = [Express.js](https://expressjs.com) 5 | - **R** = [React.js](https://reactjs.org) 6 | - **N** = [Node.js](https://nodejs.org) 7 | 8 |
9 | 10 | ## Preview of the UI 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .DS_Store 3 | .eslintcache -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "homepage": "https://gmail-clone-frontend.herokuapp.com", 3 | "name": "client", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "react-scripts start" 8 | }, 9 | "author": "Ben Elferink", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@material-ui/core": "^4.11.3", 13 | "@material-ui/icons": "^4.11.2", 14 | "axios": "^0.21.0", 15 | "react": "^17.0.1", 16 | "react-dom": "^17.0.1", 17 | "react-file-base64": "^1.0.3", 18 | "react-hook-form": "^6.15.1", 19 | "react-redux": "^7.2.2", 20 | "react-router-dom": "^5.2.0", 21 | "react-scripts": "^4.0.2", 22 | "redux": "^4.0.5", 23 | "redux-devtools-extension": "^2.13.8", 24 | "redux-logger": "^3.0.6", 25 | "redux-thunk": "^2.3.0" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BenElferink/mern-gmail-clone/4b1eaf02d93d6b3cf081f3b93765f74be9de9c48/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | Gmail 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Gmail", 3 | "name": "MERN-stack Gmail clone", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src":"android-chrome-192x192.png", 12 | "sizes":"192x192", 13 | "type":"image/png" 14 | }, 15 | { 16 | "src":"android-chrome-512x512.png", 17 | "sizes":"512x512", 18 | "type":"image/png" 19 | }] 20 | } -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { getUserAction } from './redux/actions/accountActions'; 5 | import './styles/App.css'; 6 | import AuthPage from './components/AuthPage/AuthPage'; 7 | import EmailPage from './components/EmailPage/EmailPage'; 8 | 9 | function App() { 10 | const dispatch = useDispatch(); 11 | const { isLoggedIn, token } = useSelector((state) => state.userReducer); 12 | 13 | // if a token exists, try to get the user data from the server, 14 | // if this fetch has succeeded, App will redirect us to the emails page 15 | // if this fetch failed, that means the token has expired and the user needs to login 16 | useEffect(() => { 17 | if (token) { 18 | dispatch(getUserAction()); 19 | } 20 | }, [token, dispatch]); 21 | 22 | return ( 23 | 24 |
25 | 26 | 27 | {!isLoggedIn ? : } 28 | 29 | 30 | 31 | {!isLoggedIn ? : } 32 | 33 | 34 | 35 | {/* This route has multiple sub-routes */} 36 | {isLoggedIn ? : } 37 | 38 | 39 | (window.location.href = 'https://github.com/belferink1996')} 43 | /> 44 | 45 |
46 |
47 | ); 48 | } 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /client/src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const url = 'https://gmail-clone-backend.herokuapp.com/api/v1'; 4 | const headers = (token) => ({ 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | Authorization: 'Bearer ' + token, 8 | }, 9 | }); 10 | 11 | // account routes 12 | export const register = (form) => axios.post(`${url}/account/register`, form); 13 | export const login = (form) => axios.post(`${url}/account/login`, form); 14 | export const getUser = (token) => axios.get(`${url}/account`, headers(token)); 15 | export const uploadImage = (token, image) => 16 | axios.put(`${url}/account/image`, image, headers(token)); 17 | 18 | // email routes 19 | export const getAllEmails = (token) => axios.get(`${url}/email`, headers(token)); 20 | export const sendEmail = (token, form) => axios.post(`${url}/email/send`, form, headers(token)); 21 | export const saveDraft = (token, form) => axios.post(`${url}/email/draft`, form, headers(token)); 22 | export const updateDraft = (token, id, form) => 23 | axios.put(`${url}/email/draft/${id}`, form, headers(token)); 24 | export const moveToTrash = (token, id) => 25 | axios.put(`${url}/email/${id}/trash`, null, headers(token)); 26 | export const removeFromTrash = (token, id) => 27 | axios.put(`${url}/email/${id}/untrash`, null, headers(token)); 28 | export const markAsRead = (token, id) => axios.put(`${url}/email/${id}/read`, null, headers(token)); 29 | export const markAsUnread = (token, id) => 30 | axios.put(`${url}/email/${id}/unread`, null, headers(token)); 31 | export const setFavorite = (token, id) => 32 | axios.put(`${url}/email/${id}/favorite`, null, headers(token)); 33 | export const unsetFavorite = (token, id) => 34 | axios.put(`${url}/email/${id}/unfavorite`, null, headers(token)); 35 | export const deleteEmail = (token, id) => axios.delete(`${url}/email/${id}`, headers(token)); 36 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/AuthPage.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, Fragment } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import styles from './styles/AuthPage.module.css'; 4 | import FormLogin from './Form/FormLogin'; 5 | import FormRegister from './Form/FormRegister'; 6 | import GmailIcon from './images/gmail.svg'; 7 | 8 | export default function AuthPage() { 9 | const { user, isLoading, error } = useSelector((state) => state.userReducer); 10 | 11 | // defines if the register or login form is displayed 12 | const [isCreateNew, setIsCreateNew] = useState(false); 13 | const toggleIsCreateNew = () => setIsCreateNew(!isCreateNew); 14 | 15 | // if the user has registered, and the email used has been applied, 16 | // then toggle state to show 'login' component with the registered email. 17 | useEffect(() => { 18 | if (user.email) { 19 | toggleIsCreateNew(); 20 | alert('Account successfully created!'); 21 | } 22 | // eslint-disable-next-line 23 | }, [user.email]); 24 | 25 | return ( 26 |
27 | Gmail 28 | 29 | {isCreateNew ? ( 30 | 31 | 32 | 35 | 36 | ) : ( 37 | 38 | 39 | 42 | 43 | )} 44 | 45 |

46 | Disclaimer: this clone is not associated with Google! All accounts & emails are fictive, but 47 | remain in a database. 48 |

49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/Form/FormLogin.js: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | import { loginAction } from '../../../redux/actions/accountActions'; 3 | import { clearErrors } from '../../../redux/actions/clearErrors'; 4 | import { useForm } from 'react-hook-form'; 5 | import styles from './styles/Form.module.css'; 6 | import { Button, CircularProgress } from '@material-ui/core'; 7 | 8 | export default function FormLogin({ isLoading, error, user }) { 9 | const dispatch = useDispatch(); 10 | const { register, handleSubmit, errors, formState } = useForm({ 11 | defaultValues: { 12 | email: user.email, // this is given by Redux state (if the user has successfully registered) 13 | }, 14 | }); 15 | 16 | if (error) { 17 | alert(error); 18 | setTimeout(() => { 19 | dispatch(clearErrors()); 20 | }, 0); 21 | } 22 | 23 | const onSubmit = (values) => { 24 | dispatch(loginAction(values)); 25 | }; 26 | 27 | if (isLoading) { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | } else { 34 | return ( 35 |
36 | ()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 44 | })} 45 | /> 46 |

{errors.email?.type === 'required' && 'Email is required'}

47 |

{errors.email?.type === 'pattern' && 'Invalid email'}

48 | 49 | 58 |

{errors.password?.type === 'required' && 'Password is required'}

59 |

{errors.password?.type === 'minLength' && 'Must be at least 7 characters'}

60 | 61 | 64 |
65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/Form/FormRegister.js: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { registerAction } from '../../../redux/actions/accountActions'; 4 | import { clearErrors } from '../../../redux/actions/clearErrors'; 5 | import { useForm } from 'react-hook-form'; 6 | import styles from './styles/Form.module.css'; 7 | import { Button, CircularProgress } from '@material-ui/core'; 8 | 9 | export default function FormRegister({ isLoading, error }) { 10 | const dispatch = useDispatch(); 11 | const { register, handleSubmit, errors, watch, formState } = useForm(); 12 | const password = useRef({}); // used so I can compare the password and confirmed password 13 | password.current = watch('password', ''); 14 | 15 | if (error) { 16 | alert(error); 17 | setTimeout(() => { 18 | dispatch(clearErrors()); 19 | }, 0); 20 | } 21 | 22 | const onSubmit = (values) => { 23 | dispatch(registerAction(values)); 24 | }; 25 | 26 | if (isLoading) { 27 | return ( 28 |
29 | 30 |
31 | ); 32 | } else { 33 | return ( 34 |
35 | 44 |

{errors.email?.type === 'required' && 'First name is required'}

45 |

{errors.firstName?.type === 'pattern' && 'Invalid name'}

46 | 47 | 55 |

{errors.middleName?.type === 'pattern' && 'Invalid name'}

56 | 57 | 66 |

{errors.email?.type === 'required' && 'Last name is required'}

67 |

{errors.lastName?.type === 'pattern' && 'Invalid name'}

68 | 69 | ()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 77 | })} 78 | /> 79 |

{errors.email?.type === 'required' && 'Email is required'}

80 |

{errors.email?.type === 'pattern' && 'Invalid email'}

81 | 82 | 91 |

{errors.password?.type === 'required' && 'Password is required'}

92 |

{errors.password?.type === 'minLength' && 'Must be at least 7 characters'}

93 | 94 | value === password.current, 101 | })} 102 | /> 103 |

{errors.passwordConfirm?.type === 'required' && 'Password confirmation is required'}

104 |

{errors.passwordConfirm?.type === 'validate' && 'Passwords do not match'}

105 | 106 | 109 |
110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/Form/styles/Form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | width: 320px; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .form > input { 9 | width: 92%; 10 | padding: 7px 3px; 11 | border: none; 12 | border-bottom: 1px solid #f5f5f5; 13 | outline: none; 14 | } 15 | 16 | .form > button { 17 | width: 100% !important; 18 | margin: 7px 0 !important; 19 | padding: 10px 0 !important; 20 | background-color: #1e6df6 !important; 21 | border: none !important; 22 | border-radius: 20px !important; 23 | color: #fff !important; 24 | font-size: 16px !important; 25 | cursor: pointer !important; 26 | } 27 | 28 | .form > button:hover { 29 | background-color: #4b8ef9 !important; 30 | } 31 | 32 | .form > p { 33 | width: 100%; 34 | padding-left: 15px; 35 | font-size: 14px; 36 | color: #ff0000; 37 | text-align: left; 38 | } 39 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/images/gmail.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/AuthPage/styles/AuthPage.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | width: 100%; 3 | min-height: 100vh; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | /* justify-content: center; */ 8 | } 9 | 10 | .page > img { 11 | width: 350px; 12 | margin-top: 69px; 13 | margin-bottom: 42px; 14 | } 15 | 16 | .link { 17 | margin-bottom: 20px; 18 | background-color: transparent; 19 | border: none; 20 | color: #1e6df6; 21 | font-size: 12px; 22 | text-decoration: underline; 23 | cursor: pointer; 24 | } 25 | 26 | .link:hover { 27 | font-weight: 500; 28 | } 29 | 30 | .page > p { 31 | margin: auto 0 7px auto; 32 | color: #808080; 33 | text-align: center; 34 | font-size: 12px; 35 | } 36 | 37 | @media (max-width: 768px) { 38 | .page > img { 39 | width: 290px; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/components/EmailPage/ComposeMail/ComposeMail.js: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { 4 | sendEmailAction, 5 | saveDraftAction, 6 | updateDraftAction, 7 | } from '../../../redux/actions/emailActions'; 8 | import { useForm } from 'react-hook-form'; 9 | import { Button } from '@material-ui/core'; 10 | import styles from './styles/ComposeMail.module.css'; 11 | 12 | function ComposeMail({ toggleIsCompose, composeDraft }) { 13 | const dispatch = useDispatch(); 14 | const registeredEmail = useSelector((state) => state.userReducer.user.email); 15 | const { register, handleSubmit, errors, watch } = useForm({ 16 | defaultValues: { 17 | from: registeredEmail, 18 | to: composeDraft?.to || '', 19 | subject: composeDraft?.subject || '', 20 | message: composeDraft?.message || '', 21 | }, 22 | }); 23 | 24 | // The following references purposes are to "pull" the form data from the useForm hook, 25 | // and used whenever the message will be saved as a draft 26 | const from = useRef({}); 27 | const to = useRef({}); 28 | const subject = useRef({}); 29 | const message = useRef({}); 30 | from.current = watch('from', ''); 31 | to.current = watch('to', ''); 32 | subject.current = watch('subject', ''); 33 | message.current = watch('message', ''); 34 | 35 | // the following function sends the message 36 | // (the server also creates a random reply to be received by the user) 37 | const onSubmit = (values) => { 38 | if (!composeDraft) { 39 | dispatch(sendEmailAction(values)); 40 | } else { 41 | // but if the component was called by clicking on a draft, 42 | // then the email is sent, and the draft is updated too! 43 | dispatch(sendEmailAction(values)); 44 | let form = { 45 | to: to.current, 46 | subject: subject.current, 47 | message: message.current, 48 | }; 49 | dispatch(updateDraftAction(composeDraft._id, form)); 50 | } 51 | toggleIsCompose(); 52 | }; 53 | 54 | const onClose = () => { 55 | if (!composeDraft) { 56 | // the following is used to save a message as draft 57 | // (only if one of the fields are not empty) 58 | if (to.current !== '' || subject.current !== '' || message.current !== '') { 59 | let form = { 60 | from: from.current, 61 | to: to.current, 62 | subject: subject.current, 63 | message: message.current, 64 | }; 65 | dispatch(saveDraftAction(form)); 66 | } 67 | } else { 68 | // the following is used to update the existing draft 69 | let form = { 70 | to: to.current, 71 | subject: subject.current, 72 | message: message.current, 73 | }; 74 | dispatch(updateDraftAction(composeDraft._id, form)); 75 | } 76 | toggleIsCompose(); 77 | }; 78 | 79 | return ( 80 |
81 |
82 |
New Message
83 | × 84 |
85 | 86 |
87 | 88 | ()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 96 | })} 97 | readOnly 98 | /> 99 |
100 | 101 |
102 | 103 | ()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 111 | })} 112 | /> 113 |
114 | 115 |
116 | 117 | 125 |
126 | 127 |