├── .editorconfig ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── .gitignore ├── .travis.yml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── app ├── main │ ├── analytics.js │ ├── config.js │ ├── index.js │ ├── menu.js │ ├── preferences.js │ ├── report.js │ ├── tray.js │ └── utils.js ├── renderer │ ├── browser.css │ ├── browser.js │ ├── preferences │ │ ├── index.css │ │ ├── index.html │ │ └── index.js │ ├── unreads.js │ └── utils.js └── static │ ├── Icon.png │ ├── Icon@2x.png │ ├── IconSnoozed.png │ ├── IconTray.png │ ├── IconTray@2x.png │ ├── IconTrayUnread.png │ ├── IconTrayUnread@2x.png │ └── gmail_48px.png ├── build ├── background.png ├── background@2x.png ├── icon.icns └── icon.ico ├── media ├── inboxer-linux-desktop.png ├── inboxer-linux.png ├── inboxer-mac-desktop.png ├── inboxer-mac.png ├── inboxer-windows-desktop.png ├── inboxer-windows.png └── inboxer.sketch ├── package-lock.json └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | }, 7 | "rules": { 8 | "import/no-extraneous-dependencies": [2, { "devDependencies": true }] 9 | } 10 | }; -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at denysdovhan@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | By participating in this project, you agree to abide by the [Contributor Covenant Code of Conduct](./CODE_OF_CONDUCT.md) 4 | 5 | ## Getting Started 6 | 7 | 1. Fork this repo by clicking `Fork` button. 8 | 1. Clone the repo: `git clone git@github.com:your-username/inboxer.git`. 9 | 1. Install the project dependencies: `cd inboxer && npm i`. 10 | 1. Start the app: `npm start`. 11 | 1. Add and test your changes, make sure it is working as expected. 12 | 1. Commit your changes (better to commit to branch other that `master`) 13 | 1. After confirming the correctness of your change, push it to your fork and [submit a pull request](https://github.com/denysdovhan/inboxer/compare/) 14 | 1. Wait or mention someone to review your pull request. 15 | 16 | ### Additional Resources 17 | 18 | * [How to write a good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 19 | * [How to write the perfect pull request](https://github.com/blog/1943-how-to-write-the-perfect-pull-request) 20 | * [General GitHub documentation](https://help.github.com/) 21 | * [GitHub pull request documentation](https://help.github.com/articles/creating-a-pull-request/) 22 | * [Github requesting a pull request review documentation](https://help.github.com/articles/requesting-a-pull-request-review/) 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | yarn.lock 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx 2 | osx_image: xcode10.1 3 | language: node_js 4 | node_js: 5 | - "11.0" 6 | cache: 7 | directories: 8 | - node_modules 9 | script: 10 | - npm test 11 | - npm run dist 12 | branches: 13 | except: 14 | - "/^v\\d+\\.\\d+\\.\\d+$/" 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 12 | }, 13 | "args" : ["app/main"] 14 | }, 15 | { 16 | "name": "Renderer Process", 17 | "type": "chrome", 18 | "request": "launch", 19 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 20 | "windows": { 21 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 22 | }, 23 | "runtimeArgs": [ 24 | "${workspaceRoot}/app/main/index.js", 25 | "--remote-debugging-port=9222" 26 | ], 27 | "webRoot": "${workspaceRoot}/app/renderer" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Denys Dovhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ## 🚧 This project is deprecated! 🚧 2 | > 3 | > As you know Google is going to take down Inbox service. 4 | > 5 | > **This project is not maintained anymore. Since Inbox is closed this project has to be closed as well. It just cannot operate normally without Inbox.** 6 | > 7 | > You can find more information [here](https://github.com/denysdovhan/inboxer/issues/101). 8 | 9 |
10 | 11 |

Inboxer

12 |

13 | Version 14 | Travis CI 15 | Platform 16 | Donate with card 17 | Donate with Bitcoin 18 | Donate with Ethereum 19 |

20 |
21 | 22 | > Unofficial, free and open-source Google Gmail Desktop App 23 | 24 | Inboxer — started out as an unofficial Inbox by Gmail client for desktop platforms. However, Google plans to discontinue Inbox at the end of March 2019 ([see official announcement](https://www.blog.google/products/gmail/inbox-signing-find-your-favorite-features-new-gmail/)). To keep Inboxer alive we have converted it to work with Gmail. Inboxer is now built on top of Gmail web-version. It has pleasant UI and supports useful keyboard shortcuts. 25 | 26 | Inboxer is highly inspired by these projects: 27 | 28 | * [Caprine](https://github.com/sindresorhus/caprine) by [Sindre Sorhus](https://github.com/sindresorhus) 29 | * [Ramme](https://github.com/terkelg/ramme) by [Terkel Gjervig Nielsen](https://github.com/terkelg) 30 | * [Keep](https://github.com/andrepolischuk/keep) by [Andrey Polischuk](https://github.com/andrepolischuk) 31 | 32 | Check out these ones as great examples of Electron applications. All of them are under the MIT license. 33 | 34 | ## Features 35 | 36 | * Familiar Gmail interface 37 | * Cross-platform (macOS/Linux/Windows) 38 | * Useful Keyboard shortcuts 39 | * Multiple accounts 40 | * Optional Always on Top 41 | * Auto-updates to the latest version 42 | * Desktop notifications 43 | * **TODO:** Dark theme 44 | * **TODO:** Custom text size 45 | * **TODO:** Ability to use as default mail client 46 | 47 | All feature requests and contributions are welcome! 48 | 49 | ## Screenshot 50 | 51 | * **TODO:** Update screenshot to show latest version working with Gmail 52 | ![Inboxer on Mac](./media/inboxer-mac.png) 53 | 54 | ## Installation 55 | 56 | Inboxer works well on **macOS 10.9+**, **Linux** and **Windows 7+**. You can download the latest version on [Releases](https://github.com/denysdovhan/inboxer/releases) page or below. 57 | Inboxer is also available through the nix package manager (see [package in nix](https://nixos.org/nixos/packages.html#inboxer)) 58 | 59 | ### macOS 60 | 61 | [**Download**][download] the `.dmg` file or install via [Homebrew-Cask](https://caskroom.github.io/): 62 | 63 | ``` 64 | $ brew cask install inboxer 65 | ``` 66 | 67 | ### Linux 68 | 69 | [**Download**][download] the `.AppImage`* or `.deb` or `.snap` file. 70 | 71 | \* — Notice, that the `AppImage` needs to be [made executable](http://discourse.appimage.org/t/how-to-make-an-appimage-executable/80) after download. 72 | 73 | ### Windows 74 | 75 | [**Download**][download] the `.exe` file. 76 | 77 | ### Package Inboxer from source 78 | 79 | You'll need `electron` and `npm` installed in your system. 80 | [**Download**][download] and extract the source code (`.zip` or `.tar.gz` file). 81 | Inside the source code folder, run: 82 | ``` 83 | $ npm install 84 | ``` 85 | Then package the app with: 86 | ``` 87 | $ npm run dist 88 | ``` 89 | This builds packages for all architectures. Use `dist:mac`, `dist:linux`, or `dist:win` to package a single architecture. The packages will be found in the `dist` folder. 90 | 91 | ## Keyboard shortcuts 92 | 93 | Inboxer supports all Gmail keyboard shortcuts, system-specific keybindings and more. Additional keybindings are listed below: 94 | 95 | | Description | Keys 96 | |------------------------------|--------- 97 | | Preferences | Cmd/Ctrl P 98 | | Compose Message | Cmd/Ctrl N 99 | | Go to Inbox | Cmd/Ctrl I 100 | | Go to Snoozed | Cmd/Ctrl S 101 | | Go to Done | Cmd/Ctrl D 102 | | Drafts | Cmd/Ctrl Shift D 103 | | Sent | Cmd/Ctrl Shift S 104 | | Trash | Alt Shift T 105 | | Spam | Alt Shift S 106 | | Open Contacts | Cmd/Ctrl Shift C 107 | | Search | Cmd/Ctrl F 108 | | Toggle Sidebar | Cmd/Ctrl / 109 | | Toggle "Always on Top" | Cmd/Ctrl Shift T 110 | | Keyboard Shortcuts Reference | Shift / or ? 111 | | Toggle Developer Tools | Option Cmd I _(macOS)_ or Ctrl Shift I 112 | 113 | ## Disclaimer 114 | 115 | This code is in no way affiliated with, authorised, maintained, sponsored or endorsed by Google or any of its affiliates or subsidiaries. This is an independent and unofficial Gmail app. Use it at your own risk. 116 | 117 | ## End User License Agreement 118 | 119 | * You **will not** use this repository for sending mass spam or any other malicious activity. 120 | * We / You **will not** support anyone who is violating this EULA conditions. 121 | * Repository is just for learning / personal purposes thus **should not** be part of any service available on the Internet that is trying to do any malicious activity (mass bulk request, spam etc). 122 | 123 | ## Donate 124 | 125 | Hi! I work on this project in my spare time, beside my primary job. I hope enjoy using Inboxer, and if you do, please, [support this project 🙏🏻][donate-card-url]. 126 | 127 | | Credit/Debit card | Bitcoin | Ethereum | 128 | |:-----------------:|:-------:|:--------:| 129 | | [Donate with LiqPay][donate-card-url] | `1FrPrQb6ACTkbSBAz9PduJWrDFfq41Ggb4` | `0x6aF39C917359897ae6969Ad682C14110afe1a0a1` | 130 | | | | | 131 | 132 | I would appreciate your support! _Thank you!_ 133 | 134 | [donate-readme]: https://github.com/denysdovhan/inboxer#donate 135 | [donate-card-url]: https://www.liqpay.com/en/checkout/380951100392 136 | [donate-card-image]: https://img.shields.io/badge/donate-LiqPay-blue.svg?style=flat-square 137 | [donate-btc-image]: https://img.shields.io/badge/donate-BTC-yellow.svg?style=flat-square 138 | [donate-eth-image]: https://img.shields.io/badge/donate-ETH-gray.svg?style=flat-square 139 | 140 | ## License 141 | 142 | MIT © [Denys Dovhan](http://denysdovhan.com) 143 | 144 | 145 | 146 | [download]: https://github.com/denysdovhan/inboxer/releases/latest 147 | -------------------------------------------------------------------------------- /app/main/analytics.js: -------------------------------------------------------------------------------- 1 | const firstRun = require('first-run'); 2 | const Insight = require('insight'); 3 | 4 | const pkg = require('../../package'); 5 | 6 | const trackingCode = 'UA-40496885-12'; 7 | 8 | const insight = new Insight({ 9 | trackingCode, 10 | pkg, 11 | }); 12 | 13 | module.exports = { 14 | init() { 15 | if (firstRun()) { 16 | insight.track('install'); 17 | } 18 | insight.track('start'); 19 | }, 20 | track(...paths) { 21 | insight.track(...paths); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /app/main/config.js: -------------------------------------------------------------------------------- 1 | const ElectronStore = require('electron-store'); 2 | 3 | module.exports = new ElectronStore({ 4 | defaults: { 5 | windowState: { 6 | width: 900, 7 | height: 600, 8 | }, 9 | alwaysOnTop: false, 10 | showUnreadBadge: true, 11 | bounceDockIcon: false, 12 | flashWindowOnMessage: false, 13 | autoHideMenuBar: false, 14 | notify: { 15 | unread: true, 16 | snoozed: true, 17 | download: true, 18 | period: 2, 19 | }, 20 | sendAnalytics: true, 21 | displayMigrationInfo: true, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /app/main/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { 4 | app, BrowserWindow, Menu, shell, ipcMain, nativeImage, Notification, dialog, 5 | } = require('electron'); 6 | const log = require('electron-log'); 7 | const isDev = require('electron-is-dev'); 8 | const { autoUpdater } = require('electron-updater'); 9 | const minimatch = require('minimatch-all'); 10 | const electronDL = require('electron-dl'); 11 | const { isDarwin, isLinux, isWindows } = require('./utils'); 12 | const config = require('./config'); 13 | const appMenu = require('./menu'); 14 | const appTray = require('./tray'); 15 | const analytics = require('./analytics'); 16 | 17 | app.setAppUserModelId('com.denysdovhan.inboxer'); 18 | 19 | require('electron-context-menu')(); 20 | 21 | const mainURL = 'https://mail.google.com/'; 22 | 23 | let mainWindow; 24 | let isQuitting = false; 25 | let prevUnreadCount = 0; 26 | 27 | const gotTheLock = app.requestSingleInstanceLock(); 28 | if (!gotTheLock) { 29 | app.quit(); 30 | process.exit(); 31 | } 32 | 33 | function allowedUrl(url) { 34 | const urls = [ 35 | 'https://accounts.google.com/@(u|AccountChooser|AddSession|ServiceLogin|CheckCookie|Logout){**/**,**}', 36 | 'https://accounts.google.com/signin/@(usernamerecovery|recovery|challenge|selectchallenge){**/**,**}', 37 | 'http://www.google.*/accounts/Logout2**', 38 | 'https://www.google.com/a/**/acs', 39 | 'https://**.okta.com/**', 40 | 'https://google.*/accounts/**', 41 | 'https://www.google.**/accounts/signin/continue**', 42 | 'https://mail.google.com/**', 43 | 'https://drive.google.com/**', 44 | 'https://docs.google.com/**', 45 | 'https://www.google.com/calendar**', 46 | 'https://sites.google.com/**', 47 | 'https://chat.google.com/**', 48 | 'https://contacts.google.com/**', 49 | path.join('file://', __dirname, '../renderer/preferences**'), 50 | ]; 51 | 52 | return minimatch(url, urls); 53 | } 54 | 55 | // Inform the user about Google's plan to discontinue Inbox 56 | function showMigrationDialog(win) { 57 | if (config.get('displayMigrationInfo') === 'no >1.3.0') { // indicates dialog was dismissed from version >1.3.0 58 | return; 59 | } 60 | 61 | const message = 'This version of Inboxer has been migrated to use Gmail'; 62 | const detail = `Inboxer was originally developed to provide a view of Google's Inbox packaged \ 63 | in a desktop app. However, Google has announced plans to discontinue Inbox at the end of March 2019. 64 | See Google's official announcement here: 65 | https://www.blog.google/products/gmail/inbox-signing-find-your-favorite-features-new-gmail/ 66 | 67 | Versions >= 1.3.0 have been migrated from Inbox to Gmail to ensure Inboxer continues \ 68 | to work after Google pulls the plug on Inbox. 69 | Versions 1.2.x will continue working with Inbox until the bitter end.`; 70 | 71 | dialog.showMessageBox(win, { 72 | type: 'info', 73 | icon: nativeImage.createFromPath(path.join(__dirname, '..', 'static/Icon.png')), 74 | title: 'Important Message', 75 | message, 76 | detail, 77 | checkboxLabel: 'Show this window again', 78 | checkboxChecked: true, 79 | buttons: ['Ok'], 80 | defaultId: 0, 81 | }, (response, checkBoxChecked) => { 82 | if (!checkBoxChecked) { 83 | config.set('displayMigrationInfo', 'no >1.3.0'); 84 | } 85 | }); 86 | } 87 | 88 | function createMainWindow() { 89 | const windowState = config.get('windowState'); 90 | 91 | const win = new BrowserWindow({ 92 | show: false, // Hide application until your page has loaded 93 | title: app.getName(), 94 | x: windowState.x, 95 | y: windowState.y, 96 | width: windowState.width, 97 | height: windowState.height, 98 | minWidth: 890, 99 | minHeight: 400, 100 | alwaysOnTop: config.get('alwaysOnTop'), 101 | autoHideMenuBar: config.get('autoHideMenuBar'), 102 | backgroundColor: '#f2f2f2', 103 | icon: path.join(__dirname, '..', 'static/Icon.png'), 104 | titleBarStyle: 'hidden-inset', 105 | webPreferences: { 106 | preload: path.join(__dirname, '..', 'renderer', 'browser.js'), 107 | nodeIntegration: false, 108 | }, 109 | }); 110 | 111 | if (isDarwin) { 112 | win.setSheetOffset(40); 113 | } 114 | 115 | win.loadURL(mainURL); 116 | 117 | // Show window after loading the DOM 118 | // Docs: https://electronjs.org/docs/api/browser-window#showing-window-gracefully 119 | win.once('ready-to-show', () => { 120 | win.show(); 121 | showMigrationDialog(win); 122 | }); 123 | 124 | win.on('close', (e) => { 125 | if (!isQuitting) { 126 | e.preventDefault(); 127 | 128 | if (isDarwin) { 129 | app.hide(); 130 | } else { 131 | win.hide(); 132 | } 133 | } 134 | }); 135 | 136 | return win; 137 | } 138 | 139 | app.on('ready', () => { 140 | Menu.setApplicationMenu(appMenu); 141 | mainWindow = createMainWindow(); 142 | appTray.create(mainWindow); 143 | 144 | if (config.get('sendAnalytics')) analytics.init(); 145 | 146 | if (!isDev && !isLinux) { 147 | autoUpdater.logger = log; 148 | autoUpdater.logger.transports.file.level = 'info'; 149 | autoUpdater.checkForUpdatesAndNotify(); 150 | } 151 | 152 | const { webContents } = mainWindow; 153 | 154 | webContents.on('dom-ready', () => { 155 | webContents.insertCSS(fs.readFileSync(path.join(__dirname, '../renderer/browser.css'), 'utf8')); 156 | }); 157 | 158 | webContents.on('will-navigate', (e, url) => { 159 | if (config.get('sendAnalytics')) analytics.track('will-navigate'); 160 | if (!allowedUrl(url)) { 161 | e.preventDefault(); 162 | shell.openExternal(url); 163 | } 164 | }); 165 | 166 | webContents.on('new-window', (e, url) => { 167 | if (config.get('sendAnalytics')) analytics.track('new-window'); 168 | e.preventDefault(); 169 | if (allowedUrl(url)) { 170 | webContents.loadURL(url); 171 | return; 172 | } 173 | shell.openExternal(url); 174 | }); 175 | }); 176 | 177 | app.on('second-instance', () => { 178 | if (mainWindow) { 179 | if (mainWindow.isMinimized()) { 180 | mainWindow.restore(); 181 | } 182 | if (!mainWindow.isVisible()) { 183 | mainWindow.show(); 184 | } 185 | mainWindow.focus(); 186 | } 187 | }); 188 | 189 | app.on('activate', () => { 190 | mainWindow.show(); 191 | }); 192 | 193 | app.on('before-quit', () => { 194 | if (config.get('sendAnalytics')) analytics.track('quit'); 195 | isQuitting = true; 196 | 197 | if (!mainWindow.isFullScreen()) { 198 | config.set('windowState', mainWindow.getBounds()); 199 | } 200 | }); 201 | 202 | ipcMain.on('update-unreads-count', (e, unreadCount) => { 203 | if (isDarwin || isLinux) { 204 | let isUpdated = config.get('showUnreadBadge') ? app.setBadgeCount(unreadCount) : false; 205 | if (!config.get('showUnreadBadge')) { 206 | app.setBadgeCount(0); 207 | isUpdated = false; 208 | } 209 | if (isDarwin && config.get('bounceDockIcon') && prevUnreadCount !== unreadCount && isUpdated) { 210 | app.dock.bounce('informational'); 211 | prevUnreadCount = unreadCount; 212 | } 213 | } 214 | 215 | if ((isLinux || isWindows) && config.get('showUnreadBadge')) { 216 | appTray.setBadge(unreadCount); 217 | } else if ((isLinux || isWindows)) { 218 | appTray.setBadge(false); 219 | } 220 | 221 | if (isWindows) { 222 | if (config.get('showUnreadBadge')) { 223 | if (unreadCount === 0) { 224 | mainWindow.setOverlayIcon(null, ''); 225 | } else { 226 | // Delegate drawing of overlay icon to renderer process 227 | mainWindow.webContents.send('render-overlay-icon', unreadCount); 228 | } 229 | } else { 230 | mainWindow.setOverlayIcon(null, ''); 231 | } 232 | 233 | if (config.get('flashWindowOnMessage')) { 234 | mainWindow.flashFrame(unreadCount !== 0); 235 | } 236 | } 237 | }); 238 | 239 | ipcMain.on('update-overlay-icon', (e, image, count) => { 240 | mainWindow.setOverlayIcon(nativeImage.createFromDataURL(image), count); 241 | }); 242 | 243 | ipcMain.on('show-window', () => { 244 | mainWindow.show(); 245 | }); 246 | 247 | function downloadStarted(downloadItem) { 248 | if (!config.get('notify.download')) { 249 | return; 250 | } 251 | downloadItem.on('done', (event, state) => { // notify user on download complete 252 | if (state === 'completed') { 253 | const filename = downloadItem.getSavePath(); 254 | const notification = new Notification({ 255 | title: 'Download Complete', 256 | body: filename, 257 | }); 258 | notification.on('click', () => { 259 | shell.showItemInFolder(filename); 260 | }); 261 | notification.show(); 262 | } 263 | }); 264 | } 265 | electronDL({ onStarted: downloadStarted }); 266 | -------------------------------------------------------------------------------- /app/main/menu.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { 3 | app, shell, dialog, Menu, 4 | } = require('electron'); 5 | const pkg = require('../../package'); 6 | const { 7 | isDarwin, isWindows, sendAction, sendKeybinding, 8 | } = require('./utils'); 9 | const config = require('./config'); 10 | const report = require('./report'); 11 | const preferences = require('./preferences'); 12 | 13 | const settingsItems = [ 14 | { 15 | label: 'Show Unread Badge', 16 | type: 'checkbox', 17 | checked: config.get('showUnreadBadge'), 18 | click(menuItem) { 19 | config.set('showUnreadBadge', menuItem.checked); 20 | }, 21 | }, 22 | { 23 | label: 'Bounce Dock on Notification', 24 | type: 'checkbox', 25 | checked: config.get('bounceDockIcon'), 26 | visible: isDarwin, 27 | click(menuItem) { 28 | config.set('bounceDockIcon', menuItem.checked); 29 | }, 30 | }, 31 | { 32 | label: 'Flash Window on Message', 33 | type: 'checkbox', 34 | checked: config.get('flashWindowOnMessage'), 35 | visible: isWindows, 36 | click(menuItem) { 37 | config.set('flashWindowOnMessage', menuItem.checked); 38 | }, 39 | }, 40 | { 41 | label: 'Auto Hide Menu Bar', 42 | type: 'checkbox', 43 | checked: config.get('autoHideMenuBar'), 44 | visible: !isDarwin, 45 | click(menuItem, focusedWindow) { 46 | config.set('autoHideMenuBar', menuItem.checked); 47 | focusedWindow.setAutoHideMenuBar(menuItem.checked); 48 | focusedWindow.setMenuBarVisibility(!menuItem.checked); 49 | }, 50 | }, 51 | 52 | // NOTE: Account Preferences instead of Preferences (for Inboxer Preferences) 53 | 54 | { 55 | label: 'Account Preferences', 56 | accelerator: 'CmdOrCtrl+,', 57 | click(menuItem, focusedWindow) { 58 | sendAction(focusedWindow, 'show-preferences'); 59 | }, 60 | }, 61 | 62 | // TODO: Create Preferences window 63 | 64 | { 65 | label: 'Preferences', 66 | accelerator: 'CmdOrCtrl+P', 67 | click() { 68 | preferences.showPreferencesWindow(); 69 | }, 70 | }, 71 | ]; 72 | 73 | // TODO: Switch accounts 74 | const accountItems = [ 75 | { 76 | label: 'Add Account', 77 | click(menuItem, focusedWindow) { 78 | sendAction(focusedWindow, 'add-account'); 79 | }, 80 | }, 81 | { 82 | label: 'Sign Out', 83 | click(menuItem, focusedWindow) { 84 | sendAction(focusedWindow, 'sign-out'); 85 | }, 86 | }, 87 | ]; 88 | 89 | const fileItems = [ 90 | { 91 | label: 'Compose Message', 92 | accelerator: 'CmdOrCtrl+N', 93 | click(menuItem, focusedWindow) { 94 | sendKeybinding(focusedWindow, 'c'); 95 | }, 96 | }, 97 | ]; 98 | 99 | const viewItems = [ 100 | { 101 | label: 'Go to Inbox', 102 | accelerator: 'CmdOrCtrl+I', 103 | click(menuItem, focusedWindow) { 104 | sendAction(focusedWindow, 'go-to-inbox'); 105 | }, 106 | }, 107 | { 108 | label: 'Go to Snoozed', 109 | accelerator: 'CmdOrCtrl+S', 110 | click(menuItem, focusedWindow) { 111 | sendAction(focusedWindow, 'go-to-snoozed'); 112 | }, 113 | }, 114 | { 115 | label: 'Go to Done', 116 | accelerator: 'CmdOrCtrl+D', 117 | click(menuItem, focusedWindow) { 118 | sendAction(focusedWindow, 'go-to-done'); 119 | }, 120 | }, 121 | { type: 'separator' }, 122 | { 123 | label: 'Drafts', 124 | accelerator: 'CmdOrCtrl+Shift+D', 125 | click(menuItem, focusedWindow) { 126 | sendAction(focusedWindow, 'go-to-drafts'); 127 | }, 128 | }, 129 | { 130 | label: 'Sent', 131 | accelerator: 'CmdOrCtrl+Shift+S', 132 | click(menuItem, focusedWindow) { 133 | sendAction(focusedWindow, 'go-to-sent'); 134 | }, 135 | }, 136 | { 137 | label: 'Trash', 138 | accelerator: 'Alt+Shift+T', 139 | click(menuItem, focusedWindow) { 140 | sendAction(focusedWindow, 'go-to-trash'); 141 | }, 142 | }, 143 | { 144 | label: 'Spam', 145 | accelerator: 'Alt+Shift+S', 146 | click(menuItem, focusedWindow) { 147 | sendAction(focusedWindow, 'go-to-spam'); 148 | }, 149 | }, 150 | { 151 | label: 'Contacts', 152 | accelerator: 'CmdOrCtrl+Shift+C', 153 | click(menuItem, focusedWindow) { 154 | sendAction(focusedWindow, 'go-to-contacts'); 155 | }, 156 | }, 157 | { type: 'separator' }, 158 | { 159 | label: 'Search…', 160 | accelerator: 'CmdOrCtrl+F', 161 | click(menuItem, focusedWindow) { 162 | sendAction(focusedWindow, 'go-to-search'); 163 | }, 164 | }, 165 | { type: 'separator' }, 166 | { 167 | label: 'Toggle Sidebar', 168 | accelerator: 'CmdOrCtrl+/', 169 | click(menuItem, focusedWindow) { 170 | sendAction(focusedWindow, 'toggle-sidebar'); 171 | }, 172 | }, 173 | ]; 174 | 175 | const editItems = [ 176 | { role: 'undo' }, 177 | { role: 'redo' }, 178 | { type: 'separator' }, 179 | { role: 'cut' }, 180 | { role: 'copy' }, 181 | { role: 'paste' }, 182 | { role: 'delete' }, 183 | { type: 'separator' }, 184 | { role: 'selectall' }, 185 | { type: 'separator' }, 186 | ]; 187 | 188 | const listItems = [ 189 | { 190 | label: 'Open', 191 | click(menuItem, focusedWindow) { 192 | sendKeybinding(focusedWindow, 'o'); 193 | }, 194 | }, 195 | { 196 | label: 'Close', 197 | click(menuItem, focusedWindow) { 198 | sendKeybinding(focusedWindow, 'u'); 199 | }, 200 | }, 201 | { type: 'separator' }, 202 | { 203 | label: 'Next Item', 204 | click(menuItem, focusedWindow) { 205 | sendKeybinding(focusedWindow, 'j'); 206 | }, 207 | }, 208 | { 209 | label: 'Previous Item', 210 | click(menuItem, focusedWindow) { 211 | sendKeybinding(focusedWindow, 'k'); 212 | }, 213 | }, 214 | { 215 | label: 'First Item', 216 | accelerator: 'Home', 217 | click(menuItem, focusedWindow) { 218 | sendKeybinding(focusedWindow, 'Home'); 219 | }, 220 | }, 221 | { type: 'separator' }, 222 | { 223 | label: 'Next Message', 224 | click(menuItem, focusedWindow) { 225 | sendKeybinding(focusedWindow, 'n'); 226 | }, 227 | }, 228 | { 229 | label: 'Previous Message', 230 | click(menuItem, focusedWindow) { 231 | sendKeybinding(focusedWindow, 'p'); 232 | }, 233 | }, 234 | ]; 235 | 236 | const itemItems = [ 237 | { 238 | label: 'Mark Done', 239 | click(menuItem, focusedWindow) { 240 | sendKeybinding(focusedWindow, 'e'); 241 | }, 242 | }, 243 | { 244 | label: 'Mark Done and Forward', 245 | click(menuItem, focusedWindow) { 246 | sendKeybinding(focusedWindow, '['); 247 | }, 248 | }, 249 | { 250 | label: 'Mark Done and Backward', 251 | click(menuItem, focusedWindow) { 252 | sendKeybinding(focusedWindow, ']'); 253 | }, 254 | }, 255 | { type: 'separator' }, 256 | { 257 | label: 'Snooze', 258 | click(menuItem, focusedWindow) { 259 | sendKeybinding(focusedWindow, 'b'); 260 | }, 261 | }, 262 | { 263 | label: 'Star', 264 | click(menuItem, focusedWindow) { 265 | sendKeybinding(focusedWindow, 's'); 266 | }, 267 | }, 268 | // { 269 | // label: 'Pin', 270 | // click(menuItem, focusedWindow) { 271 | // sendKeybinding(focusedWindow, 'Shift+p'); 272 | // }, 273 | // }, 274 | { type: 'separator' }, 275 | { 276 | label: 'Reply', 277 | click(menuItem, focusedWindow) { 278 | sendKeybinding(focusedWindow, 'r'); 279 | }, 280 | }, 281 | // { 282 | // label: 'Reply in a new window', 283 | // click(menuItem, focusedWindow) { 284 | // sendKeybinding(focusedWindow, 'Shift+r'); 285 | // }, 286 | // }, 287 | { 288 | label: 'Reply All', 289 | click(menuItem, focusedWindow) { 290 | sendKeybinding(focusedWindow, 'a'); 291 | }, 292 | }, 293 | // { 294 | // label: 'Reply all in a new window', 295 | // click(menuItem, focusedWindow) { 296 | // sendKeybinding(focusedWindow, 'Shift+a'); 297 | // }, 298 | // }, 299 | { 300 | label: 'Forward', 301 | click(menuItem, focusedWindow) { 302 | sendKeybinding(focusedWindow, 'f'); 303 | }, 304 | }, 305 | { type: 'separator' }, 306 | { 307 | label: 'Trash', 308 | click(menuItem, focusedWindow) { 309 | sendKeybinding(focusedWindow, '#'); 310 | }, 311 | }, 312 | { 313 | label: 'Report as Spam', 314 | click(menuItem, focusedWindow) { 315 | sendKeybinding(focusedWindow, '!'); 316 | }, 317 | }, 318 | { 319 | label: 'Mute', 320 | click(menuItem, focusedWindow) { 321 | sendKeybinding(focusedWindow, 'm'); 322 | }, 323 | }, 324 | { 325 | label: 'Move to…', 326 | click(menuItem, focusedWindow) { 327 | sendKeybinding(focusedWindow, 'v'); 328 | }, 329 | }, 330 | { 331 | label: 'Select…', 332 | click(menuItem, focusedWindow) { 333 | sendKeybinding(focusedWindow, 'x'); 334 | }, 335 | }, 336 | ]; 337 | 338 | const windowItems = [ 339 | { 340 | type: 'checkbox', 341 | label: 'Always on Top', 342 | accelerator: 'CmdOrCtrl+Shift+T', 343 | checked: config.get('alwaysOnTop'), 344 | click(menuItem, focusedWindow) { 345 | config.set('alwaysOnTop', menuItem.checked); 346 | focusedWindow.setAlwaysOnTop(menuItem.checked); 347 | }, 348 | }, 349 | ]; 350 | 351 | const helpItems = [ 352 | { 353 | label: 'Keyboard Shortcuts Reference', 354 | accelerator: ['Shift+/', '?'], 355 | click(menuItem, focusedWindow) { 356 | sendKeybinding(focusedWindow, '?'); 357 | }, 358 | }, 359 | { type: 'separator' }, 360 | { 361 | label: `${app.getName()} Website`, 362 | click() { 363 | shell.openExternal(pkg.homepage); 364 | }, 365 | }, 366 | { 367 | label: 'Source Code', 368 | click() { 369 | shell.openExternal('https://github.com/denysdovhan/inboxer'); 370 | }, 371 | }, 372 | { 373 | label: 'Report an Issue…', 374 | click() { 375 | shell.openExternal(`${pkg.bugs.url}/new?body=${encodeURIComponent(report)}`); 376 | }, 377 | }, 378 | { type: 'separator' }, 379 | { 380 | label: 'Toggle Developer Tools', 381 | type: 'checkbox', 382 | accelerator: isDarwin ? 'Option+Cmd+I' : 'Ctrl+Shift+I', 383 | click(item, focusedWindow) { 384 | focusedWindow.toggleDevTools(); 385 | }, 386 | }, 387 | ]; 388 | 389 | const darwinTemplate = [ 390 | { 391 | label: app.getName(), 392 | submenu: [ 393 | { role: 'about' }, 394 | { type: 'separator' }, 395 | ...settingsItems, 396 | { type: 'separator' }, 397 | { 398 | role: 'services', 399 | submenu: [], 400 | }, 401 | { type: 'separator' }, 402 | ...accountItems, 403 | { type: 'separator' }, 404 | { role: 'hide' }, 405 | { role: 'hideothers' }, 406 | { role: 'unhide' }, 407 | { type: 'separator' }, 408 | { role: 'quit' }, 409 | ], 410 | }, 411 | { 412 | label: 'File', 413 | submenu: fileItems, 414 | }, 415 | { 416 | label: 'View', 417 | submenu: viewItems, 418 | }, 419 | { 420 | label: 'Edit', 421 | submenu: editItems, 422 | }, 423 | { 424 | label: 'List', 425 | submenu: listItems, 426 | }, 427 | { 428 | label: 'Item', 429 | submenu: itemItems, 430 | }, 431 | { 432 | role: 'window', 433 | submenu: [ 434 | { role: 'minimize' }, 435 | { role: 'close' }, 436 | { type: 'separator' }, 437 | { role: 'front' }, 438 | { role: 'togglefullscreen' }, 439 | { type: 'separator' }, 440 | ...windowItems, 441 | ], 442 | }, 443 | { 444 | role: 'help', 445 | submenu: helpItems, 446 | }, 447 | ]; 448 | 449 | const otherTemplate = [ 450 | { 451 | label: 'File', 452 | submenu: [ 453 | ...fileItems, 454 | { type: 'separator' }, 455 | ...accountItems, 456 | { type: 'separator' }, 457 | { role: 'quit' }, 458 | ], 459 | }, 460 | { 461 | label: 'View', 462 | submenu: [ 463 | ...viewItems, 464 | ...windowItems, 465 | ], 466 | }, 467 | { 468 | label: 'Edit', 469 | submenu: [...editItems, ...settingsItems], 470 | }, 471 | { 472 | label: 'List', 473 | submenu: listItems, 474 | }, 475 | { 476 | label: 'Item', 477 | submenu: itemItems, 478 | }, 479 | { 480 | role: 'help', 481 | submenu: [ 482 | ...helpItems, 483 | { type: 'separator' }, 484 | { 485 | role: 'about', 486 | click() { 487 | dialog.showMessageBox({ 488 | title: `About ${app.getName()}`, 489 | message: `${app.getName()} ${app.getVersion()}`, 490 | detail: `Created by ${pkg.author.name}`, 491 | icon: path.join(__dirname, '..', 'static/Icon.png'), 492 | }); 493 | }, 494 | }, 495 | ], 496 | }, 497 | ]; 498 | 499 | module.exports = Menu.buildFromTemplate(isDarwin ? darwinTemplate : otherTemplate); 500 | -------------------------------------------------------------------------------- /app/main/preferences.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { BrowserWindow } = require('electron'); 3 | 4 | let preferencesWindow = null; 5 | 6 | function createPreferencesWindow() { 7 | const preferencesPath = path.join('file://', __dirname, '../renderer/preferences/index.html'); 8 | preferencesWindow = new BrowserWindow({ 9 | width: 600, 10 | height: 400, 11 | icon: path.join(__dirname, '..', 'static/Icon.png'), 12 | title: 'Preferences', 13 | }); 14 | 15 | preferencesWindow.on('close', () => { 16 | preferencesWindow = null; 17 | }); 18 | 19 | preferencesWindow.setResizable(false); 20 | preferencesWindow.setSkipTaskbar(true); 21 | preferencesWindow.setMenu(null); 22 | preferencesWindow.loadURL(preferencesPath); 23 | } 24 | 25 | module.exports = { 26 | showPreferencesWindow() { 27 | if (!preferencesWindow) createPreferencesWindow(); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /app/main/report.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const { app } = require('electron'); 3 | 4 | module.exports = ` 5 | 6 | --- 7 | ${app.getName()} ${app.getVersion()} 8 | Electron ${process.versions.electron} 9 | ${process.platform} ${process.arch} ${os.release()}`; 10 | -------------------------------------------------------------------------------- /app/main/tray.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { app, Tray, Menu } = require('electron'); 3 | const { isDarwin, sendAction } = require('./utils'); 4 | 5 | const iconTrayFile = 'IconTray.png'; 6 | const iconTrayUnreadFile = 'IconTrayUnread.png'; 7 | 8 | let tray = null; 9 | 10 | const contextMenu = focusedWindow => [ 11 | { 12 | label: 'Open/Close', 13 | click() { 14 | return focusedWindow.isVisible() ? focusedWindow.hide() : focusedWindow.show(); 15 | }, 16 | }, 17 | { type: 'separator' }, 18 | { 19 | label: 'Go to Inbox', 20 | click() { 21 | sendAction(focusedWindow, 'go-to-inbox'); 22 | }, 23 | }, 24 | { 25 | label: 'Go to Snoozed', 26 | click() { 27 | sendAction(focusedWindow, 'go-to-snoozed'); 28 | }, 29 | }, 30 | { 31 | label: 'Go to Done', 32 | click() { 33 | sendAction(focusedWindow, 'go-to-done'); 34 | }, 35 | }, 36 | { type: 'separator' }, 37 | { 38 | label: 'Sign Out', 39 | click() { 40 | sendAction(focusedWindow, 'sign-out'); 41 | }, 42 | }, 43 | { type: 'separator' }, 44 | { role: 'quit' }, 45 | ]; 46 | 47 | function create(win) { 48 | if (isDarwin || tray) return; 49 | 50 | const iconPath = path.join(__dirname, '..', `static/${iconTrayFile}`); 51 | 52 | tray = new Tray(iconPath); 53 | tray.setToolTip(app.getName()); 54 | tray.setContextMenu(Menu.buildFromTemplate(contextMenu(win))); 55 | 56 | tray.on('click', () => (win.isVisible() ? win.hide() : win.show())); 57 | } 58 | 59 | function setBadge(shouldDisplayUnread) { 60 | if (isDarwin || !tray) return; 61 | 62 | const iconPath = path.join(__dirname, '..', `static/${shouldDisplayUnread ? iconTrayUnreadFile : iconTrayFile}`); 63 | tray.setImage(iconPath); 64 | } 65 | 66 | module.exports = { 67 | create, 68 | setBadge, 69 | }; 70 | -------------------------------------------------------------------------------- /app/main/utils.js: -------------------------------------------------------------------------------- 1 | const analytics = require('./analytics'); 2 | const config = require('./config'); 3 | 4 | const isDarwin = process.platform === 'darwin'; 5 | const isLinux = process.platform === 'linux'; 6 | const isWindows = process.platform === 'win32'; 7 | 8 | function sendAction(win, action) { 9 | if (config.get('sendAnalytics')) analytics.track(action); 10 | if (isDarwin) win.restore(); 11 | win.webContents.send(action); 12 | } 13 | 14 | // @FIXME: Shift keybindings do not work. 15 | // https://stackoverflow.com/q/47378160/5508862 16 | function sendKeybinding(win, keyCode) { 17 | win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); 18 | win.webContents.sendInputEvent({ type: 'char', keyCode }); 19 | win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); 20 | } 21 | 22 | module.exports = { 23 | isDarwin, 24 | isLinux, 25 | isWindows, 26 | sendAction, 27 | sendKeybinding, 28 | }; 29 | -------------------------------------------------------------------------------- /app/renderer/browser.css: -------------------------------------------------------------------------------- 1 | /* Tooltip to display link contents */ 2 | /*
contains message view area (right side of window) */ 3 | div.AO a[href]::after { 4 | content: attr(href); 5 | position: absolute; 6 | transform: translateY(100%); 7 | left: 0; 8 | background: rgba(0,0,0,0.7); 9 | text-align: center; 10 | color: #fff; 11 | font-size: small; 12 | border-radius: 5px; 13 | pointer-events: none; 14 | padding: 4px 8px; 15 | z-index:99; 16 | opacity:0; 17 | transition: opacity 0.5s; 18 | } 19 | 20 | div.AO a[href]:hover::after { 21 | opacity:1 22 | } 23 | -------------------------------------------------------------------------------- /app/renderer/browser.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer: ipc } = require('electron'); 2 | const checkUnreads = require('./unreads'); 3 | const { $, renderOverlayIcon } = require('./utils'); 4 | 5 | const settingsURL = 'https://mail.google.com/mail/u/0/#settings/general'; 6 | const doneURL = 'https://mail.google.com/mail/u/0/#search/-in%3Ainbox+-in%3Aspam+-in%3Atrash+-in%3Achats+-in%3Asnoozed+-in%3Adrafts+-in%3Asent'; 7 | const contactsURL = 'https://contacts.google.com/'; 8 | const addAccountURL = 'https://accounts.google.com/AddSession'; 9 | 10 | ipc.on('toggle-sidebar', () => $('div.gb_td div[aria-label="Main menu"]').click()); 11 | 12 | function selectFolder(name) { 13 | const selector = `div.TK div.aim div.TO[data-tooltip="${name}"]`; 14 | const folder = $(selector); 15 | if (folder) { 16 | folder.click(); 17 | } else { 18 | // if folder was not found, try loading correct URL 19 | const urlName = name.split(' ')[0].toLowerCase(); 20 | const url = `https://mail.google.com/mail/u/0/#${urlName}`; 21 | window.location.assign(url); 22 | } 23 | } 24 | 25 | function loadURL(url) { 26 | window.location.assign(url); 27 | } 28 | 29 | ipc.on('show-preferences', () => loadURL(settingsURL)); 30 | 31 | // primary folder shortcuts 32 | 33 | ipc.on('go-to-inbox', () => selectFolder('Inbox')); 34 | ipc.on('go-to-snoozed', () => selectFolder('Snoozed')); 35 | ipc.on('go-to-done', () => loadURL(doneURL)); 36 | 37 | // secondary folder shortcuts 38 | 39 | ipc.on('go-to-drafts', () => selectFolder('Drafts')); 40 | ipc.on('go-to-sent', () => selectFolder('Sent')); 41 | ipc.on('go-to-trash', () => selectFolder('Trash')); 42 | ipc.on('go-to-spam', () => selectFolder('Spam')); 43 | ipc.on('go-to-contacts', () => loadURL(contactsURL)); 44 | 45 | ipc.on('go-to-search', () => { 46 | const searchBar = $('div.gb_td input[aria-label="Search mail"]'); 47 | if (searchBar) { 48 | searchBar.focus(); 49 | } 50 | }); 51 | 52 | ipc.on('sign-out', () => $('#gb_71').click()); 53 | ipc.on('add-account', () => loadURL(addAccountURL)); 54 | 55 | ipc.on('render-overlay-icon', (event, unreadsCount) => { 56 | ipc.send( 57 | 'update-overlay-icon', 58 | renderOverlayIcon(unreadsCount).toDataURL(), 59 | unreadsCount.toString(), 60 | ); 61 | }); 62 | 63 | document.addEventListener('DOMContentLoaded', () => { 64 | document.documentElement.classList.add(`platform-${process.platform}`); 65 | 66 | checkUnreads(); 67 | 68 | // Change application title on login page 69 | const appTitle = $('.wrapper .banner h1'); 70 | if (appTitle) { 71 | appTitle.innerHTML = 'Inboxer'; 72 | } 73 | 74 | // Put the name of active user on the topbar 75 | const activeUserName = $('div.gb_Bb.gb_Ab'); 76 | const topbarUserLink = $('a.gb_b.gb_fb.gb_R'); 77 | if (activeUserName && topbarUserLink) { 78 | const span = document.createElement('span'); 79 | span.textContent = activeUserName.textContent; 80 | span.classList.add('active-user-name'); 81 | topbarUserLink.appendChild(span); 82 | } 83 | }); 84 | -------------------------------------------------------------------------------- /app/renderer/preferences/index.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | font-family: Arial, Helvetica, sans-serif; 6 | font-size: 0.95em; 7 | } 8 | 9 | .container { 10 | position: relative; 11 | width: 100%; 12 | height: 100%; 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .title { 18 | padding-top: 24px; 19 | padding-bottom: 24px; 20 | padding-left: 24px; 21 | border-bottom: 1px solid #e0e0e0; 22 | font-weight: bolder; 23 | font-size: large; 24 | } 25 | 26 | .preferences-container { 27 | width: 100%; 28 | height: 100%; 29 | display: flex; 30 | flex-direction: row; 31 | } 32 | 33 | .category-container { 34 | width: 30%; 35 | border-right: 1px solid #e0e0e0; 36 | } 37 | 38 | .category { 39 | padding-left: 24px; 40 | padding-bottom: 10px; 41 | padding-top: 10px; 42 | margin: 0; 43 | cursor: pointer; 44 | } 45 | 46 | .active { 47 | background-color: #f2f2f2; 48 | font-weight: bold; 49 | cursor: default; 50 | } 51 | 52 | .category-preferences { 53 | position: relative; 54 | width: 70%; 55 | display: flex; 56 | flex-direction: column; 57 | justify-content: space-between; 58 | } 59 | 60 | .preference-list { 61 | width: 100%; 62 | } 63 | 64 | .preference { 65 | padding-left: 24px; 66 | } 67 | 68 | .actions-container { 69 | width: calc(100% - 24px); 70 | height: 48px; 71 | margin-right: 24px; 72 | display: flex; 73 | flex-direction: row; 74 | justify-content: flex-end; 75 | align-items: center; 76 | } 77 | 78 | .action { 79 | height: 32px; 80 | line-height: 32px; 81 | font-weight: bold; 82 | color: #3367d6; 83 | cursor: pointer; 84 | padding: 0px 16px 0px 16px; 85 | border-radius: 2px; 86 | border:none; 87 | } 88 | 89 | .action:hover { 90 | background-color: rgba(10,10,10,0.2) 91 | } 92 | 93 | input[type="number"] { 94 | width: 4em; 95 | margin-left: 1em; 96 | margin-right: 0.5em; 97 | } 98 | -------------------------------------------------------------------------------- /app/renderer/preferences/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
Preferences
12 |
13 |
14 |

Notifications

15 |
16 |
17 |
18 |
19 |

Notify on New messages

20 |
21 |
22 |

Notify on Snoozed messages

23 |
24 |
25 |

Notify on Downloaded files

26 |
27 |
28 |

Notification update period:seconds

29 |
30 |
31 |
32 |
33 |
34 |
35 |

Analytics

36 |
37 |
38 |
39 |
40 |

Send usage reports

41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/renderer/preferences/index.js: -------------------------------------------------------------------------------- 1 | const { remote } = require('electron'); 2 | 3 | const config = remote.require('../../app/main/config'); 4 | 5 | const PreferencesWindow = {}; 6 | 7 | PreferencesWindow.savePreferencesButton = document.querySelector('#savePreferencesButton'); 8 | PreferencesWindow.sendAnalyticsCheckbox = document.querySelector('#sendAnalyticsCheckbox'); 9 | PreferencesWindow.notifyUnread = document.querySelector('#notifyUnread'); 10 | PreferencesWindow.notifySnoozed = document.querySelector('#notifySnoozed'); 11 | PreferencesWindow.notifyDownload = document.querySelector('#notifyDownload'); 12 | PreferencesWindow.notifyPeriod = document.querySelector('#notifyPeriod'); 13 | 14 | PreferencesWindow.setEventListeners = () => { 15 | const self = this; 16 | this.savePreferencesButton.addEventListener('click', () => { 17 | config.set('sendAnalytics', self.sendAnalyticsCheckbox.checked); 18 | config.set('notify.unread', self.notifyUnread.checked); 19 | config.set('notify.snoozed', self.notifySnoozed.checked); 20 | config.set('notify.download', self.notifyDownload.checked); 21 | config.set('notify.period', parseFloat(self.notifyPeriod.value)); 22 | remote.getCurrentWindow().close(); 23 | }); 24 | }; 25 | 26 | PreferencesWindow.init = () => { 27 | this.sendAnalyticsCheckbox.checked = config.get('sendAnalytics'); 28 | this.notifyUnread.checked = config.get('notify.unread'); 29 | this.notifySnoozed.checked = config.get('notify.snoozed'); 30 | this.notifyDownload.checked = config.get('notify.download'); 31 | this.notifyPeriod.value = config.get('notify.period'); 32 | PreferencesWindow.setEventListeners(); 33 | }; 34 | 35 | window.addEventListener('DOMContentLoaded', () => { 36 | PreferencesWindow.init(); 37 | }); 38 | -------------------------------------------------------------------------------- /app/renderer/unreads.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer: ipc, remote } = require('electron'); 2 | const path = require('path'); 3 | const { 4 | $, $$, sendNotification, sendClick, 5 | } = require('./utils'); 6 | 7 | const config = remote.require('../../app/main/config'); 8 | 9 | const seenMessages = new Map(); 10 | 11 | // gmail logo from https://gsuite.google.com/setup/resources/logos/ 12 | const iconMail = path.join(__dirname, '..', 'static/gmail_48px.png'); 13 | // snoozed logo copied from Inboxer 14 | const iconSnoozed = path.join(__dirname, '..', 'static/IconSnoozed.png'); 15 | 16 | function keyByMessage({ 17 | messageType, subject, sender, conversationLength, 18 | }) { 19 | try { 20 | return JSON.stringify({ 21 | messageType, subject, sender, conversationLength, 22 | }); 23 | } catch (error) { 24 | console.error(error); // eslint-disable-line 25 | return undefined; 26 | } 27 | } 28 | 29 | function extractSubject(message) { 30 | return $('.y6 span span', message).textContent; 31 | } 32 | 33 | function extractSender(message) { 34 | return $('span.bA4', message).textContent; 35 | } 36 | 37 | function extractConversationLength(message) { 38 | const lenSpan = $('span.bx0', message); 39 | return (lenSpan) ? lenSpan.textContent : null; 40 | } 41 | 42 | // name of currently selected folder: Inbox, Sent, ... 43 | function folderName() { 44 | const folder = $('div.TK div.aim.ain div.TO'); 45 | return (folder) ? folder.getAttribute('data-tooltip') : null; 46 | } 47 | 48 | // extract number of unread messages in Inbox from the left column 49 | // works even if we're not in Inbox 50 | function extractNumberUnread() { 51 | // div.TK: left column, main folders 52 | // div.aim: each folder (Inbox, Starred, Sent, ...) 53 | // div.TO with data-tooltip="Inbox": Inbox folder 54 | // div.bsU: contains number of unread messages 55 | const numUnreadDiv = $('div.TK div.aim div.TO[data-tooltip="Inbox"] div.bsU'); 56 | const numUnread = (numUnreadDiv) ? parseInt(numUnreadDiv.textContent, 10) : 0; 57 | return (Number.isNaN(numUnread)) ? 0 : numUnread; 58 | } 59 | 60 | // returns array of notifications: {message, title, body, icon} 61 | function findUnreadSnoozedMessages() { 62 | const messageTable = $('div.Cp table.F'); 63 | if (messageTable === null) { 64 | return []; 65 | } 66 | const notifications = []; 67 | 68 | // mark already seen messages false 69 | seenMessages.forEach((value, key, map) => { 70 | map.set(key, false); 71 | }); 72 | 73 | const notifyUnread = config.get('notify.unread'); 74 | const notifySnoozed = config.get('notify.snoozed'); 75 | // iterate through all messages (rows in table) 76 | $$('table.F > tbody > tr', messageTable).forEach((message) => { 77 | let messageType = null; 78 | if (message.className.includes('zA zE')) { // unread message 79 | messageType = 'unread'; 80 | } else if ($('td.byZ div.by1', message) !== null) { // snoozed message 81 | messageType = 'snoozed'; 82 | } 83 | 84 | if (messageType !== null) { 85 | const subject = extractSubject(message); 86 | const sender = extractSender(message); 87 | const conversationLength = extractConversationLength(message); 88 | const key = keyByMessage({ 89 | messageType, 90 | subject, 91 | sender, 92 | conversationLength, 93 | }); 94 | 95 | // if message hasn't been seen before, schedule notification 96 | if (!seenMessages.has(key)) { 97 | if ((messageType === 'unread' && notifyUnread) 98 | || (messageType === 'snoozed' && notifySnoozed)) { 99 | const icon = (messageType === 'unread') ? iconMail : iconSnoozed; 100 | notifications.push({ 101 | message, 102 | title: sender, 103 | body: subject, 104 | icon: `file://${icon}`, 105 | }); 106 | } 107 | } 108 | seenMessages.set(key, true); // mark message as seen 109 | } 110 | }); 111 | 112 | // delete any seenMessages still marked false 113 | seenMessages.forEach((value, key, map) => { 114 | if (value === false) { 115 | map.delete(key); 116 | } 117 | }); 118 | 119 | return notifications; 120 | } 121 | 122 | function checkUnreads() { 123 | if (typeof checkUnreads.haveUnread === 'undefined') { 124 | checkUnreads.haveUnread = false; 125 | } 126 | 127 | let period = parseFloat(config.get('notify.period'), 10) * 1000; // convert seconds to milliseconds 128 | if (period < 100) { 129 | period = 100; // no faster than every 100 ms 130 | } 131 | 132 | const numUnread = extractNumberUnread(); 133 | if (checkUnreads.haveUnread !== (numUnread > 0)) { 134 | ipc.send('update-unreads-count', numUnread); 135 | checkUnreads.haveUnread = (numUnread > 0); 136 | } 137 | 138 | // skip if we're not inside the inbox 139 | if (folderName() !== 'Inbox') { 140 | setTimeout(checkUnreads, period); 141 | return; 142 | } 143 | 144 | if (typeof checkUnreads.startingUp === 'undefined') { 145 | checkUnreads.startingUp = true; 146 | } 147 | 148 | // notifications for new unread or snoozed messages 149 | const notifications = findUnreadSnoozedMessages(); 150 | if (!checkUnreads.startingUp) { // send notifications only if we're not just starting up 151 | notifications.reverse().forEach((notification) => { 152 | const { 153 | message, title, body, icon, 154 | } = notification; 155 | sendNotification({ 156 | title, 157 | body, 158 | icon, 159 | }).addEventListener('click', () => { 160 | ipc.send('show-window', true); 161 | sendClick(message); 162 | }); 163 | }); 164 | } 165 | 166 | if (checkUnreads.startingUp) { 167 | checkUnreads.startingUp = false; 168 | } 169 | 170 | setTimeout(checkUnreads, period); 171 | } 172 | 173 | module.exports = checkUnreads; 174 | -------------------------------------------------------------------------------- /app/renderer/utils.js: -------------------------------------------------------------------------------- 1 | function $(selector, context = document) { 2 | return context.querySelector(selector); 3 | } 4 | 5 | function $$(selector, context = document) { 6 | return context.querySelectorAll(selector); 7 | } 8 | 9 | function createEvent(type) { 10 | return new MouseEvent(type, { 11 | view: window, 12 | bubbles: true, 13 | cancelable: true, 14 | }); 15 | } 16 | 17 | function sendClick(el) { 18 | el.dispatchEvent(createEvent('mousedown')); 19 | el.dispatchEvent(createEvent('click')); 20 | } 21 | 22 | function sendNotification(notification) { 23 | const { title, body, icon } = notification; 24 | return new Notification(title, { body, icon }); 25 | } 26 | 27 | function ancestor(el, selector) { 28 | return el.closest(selector); 29 | } 30 | 31 | // Drawing overlay icon for main proccess 32 | // https://github.com/sindresorhus/caprine/blob/f67cc47fd4c9e5a44e171a5cc51c3e5a11cea600/browser.js#L104-L119 33 | function renderOverlayIcon(unreadsCount) { 34 | const canvas = document.createElement('canvas'); 35 | canvas.height = 128; 36 | canvas.width = 128; 37 | canvas.style.letterSpacing = '-5px'; 38 | 39 | const ctx = canvas.getContext('2d'); 40 | ctx.fillStyle = '#f42020'; 41 | ctx.beginPath(); 42 | ctx.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI); 43 | ctx.fill(); 44 | ctx.textAlign = 'center'; 45 | ctx.fillStyle = 'white'; 46 | ctx.font = '90px sans-serif'; 47 | ctx.fillText(Math.min(99, unreadsCount).toString(), 64, 96); 48 | 49 | return canvas; 50 | } 51 | 52 | module.exports = { 53 | $, 54 | $$, 55 | createEvent, 56 | sendClick, 57 | sendNotification, 58 | ancestor, 59 | renderOverlayIcon, 60 | }; 61 | -------------------------------------------------------------------------------- /app/static/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/Icon.png -------------------------------------------------------------------------------- /app/static/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/Icon@2x.png -------------------------------------------------------------------------------- /app/static/IconSnoozed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/IconSnoozed.png -------------------------------------------------------------------------------- /app/static/IconTray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/IconTray.png -------------------------------------------------------------------------------- /app/static/IconTray@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/IconTray@2x.png -------------------------------------------------------------------------------- /app/static/IconTrayUnread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/IconTrayUnread.png -------------------------------------------------------------------------------- /app/static/IconTrayUnread@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/IconTrayUnread@2x.png -------------------------------------------------------------------------------- /app/static/gmail_48px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/app/static/gmail_48px.png -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/build/background.png -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/build/background@2x.png -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/build/icon.ico -------------------------------------------------------------------------------- /media/inboxer-linux-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-linux-desktop.png -------------------------------------------------------------------------------- /media/inboxer-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-linux.png -------------------------------------------------------------------------------- /media/inboxer-mac-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-mac-desktop.png -------------------------------------------------------------------------------- /media/inboxer-mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-mac.png -------------------------------------------------------------------------------- /media/inboxer-windows-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-windows-desktop.png -------------------------------------------------------------------------------- /media/inboxer-windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer-windows.png -------------------------------------------------------------------------------- /media/inboxer.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/inboxer/c3f49fa5c5d55045074fb9bfafd161b84810683d/media/inboxer.sketch -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "inboxer", 4 | "productName": "Inboxer", 5 | "version": "1.3.2", 6 | "description": "Simple client for Google Inbox", 7 | "author": { 8 | "name": "Denys Dovhan", 9 | "email": "denysdovhan@gmail.com", 10 | "url": "https://denysdovhan.com" 11 | }, 12 | "license": "MIT", 13 | "homepage": "https://denysdovhan.com/inboxer", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/denysdovhan/inboxer.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/denysdovhan/inboxer/issues" 20 | }, 21 | "keywords": [ 22 | "client", 23 | "inbox", 24 | "gmail", 25 | "electron", 26 | "app" 27 | ], 28 | "main": "app/main", 29 | "scripts": { 30 | "postinstall": "electron-builder install-app-deps", 31 | "start": "electron app/main", 32 | "lint": "eslint app", 33 | "lint:fix": "npm run lint -- --fix", 34 | "test": "npm-run-all lint", 35 | "pack": "electron-builder --dir", 36 | "dist": "npm-run-all test dist:*", 37 | "dist:mac": "electron-builder --mac", 38 | "dist:linux": "electron-builder --linux", 39 | "dist:win": "electron-builder --win", 40 | "format": "prettier-eslint --write app/**/*.js", 41 | "icon": "npm-run-all icon:*", 42 | "icon:icns": "png2icns app/static/Icon@2x.png -o build/icon.icns", 43 | "icon:ico": "png2icons app/static/Icon@2x.png build/icon -ico" 44 | }, 45 | "devDependencies": { 46 | "electron": "^3.0", 47 | "electron-builder": "^20.39.0", 48 | "eslint": "^5.14.1", 49 | "eslint-config-airbnb-base": "^13.1.0", 50 | "eslint-plugin-import": "^2.16.0", 51 | "husky": "^1.3.1", 52 | "lint-staged": "^6.0.0", 53 | "npm-run-all": "^4.1.2", 54 | "png2icns": "0.0.1", 55 | "png2icons": "^0.9.1", 56 | "prettier-eslint-cli": "^4.7.1" 57 | }, 58 | "dependencies": { 59 | "asar": "^1.0.0", 60 | "electron-context-menu": "^0.11.0", 61 | "electron-dl": "^1.13.0", 62 | "electron-is-dev": "^1.0.1", 63 | "electron-log": "^3.0.1", 64 | "electron-store": "^2.0.0", 65 | "electron-updater": "^4.0.0", 66 | "first-run": "^1.2.0", 67 | "insight": "^0.10.1", 68 | "minimatch-all": "^1.1.0" 69 | }, 70 | "build": { 71 | "appId": "com.denysdovhan.inboxer", 72 | "files": [ 73 | "**/*" 74 | ], 75 | "mac": { 76 | "category": "public.app-category.social-networking", 77 | "target": [ 78 | "dmg", 79 | "zip" 80 | ] 81 | }, 82 | "dmg": { 83 | "iconSize": 160, 84 | "contents": [ 85 | { 86 | "x": 180, 87 | "y": 170 88 | }, 89 | { 90 | "x": 480, 91 | "y": 170, 92 | "type": "link", 93 | "path": "/Applications" 94 | } 95 | ] 96 | }, 97 | "linux": { 98 | "target": [ 99 | "AppImage", 100 | "deb", 101 | "snap" 102 | ] 103 | }, 104 | "win": { 105 | "target": [ 106 | "nsis" 107 | ] 108 | } 109 | }, 110 | "lint-staged": { 111 | "app/**/*.js": [ 112 | "lint", 113 | "format" 114 | ] 115 | } 116 | } 117 | --------------------------------------------------------------------------------