├── .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 |
10 |
11 |
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 |
12 |
13 |
14 |
15 | {children}
16 |
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 |
25 | {children}
26 |
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 |
13 | {" "}
18 | Back
19 |
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 |
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 |
Router.push(previous.href, previous.as)}
14 | >
15 |
20 | {variant === "cursor" && "Previous"}
21 |
22 | {variant === "offset" && label &&
{label}
}
23 |
Router.push(next.href, next.as)}
27 | >
28 | {variant === "cursor" && "Next"}
29 |
34 |
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 |
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 |
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 |
64 | );
65 | }
66 |
67 | Frame.propTypes = {
68 | content: PropTypes.string.isRequired
69 | };
70 |
71 | export default Frame;
72 |
--------------------------------------------------------------------------------
/app/components/Messages/Messages.module.css:
--------------------------------------------------------------------------------
1 | .Messages {
2 | width: 100%;
3 | }
4 |
5 | .Message {
6 | border: 1px solid transparent;
7 | border-top-color: #979797;
8 | cursor: pointer;
9 | transition: background 0.15s, border 0.15s, box-shadow 0.15s;
10 | display: grid;
11 | grid-template-columns: 40px 1fr 3fr 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 | .Message.isOpen {
21 | cursor: auto;
22 | }
23 |
24 | .Messages:not(.divideTop) .Message:first-child {
25 | transition: background 0.15s, border 0.15s, box-shadow 0.15s, border-top 0s;
26 | border-top-color: transparent;
27 | }
28 |
29 | .Message.isClosed:last-child {
30 | border-bottom-color: #979797;
31 | }
32 |
33 | .Messages .Message.isClosed:hover,
34 | .Messages .Message.isClosed:focus {
35 | border: 1px solid #d8d8d8;
36 | box-shadow: 0px 0px 14px rgba(201, 201, 201, 0.5);
37 | }
38 |
39 | .Message.isClosed:hover + .Message,
40 | .Message.isClosed:focus + .Message {
41 | border-top-color: transparent;
42 | }
43 |
44 | .Message__iconCell {
45 | }
46 |
47 | .Message__icon {
48 | background: #00e5bf;
49 | border-radius: 50%;
50 | height: 40px;
51 | width: 40px;
52 | line-height: 40px;
53 | text-align: center;
54 | font-weight: bold;
55 | font-size: 24px;
56 | display: block;
57 | }
58 |
59 | .Message__fromName {
60 | padding: 8px 8px 0;
61 | white-space: nowrap;
62 | overflow: hidden;
63 | text-overflow: ellipsis;
64 | }
65 |
66 | .Message__fromEmailAddress {
67 | padding: 8px 8px 0;
68 | }
69 |
70 | .Message__hasAttachments {
71 | margin: -6px 0 0 32px;
72 | vertical-align: top;
73 | }
74 |
75 | .Message__date {
76 | padding: 8px 8px 0;
77 | font-weight: normal;
78 | color: #848484;
79 | text-align: right;
80 | }
81 |
82 | .MessageContents {
83 | padding: 12px 24px 12px;
84 | }
85 |
86 | .MessageContents:last-child {
87 | padding-bottom: 24px;
88 | border-bottom: 1px solid #979797;
89 | }
90 |
91 | .AttachmentWrapper {
92 | padding: 32px 0 0 0;
93 | }
94 |
95 | .AttachmentWrapper > * {
96 | margin: 0 0 10px 10px;
97 | }
98 |
--------------------------------------------------------------------------------
/app/assets/style.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600,700&display=swap");
2 | * {
3 | position: relative;
4 | box-sizing: border-box;
5 | }
6 |
7 | /*a:focus {
8 | outline: 1px dotted rgba(0, 0, 0, 0.5);
9 | outline-offset: 0.25rem;
10 | }*/
11 |
12 | body {
13 | font-family: "Source Sans Pro", sans-serif;
14 | font-size: 18px;
15 | line-height: 1.25;
16 | -webkit-font-smoothing: antialiased;
17 | -moz-osx-font-smoothing: grayscale;
18 | }
19 | li,
20 | p {
21 | margin-top: 0;
22 | line-height: 1.5;
23 | }
24 |
25 | /** Loading bar */
26 | #nprogress {
27 | pointer-events: none;
28 | }
29 | #nprogress .bar {
30 | background: #00e5bf;
31 | position: fixed;
32 | z-index: 1031;
33 | top: 0;
34 | left: 0;
35 | width: 100%;
36 | height: 4px;
37 | }
38 | #nprogress .peg {
39 | display: block;
40 | position: absolute;
41 | right: 0;
42 | width: 100px;
43 | height: 100%;
44 | opacity: 1;
45 | -webkit-transform: rotate(3deg) translate(0px, -4px);
46 | -ms-transform: rotate(3deg) translate(0px, -4px);
47 | transform: rotate(3deg) translate(0px, -4px);
48 | }
49 | #nprogress .spinner {
50 | display: block;
51 | position: fixed;
52 | z-index: 1031;
53 | top: 15px;
54 | right: 15px;
55 | }
56 | #nprogress .spinner-icon {
57 | display: none;
58 | width: 18px;
59 | height: 18px;
60 | box-sizing: border-box;
61 | border: solid 2px transparent;
62 | border-top-color: #29d;
63 | border-left-color: #29d;
64 | border-radius: 50%;
65 | -webkit-animation: nprogress-spinner 400ms linear infinite;
66 | animation: nprogress-spinner 400ms linear infinite;
67 | }
68 | .nprogress-custom-parent {
69 | overflow: hidden;
70 | position: relative;
71 | }
72 | .nprogress-custom-parent #nprogress .spinner,
73 | .nprogress-custom-parent #nprogress .bar {
74 | position: absolute;
75 | }
76 |
77 | @-webkit-keyframes nprogress-spinner {
78 | 0% {
79 | -webkit-transform: rotate(0deg);
80 | }
81 | 100% {
82 | -webkit-transform: rotate(360deg);
83 | }
84 | }
85 | @keyframes nprogress-spinner {
86 | 0% {
87 | transform: rotate(0deg);
88 | }
89 | 100% {
90 | transform: rotate(360deg);
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/api/threads/[id]/update.js:
--------------------------------------------------------------------------------
1 | const Promise = require("bluebird");
2 | const { DEFAULT_LABELS } = require("../../utils/constants");
3 | const getThreadFrom = require("../../utils/getThreadFrom");
4 |
5 | /**
6 | * Description: Updates an email thread. Updates include: mark thread
7 | * as read, mark sender as read, and update labels
8 | * Endpoint: PUT /api/threads/:id
9 | * Request:
10 | * {
11 | * unread: Boolean,
12 | * senderUnread: Boolean,
13 | * labels: [
14 | * {
15 | * id: String,
16 | * displayName: String,
17 | * checked: Boolean
18 | * }
19 | * ]
20 | * }
21 | * Response: {}
22 | */
23 | module.exports = async (req, res) => {
24 | try {
25 | const id = req.params.id;
26 | const thread = await req.nylas.threads.find(id, null, { view: "expanded" });
27 |
28 | /** mark thread as read */
29 | if (req.body.unread === false) {
30 | thread.unread = false;
31 | }
32 |
33 | /** update labels */
34 | if (req.body.labels) {
35 | // replace all but the default labels
36 | const threadDefaultLabels = thread.labels.filter(label =>
37 | DEFAULT_LABELS.includes(label.name)
38 | );
39 | thread.labels = [
40 | ...threadDefaultLabels,
41 | ...req.body.labels.filter(label => label.checked)
42 | ];
43 | }
44 |
45 | await thread.save();
46 |
47 | /** mark sender as read */
48 | if (req.body.senderUnread === false) {
49 | await markSenderAsRead({
50 | nylas: req.nylas,
51 | thread,
52 | account: req.account
53 | });
54 | }
55 |
56 | res.status(200).json({});
57 | } catch (error) {
58 | console.log(error);
59 | res.status(500).json({ error: "Failed to update thread." });
60 | }
61 | };
62 |
63 | async function markSenderAsRead({ nylas, thread, account }) {
64 | const fromEmailAddress = getThreadFrom(thread).email;
65 | const unreadThreads = await nylas.threads.list({
66 | in: "inbox",
67 | from: fromEmailAddress,
68 | unread: true
69 | });
70 |
71 | return Promise.map(
72 | unreadThreads,
73 | thread => {
74 | thread.unread = false;
75 | return thread.save();
76 | },
77 | { concurrency: 5 }
78 | );
79 | // limit to 5 calls at a time to stay under the rate limit
80 | }
81 |
--------------------------------------------------------------------------------
/app/components/threadActions/AttachmentsAction/index.js:
--------------------------------------------------------------------------------
1 | import { Fragment, useRef } from "react";
2 | import NProgress from "nprogress";
3 | import PropTypes from "prop-types";
4 | import { Action } from "../../Actions";
5 | import Attachment from "../../Attachment";
6 | import addAttachmentIcon from "../../../assets/add_attachment.svg";
7 | import removeIcon from "../../../assets/remove.svg";
8 | import request from "../../../utils/request";
9 |
10 | /**
11 | * Action components to upload, download, and delete attachments
12 | */
13 | function AttachmentsAction({ files, onUpload, onDelete }) {
14 | return (
15 |
16 |
17 | {files.map(file => (
18 |
19 | ))}
20 |
21 | );
22 | }
23 |
24 | AttachmentsAction.propTypes = {
25 | files: PropTypes.array.isRequired,
26 | onUpload: PropTypes.func.isRequired,
27 | onDelete: PropTypes.func.isRequired
28 | };
29 |
30 | function UploadAction({ onUpload }) {
31 | const fileInputRef = useRef(null);
32 |
33 | async function handleFileChange(event) {
34 | NProgress.start();
35 | const formData = new FormData();
36 | formData.append("upload", event.target.files[0]);
37 |
38 | const response = await fetch("/api/files", {
39 | method: "POST",
40 | body: formData
41 | });
42 |
43 | const file = await response.json();
44 | onUpload(file);
45 | NProgress.done();
46 | }
47 |
48 | return (
49 | {
52 | fileInputRef.current.click();
53 | }}
54 | >
55 |
61 | Add Attachment »
62 |
63 | );
64 | }
65 |
66 | function FileAction({ onDelete, file }) {
67 | async function deleteFile({ filename, id }) {
68 | NProgress.start();
69 |
70 | await request(`/files/${filename}?id=${id}`, {
71 | method: "DELETE"
72 | });
73 |
74 | onDelete({ filename, id });
75 | NProgress.done();
76 | }
77 |
78 | return (
79 | {
82 | deleteFile(file);
83 | }}
84 | >
85 |
86 |
87 | );
88 | }
89 |
90 | export default AttachmentsAction;
91 |
--------------------------------------------------------------------------------
/app/components/Messages/Message.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from "react";
2 | import PropTypes from "prop-types";
3 | import Link from "next/link";
4 | import classnames from "classnames";
5 | import styles from "./Messages.module.css";
6 | import attachment from "../../assets/attachment.svg";
7 | import formatDate from "../../utils/formatDate";
8 | import Frame from "./Frame";
9 | import Attachment from "../Attachment";
10 |
11 | function Message({
12 | id,
13 | fromName,
14 | fromEmailAddress,
15 | date,
16 | body,
17 | hasAttachments = false,
18 | files,
19 | // Injected by parent
20 | isOpen = false,
21 | onClick
22 | }) {
23 | return (
24 |
25 |
32 |
33 |
34 | {fromName && fromName.charAt(0).toUpperCase()}
35 |
36 |
37 | {fromName}
38 |
39 | {fromEmailAddress}
40 | {hasAttachments && (
41 |
46 | )}
47 |
48 |
49 | {formatDate(new Date(date))}
50 |
51 |
52 | {isOpen && (
53 |
54 |
55 | {hasAttachments && (
56 |
57 | {files.map(file => (
58 |
59 | ))}
60 |
61 | )}
62 |
63 | )}
64 |
65 | );
66 | }
67 |
68 | Message.propTypes = {
69 | id: PropTypes.string.isRequired,
70 | fromName: PropTypes.string.isRequired,
71 | fromEmailAddress: PropTypes.string.isRequired,
72 | date: PropTypes.string.isRequired,
73 | body: PropTypes.string.isRequired,
74 | hasAttachments: PropTypes.bool,
75 | files: PropTypes.array
76 | };
77 |
78 | export default Message;
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 
5 |
6 | **Automate Your Way to Inbox Zero With Nylas**
7 |
8 | A demo inbox built using the Nylas API
9 |
10 |
11 |
12 |
13 |
14 | ## Getting Started
15 |
16 | To get started, you can run the app locally or remix it as a Glitch app.
17 |
18 | #### Installing Locally
19 |
20 | **Note:** You must have node 10 or higher installed to run this app.
21 |
22 | First, clone the repository locally:
23 |
24 | ```sh
25 | git clone https://github.com/nylas/inbox-zero.git
26 | ```
27 |
28 | Next, install the dependencies:
29 |
30 | ```sh
31 | cd inbox-zero && npm install
32 | ```
33 |
34 | #### Remix on Glitch
35 |
36 | [Glitch](https://glitch.com/) is a community coding platform that allows you to build fast, full-stack web apps in your browser for free. Click the "Remix on Glitch" button to get started.
37 |
38 | [](https://glitch.com/edit/#!/import/github/Nylas/inbox-zero?NYLAS_ID&NYLAS_SECRET&JWT_SECRET)
39 |
40 | #### Configuration
41 |
42 | Next, you'll need to configure the app to use your Nylas Client ID and Client secret. You can find these on the [application dashboard](https://dashboard.nylas.com/applications). Don't have a Nylas account yet? [Try it free](https://dashboard.nylas.com/register)
43 |
44 | Create a `.env` file with the following content:
45 |
46 | ```sh
47 | NYLAS_ID=your-client-id
48 | NYLAS_SECRET=your-nylas-secret
49 | JWT_SECRET=any-random-string
50 | ```
51 |
52 | You need to add a callback URL to your nylas aplication settings. If you are running locally, add `http://localhost:3000/api/authorize`. If you are using Glitch, add `https://your-glitch-url.glitch.me/api/authorize`
53 |
54 | #### Starting your app
55 |
56 | Finally, run `npm run local` and navigate to [http://localhost:3000](http://localhost:3000). Or if you are using Glitch, visit your live Glitch app. You should now see Inbox Zero!
57 |
58 | #### Production build
59 |
60 | To deploy Inbox Zero to production, first create a build for the app. This should most likely happen in your build step.
61 |
62 | ```sh
63 | npm run build
64 | ```
65 |
66 | Next, on your server, run the following to start Inbox Zero.
67 |
68 | ```sh
69 | npm run prod
70 | ```
71 |
72 | ## Built With
73 |
74 | - [Nylas](https://www.nylas.com/) - The Leading Platform for Email, Calendar, and Contacts
75 | - [Next.js](https://nextjs.org/) - A React framework
76 | - [Express.js](https://expressjs.com/) - A Node.js framework
77 | - [JSON Web Tokens](https://jwt.io/) - Signed Tokens
78 | - [lowdb](https://github.com/typicode/lowdb) - JSON database powered by Lodash
79 |
--------------------------------------------------------------------------------
/api/threads/get.js:
--------------------------------------------------------------------------------
1 | const Promise = require("bluebird");
2 | const getThreadFrom = require("../utils/getThreadFrom");
3 | const { PAGE_LIMIT } = require("../utils/constants");
4 |
5 | /**
6 | * Description: Retrieves a list of threads based on the page and search parameters
7 | * Endpoint: GET /api/threads?page=:page&search=:search
8 | * Response:
9 | * [
10 | * {
11 | * id: String,
12 | * subject: String,
13 | * from: { name: String, email: String },
14 | * date: Timestamp,
15 | * snippet: String,
16 | * hasAttachments: Boolean,
17 | * unread: Boolean
18 | * }
19 | * ]
20 | */
21 | module.exports = async (req, res) => {
22 | try {
23 | const page = req.query.page >= 1 ? req.query.page : 1;
24 | const search = req.query.search || "";
25 |
26 | /**
27 | * We request one more result than we will return so we can
28 | * check if there is a next page.
29 | */
30 | const pagination = {
31 | limit: PAGE_LIMIT + 1,
32 | offset: (page - 1) * PAGE_LIMIT
33 | };
34 |
35 | let threads = await (search.length > 0
36 | ? expandThreads({
37 | nylas: req.nylas,
38 | threads: await req.nylas.threads.search(search, { ...pagination })
39 | })
40 | : req.nylas.threads.list({
41 | in: "inbox",
42 | unread: true,
43 | view: "expanded",
44 | ...pagination
45 | }));
46 |
47 | threads = threads.map(thread => {
48 | thread.from = getThreadFrom(thread);
49 | return thread;
50 | });
51 |
52 | res.status(200).json({
53 | hasPrevious: page > 1,
54 | hasNext: threads.length > PAGE_LIMIT,
55 | threads: threads.map(simplifyThread).slice(0, PAGE_LIMIT)
56 | });
57 | } catch (error) {
58 | console.log(error);
59 | res.status(500).json({ error: "Something went wrong. Please try again." });
60 | }
61 | };
62 |
63 | /**
64 | * We need the expanded thread view. Search does not support views
65 | * so we enrich the results ourselves with the same "messages" key
66 | */
67 | function expandThreads({ nylas, threads }) {
68 | return Promise.map(
69 | threads,
70 | async thread => {
71 | const messages = await nylas.messages.list({
72 | thread_id: thread.id
73 | });
74 |
75 | thread.messages = messages;
76 |
77 | return thread;
78 | },
79 | { concurrency: 5 }
80 | );
81 | // limit to 5 calls at a time to stay under the rate limit
82 | }
83 |
84 | function simplifyThread(thread) {
85 | return {
86 | id: thread.id,
87 | subject: thread.subject,
88 | from: thread.from,
89 | date: thread.lastMessageTimestamp,
90 | snippet: thread.snippet,
91 | unread: thread.unread,
92 | hasAttachments: thread.hasAttachments
93 | };
94 | }
95 |
--------------------------------------------------------------------------------
/app/assets/nylas.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Hello! 👋
3 | *
4 | * This is the entry file for this project. It starts up the Next.js
5 | * app and express.js server.
6 | *
7 | * Run `npm run dev` to get started.
8 | *
9 | * Learn more: https://github.com/nylas/inbox-zero
10 | */
11 | require("dotenv").config();
12 |
13 | const express = require("express");
14 | const next = require("next");
15 | const cookieParser = require("cookie-parser");
16 | const bodyParser = require("body-parser");
17 | const authenticate = require("./api/utils/middleware/authenticate");
18 |
19 | const port = parseInt(process.env.PORT, 10) || 3000;
20 | const dev = process.env.NODE_ENV !== "production";
21 | const app = next({ dev, dir: "./app" });
22 | const appHandler = app.getRequestHandler();
23 | const api = express();
24 |
25 | if (dev) {
26 | require("./logNylasRequests");
27 | }
28 |
29 | /** authorization */
30 | api.get("/login", require("./api/login"));
31 | api.get("/authorize", require("./api/authorize"));
32 | api.get("/logout", require("./api/logout"));
33 |
34 | /** account-level management */
35 | api.get("/account", authenticate, require("./api/account/get"));
36 | api.post("/labels", authenticate, require("./api/labels/create"));
37 |
38 | /** threads */
39 | api.get("/threads", authenticate, require("./api/threads/get"));
40 | api.get("/threads/:id", authenticate, require("./api/threads/[id]/get"));
41 | api.put("/threads/:id", authenticate, require("./api/threads/[id]/update"));
42 | api.post("/threads/:id", authenticate, require("./api/threads/[id]/reply"));
43 |
44 | /** files */
45 | api.get("/files/:name", authenticate, require("./api/files/download"));
46 | api.delete("/files/:name", authenticate, require("./api/files/delete"));
47 | api.post("/files", authenticate, require("./api/files/upload"));
48 |
49 | app.prepare().then(() => {
50 | const server = express();
51 | server.use(cookieParser());
52 | server.use(bodyParser.json());
53 |
54 | /** Show a warning when the env vars aren't configured */
55 | if (!process.env.NYLAS_ID || !process.env.NYLAS_SECRET) {
56 | server.use("*", (req, res) => {
57 | res.send(`
58 |
59 | Add your Nylas App ID and Secret in the .env file to get started.
60 | Don't have a Nylas account? Try it free »
61 |
62 |
70 | `);
71 | });
72 | }
73 |
74 | /** Serve the API endpoints with the prefix "/api" */
75 | server.use("/api", api);
76 |
77 | /** Serve the next.js app for all other requests */
78 | server.all("*", (req, res) => {
79 | return appHandler(req, res);
80 | });
81 |
82 | /** Start the server */
83 | server.listen(port, err => {
84 | if (err) throw err;
85 | console.log(`> Ready on http://localhost:${port}`);
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/app/assets/nylas_vertical.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/assets/calendar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/components/threadActions/SchedulerAction/index.js:
--------------------------------------------------------------------------------
1 | import { Fragment, useState } from "react";
2 | import PropTypes from "prop-types";
3 | import styles from "./SchedulerAction.module.css";
4 | import { Action, Slot } from "../../Actions";
5 | import calendarIcon from "../../../assets/calendar.svg";
6 | import request from "../../../utils/request";
7 | import useScript from "../../../utils/useScript";
8 | import onRemove from "../../../utils/onRemove";
9 |
10 | const schedulerConfig = {
11 | style: {
12 | tintColor: "#32325d",
13 | backgroundColor: "white"
14 | },
15 | defaults: {
16 | event: {
17 | title: "30-min Coffee Meeting",
18 | duration: 30
19 | }
20 | }
21 | };
22 |
23 | /**
24 | * Action components to manage scheduler pages and quick-start a reply with a
25 | * scheduler link.
26 | */
27 | function SchedulerAction({
28 | accessToken,
29 | schedulerPages: defaultSchedulerPages,
30 | onSchedule
31 | }) {
32 | /** Load Nylas Scheduler */
33 | useScript(
34 | "https://schedule.nylas.com/schedule-editor/v1.0/schedule-editor.js"
35 | );
36 |
37 | const [showSchedulerPages, setShowSchedulerPages] = useState(false);
38 | const [schedulerPages, setSchedulerPages] = useState(defaultSchedulerPages);
39 | const refreshSchedulerPages = async () => {
40 | const newSchedulerPages = await request(
41 | "https://schedule.api.nylas.com/manage/pages",
42 | {
43 | headers: { Authorization: `Bearer ${accessToken}` }
44 | }
45 | );
46 |
47 | setSchedulerPages(newSchedulerPages);
48 | };
49 |
50 | return (
51 |
52 | {
55 | setShowSchedulerPages(!showSchedulerPages);
56 | }}
57 | >
58 | Schedule Meeting »
59 |
60 | {showSchedulerPages && (
61 |
62 |
63 | {schedulerPages.map(page => (
64 |
65 |
66 | {
69 | onSchedule(page);
70 | }}
71 | >
72 | {page.name}
73 |
74 |
79 | schedule.nylas.com/{page.slug}
80 |
81 |
82 |
83 | ))}
84 |
85 | {
88 | nylas.scheduler.show({
89 | auth: { accessToken },
90 | ...schedulerConfig
91 | });
92 |
93 | /**
94 | * When the Nylas Scheduler is hidden, refresh
95 | * the list of scheduler pages
96 | */
97 | onRemove(
98 | document.querySelector(".nylas-backdrop"),
99 | refreshSchedulerPages
100 | );
101 | }}
102 | >
103 | Open Schedule Editor »
104 |
105 |
106 |
107 |
108 | )}
109 |
110 | );
111 | }
112 |
113 | SchedulerAction.propTypes = {
114 | accessToken: PropTypes.string.isRequired,
115 | schedulerPages: PropTypes.array.isRequired,
116 | onSchedule: PropTypes.func.isRequired
117 | };
118 |
119 | export default SchedulerAction;
120 |
--------------------------------------------------------------------------------
/app/pages/index/index.js:
--------------------------------------------------------------------------------
1 | import { Fragment, useState } from "react";
2 | import Router from "next/router";
3 | import Head from "next/head";
4 | import styles from "./index.module.css";
5 | import Layout, { Header, Content, Sidebar } from "../../layouts/Inbox";
6 | import Button from "../../components/Button";
7 | import Threads, { Thread } from "../../components/Threads";
8 | import Input from "../../components/Input";
9 | import Pagination from "../../components/Pagination";
10 | import completeIcon from "../../assets/complete.svg";
11 | import request from "../../utils/request";
12 | import redirect from "../../utils/redirect";
13 | import withAuth from "../../utils/withAuth";
14 |
15 | export const getServerSideProps = withAuth(async context => {
16 | const page = parseInt(context.query.page) || 1;
17 | const search = context.query.search || "";
18 |
19 | if (page < 1) {
20 | return redirect("/", { context });
21 | }
22 |
23 | const { hasNext, hasPrevious, threads } = await request(
24 | `/threads?page=${page}&search=${search}`,
25 | {
26 | context
27 | }
28 | );
29 |
30 | // redirect home if we are on a page that doesn't have any threads
31 | if (threads.length === 0 && page > 1) {
32 | return redirect("/", { context });
33 | }
34 |
35 | return {
36 | props: {
37 | account: context.account,
38 | page,
39 | search,
40 | threads,
41 | hasNext,
42 | hasPrevious
43 | }
44 | };
45 | });
46 |
47 | export default function InboxPage({
48 | account,
49 | page,
50 | search,
51 | threads,
52 | hasNext,
53 | hasPrevious
54 | }) {
55 | const isInboxEmpty = threads.length === 0;
56 | const maybeSearch = search.length > 0 ? `&search=${search}` : "";
57 | const previousLink = hasPrevious ? `/?page=${page - 1}${maybeSearch}` : null;
58 | const nextLink = hasNext ? `/?page=${page + 1}${maybeSearch}` : null;
59 |
60 | return (
61 |
62 |
63 |
64 | Inbox ({account.unreadCount}) - {account.emailAddress}
65 |
66 |
67 |
68 |
69 |
70 |
71 | {account.unreadCount.toLocaleString()}
72 |
73 |
74 | unread emails
75 |
76 |
77 | Refresh
78 |
79 |
80 |
81 |
82 | {isInboxEmpty ? (
83 |
84 | ) : (
85 |
86 |
87 | {threads.map(thread => (
88 |
98 | ))}
99 |
100 |
105 |
106 | )}
107 |
108 |
109 | );
110 | }
111 |
112 | function SearchForm({ search }) {
113 | const [searchInput, setSearchInput] = useState(search);
114 |
115 | return (
116 |
130 | );
131 | }
132 |
133 | function EmptyState() {
134 | return ;
135 | }
136 |
--------------------------------------------------------------------------------
/api/threads/[id]/get.js:
--------------------------------------------------------------------------------
1 | const { DEFAULT_LABELS } = require("../../utils/constants");
2 | const getThreadFrom = require("../../utils/getThreadFrom");
3 |
4 | /**
5 | * Description: Retrieves an email thread
6 | * Endpoint: GET /api/threads/:id
7 | * Response:
8 | * {
9 | * id: String,
10 | * subject: String,
11 | * from: { name: String, email: String },
12 | * messages: [
13 | * {
14 | * id: String,
15 | * subject: String,
16 | * from: [
17 | * { name: String, email: String }
18 | * ],
19 | * to: [
20 | * { name: String, email: String }
21 | * ],
22 | * cc: [
23 | * { name: String, email: String }
24 | * ],
25 | * bcc: [
26 | * { name: String, email: String }
27 | * ],
28 | * date: Timestamp,
29 | * unread: Boolean,
30 | * body: String,
31 | * hasAttachments: Boolean,
32 | * files: [
33 | * { filename: String, id: String }
34 | * ],
35 | * }
36 | * ],
37 | * date: Timestamp,
38 | * snippet: String,
39 | * hasAttachments: Boolean,
40 | * unread: Boolean,
41 | * senderUnread: Boolean,
42 | * labels: [
43 | * {
44 | * id: String,
45 | * displayName: String,
46 | * checked: Boolean
47 | * }
48 | * ],
49 | * previousThreadId: String|null,
50 | * nextThreadId: String|null
51 | * }
52 | */
53 | module.exports = async (req, res) => {
54 | const nylas = req.nylas;
55 | const account = req.account;
56 | const id = req.params.id;
57 |
58 | try {
59 | const thread = await nylas.threads.find(id, null, { view: "expanded" });
60 | const threadFrom = getThreadFrom(thread);
61 | const senderUnread = await checkIfSenderUnread({ nylas, account, thread });
62 | const { previousThreadId, nextThreadId } = await getThreadPagination({
63 | nylas,
64 | thread
65 | });
66 | const labels = await getThreadLabels({ nylas, account, thread });
67 | const messages = await nylas.messages.list({
68 | thread_id: id,
69 | view: "expanded"
70 | });
71 |
72 | return res.status(200).json({
73 | id,
74 | subject: thread.subject,
75 | from: threadFrom,
76 | messages: messages.map(simplifyMessage),
77 | date: thread.lastMessageTimestamp,
78 | snippet: thread.snippet,
79 | hasAttachments: thread.hasAttachments,
80 | unread: thread.unread,
81 | senderUnread,
82 | labels,
83 | previousThreadId,
84 | nextThreadId
85 | });
86 | } catch (error) {
87 | console.log(error);
88 | res.status(500).json({ error: "Something went wrong. Please try again." });
89 | }
90 | };
91 |
92 | async function checkIfSenderUnread({ nylas, account, thread }) {
93 | const threadFrom = getThreadFrom(thread);
94 |
95 | const senderUnreadCount = await nylas.threads.count({
96 | in: "inbox",
97 | from: threadFrom.email,
98 | unread: true,
99 | limit: 1
100 | });
101 |
102 | return senderUnreadCount > 0;
103 | }
104 |
105 | async function getThreadPagination({ nylas, thread }) {
106 | const [previousThreadIds, nextThreadIds] = await Promise.all([
107 | nylas.threads.list({
108 | in: "inbox",
109 | unread: true,
110 | last_message_after: thread.lastMessageTimestamp,
111 | limit: 1000,
112 | view: "ids"
113 | }),
114 | nylas.threads.list({
115 | in: "inbox",
116 | unread: true,
117 | last_message_before: thread.lastMessageTimestamp,
118 | limit: 1,
119 | view: "ids"
120 | })
121 | ]);
122 |
123 | const previousThreadId =
124 | previousThreadIds.length > 0
125 | ? previousThreadIds[previousThreadIds.length - 1]
126 | : null;
127 | const nextThreadId = nextThreadIds.length > 0 ? nextThreadIds[0] : null;
128 |
129 | return {
130 | previousThreadId,
131 | nextThreadId
132 | };
133 | }
134 |
135 | async function getThreadLabels({ nylas, account, thread }) {
136 | if (account.organizationUnit !== "label") {
137 | return [];
138 | }
139 |
140 | const accountLabels = await nylas.labels.list();
141 |
142 | // return labels without any of the default labels we don't want to be visible
143 | return accountLabels
144 | .filter(label => !DEFAULT_LABELS.includes(label.name))
145 | .map(label => {
146 | return {
147 | id: label.id,
148 | displayName: label.displayName,
149 | checked: !!thread.labels.find(({ id }) => id === label.id)
150 | };
151 | });
152 | }
153 |
154 | function simplifyMessage(message) {
155 | return {
156 | id: message.id,
157 | subject: message.subject,
158 | from: message.from,
159 | to: message.to,
160 | cc: message.cc,
161 | bcc: message.bcc,
162 | date: message.date,
163 | body: message.body,
164 | hasAttachments: message.files.length > 0,
165 | files: message.files
166 | ? message.files.map(({ filename, id }) => {
167 | return { filename: filename || "noname", id };
168 | })
169 | : []
170 | };
171 | }
172 |
--------------------------------------------------------------------------------
/app/components/threadActions/LabelsAction/index.js:
--------------------------------------------------------------------------------
1 | import { Fragment, useState, useRef, useEffect } from "react";
2 | import PropTypes from "prop-types";
3 | import NProgress from "nprogress";
4 | import styles from "./LabelsAction.module.css";
5 | import { Action, Slot } from "../../Actions";
6 | import Input from "../..//Input";
7 | import request from "../../../utils/request";
8 | import checkIcon from "../../../assets/check.svg";
9 | import addIcon from "../../../assets/add.svg";
10 | import checkboxUncheckedIcon from "../../../assets/checkbox_unchecked.svg";
11 | import checkboxCheckedIcon from "../../../assets/checkbox_checked.svg";
12 | import useScript from "../../../utils/useScript";
13 | import onRemove from "../../../utils/onRemove";
14 |
15 | /**
16 | * Action components to add, remove, and create labels
17 | */
18 | function LabelsAction({ thread, onAdd, onRemove, onCreate }) {
19 | const [showLabels, setShowLabels] = useState(false);
20 |
21 | async function updateThread(update) {
22 | NProgress.start();
23 | try {
24 | const updatedThread = await request(`/threads/${thread.id}`, {
25 | method: "PUT",
26 | body: update
27 | });
28 | } catch (e) {
29 | console.log(e);
30 | alert("Something went wrong");
31 | }
32 | NProgress.done();
33 | }
34 |
35 | async function addLabel(newLabel) {
36 | await updateThread({
37 | labels: thread.labels.map(label => {
38 | return newLabel.id === label.id ? { ...label, checked: true } : label;
39 | })
40 | });
41 |
42 | onAdd({ ...newLabel, checked: true });
43 | }
44 |
45 | async function removeLabel(oldLabel) {
46 | await updateThread({
47 | labels: thread.labels.map(label => {
48 | return oldLabel.id === label.id ? { ...label, checked: false } : label;
49 | })
50 | });
51 |
52 | onRemove({ ...oldLabel, checked: false });
53 | }
54 |
55 | async function createLabel(displayName) {
56 | NProgress.start();
57 | try {
58 | const label = await request("/labels", {
59 | body: { displayName }
60 | });
61 |
62 | onCreate(label);
63 | } catch (e) {
64 | alert("Something went wrong");
65 | }
66 | NProgress.done();
67 | }
68 |
69 | return (
70 |
71 | setShowLabels(!showLabels)}>
72 | Add to List »
73 |
74 | {showLabels && (
75 |
76 |
77 | {thread.labels.map(label => (
78 | {
82 | if (label.checked) {
83 | removeLabel(label);
84 | } else {
85 | addLabel(label);
86 | }
87 | }}
88 | />
89 | ))}
90 |
91 |
92 |
93 | )}
94 |
95 | );
96 | }
97 |
98 | LabelsAction.propTypes = {
99 | thread: PropTypes.object.isRequired,
100 | onAdd: PropTypes.func.isRequired,
101 | onRemove: PropTypes.func.isRequired,
102 | onCreate: PropTypes.func.isRequired
103 | };
104 |
105 | function Labels({ children }) {
106 | return ;
107 | }
108 |
109 | function Label({ id, displayName, checked, onClick }) {
110 | return (
111 |
112 |
113 |
114 |
115 |
116 | {displayName}
117 |
118 |
119 | );
120 | }
121 |
122 | function CreateLabelForm({ createLabel }) {
123 | const [labelInput, setLabelInput] = useState("");
124 | const [showCreateLabelForm, setShowCreateLabelForm] = useState(false);
125 | const labelInputRef = useRef(null);
126 |
127 | /** Focus input when form is shown */
128 | useEffect(() => {
129 | if (showCreateLabelForm) {
130 | labelInputRef.current.focus();
131 | }
132 | }, [showCreateLabelForm]);
133 |
134 | async function handleSubmit(e) {
135 | e.preventDefault();
136 | await createLabel(labelInput);
137 | setLabelInput("");
138 | setShowCreateLabelForm(false);
139 | }
140 |
141 | return (
142 |
143 | {showCreateLabelForm && (
144 |
145 |
146 |
147 |
148 |
154 |
155 | )}
156 |
157 | {
160 | setShowCreateLabelForm(!showCreateLabelForm);
161 | }}
162 | >
163 |
164 |
165 |
166 | Create List
167 |
168 |
169 |
170 | );
171 | }
172 |
173 | export default LabelsAction;
174 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/assets/inbox_zero_vertical.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/assets/complete.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/assets/inbox_zero.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/pages/threads/[id]/index.js:
--------------------------------------------------------------------------------
1 | import { Fragment, useReducer, useState, useRef, useEffect } from "react";
2 | import NextError from "next/error";
3 | import Head from "next/head";
4 | import Router from "next/router";
5 | import classnames from "classnames";
6 | import NProgress from "nprogress";
7 | import styles from "./id.module.css";
8 | import Layout, { Header, Content, Sidebar } from "../../../layouts/Inbox";
9 | import Input from "../../../components/Input";
10 | import Button from "../../../components/Button";
11 | import Messages, { Message } from "../../../components/Messages";
12 | import Pagination from "../../../components/Pagination";
13 | import Editor from "../../../components/Editor";
14 | import BackButton from "../../../components/BackButton";
15 | import Actions from "../../../components/Actions";
16 | import AttachmentsAction from "../../../components/threadActions/AttachmentsAction";
17 | import SchedulerAction from "../../../components/threadActions/SchedulerAction";
18 | import LabelsAction from "../../../components/threadActions/LabelsAction";
19 | import MarkReadAction from "../../../components/threadActions/MarkReadAction";
20 | import MarkSenderReadAction from "../../../components/threadActions/MarkSenderReadAction";
21 | import request from "../../../utils/request";
22 | import withAuth from "../../../utils/withAuth";
23 |
24 | export const getServerSideProps = withAuth(async context => {
25 | try {
26 | const thread = await request(`/threads/${context.query.id}`, { context });
27 | const schedulerPages = await request(
28 | `https://schedule.api.nylas.com/manage/pages`,
29 | {
30 | headers: { Authorization: `Bearer ${context.account.accessToken}` }
31 | }
32 | );
33 |
34 | return {
35 | props: {
36 | account: context.account,
37 | serverThread: thread,
38 | schedulerPages
39 | }
40 | };
41 | } catch (e) {
42 | return { props: { errorCode: 404 } };
43 | }
44 | });
45 |
46 | export default function ThreadPage({
47 | errorCode,
48 | account,
49 | serverThread,
50 | schedulerPages
51 | }) {
52 | if (errorCode) {
53 | return ;
54 | }
55 |
56 | const [thread, threadDispatch] = useReducer(threadReducer, serverThread);
57 | const [reply, replyDispatch] = useReducer(
58 | replyReducer,
59 | generateReplyState({ thread: serverThread, account })
60 | );
61 |
62 | const showReply = () => {
63 | NProgress.start();
64 | replyDispatch({ type: "show" });
65 | NProgress.done();
66 | };
67 |
68 | const hideReply = () => {
69 | NProgress.start();
70 | replyDispatch({ type: "hide" });
71 | NProgress.done();
72 | };
73 |
74 | useEffect(() => {
75 | threadDispatch({ type: "reset", thread: serverThread });
76 | replyDispatch({
77 | type: "reset",
78 | reply: generateReplyState({ thread: serverThread, account })
79 | });
80 | }, [serverThread]);
81 |
82 | async function sendReply(event) {
83 | event.preventDefault();
84 | replyDispatch({ type: "submitting" });
85 |
86 | const fields = reply.fields;
87 | const toEmails = inputToEmails(fields.to);
88 | const ccEmails = inputToEmails(fields.cc);
89 | const bccEmails = inputToEmails(fields.bcc);
90 | const allEmails = [...toEmails, ...ccEmails, ...bccEmails];
91 |
92 | const invalidEmail = allEmails.find(({ email }) => !email.includes("@"));
93 | if (invalidEmail) {
94 | return alert(`${invalidEmail} is not a valid email.`);
95 | }
96 |
97 | if (allEmails.length === 0) {
98 | return alert(`Please specify at least one recipient.`);
99 | }
100 |
101 | NProgress.start();
102 | try {
103 | await request(`/threads/${thread.id}`, {
104 | body: {
105 | to: toEmails,
106 | cc: ccEmails,
107 | bcc: bccEmails,
108 | body: fields.body,
109 | files: fields.files
110 | }
111 | });
112 |
113 | Router.push("/");
114 | } catch (error) {
115 | /**
116 | * We only mark the form as completed if it fails.
117 | * If it succeeds, we keep it locked so there is no chance
118 | * the user sends two messages instead of one.
119 | */
120 | NProgress.done();
121 | replyDispatch({ type: "completed" });
122 | alert(error.error);
123 | }
124 | }
125 |
126 | return (
127 |
128 |
129 | {thread.subject} - Inbox Zero
130 |
131 |
132 |
133 | {reply.isVisible && (
134 |
135 | hideReply()} />
136 |
137 | Send
138 |
139 |
140 | {
143 | replyDispatch({ type: "uploadFile", file });
144 | }}
145 | onDelete={file => {
146 | replyDispatch({ type: "deleteFile", file });
147 | }}
148 | />
149 | {
153 | replyDispatch({
154 | type: "field",
155 | field: "body",
156 | value: `${page.name} `
157 | });
158 | showReply();
159 | }}
160 | />
161 |
162 |
163 | )}
164 | {!reply.isVisible && (
165 |
166 |
167 | showReply()}>Reply
168 |
169 | {
173 | replyDispatch({
174 | type: "field",
175 | field: "body",
176 | value: `${page.name} `
177 | });
178 | showReply();
179 | }}
180 | />
181 | {
184 | threadDispatch({ type: "addLabel", label });
185 | }}
186 | onRemove={label => {
187 | threadDispatch({ type: "removeLabel", label });
188 | }}
189 | onCreate={label => {
190 | threadDispatch({ type: "createLabel", label });
191 | }}
192 | />
193 | {
196 | threadDispatch({ type: "markRead" });
197 | }}
198 | />
199 | {
202 | threadDispatch({ type: "markSenderRead" });
203 | }}
204 | />
205 |
206 |
207 | )}
208 |
209 |
210 |
215 | {thread.subject}
216 |
217 | {reply.isVisible && (
218 |
219 | )}
220 |
221 | {thread.messages.map(message => (
222 |
232 | ))}
233 |
234 | {!reply.isVisible && (
235 |
254 | )}
255 |
256 |
257 | );
258 | }
259 |
260 | function threadReducer(state, action) {
261 | switch (action.type) {
262 | case "addLabel":
263 | return {
264 | ...state,
265 | labels: state.labels.map(label =>
266 | label.id === action.label.id ? { ...label, checked: true } : label
267 | )
268 | };
269 | case "removeLabel":
270 | return {
271 | ...state,
272 | labels: state.labels.map(label =>
273 | label.id === action.label.id ? { ...label, checked: false } : label
274 | )
275 | };
276 | case "createLabel":
277 | return {
278 | ...state,
279 | labels: [...state.labels, action.label]
280 | };
281 | case "markRead":
282 | return { ...state, unread: false };
283 | case "markSenderRead":
284 | return { ...state, unread: false, senderUnread: false };
285 | case "reset":
286 | return action.thread;
287 | default:
288 | throw new Error();
289 | }
290 | }
291 |
292 | function replyReducer(state, action) {
293 | switch (action.type) {
294 | case "show":
295 | return {
296 | ...state,
297 | isVisible: true
298 | };
299 | case "hide":
300 | return {
301 | ...state,
302 | isVisible: false
303 | };
304 | case "submitting":
305 | return {
306 | ...state,
307 | isSubmitting: true
308 | };
309 | case "completed":
310 | return {
311 | ...state,
312 | isSubmitting: false
313 | };
314 | case "field":
315 | return {
316 | ...state,
317 | fields: {
318 | ...state.fields,
319 | [action.field]: action.value
320 | }
321 | };
322 | case "deleteFile":
323 | return {
324 | ...state,
325 | fields: {
326 | ...state.fields,
327 | files: state.fields.files.filter(file => file.id !== action.file.id)
328 | }
329 | };
330 | case "uploadFile":
331 | return {
332 | ...state,
333 | fields: {
334 | ...state.fields,
335 | files: [...state.fields.files, action.file]
336 | }
337 | };
338 | case "reset":
339 | return action.reply;
340 | default:
341 | throw new Error();
342 | }
343 | }
344 |
345 | function generateReplyState({ thread, account }) {
346 | const lastSentMessage = thread.messages[0];
347 |
348 | const participantsToEmails = participants =>
349 | participants
350 | .map(p => p.email)
351 | .filter(email => email !== account.emailAddress)
352 | .join(",");
353 |
354 | return {
355 | isSubmitting: false,
356 | isVisible: false,
357 | fields: {
358 | to: participantsToEmails([
359 | ...lastSentMessage.to,
360 | ...lastSentMessage.from
361 | ]),
362 | cc: participantsToEmails(lastSentMessage.cc),
363 | bcc: participantsToEmails(lastSentMessage.bcc),
364 | body: "",
365 | files: []
366 | }
367 | };
368 | }
369 |
370 | function inputToEmails(input) {
371 | if (!input) {
372 | return [];
373 | }
374 |
375 | return input.split(",").map(email => {
376 | return {
377 | email: email.trim().toLowerCase()
378 | };
379 | });
380 | }
381 |
382 | function ReplyForm({ fields, dispatch }) {
383 | const [showSecondaryEmails, setShowSecondaryEmails] = useState(
384 | fields.cc.length > 0 || fields.bcc.length > 0
385 | );
386 |
387 | const onChange = ({ target }) => {
388 | dispatch({ type: "field", field: target.name, value: target.value });
389 | };
390 |
391 | return (
392 |
393 |
427 |
428 | {
431 | dispatch({ type: "field", field: "body", value });
432 | }}
433 | />
434 |
435 |
436 | );
437 | }
438 |
--------------------------------------------------------------------------------