├── .gitignore ├── .example-env ├── app ├── components │ ├── Actions │ │ ├── Slot.js │ │ ├── index.js │ │ ├── Actions.module.css │ │ └── Action.js │ ├── Attachment │ │ ├── Attachment.module.css │ │ └── index.js │ ├── Threads │ │ ├── index.js │ │ ├── Thread.js │ │ └── Threads.module.css │ ├── Input │ │ ├── index.js │ │ └── Input.module.css │ ├── Editor │ │ ├── index.js │ │ └── Editor.module.css │ ├── BackButton │ │ ├── BackButton.module.css │ │ └── index.js │ ├── Pagination │ │ ├── Pagination.module.css │ │ └── index.js │ ├── Button │ │ ├── Button.module.css │ │ └── index.js │ ├── Referrer.js │ ├── threadActions │ │ ├── LabelsAction │ │ │ ├── LabelsAction.module.css │ │ │ └── index.js │ │ ├── MarkReadAction │ │ │ └── index.js │ │ ├── SchedulerAction │ │ │ ├── SchedulerAction.module.css │ │ │ └── index.js │ │ ├── MarkSenderReadAction │ │ │ └── index.js │ │ └── AttachmentsAction │ │ │ └── index.js │ └── Messages │ │ ├── index.js │ │ ├── Frame.js │ │ ├── Messages.module.css │ │ └── Message.js ├── assets │ ├── check.svg │ ├── chevron_right.svg │ ├── checkbox_unchecked.svg │ ├── chevron_left.svg │ ├── add.svg │ ├── checkbox_checked.svg │ ├── remove.svg │ ├── page_left.svg │ ├── page_right.svg │ ├── flag.svg │ ├── attachment.svg │ ├── add_attachment.svg │ ├── double_flag.svg │ ├── style.css │ ├── nylas.svg │ ├── nylas_vertical.svg │ ├── calendar.svg │ ├── inbox_zero_vertical.svg │ ├── complete.svg │ └── inbox_zero.svg ├── pages │ ├── login │ │ ├── login.module.css │ │ └── index.js │ ├── index │ │ ├── index.module.css │ │ └── index.js │ ├── threads │ │ └── [id] │ │ │ ├── id.module.css │ │ │ └── index.js │ └── _app.js ├── utils │ ├── redirect.js │ ├── formatDate.js │ ├── useScript.js │ ├── onRemove.js │ ├── withAuth.js │ └── request.js └── layouts │ ├── Public │ ├── PublicLayout.module.css │ └── index.js │ └── Inbox │ ├── InboxLayout.module.css │ └── index.js ├── next.config.js ├── api ├── utils │ ├── nylas.js │ ├── constants.js │ ├── getThreadFrom.js │ ├── cache.js │ └── middleware │ │ └── authenticate.js ├── login.js ├── files │ ├── delete.js │ ├── download.js │ └── upload.js ├── account │ └── get.js ├── logout.js ├── labels │ └── create.js ├── threads │ ├── [id] │ │ ├── reply.js │ │ ├── update.js │ │ └── get.js │ └── get.js └── authorize.js ├── package.json ├── logNylasRequests.js ├── README.md ├── server.js └── logo.svg /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | .env 4 | .data -------------------------------------------------------------------------------- /.example-env: -------------------------------------------------------------------------------- 1 | NYLAS_ID= 2 | NYLAS_SECRET= 3 | JWT_SECRET= # this can be any random string -------------------------------------------------------------------------------- /app/components/Actions/Slot.js: -------------------------------------------------------------------------------- 1 | function Slot(props) { 2 | return
  • ; 3 | } 4 | 5 | export default Slot; 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withImages = require("next-images"); 2 | module.exports = withImages({ 3 | env: { 4 | API_URL: process.env.API_URL 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /app/assets/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /api/utils/nylas.js: -------------------------------------------------------------------------------- 1 | const Nylas = require("nylas"); 2 | 3 | Nylas.config({ 4 | clientId: process.env.NYLAS_ID, 5 | clientSecret: process.env.NYLAS_SECRET 6 | }); 7 | 8 | module.exports = Nylas; 9 | -------------------------------------------------------------------------------- /app/assets/chevron_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/checkbox_unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/chevron_left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/pages/login/login.module.css: -------------------------------------------------------------------------------- 1 | .Title { 2 | font-size: 60px; 3 | } 4 | 5 | .InputWrapper { 6 | margin: 40px 0; 7 | } 8 | 9 | .ErrorMessage { 10 | margin-top: 12px; 11 | color: #ff1200; 12 | } 13 | -------------------------------------------------------------------------------- /app/assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/assets/checkbox_checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/assets/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/utils/redirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility to redirect from both the server and client 3 | */ 4 | export default function redirect(location, { context } = {}) { 5 | if (context) { 6 | context.res.redirect(location); 7 | } else { 8 | window.location.href = location; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/assets/page_left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/pages/index/index.module.css: -------------------------------------------------------------------------------- 1 | .InboxOverview { 2 | font-size: 38px; 3 | } 4 | 5 | .InboxOverview__UnreadCount { 6 | font-size: 66px; 7 | font-weight: bold; 8 | } 9 | 10 | .InboxOverview__SenderCount { 11 | font-weight: bold; 12 | } 13 | 14 | .SearchForm { 15 | margin-top: 32px; 16 | } 17 | -------------------------------------------------------------------------------- /app/assets/page_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/components/Actions/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import styles from "./Actions.module.css"; 3 | import Action from "./Action"; 4 | import Slot from "./Slot"; 5 | 6 | function Actions({ children }) { 7 | return ; 8 | } 9 | 10 | export default Actions; 11 | 12 | export { Action, Slot }; 13 | -------------------------------------------------------------------------------- /app/components/Attachment/Attachment.module.css: -------------------------------------------------------------------------------- 1 | .Attachment { 2 | display: inline-block; 3 | border: 1px solid #9a9a9a; 4 | box-sizing: border-box; 5 | border-radius: 5px; 6 | padding: 8px 32px; 7 | color: inherit; 8 | text-decoration: none; 9 | font-weight: normal; 10 | } 11 | 12 | .Attachment:hover { 13 | background: #e6e6e6; 14 | } 15 | -------------------------------------------------------------------------------- /app/assets/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/utils/formatDate.js: -------------------------------------------------------------------------------- 1 | export default function formatDate(date) { 2 | const monthNames = [ 3 | "Jan", 4 | "Feb", 5 | "Mar", 6 | "Apr", 7 | "May", 8 | "Jun", 9 | "Jul", 10 | "Aug", 11 | "Sep", 12 | "Oct", 13 | "Nov", 14 | "Dec" 15 | ]; 16 | 17 | return `${monthNames[date.getMonth()]} ${date.getDate()}`; 18 | } 19 | -------------------------------------------------------------------------------- /app/components/Threads/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import styles from "./Threads.module.css"; 3 | import Thread from "./Thread"; 4 | 5 | function Threads({ children }) { 6 | return
    {children}
    ; 7 | } 8 | 9 | Threads.propTypes = { 10 | children: PropTypes.arrayOf(Thread).isRequired 11 | }; 12 | 13 | export default Threads; 14 | export { Thread }; 15 | -------------------------------------------------------------------------------- /app/components/Input/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | import styles from "./Input.module.css"; 4 | 5 | export default React.forwardRef(function Input( 6 | { className = "", ...props }, 7 | ref 8 | ) { 9 | return ( 10 | 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /app/components/Input/Input.module.css: -------------------------------------------------------------------------------- 1 | .Input { 2 | width: 100%; 3 | font-size: 16px; 4 | line-height: 16px; 5 | letter-spacing: 0.533333px; 6 | color: #000000; 7 | border: 0; 8 | padding: 8px; 9 | border-bottom: 1px solid #000000; 10 | background: transparent; 11 | } 12 | 13 | .Input::placeholder { 14 | color: #aaaaaa; 15 | } 16 | 17 | .Input:focus { 18 | background: white; 19 | outline: 0; 20 | } 21 | -------------------------------------------------------------------------------- /app/components/Editor/index.js: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import styles from "./Editor.module.css"; 3 | 4 | const Quill = dynamic(import("react-quill"), { 5 | ssr: false, 6 | loading: () =>
    7 | }); 8 | 9 | export default function Editor(props) { 10 | return ( 11 |
    12 | 13 |
    14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/utils/useScript.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | /** 4 | * React hook to include a script tag with the given URL 5 | * at the end of the document. 6 | */ 7 | export default function useScript(src) { 8 | useEffect(() => { 9 | const body = document.querySelector("body"); 10 | const script = document.createElement("script"); 11 | script.src = src; 12 | 13 | body.appendChild(script); 14 | }, []); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/BackButton/BackButton.module.css: -------------------------------------------------------------------------------- 1 | .BackButton { 2 | font: inherit; 3 | font-weight: 600; 4 | text-decoration: none; 5 | color: inherit; 6 | background: transparent; 7 | border: 0; 8 | padding: 0; 9 | display: flex; 10 | align-items: center; 11 | margin-bottom: 40px; 12 | width: 100%; 13 | cursor: pointer; 14 | } 15 | 16 | .BackButton__icon { 17 | display: inline-block; 18 | margin-right: 32px; 19 | } 20 | -------------------------------------------------------------------------------- /api/utils/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Standard categories labels that we don't want to show in the app 3 | * or modify when we add/remove other labels 4 | * 5 | * Learn more: https://docs.nylas.com/reference#labels 6 | */ 7 | const DEFAULT_LABELS = [ 8 | "inbox", 9 | "all", 10 | "trash", 11 | "archive", 12 | "drafts", 13 | "sent", 14 | "spam", 15 | "important" 16 | ]; 17 | 18 | const PAGE_LIMIT = 6; 19 | 20 | module.exports = { DEFAULT_LABELS, PAGE_LIMIT }; 21 | -------------------------------------------------------------------------------- /app/components/Attachment/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import styles from "./Attachment.module.css"; 3 | 4 | function Attachment({ filename, id }) { 5 | return ( 6 | 11 | {filename} 12 | 13 | ); 14 | } 15 | Attachment.propTypes = { 16 | id: PropTypes.string.isRequired, 17 | filename: PropTypes.string.isRequired 18 | }; 19 | 20 | export default Attachment; 21 | -------------------------------------------------------------------------------- /app/assets/attachment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/assets/add_attachment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /api/utils/getThreadFrom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Finds the last message in the given thread that was not sent 3 | * by the current account and returns the first "from" name and email 4 | */ 5 | module.exports = function getThreadFrom(thread) { 6 | const lastMessageReceivedTimestamp = ( 7 | thread.lastMessageReceivedTimestamp || thread.lastMessageTimestamp 8 | ).getTime(); 9 | 10 | const lastMessageReceived = thread.messages.find(message => { 11 | return message.date.getTime() === lastMessageReceivedTimestamp; 12 | }); 13 | 14 | return lastMessageReceived.from[0]; 15 | }; 16 | -------------------------------------------------------------------------------- /app/pages/threads/[id]/id.module.css: -------------------------------------------------------------------------------- 1 | /** Contents */ 2 | 3 | .Details { 4 | } 5 | 6 | .Subject { 7 | font-size: 24px; 8 | padding: 0 24px; 9 | font-weight: normal; 10 | } 11 | 12 | .Subject.unread { 13 | font-weight: bold; 14 | } 15 | 16 | /** Reply */ 17 | 18 | .ShowSecondaryEmailsButton { 19 | font: inherit; 20 | color: inherit; 21 | background: transparent; 22 | border: 0; 23 | cursor: pointer; 24 | } 25 | 26 | .Recipients { 27 | display: grid; 28 | grid-template-columns: repeat(2, 1fr); 29 | grid-column-gap: 24px; 30 | grid-row-gap: 12px; 31 | padding: 0 24px; 32 | } 33 | -------------------------------------------------------------------------------- /app/layouts/Public/PublicLayout.module.css: -------------------------------------------------------------------------------- 1 | .PublicLayout { 2 | display: grid; 3 | grid-template-columns: 480px 1fr; 4 | height: 100vh; 5 | } 6 | 7 | .Sidebar { 8 | background: #000000; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | flex-direction: column; 13 | } 14 | 15 | .LogosDivider { 16 | background: #ffffff; 17 | width: 22px; 18 | height: 2px; 19 | margin: 100px 0; 20 | } 21 | 22 | .Main { 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | 28 | .Main__content { 29 | width: 100%; 30 | max-width: 450px; 31 | } 32 | -------------------------------------------------------------------------------- /api/login.js: -------------------------------------------------------------------------------- 1 | const Nylas = require("./utils/nylas"); 2 | 3 | /** 4 | * Description: Redirect the user to the Nylas hosted auth page to 5 | * complete their authentication 6 | * Endpoint: GET /api/login 7 | * Redirects: Nylas Hosted Auth 8 | */ 9 | module.exports = async (req, res) => { 10 | const options = { 11 | loginHint: req.query.loginHint || "", 12 | redirectURI: `${req.protocol}://${req.get("host")}/api/authorize`, 13 | scopes: ["email.read_only", "email.modify", "email.send", "calendar"] 14 | }; 15 | 16 | const authUrl = Nylas.urlForAuthentication(options); 17 | 18 | res.redirect(authUrl); 19 | }; 20 | -------------------------------------------------------------------------------- /app/components/Pagination/Pagination.module.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin-top: 32px; 3 | display: flex; 4 | justify-content: space-between; 5 | } 6 | 7 | .Pagination__button { 8 | display: flex; 9 | align-items: center; 10 | font: inherit; 11 | color: inherit; 12 | background: transparent; 13 | border: 0; 14 | padding: 0; 15 | cursor: pointer; 16 | } 17 | 18 | .Pagination__button[disabled] { 19 | opacity: 0.2; 20 | cursor: not-allowed; 21 | } 22 | 23 | .Pagination__button:first-child .Pagination__icon { 24 | margin-right: 18px; 25 | } 26 | 27 | .Pagination__button:last-child .Pagination__icon { 28 | margin-left: 18px; 29 | } 30 | -------------------------------------------------------------------------------- /api/files/delete.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: Deletes a file by ID 3 | * Endpoint: DELETE /api/files/:name?id=:id 4 | * Response: {} 5 | */ 6 | module.exports = async (req, res) => { 7 | try { 8 | const id = req.query.id; 9 | 10 | if (!id) { 11 | return res.status(404).json({ error: "File not found." }); 12 | } 13 | 14 | await req.nylas.files.delete(id); 15 | 16 | res.json({}); 17 | } catch (error) { 18 | if (error.statusCode === 404) { 19 | return res.status(404).json({ error: "File not found." }); 20 | } 21 | 22 | console.log(error); 23 | return res.status(500).json({ 24 | error: "Failed to delete file." 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /app/layouts/Public/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./PublicLayout.module.css"; 2 | import nylasLogo from "../../assets/nylas_vertical.svg"; 3 | import inboxZeroLogo from "../../assets/inbox_zero_vertical.svg"; 4 | 5 | export default function PublicLayout({ children }) { 6 | return ( 7 |
    8 |
    9 | Inbox Zero 10 |
    11 | Nylas 12 |
    13 |
    14 |
    {children}
    15 |
    16 |
    17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/components/Actions/Actions.module.css: -------------------------------------------------------------------------------- 1 | .Actions { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | 7 | .Action { 8 | margin-top: 32px; 9 | font-weight: 600; 10 | cursor: pointer; 11 | } 12 | 13 | .Action__button { 14 | font: inherit; 15 | color: inherit; 16 | background: transparent; 17 | border: 0; 18 | padding: 0; 19 | margin: 0; 20 | text-align: inherit; 21 | display: flex; 22 | align-items: center; 23 | cursor: inherit; 24 | width: 100%; 25 | } 26 | 27 | .Action__icon { 28 | margin-right: 24px; 29 | width: 30px; 30 | display: flex; 31 | } 32 | 33 | .Action__button[disabled] { 34 | font-weight: normal; 35 | color: #585858; 36 | cursor: default; 37 | } 38 | -------------------------------------------------------------------------------- /app/utils/onRemove.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Watches the given element and when it is removed from the dom, 3 | * runs the callback. 4 | * 5 | * This is used to refresh the "schedulerPages" list when the scheduler 6 | * modal is closed. 7 | */ 8 | export default function onRemove(element, callback) { 9 | const parent = element.parentNode; 10 | if (!parent) throw new Error("The node must already be attached"); 11 | 12 | const obs = new MutationObserver(mutations => { 13 | for (const mutation of mutations) { 14 | for (const el of mutation.removedNodes) { 15 | if (el === element) { 16 | obs.disconnect(); 17 | callback(); 18 | } 19 | } 20 | } 21 | }); 22 | obs.observe(parent, { 23 | childList: true 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /app/components/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | font-weight: bold; 3 | color: #000000; 4 | background: #00e5bf; 5 | border: 0; 6 | text-transform: uppercase; 7 | min-width: 180px; 8 | cursor: pointer; 9 | transition: 0.15s; 10 | text-align: center; 11 | text-decoration: none; 12 | display: inline-block; 13 | line-height: 1.25; 14 | } 15 | 16 | .Button:not([disabled]):hover { 17 | background: #18f9d4; 18 | } 19 | 20 | .Button.primary { 21 | font-size: 16px; 22 | padding: 18px 12px; 23 | letter-spacing: 0.53px; 24 | width: 100%; 25 | } 26 | 27 | .Button.secondary { 28 | font-size: 13px; 29 | padding: 8px 12px; 30 | letter-spacing: 0.43px; 31 | } 32 | 33 | .Button[disabled] { 34 | cursor: not-allowed; 35 | color: rgba(0, 0, 0, 0.5); 36 | } 37 | -------------------------------------------------------------------------------- /app/pages/_app.js: -------------------------------------------------------------------------------- 1 | import NProgress from "nprogress"; 2 | import Router from "next/router"; 3 | import Head from "next/head"; 4 | import Referrer from "../components/Referrer"; 5 | 6 | import "normalize.css"; 7 | import "react-quill/dist/quill.snow.css"; 8 | import "../assets/style.css"; 9 | 10 | Router.events.on("routeChangeStart", () => NProgress.start()); 11 | Router.events.on("routeChangeComplete", () => NProgress.done()); 12 | Router.events.on("routeChangeError", () => NProgress.done()); 13 | 14 | export default function InboxZeroApp({ Component, pageProps }) { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /api/utils/cache.js: -------------------------------------------------------------------------------- 1 | const low = require("lowdb"); 2 | const FileSync = require("lowdb/adapters/FileSync"); 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | 6 | const dir = path.join(__dirname, "../../.data/"); 7 | const file = path.join(dir, "cache.json"); 8 | 9 | // Create the .data directory and cache.json file, if they don't exist 10 | if (!fs.existsSync(dir)) { 11 | fs.mkdirSync(dir); 12 | } 13 | 14 | if (fs.existsSync(file)) { 15 | fs.writeFileSync(file, "{}"); 16 | } 17 | 18 | const adapter = new FileSync(file); 19 | const cache = low(adapter); 20 | 21 | module.exports = { 22 | set(emailAddress, accessToken) { 23 | return cache.set([emailAddress], accessToken).write(); 24 | }, 25 | get(emailAddress) { 26 | return cache.get([emailAddress]).value(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /api/files/download.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: Downloads a file with the given name, found by the given ID 3 | * Endpoint: GET /api/files/:name?id=:id 4 | * Response: File Data 5 | */ 6 | module.exports = async (req, res) => { 7 | try { 8 | const id = req.query.id; 9 | 10 | if (!id) { 11 | return res.status(404).json({ error: "File not found." }); 12 | } 13 | 14 | const file = await req.nylas.files.find(id); 15 | const fileData = await file.download(); 16 | 17 | res.send(fileData.body); 18 | } catch (error) { 19 | if (error.statusCode === 404) { 20 | return res.status(404).json({ error: "File not found." }); 21 | } 22 | 23 | console.log(error); 24 | return res.status(500).json({ 25 | error: "Failed to download file." 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /app/components/Actions/Action.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import styles from "./Actions.module.css"; 3 | 4 | function Action({ disabled, icon, onClick, onClickIcon = null, children }) { 5 | return ( 6 |
  • 7 | 17 |
  • 18 | ); 19 | } 20 | 21 | Action.propTypes = { 22 | disabled: PropTypes.bool, 23 | icon: PropTypes.string.isRequired, 24 | onClick: PropTypes.func, 25 | onClickIcon: PropTypes.func 26 | }; 27 | 28 | export default Action; 29 | -------------------------------------------------------------------------------- /app/components/Editor/Editor.module.css: -------------------------------------------------------------------------------- 1 | /** Editor */ 2 | .Editor { 3 | margin: 18px 0 48px 0; 4 | border: 1px solid #e8e8e8; 5 | } 6 | 7 | /* Placeholder when the page is SSR to avoid a big jump when the editor loads */ 8 | .EditorPlaceholder { 9 | height: 340px; 10 | } 11 | 12 | .Editor :global(.quill) { 13 | display: flex; 14 | flex-direction: column-reverse; 15 | border: 0; 16 | } 17 | 18 | .Editor :global(.ql-container) { 19 | border: 0 !important; 20 | } 21 | 22 | .Editor :global(.ql-editor) { 23 | min-height: 300px; 24 | 25 | /** email styling */ 26 | font-size: 15px; 27 | font-family: Arial, Helvetica, sans-serif; 28 | color: #111; 29 | } 30 | 31 | .Editor :global(.ql-toolbar) { 32 | border: 0 !important; 33 | border-top: 1px solid #e8e8e8 !important; 34 | padding: 12px; 35 | } 36 | -------------------------------------------------------------------------------- /app/utils/withAuth.js: -------------------------------------------------------------------------------- 1 | import request from "./request"; 2 | import redirect from "./redirect"; 3 | 4 | /** 5 | * A wrapper function that ensures that the visitor is authenticated 6 | * when visiting a wrapped page, and adds their account information to 7 | * context. 8 | */ 9 | export default function withAuth( 10 | handler = () => { 11 | return { 12 | props: {} 13 | }; 14 | } 15 | ) { 16 | return async function getServerSideProps(context, ...restArgs) { 17 | try { 18 | const account = await request("/account", { context }); 19 | 20 | context.account = account; 21 | return handler(context, ...restArgs); 22 | } catch (e) { 23 | // if our authentication check failed, then log the user out 24 | redirect("/api/logout", { context }); 25 | 26 | return { props: {} }; 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /app/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import PropTypes from "prop-types"; 3 | import classnames from "classnames"; 4 | import styles from "./Button.module.css"; 5 | 6 | export default function Button({ 7 | href, 8 | as: asHref, 9 | children, 10 | variant = "primary", 11 | className = "", 12 | ...props 13 | }) { 14 | const classes = classnames(styles.Button, styles[variant], className); 15 | if (href) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | 27 | ); 28 | } 29 | 30 | Button.propTypes = { 31 | href: PropTypes.string, 32 | as: PropTypes.string, 33 | variant: PropTypes.oneOf(["primary", "secondary"]), 34 | children: PropTypes.node.isRequired 35 | }; 36 | -------------------------------------------------------------------------------- /api/account/get.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: Retrieves information about the authenticated account. 3 | * Endpoint: GET /api/account 4 | * Response: 5 | * { 6 | * name: String, 7 | * emailAddress: String, 8 | * organizationUnit: Enum('label', 'folder'), 9 | * unreadCount: Number, 10 | * accessToken: String 11 | * } 12 | */ 13 | module.exports = async (req, res) => { 14 | try { 15 | const unreadCount = await req.nylas.threads.count({ 16 | in: "inbox", 17 | unread: true 18 | }); 19 | 20 | res.status(200).json({ 21 | name: req.account.name, 22 | emailAddress: req.account.emailAddress, 23 | organizationUnit: req.account.organizationUnit, 24 | unreadCount: unreadCount, 25 | accessToken: req.account.accessToken 26 | }); 27 | } catch (error) { 28 | console.log(error); 29 | res.status(500).json({ error: "Failed to fetch account." }); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /api/utils/middleware/authenticate.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const Nylas = require("../nylas"); 3 | const cache = require("../cache"); 4 | 5 | /** 6 | * Express.js middleware to authenticate the account and pass through 7 | * nylas information. If authentication fails, it returns an 8 | * Unauthorized response. 9 | * 10 | * Learn more: http://expressjs.com/en/guide/writing-middleware.html 11 | */ 12 | module.exports = async function authenticate(req, res, next) { 13 | try { 14 | const token = req.cookies.token; 15 | const { emailAddress } = jwt.verify(token, process.env.JWT_SECRET); 16 | const accessToken = await cache.get(emailAddress); 17 | req.nylas = Nylas.with(accessToken); 18 | req.account = await req.nylas.account.get(); 19 | req.account.accessToken = accessToken; 20 | 21 | return next(); 22 | } catch (err) { 23 | res.status(401).json({ error: "Unauthorized" }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /app/assets/double_flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/components/Referrer.js: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import React, { useState, useEffect, useContext } from "react"; 3 | 4 | const ReferrerContext = React.createContext(null); 5 | 6 | /** 7 | * React hook and context which retrieves the page that the visitor 8 | * came from to get to the current pages 9 | */ 10 | function Provider(props) { 11 | const [referrer, setReferrer] = useState(null); 12 | useEffect(() => { 13 | setReferrer(document.referrer || null); 14 | 15 | const handleHistoryChange = url => { 16 | setReferrer(window.location.href); 17 | }; 18 | 19 | Router.events.on("beforeHistoryChange", handleHistoryChange); 20 | return () => { 21 | Router.events.off("beforeHistoryChange", handleHistoryChange); 22 | }; 23 | }, []); 24 | 25 | return ; 26 | } 27 | 28 | function useReferrer() { 29 | return useContext(ReferrerContext); 30 | } 31 | 32 | export default Provider; 33 | export { useReferrer }; 34 | -------------------------------------------------------------------------------- /app/components/threadActions/LabelsAction/LabelsAction.module.css: -------------------------------------------------------------------------------- 1 | .Labels { 2 | margin: 18px 0 40px 54px; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | 7 | .Label + .Label { 8 | margin-top: 8px; 9 | } 10 | 11 | .Label__icon { 12 | display: flex; 13 | margin-right: 18px; 14 | } 15 | 16 | .Label__button { 17 | font: inherit; 18 | color: inherit; 19 | background: transparent; 20 | border: 0; 21 | padding: 0; 22 | margin: 0; 23 | text-align: inherit; 24 | display: flex; 25 | align-items: center; 26 | cursor: pointer; 27 | width: 100%; 28 | } 29 | 30 | .CreateLabelButton { 31 | font: inherit; 32 | color: inherit; 33 | background: transparent; 34 | border: 0; 35 | padding: 0; 36 | margin: 12px 0 0 0; 37 | text-align: inherit; 38 | display: flex; 39 | align-items: center; 40 | cursor: inherit; 41 | width: 100%; 42 | } 43 | 44 | .CreateLabelButton__icon { 45 | display: flex; 46 | margin-right: 18px; 47 | } 48 | 49 | .CreateLabelInputWrapper { 50 | display: flex; 51 | } 52 | -------------------------------------------------------------------------------- /api/logout.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const Nylas = require("./utils/nylas"); 3 | const cache = require("./utils/cache"); 4 | 5 | /** 6 | * Description: Revokes the access token for the current user and 7 | * clears the accessToken cookie, logging the user out 8 | * of Inbox Zero. 9 | * Endpoint: GET /api/logout 10 | * Redirects: / 11 | */ 12 | module.exports = async (req, res) => { 13 | try { 14 | const token = req.cookies.token; 15 | const { emailAddress } = jwt.verify(token, process.env.JWT_SECRET); 16 | const accessToken = await cache.get(emailAddress); 17 | if (accessToken) { 18 | const nylas = Nylas.with(accessToken); 19 | const account = await nylas.account.get(); 20 | 21 | if (account) { 22 | // get top-level nylas account and revoke our access 23 | const fullAccount = await Nylas.accounts.find(account.id); 24 | await fullAccount.revokeAll(); 25 | } 26 | } 27 | } catch (error) { 28 | console.log(error); 29 | } 30 | 31 | // delete the token cookie 32 | res.clearCookie("token", { path: "/" }); 33 | return res.redirect("/login"); 34 | }; 35 | -------------------------------------------------------------------------------- /app/components/Messages/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styles from "./Messages.module.css"; 4 | import Message from "./Message"; 5 | import classnames from "classnames"; 6 | 7 | /** 8 | * Accordion-like list for displaying all the messages in a thread 9 | */ 10 | function Messages({ children, divideTop }) { 11 | const [index, setIndex] = useState(0); 12 | 13 | useEffect(() => { 14 | setIndex(0); 15 | }, [children]); 16 | 17 | return ( 18 |
    23 | {/** Map children with additional properties: isOpen and onClick */ 24 | React.Children.map(children, (child, i) => 25 | React.cloneElement(child, { 26 | ...child.props, 27 | isOpen: i === index, 28 | onClick: () => { 29 | setIndex(i); 30 | } 31 | }) 32 | )} 33 |
    34 | ); 35 | } 36 | 37 | Messages.propTypes = { 38 | divideTop: PropTypes.bool, 39 | children: PropTypes.arrayOf(Message).isRequired 40 | }; 41 | 42 | export default Messages; 43 | export { Message }; 44 | -------------------------------------------------------------------------------- /app/components/threadActions/MarkReadAction/index.js: -------------------------------------------------------------------------------- 1 | import NProgress from "nprogress"; 2 | import PropTypes from "prop-types"; 3 | import { Action } from "../../Actions"; 4 | import flagIcon from "../../../assets/flag.svg"; 5 | import request from "../../../utils/request"; 6 | 7 | /** 8 | * Action component that when clicked marks the given thread 9 | * as read. 10 | */ 11 | function MarkReadAction({ thread, onChange }) { 12 | return ( 13 | { 17 | NProgress.start(); 18 | try { 19 | const updatedThread = await request(`/threads/${thread.id}`, { 20 | method: "PUT", 21 | body: { unread: false } 22 | }); 23 | 24 | onChange({ unread: false }); 25 | } catch (e) { 26 | console.log(e); 27 | alert("Something went wrong"); 28 | } 29 | NProgress.done(); 30 | }} 31 | > 32 | Mark as Read » 33 | 34 | ); 35 | } 36 | 37 | MarkReadAction.propTypes = { 38 | thread: PropTypes.object.isRequired, 39 | onChange: PropTypes.func.isRequired 40 | }; 41 | 42 | export default MarkReadAction; 43 | -------------------------------------------------------------------------------- /api/labels/create.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: Creates a new label 3 | * Endpoint: POST /api/labels 4 | * Request: 5 | * { 6 | * displayName: String 7 | * } 8 | * Response: 9 | * { 10 | * id: String, 11 | * displayName: String, 12 | * checked: false 13 | * } 14 | */ 15 | module.exports = async (req, res) => { 16 | try { 17 | const displayName = req.body.displayName; 18 | 19 | if (req.account.organizationUnit !== "label") { 20 | return res.status(400).json({ 21 | error: "Labels are not supported." 22 | }); 23 | } 24 | 25 | if (!displayName) { 26 | return res.status(400).json({ error: "displayName is required." }); 27 | } 28 | 29 | const label = req.nylas.labels.build(); 30 | label.displayName = displayName; 31 | await label.save(); 32 | 33 | res.json({ 34 | id: label.id, 35 | displayName: label.displayName, 36 | checked: false 37 | }); 38 | } catch (error) { 39 | if (error.message.endsWith("already exists")) { 40 | return res.status(400).json({ 41 | error: "Label already exists." 42 | }); 43 | } 44 | 45 | res.status(500).json({ 46 | error: "Failed to create label." 47 | }); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /app/components/threadActions/SchedulerAction/SchedulerAction.module.css: -------------------------------------------------------------------------------- 1 | /** Schedule Meeting */ 2 | 3 | .SchedulePages { 4 | margin: 18px 0 40px 54px; 5 | padding: 0; 6 | list-style: none; 7 | } 8 | 9 | .SchedulePage { 10 | display: flex; 11 | align-items: flex-start; 12 | } 13 | 14 | .SchedulePage + .SchedulePage { 15 | margin-top: 8px; 16 | } 17 | 18 | .SchedulePage__icon { 19 | display: flex; 20 | margin: 4px 12px 0 0; 21 | } 22 | 23 | .SchedulePage__button { 24 | font: inherit; 25 | color: inherit; 26 | background: transparent; 27 | border: 0; 28 | padding: 0; 29 | margin: 0; 30 | text-align: inherit; 31 | display: flex; 32 | align-items: center; 33 | cursor: pointer; 34 | width: 100%; 35 | } 36 | 37 | .SchedulePage__link { 38 | font-size: 11px; 39 | line-height: 28px; 40 | text-decoration-line: underline; 41 | color: #787878; 42 | white-space: nowrap; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | } 46 | 47 | .ScheduleEditorButton { 48 | font: inherit; 49 | color: inherit; 50 | background: transparent; 51 | border: 0; 52 | padding: 0; 53 | margin: 12px 0 0 0; 54 | text-align: inherit; 55 | display: flex; 56 | align-items: center; 57 | cursor: pointer; 58 | width: 100%; 59 | } 60 | -------------------------------------------------------------------------------- /app/layouts/Inbox/InboxLayout.module.css: -------------------------------------------------------------------------------- 1 | .InboxLayout { 2 | display: grid; 3 | grid-template-columns: 400px auto; 4 | grid-template-rows: 80px auto; 5 | height: 100vh; 6 | } 7 | 8 | .Content { 9 | padding: 56px; 10 | overflow: scroll; 11 | } 12 | 13 | .Sidebar { 14 | background: #eaeaea; 15 | padding: 64px 64px 40px; 16 | overflow: scroll; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: space-between; 20 | } 21 | 22 | .Sidebar__content { 23 | } 24 | 25 | .Sidebar__fixedLink { 26 | font: inherit; 27 | color: inherit; 28 | text-decoration: none; 29 | font-weight: 600; 30 | display: block; 31 | margin-top: 40px; 32 | } 33 | 34 | .Header { 35 | grid-column-end: span 2; 36 | background: #000000; 37 | color: #ffffff; 38 | font-size: 18px; 39 | display: flex; 40 | align-items: center; 41 | justify-content: space-between; 42 | padding: 0 32px; 43 | } 44 | 45 | .Header__logos { 46 | display: flex; 47 | align-items: center; 48 | } 49 | 50 | .Header__logosDivider { 51 | background: #ffffff; 52 | height: 20px; 53 | width: 2px; 54 | margin: 0 40px; 55 | display: block; 56 | } 57 | 58 | .Header__profile { 59 | display: flex; 60 | align-items: center; 61 | } 62 | 63 | .Header__emailAddress { 64 | margin: 0 32px; 65 | } 66 | -------------------------------------------------------------------------------- /app/utils/request.js: -------------------------------------------------------------------------------- 1 | import fetch from "isomorphic-unfetch"; 2 | 3 | /** 4 | * A simple wrapper for API requests 5 | */ 6 | export default async function request( 7 | endpoint, 8 | { body, context, ...customConfig } = {} 9 | ) { 10 | const headers = { 11 | "content-type": "application/json" 12 | }; 13 | 14 | const config = { 15 | method: body ? "POST" : "GET", 16 | ...customConfig, 17 | headers: { 18 | ...headers, 19 | ...(context ? context.req.headers : {}), // inherit headers from req on server 20 | ...customConfig.headers 21 | } 22 | }; 23 | if (body) { 24 | config.body = JSON.stringify(body); 25 | } 26 | 27 | const url = endpoint.startsWith("http") 28 | ? endpoint 29 | : `${getOrigin(context)}/api/${endpoint}`; 30 | const response = await fetch(url, config); 31 | const data = await response.json(); 32 | if (response.ok) { 33 | return data; 34 | } else { 35 | return Promise.reject(data); 36 | } 37 | } 38 | 39 | /** 40 | * Gets the origin from the request if we are on the server, 41 | * or the window if we in the browser. 42 | */ 43 | function getOrigin(context) { 44 | if (context) { 45 | return context.req.protocol + "://" + context.req.get("host"); 46 | } else { 47 | return window.location.origin; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/threads/[id]/reply.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: Reply to the last message in a thread and mark the 3 | * thread as read. 4 | * Endpoint: POST /api/threads/:id 5 | * Request: 6 | * { 7 | * to: [ { email: String } ], 8 | * cc: [ { email: String } ], 9 | * bcc: [ { email: String } ], 10 | * body: String 11 | * } 12 | * Response: {} 13 | */ 14 | module.exports = async (req, res) => { 15 | const id = req.params.id; 16 | const thread = await req.nylas.threads.find(id); 17 | thread.unread = false; 18 | const draft = req.nylas.drafts.build(); 19 | // Send replies by setting replyToMessageId for a draft 20 | draft.subject = thread.subject; 21 | draft.replyToMessageId = thread.messageIds[thread.messageIds.length - 1]; 22 | draft.to = req.body.to; 23 | draft.cc = req.body.cc; 24 | draft.bcc = req.body.bcc; 25 | draft.body = req.body.body; 26 | draft.files = req.body.files; 27 | 28 | try { 29 | await draft.send(); 30 | await thread.save(); 31 | res.json({}); 32 | } catch (error) { 33 | // pass through nylas errors 34 | if (error.statusCode) { 35 | return res.status(400).json({ 36 | error: error.message 37 | }); 38 | } 39 | 40 | res.status(500).json({ 41 | error: "Failed to send reply." 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /app/components/BackButton/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import Router from "next/router"; 3 | import styles from "./BackButton.module.css"; 4 | import { useReferrer } from "../Referrer"; 5 | import chevronLeftIcon from "../../assets/chevron_left.svg"; 6 | 7 | function BackButton({ onClick }) { 8 | const referrer = useReferrer(); 9 | const defaultOnClick = () => backToListPage(referrer); 10 | 11 | return ( 12 | 20 | ); 21 | } 22 | 23 | BackButton.propTypes = { 24 | onClick: PropTypes.func 25 | }; 26 | 27 | /** 28 | * Navigate to the previous page if it is a list page. Otherwise, 29 | * go to the home page. 30 | */ 31 | const backToListPage = referrer => { 32 | const isOutsideReferrer = 33 | referrer === null || new URL(referrer).origin !== window.location.origin; 34 | 35 | const fromListPage = !isOutsideReferrer && new URL(referrer).pathname === "/"; 36 | 37 | if (isOutsideReferrer || !fromListPage) { 38 | Router.push("/"); 39 | } else { 40 | Router.back(); 41 | } 42 | }; 43 | 44 | export default BackButton; 45 | -------------------------------------------------------------------------------- /app/components/threadActions/MarkSenderReadAction/index.js: -------------------------------------------------------------------------------- 1 | import NProgress from "nprogress"; 2 | import PropTypes from "prop-types"; 3 | import { Action } from "../../Actions"; 4 | import doubleFlagIcon from "../../../assets/double_flag.svg"; 5 | import request from "../../../utils/request"; 6 | 7 | /** 8 | * Action component that when clicked marks all threads sent by the 9 | * sender of the given thread as read. 10 | */ 11 | function MarkSenderReadAction({ thread, onChange }) { 12 | return ( 13 | { 17 | NProgress.start(); 18 | try { 19 | const updatedThread = await request(`/threads/${thread.id}`, { 20 | method: "PUT", 21 | body: { senderUnread: false } 22 | }); 23 | 24 | onChange({ senderUnread: false }); 25 | } catch (e) { 26 | console.log(e); 27 | alert("Something went wrong"); 28 | } 29 | NProgress.done(); 30 | }} 31 | > 32 | Mark All Emails From Sender as Read » 33 | 34 | ); 35 | } 36 | 37 | MarkSenderReadAction.propTypes = { 38 | thread: PropTypes.object.isRequired, 39 | onChange: PropTypes.func.isRequired 40 | }; 41 | 42 | export default MarkSenderReadAction; 43 | -------------------------------------------------------------------------------- /api/files/upload.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const formidable = require("formidable"); 3 | 4 | /** 5 | * Description: Upload a file 6 | * Endpoint: POST /api/files/ 7 | * Request: Form Data 8 | * Response: 9 | * { 10 | * id: String, 11 | * filename: String 12 | * } 13 | */ 14 | module.exports = async (req, res) => { 15 | const form = formidable({ multiples: true }); 16 | 17 | return form.parse(req, async (error, fields, files) => { 18 | if (error) { 19 | return res.status(400).json({ 20 | error: "Failed to parse file upload." 21 | }); 22 | } 23 | 24 | const upload = files.upload; 25 | 26 | if (!upload) { 27 | return res.status(400).json({ 28 | error: "No file uploaded." 29 | }); 30 | } 31 | 32 | const filename = upload.name; 33 | const data = await fs.readFile(upload.path); 34 | const contentType = upload.type; 35 | 36 | try { 37 | const file = req.nylas.files.build({ 38 | filename, 39 | data, 40 | contentType 41 | }); 42 | 43 | await file.upload(); 44 | 45 | res.json({ 46 | id: file.id, 47 | filename: file.filename 48 | }); 49 | } catch (error) { 50 | console.log(error); 51 | res.status(500).json({ 52 | error: "Failed to upload file to email account." 53 | }); 54 | } 55 | }); 56 | }; 57 | -------------------------------------------------------------------------------- /app/pages/login/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Head from "next/head"; 3 | import Layout from "../../layouts/Public"; 4 | import Input from "../../components/Input"; 5 | import Button from "../../components/Button"; 6 | import redirect from "../../utils/redirect"; 7 | import styles from "./login.module.css"; 8 | 9 | export function getServerSideProps(context) { 10 | if (context.req.cookies.token) { 11 | redirect("/", { context }); 12 | } 13 | 14 | return { 15 | props: { 16 | message: context.query.message || null 17 | } 18 | }; 19 | } 20 | 21 | export default function LoginPage({ message }) { 22 | const [email, setEmail] = useState(""); 23 | 24 | const handleSubmit = event => { 25 | event.preventDefault(); 26 | redirect(`/api/login?loginHint=${email}`); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | Inbox Zero | Login 33 | 34 |
    35 |

    Login

    36 |
    37 | setEmail(target.value)} 42 | autoFocus 43 | /> 44 |
    45 | 46 | {message ?
    {message}
    : ""} 47 |
    48 |
    49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/components/Pagination/index.js: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import PropTypes from "prop-types"; 3 | import styles from "./Pagination.module.css"; 4 | import chevronLeftIcon from "../../assets/chevron_left.svg"; 5 | import chevronRightIcon from "../../assets/chevron_right.svg"; 6 | 7 | function Pagination({ label, previous, next, variant = "offset" }) { 8 | return ( 9 |
    10 | 22 | {variant === "offset" && label &&
    {label}
    } 23 | 35 |
    36 | ); 37 | } 38 | 39 | Pagination.propTypes = { 40 | label: PropTypes.string, 41 | previous: PropTypes.shape({ 42 | href: PropTypes.string.isRequired, 43 | as: PropTypes.string 44 | }), 45 | next: PropTypes.shape({ 46 | href: PropTypes.string.isRequired, 47 | as: PropTypes.string 48 | }), 49 | variant: PropTypes.oneOf(["offset", "cursor"]) 50 | }; 51 | 52 | export default Pagination; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inbox-zero", 3 | "version": "1.0.0", 4 | "description": "Automate Your Way to Inbox Zero With Nylas", 5 | "main": "index.js", 6 | "scripts": { 7 | "//1": "Run `npm run local` for local development", 8 | "local": "nodemon --watch ./api --watch server.js server.js", 9 | "//2": "Glitch will run `npm run start`", 10 | "start": "node server.js", 11 | "//3": "For production run `npm run build` followed by `npm run prod`", 12 | "build": "next build", 13 | "prod": "NODE_ENV=production node server.js" 14 | }, 15 | "husky": { 16 | "hooks": { 17 | "pre-commit": "pretty-quick --staged" 18 | } 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/nylas/inbox-zero.git" 23 | }, 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/nylas/inbox-zero/issues" 27 | }, 28 | "homepage": "https://github.com/nylas/inbox-zero#readme", 29 | "dependencies": { 30 | "bluebird": "^3.7.2", 31 | "body-parser": "^1.19.0", 32 | "classnames": "^2.2.6", 33 | "cookie-parser": "^1.4.5", 34 | "dotenv": "^8.2.0", 35 | "express": "^4.17.1", 36 | "formidable": "^1.2.2", 37 | "isomorphic-unfetch": "^3.0.0", 38 | "jsonwebtoken": "^8.5.1", 39 | "lowdb": "^1.0.0", 40 | "next": "^9.3.4", 41 | "next-images": "^1.3.1", 42 | "normalize.css": "^8.0.1", 43 | "nprogress": "^0.2.0", 44 | "nylas": "^4.10.0", 45 | "prop-types": "^15.7.2", 46 | "react": "^16.13.0", 47 | "react-dom": "^16.13.0", 48 | "react-quill": "^1.3.5" 49 | }, 50 | "devDependencies": { 51 | "husky": "^4.2.3", 52 | "nodemon": "^2.0.3", 53 | "prettier": "^1.19.1", 54 | "pretty-quick": "^2.0.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /logNylasRequests.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file wraps the http.request and https.request methods 3 | * to log all outgoing requests to api.nylas.com 4 | * 5 | * Sample output: 6 | * ``` 7 | * GET /account 8 | * Host: api.nylas.com 9 | * Content-Type: application/json 10 | * Authorization: Basic AOPui**** 11 | * ```` 12 | */ 13 | const http = require("http"); 14 | const https = require("https"); 15 | 16 | function green(str) { 17 | return "\x1b[32m" + str + "\x1b[0m"; 18 | } 19 | function blue(str) { 20 | return "\x1b[36m" + str + "\x1b[0m"; 21 | } 22 | function wrapRequestMethod(module) { 23 | const original = module.request; 24 | 25 | module.request = function wrappedRequest(req) { 26 | if (req.host === "api.nylas.com") { 27 | const method = req.method; 28 | const path = req.path; 29 | const host = req.host; 30 | const contentType = req.headers["content-type"]; 31 | const authorization = req.headers["authorization"] 32 | ? `${req.headers["authorization"].substring(0, 11)}****` 33 | : ""; 34 | const body = 35 | req.body && req.body !== "{}" 36 | ? `${JSON.stringify(JSON.parse(req.body), null, 2)}` 37 | : ""; 38 | 39 | console.log( 40 | `${[ 41 | `${green(method)} ${path}`, 42 | `${blue("Host")}: ${host}`, 43 | contentType ? `${blue("Content-Type")}: ${contentType}` : "", 44 | authorization ? `${blue("Authorization")}: ${authorization}` : "", 45 | `${body}` 46 | ] 47 | .join("\n") 48 | .trim()}\n` 49 | ); 50 | } 51 | 52 | // Call original request function and pass through the results 53 | return original.apply(this, arguments); 54 | }; 55 | } 56 | 57 | wrapRequestMethod(http); 58 | wrapRequestMethod(https); 59 | -------------------------------------------------------------------------------- /app/layouts/Inbox/index.js: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import Link from "next/link"; 3 | import styles from "./InboxLayout.module.css"; 4 | import Button from "../../components/Button"; 5 | import nylasLogo from "../../assets/nylas.svg"; 6 | import inboxZeroLogo from "../../assets/inbox_zero.svg"; 7 | 8 | Router.events.on("routeChangeComplete", url => { 9 | const mainElement = document.querySelector(`.${styles.Content}`); 10 | 11 | if (mainElement) { 12 | mainElement.scrollTo(0, 0); 13 | } 14 | }); 15 | 16 | export default function InboxLayout({ children }) { 17 | return
    {children}
    ; 18 | } 19 | 20 | export function Content({ children }) { 21 | return
    {children}
    ; 22 | } 23 | 24 | export function Sidebar({ children }) { 25 | return ( 26 | 36 | ); 37 | } 38 | 39 | export function Header({ account }) { 40 | return ( 41 |
    42 | 43 | 44 | Nylas 45 |
    46 | Inbox Zero 47 | 48 | 49 |
    50 |
    51 | {account.emailAddress} 52 |
    53 | 56 |
    57 |
    58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /api/authorize.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const Nylas = require("./utils/nylas"); 3 | const cache = require("./utils/cache"); 4 | 5 | /** 6 | * Description: After the user successfully authenticates via Nylas 7 | * hosted auth, this endpoint is sent a code which we 8 | * exchange for an access token to the user's mailbox. 9 | * We store the token in a cache and create a signed cooke 10 | * that we'll use to lookup up the Nylas access token 11 | * verify future API requests. 12 | * Endpoint: GET /api/authorize 13 | * Redirects: / 14 | */ 15 | module.exports = async (req, res) => { 16 | try { 17 | const code = req.query.code; 18 | const accessToken = await Nylas.exchangeCodeForToken(code); 19 | 20 | // if we didn't get an access token back, send the user back to try again 21 | if (!accessToken) { 22 | return res.redirect( 23 | `/login?message=${encodeURIComponent( 24 | "We couldn't access your account. Please try again." 25 | )}` 26 | ); 27 | } 28 | 29 | // With our new access token, get the account's email address 30 | const nylas = Nylas.with(accessToken); 31 | const { emailAddress } = await nylas.account.get(); 32 | 33 | // store the access token in our cache 34 | await cache.set(emailAddress, accessToken); 35 | 36 | // create a cookie with a JSON Web Token (JWT) so we can authenticate API requests 37 | res.cookie("token", jwt.sign({ emailAddress }, process.env.JWT_SECRET), { 38 | path: "/", 39 | httpOnly: false 40 | }); 41 | 42 | // Redirect the user to their inbox 43 | return res.redirect("/"); 44 | } catch (error) { 45 | console.log(error); 46 | 47 | return res.redirect( 48 | `/login?message=${encodeURIComponent( 49 | "Something went wrong. Please try again." 50 | )}` 51 | ); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /app/components/Threads/Thread.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import PropTypes from "prop-types"; 3 | import classnames from "classnames"; 4 | import styles from "./Threads.module.css"; 5 | import attachment from "../../assets/attachment.svg"; 6 | import formatDate from "../../utils/formatDate"; 7 | 8 | function Thread({ 9 | id, 10 | unread, 11 | fromName, 12 | subject, 13 | snippet, 14 | date, 15 | hasAttachment = false 16 | }) { 17 | return ( 18 | 19 | 25 | 26 | 27 | {fromName && fromName.charAt(0).toUpperCase()} 28 | 29 | 30 | {fromName} 31 | 32 | {subject} 33 | {hasAttachment && ( 34 | email has an attachment 39 | )} 40 | 41 | 45 | 46 | {formatDate(new Date(date))} 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | Thread.propTypes = { 54 | id: PropTypes.string.isRequired, 55 | unread: PropTypes.bool.isRequired, 56 | fromName: PropTypes.string.isRequired, 57 | subject: PropTypes.string.isRequired, 58 | snippet: PropTypes.string.isRequired, 59 | date: PropTypes.string.isRequired, 60 | hasAttachment: PropTypes.bool 61 | }; 62 | 63 | export default Thread; 64 | -------------------------------------------------------------------------------- /app/components/Threads/Threads.module.css: -------------------------------------------------------------------------------- 1 | .ThreadList { 2 | width: 100%; 3 | } 4 | 5 | .Thread { 6 | border: 1px solid transparent; 7 | border-top-color: #979797; 8 | cursor: pointer; 9 | transition: 0.15s; 10 | display: grid; 11 | grid-template-columns: 40px 1fr 1fr 1fr 100px; 12 | grid-template-rows: auto; 13 | padding: 24px; 14 | line-height: 1.25em; 15 | color: inherit; 16 | text-decoration: none; 17 | outline: 0; 18 | } 19 | 20 | .Thread:first-child { 21 | border-top-color: transparent; 22 | } 23 | 24 | .Thread:last-child { 25 | border-bottom-color: #979797; 26 | } 27 | 28 | .Thread:hover, 29 | .Thread:focus { 30 | border: 1px solid #d8d8d8; 31 | box-shadow: 0px 0px 14px rgba(201, 201, 201, 0.5); 32 | } 33 | 34 | .Thread:hover + .Thread, 35 | .Thread:focus + .Thread { 36 | border-top-color: transparent; 37 | } 38 | 39 | .Thread.unread { 40 | font-weight: bold; 41 | } 42 | 43 | .Thread.read { 44 | font-weight: normal; 45 | } 46 | 47 | .Thread__iconCell { 48 | } 49 | 50 | .Thread__icon { 51 | background: #00e5bf; 52 | border-radius: 50%; 53 | height: 40px; 54 | width: 40px; 55 | line-height: 40px; 56 | text-align: center; 57 | font-weight: bold; 58 | font-size: 24px; 59 | display: block; 60 | } 61 | 62 | .Thread__fromName { 63 | padding: 8px 8px 0; 64 | white-space: nowrap; 65 | overflow: hidden; 66 | text-overflow: ellipsis; 67 | } 68 | 69 | .Thread__subjectAndAttachment { 70 | padding: 8px 8px 0; 71 | } 72 | 73 | .Thread__subject { 74 | -webkit-line-clamp: 2; 75 | -webkit-box-orient: vertical; 76 | overflow: hidden; 77 | display: -webkit-box; 78 | } 79 | 80 | .Thread__hasAttachment { 81 | margin-top: 12px; 82 | } 83 | 84 | .Thread__snippet { 85 | padding: 8px 8px 0; 86 | font-weight: normal; 87 | color: #848484; 88 | white-space: nowrap; 89 | overflow: hidden; 90 | text-overflow: ellipsis; 91 | } 92 | 93 | .Thread__date { 94 | padding: 8px 8px 0; 95 | font-weight: normal; 96 | color: #848484; 97 | text-align: right; 98 | } 99 | -------------------------------------------------------------------------------- /app/components/Messages/Frame.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | /** 5 | * React component which safely diplays the content of an email 6 | * 7 | * How it works: 8 | * - when the component is first rendered it renders an iframe 9 | * - when React is ready to run side affects, we inject the 10 | * content, our styling, and javascript 11 | * - the injected javascript will open all links in a new tab and 12 | * when each image loads it will force the iframe to get resized 13 | * so it properly contains the full email content 14 | */ 15 | function Frame({ content }) { 16 | const [iframeHeight, setIframeHeight] = useState(0); 17 | 18 | const ref = useRef(null); 19 | useEffect(() => { 20 | const doc = ref.current.contentWindow.document; 21 | doc.body.innerHTML = content; 22 | 23 | // add default styling 24 | const style = doc.createElement("style"); 25 | style.innerHTML = ` 26 | body { 27 | font-family: "Source Sans Pro", Roboto, RobotoDraft, Helvetica, Arial, sans-serif; 28 | overflow: hidden; 29 | } 30 | 31 | .gmail_quote { 32 | display: none; 33 | } 34 | `; 35 | doc.head.appendChild(style); 36 | 37 | // Set the initial height 38 | setIframeHeight(doc.documentElement.scrollHeight); 39 | 40 | // Force all links to open in a new tab 41 | [...doc.links].forEach(link => { 42 | link.target = "_blank"; 43 | link.rel = "noopener noreferrer"; 44 | }); 45 | 46 | // Refresh height after each image loads 47 | [...doc.images].forEach(image => { 48 | image.onload = function() { 49 | setIframeHeight(doc.documentElement.scrollHeight); 50 | }; 51 | }); 52 | }, [content]); 53 | 54 | return ( 55 |