├── .babelrc ├── .dockerignore ├── .env ├── .eslintrc ├── .git-blame-ignore-revs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── stale.yml ├── .gitignore ├── .node-version ├── .python-version ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── Procfile ├── Procfile-app ├── README.md ├── docker-compose.yml ├── kanmail ├── __init__.py ├── client │ ├── boot.jsx │ ├── components │ │ ├── Avatar.jsx │ │ ├── Editor.jsx │ │ ├── ErrorInformation.jsx │ │ ├── HeaderErrors.jsx │ │ ├── Tooltip.jsx │ │ ├── contacts │ │ │ ├── AddNewContactForm.jsx │ │ │ ├── Contact.jsx │ │ │ ├── ContactsApp.jsx │ │ │ └── main.js │ │ ├── emails │ │ │ ├── AddNewColumnForm.jsx │ │ │ ├── ControlInput.jsx │ │ │ ├── EmailColumn.jsx │ │ │ ├── EmailColumnHeader.jsx │ │ │ ├── EmailColumnThread.jsx │ │ │ ├── EmailsApp.jsx │ │ │ ├── Filters.jsx │ │ │ ├── FooterStatus.jsx │ │ │ ├── MainColumn.jsx │ │ │ ├── Search.jsx │ │ │ ├── Sidebar.jsx │ │ │ ├── Thread.jsx │ │ │ ├── ThreadMessage.jsx │ │ │ ├── ThreadMessageAttachment.jsx │ │ │ ├── WelcomeSettings.jsx │ │ │ └── main.js │ │ ├── license │ │ │ ├── LicenseApp.jsx │ │ │ └── main.js │ │ ├── meta │ │ │ ├── MetaApp.jsx │ │ │ └── main.js │ │ ├── metaFile │ │ │ ├── MetaFileApp.jsx │ │ │ └── main.js │ │ ├── send │ │ │ ├── SendApp.jsx │ │ │ └── main.js │ │ └── settings │ │ │ ├── AccountForm.jsx │ │ │ ├── AccountList.jsx │ │ │ ├── NewAccountForm.jsx │ │ │ ├── OverlayItemList.jsx │ │ │ ├── SettingsApp.jsx │ │ │ ├── SignatureForm.jsx │ │ │ ├── SignatureList.jsx │ │ │ └── main.js │ ├── constants.js │ ├── fonts │ │ ├── fontawesome │ │ │ ├── css │ │ │ │ └── font-awesome.css │ │ │ └── fonts │ │ │ │ ├── FontAwesome.otf │ │ │ │ ├── fontawesome-webfont.eot │ │ │ │ ├── fontawesome-webfont.svg │ │ │ │ ├── fontawesome-webfont.ttf │ │ │ │ ├── fontawesome-webfont.woff │ │ │ │ └── fontawesome-webfont.woff2 │ │ └── open-sans │ │ │ ├── css │ │ │ └── open-sans.css │ │ │ └── fonts │ │ │ ├── open-sans-v18-latin-600.woff │ │ │ ├── open-sans-v18-latin-600.woff2 │ │ │ ├── open-sans-v18-latin-700.woff │ │ │ ├── open-sans-v18-latin-700.woff2 │ │ │ ├── open-sans-v18-latin-regular.woff │ │ │ └── open-sans-v18-latin-regular.woff2 │ ├── icon.png │ ├── images │ │ └── providers │ │ │ ├── gmail.png │ │ │ ├── icloud.png │ │ │ ├── outlook.png │ │ │ └── yahoo.png │ ├── keyboard.js │ ├── stores │ │ ├── base.jsx │ │ ├── columns.js │ │ ├── control.js │ │ ├── emails │ │ │ ├── base.js │ │ │ ├── controller.js │ │ │ ├── main.js │ │ │ └── search.js │ │ ├── filters.js │ │ ├── folders.js │ │ ├── request.js │ │ ├── search.js │ │ ├── settings.js │ │ ├── sidebarFolderLinks.js │ │ ├── thread.js │ │ ├── tooltip.js │ │ └── update.js │ ├── style.less │ ├── styles │ │ ├── base.less │ │ ├── columns.less │ │ ├── contacts.less │ │ ├── control.less │ │ ├── header.less │ │ ├── meta.less │ │ ├── platforms.less │ │ ├── select.less │ │ ├── send.less │ │ ├── settings.less │ │ ├── sidebar.less │ │ ├── themes │ │ │ ├── base-default-variables.less │ │ │ ├── base.less │ │ │ ├── default-dark.less │ │ │ ├── default-light.less │ │ │ └── default.less │ │ └── thread.less │ ├── templates │ │ ├── base.html │ │ ├── contacts.html │ │ ├── emails.html │ │ ├── license.html │ │ ├── meta.html │ │ ├── meta_file.html │ │ ├── oauth_complete.html │ │ ├── oauth_error.html │ │ ├── send.html │ │ └── settings.html │ ├── theme.js │ ├── threading.js │ ├── util │ │ ├── accounts.js │ │ ├── array.js │ │ ├── element.js │ │ ├── html.js │ │ ├── message.js │ │ ├── requests.js │ │ ├── string.js │ │ └── threads.js │ └── window.js ├── license.py ├── log.py ├── notifications │ ├── __init__.py │ └── macos.py ├── secrets.py ├── server │ ├── __init__.py │ ├── app.py │ ├── mail │ │ ├── __init__.py │ │ ├── account.py │ │ ├── allowed_images.py │ │ ├── autoconf.py │ │ ├── autoconf_settings.py │ │ ├── connection.py │ │ ├── connection_mocks.py │ │ ├── contacts.py │ │ ├── fixes.py │ │ ├── folder.py │ │ ├── folder_cache.py │ │ ├── icon.py │ │ ├── message.py │ │ ├── oauth.py │ │ ├── oauth_settings.py │ │ ├── smtp.py │ │ └── util.py │ ├── util.py │ └── views │ │ ├── __init__.py │ │ ├── accounts_api.py │ │ ├── contacts_api.py │ │ ├── email_api.py │ │ ├── error.py │ │ ├── license_api.py │ │ ├── notification_api.py │ │ ├── oauth_api.py │ │ ├── settings_api.py │ │ ├── update_api.py │ │ └── window_api.py ├── settings │ ├── __init__.py │ ├── constants.py │ ├── hidden.py │ └── model.py ├── update.py ├── version.py └── window │ ├── __init__.py │ └── macos.py ├── main.py ├── make ├── Dockerfile-ubuntu-linux-build ├── __init__.py ├── __main__.py ├── build_linux_client_docker.sh ├── clean.py ├── entitlements.plist ├── github-config.pyu ├── icon.icns ├── icon.ico ├── macos.py ├── settings.py ├── spec.j2.py └── util.py ├── package.json ├── poetry.lock ├── pyproject.toml ├── screenshot.png ├── scripts ├── run_cache_cleanup.py └── run_server.py ├── setup.cfg ├── tests ├── __init__.py └── test_app_run.py ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "es2015", 4 | "react", 5 | ], 6 | "plugins": [ 7 | "babel-plugin-transform-decorators-legacy", 8 | "transform-object-rest-spread", 9 | "transform-class-properties", 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log 3 | 4 | node_modules/ 5 | 6 | # Build stuff 7 | build/ 8 | pyu-data/ 9 | .pyupdater/ 10 | .mypy_cache/ 11 | 12 | .git/ 13 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PYTHONUNBUFFERED=true 2 | KANMAIL_DEBUG=on 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:react/recommended"], 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "es6": true, 7 | "commonjs": true 8 | }, 9 | "plugins": ["react", "import", "prettier"], 10 | "rules": { 11 | "no-console": [0], 12 | "import/no-unresolved": [2], 13 | "import/named": [2], 14 | "import/default": [2], 15 | "import/export": [2], 16 | "import/first": [2], 17 | "import/no-duplicates": [2], 18 | "prettier/prettier": "error" 19 | }, 20 | "settings": { 21 | "import/resolver": "webpack" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Run black & isort on everything 2 | ddccdabe1d2b226812c346e641463dd0e024be02 3 | 4 | # Run prettier on everything 5 | 7723c9070a33fe516c1c16a1f4c299f25f17dce1 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://kanmail.io/license?ref=github"] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Create a report to help us improve. 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | 3 | contact_links: 4 | - name: 🛡 License issues 5 | url: https://kanmail.io/support 6 | about: Purchase and recover Kanmail licenses. 7 | 8 | - name: 📖 Documentation 9 | url: https://kanmail.io/docs 10 | about: View the Kanmail user documentation. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💡 Feature request 3 | about: Suggest an idea for the Kanmail project. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 18 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Mark & close stale/unassigned bug issues' 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v3 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | days-before-close: 7 14 | days-before-stale: 30 15 | only-issue-labels: bug 16 | exempt-all-assignees: true 17 | stale-issue-message: 'This bug is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log 3 | .python-version 4 | 5 | node_modules 6 | 7 | # Build stuff 8 | build/ 9 | dist/ 10 | pyu-data/ 11 | .pyupdater/ 12 | .mypy_cache/ 13 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.1.0 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.12 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 python:3.9.12-alpine3.15 2 | 3 | LABEL maintainer="Nick Barrett, Oxygem " 4 | 5 | ARG PACKAGES='gcc make git musl-dev libc-dev libffi-dev libressl-dev zlib-dev cargo' 6 | 7 | ADD pyproject.toml poetry.lock /opt/kanmail/ 8 | WORKDIR /opt/kanmail 9 | 10 | RUN apk add --no-cache $PACKAGES \ 11 | && pip install --no-cache-dir poetry \ 12 | && poetry export > requirements.txt \ 13 | && pip install --no-cache-dir -r requirements.txt \ 14 | && apk del --purge $PACKAGES 15 | 16 | ADD . /opt/kanmail 17 | ADD ./dist /opt/kanmail/kanmail/client/static/dist 18 | 19 | RUN adduser --disabled-password --gecos '' kanmail 20 | 21 | RUN chown -R kanmail:kanmail /opt/kanmail 22 | 23 | RUN mkdir -p /home/kanmail/.config/kanmail \ 24 | && chown -R kanmail:kanmail /home/kanmail 25 | 26 | VOLUME /home/kanmail/.config/kanmail 27 | 28 | USER kanmail 29 | 30 | ENTRYPOINT [ "/opt/kanmail/scripts/run_server.py" ] 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Kanmail End User License Agreement 2 | 3 | Please read this EULA agreement carefully before purchasing a license key and/or downloading and using the Software. 4 | 5 | By purchasing a License Key and/or downloading and using the Software, You agree, without reservation, to be bound by the terms of this EULA. If You do not agree with the terms of this EULA, please do not purchase a License Key and/or download and use the Software. 6 | 7 | If you are entering into this EULA agreement on behalf of a company or other legal entity, you represent that you have the authority to bind such entity and its affiliates to these terms and conditions. If you do not have such authority or if you do not agree with the terms and conditions of this EULA agreement, do not install or use the Software, and you must not accept this EULA agreement. 8 | 9 | ## License 10 | 11 | OxygemDigital Inc hereby grants you a personal, non-transferable, non-exclusive licence to use the Kanmail software on your devices in accordance with the terms of this EULA agreement. 12 | 13 | You are permitted to use Kanmail on any devices under your control, provided the license key holder is the primary user. You are responsible for ensuring your device meets the minimum requirements of the Kanmail software. 14 | 15 | You are not permitted to: 16 | 17 | - Reproduce, copy, distribute, resell or otherwise use the Software for any commercial purpose 18 | - Allow any third party to use the Software on behalf of or for the benefit of any third party 19 | - Use the Software in any way which breaches any applicable local, national or international law 20 | - Use the Software for any purpose that OxygemDigital Inc considers is a breach of this EULA agreement 21 | 22 | ## Disclaimer 23 | 24 | The Software and accompanying documentation are provided on an “as is” and “as available” basis without warranty - express or implied- of any kind, and OxygemDigital Inc specifically disclaims the warranty of fitness for a particular purpose. No oral or written advice given by OxygemDigital Inc, its dealers, distributors, agents or employees shall create a warranty or in any way increase the scope of this warranty and You may not rely upon such information or advice. 25 | 26 | ## Limitation of Liability 27 | 28 | In no event shall OxygemDigital Inc be liable for any direct, indirect, incidental, special or consequential damages, or damages for loss of profits, revenue, data or data use, incurred by you or any third party, whether in an action in contract or tort, arising from your access to, or use of, the site or any content provided on or through the site. 29 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | server: python scripts/run_server.py 2 | client: yarn run dev 3 | -------------------------------------------------------------------------------- /Procfile-app: -------------------------------------------------------------------------------- 1 | server: python main.py 2 | client: yarn run dev 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | web: 4 | build: . 5 | env_file: 6 | - .env 7 | container_name: kanmail 8 | ports: 9 | - "4420:4420" 10 | volumes: 11 | - kanmail_data:/home/kanmail/.config/kanmail/ 12 | restart: always 13 | volumes: 14 | kanmail_data: {} 15 | -------------------------------------------------------------------------------- /kanmail/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | if sys.version_info.major != 3: 5 | raise RuntimeError("Python 2 is *not* supported.") 6 | 7 | 8 | # Set the default/global log level to WARN 9 | logging.getLogger().setLevel(logging.WARNING) 10 | -------------------------------------------------------------------------------- /kanmail/client/boot.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import * as Sentry from "@sentry/react"; 4 | import posthog from "posthog-js"; 5 | 6 | import "fonts/fontawesome/css/font-awesome.css"; 7 | import "fonts/open-sans/css/open-sans.css"; 8 | import "style.less"; 9 | 10 | import { setupThemes } from "theme.js"; 11 | 12 | import showErrorInformation from "components/ErrorInformation.jsx"; 13 | import { TheTooltip } from "components/Tooltip.jsx"; 14 | 15 | import settingsStore from "stores/settings.js"; 16 | 17 | // ScrollIntoViewOptions support for Cocoa/WebKit 18 | // Deep import for Edge: https://github.com/magic-akari/seamless-scroll-polyfill/issues/89 19 | import { elementScrollIntoViewPolyfill } from "seamless-scroll-polyfill/dist/es5/seamless.js"; 20 | elementScrollIntoViewPolyfill(); 21 | 22 | // Bootstrap Sentry error logging if we're not in debug (dev) mode 23 | if (window.KANMAIL_DEBUG && !window.KANMAIL_DEBUG_SENTRY) { 24 | console.debug("Not enabling Sentry error logging in debug mode..."); 25 | } else if (window.KANMAIL_DISABLE_ERROR_LOGGING) { 26 | console.debug("Not enabling Sentry error logging per user settings"); 27 | } else { 28 | Sentry.init({ 29 | dsn: window.KANMAIL_SENTRY_DSN, 30 | release: `kanmail-app@${window.KANMAIL_VERSION}`, 31 | beforeSend(event, hint) { 32 | const error = hint.originalException; 33 | // Ignore "expected" network errors (logged serverside) and critical request nonce errors 34 | if ( 35 | error && 36 | (error.isNetworkResponseFailure || error.isCriticalRequestNonceFailure) 37 | ) { 38 | return null; 39 | } 40 | // Ignore errors that were captured (and thus reported) by the server 41 | if (error && error.data && error.data.errorName) { 42 | return null; 43 | } 44 | return event; 45 | }, 46 | }); 47 | Sentry.setUser({ id: window.KANMAIL_DEVICE_ID }); 48 | } 49 | 50 | if (window.KANMAIL_DEBUG && !window.KANMAIL_DEBUG_POSTHOG) { 51 | console.debug("Not enabling Posthog event logging in debug mode..."); 52 | posthog.capture = () => {}; 53 | } else if (window.KANMAIL_DISABLE_ANALYTICS) { 54 | console.debug("Not enabling Posthog event logging per user settings"); 55 | posthog.capture = () => {}; 56 | } else { 57 | posthog.init(window.KANMAIL_POSTHOG_API_KEY, { 58 | api_host: "https://app.posthog.com", 59 | autocapture: false, 60 | capture_pageview: false, 61 | disable_cookie: true, 62 | disable_session_recording: true, 63 | loaded: () => posthog.identify(window.KANMAIL_DEVICE_ID), 64 | }); 65 | } 66 | 67 | const bootApp = (Component, appName, getPropsFromElement = () => {}) => { 68 | const selector = `div[data-${appName}-app]`; 69 | const rootElement = document.querySelector(selector); 70 | if (!rootElement) { 71 | throw new Error(`No root element found matching: ${selector}`); 72 | } 73 | 74 | document.body.removeChild(document.getElementById("no-app")); 75 | 76 | const classNames = [window.KANMAIL_PLATFORM]; 77 | if (window.KANMAIL_FRAMELESS) { 78 | classNames.push("frameless"); 79 | } 80 | 81 | // Load the settings *then* bootstrap the app into the DOM 82 | settingsStore.getSettings().then(({ styleSettings }) => { 83 | setupThemes(styleSettings); 84 | 85 | const rootProps = getPropsFromElement(rootElement); 86 | console.debug("Settings loaded, bootstrapping app to DOM..."); 87 | 88 | ReactDOM.render( 89 | 90 | 91 |
92 | 93 |
94 |
, 95 | rootElement 96 | ); 97 | }); 98 | 99 | posthog.capture(`launch-app-${appName}`, { selector: selector }); 100 | }; 101 | export default bootApp; 102 | -------------------------------------------------------------------------------- /kanmail/client/components/Avatar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import randomColor from "randomcolor"; 4 | 5 | import settingsStore from "stores/settings.js"; 6 | 7 | const emailToColorCache = {}; 8 | 9 | function getColorForAddress(address) { 10 | const email = address[1]; 11 | if (!emailToColorCache[email]) { 12 | emailToColorCache[email] = randomColor(); 13 | } 14 | return emailToColorCache[email]; 15 | } 16 | 17 | function getInitialsFromAddress(address) { 18 | const text = address[0] || address[1]; 19 | const textBits = text.split(" "); 20 | if (textBits.length > 1) { 21 | return `${textBits[0][0]}${textBits[1][0]}`; 22 | } 23 | 24 | const capitalOnlyText = text.replace(/[^A-Z]/g, ""); 25 | if (capitalOnlyText.length) { 26 | return capitalOnlyText; 27 | } 28 | 29 | return text[0]; 30 | } 31 | 32 | export default class Avatar extends React.Component { 33 | static propTypes = { 34 | address: PropTypes.array.isRequired, 35 | }; 36 | 37 | constructor() { 38 | super(); 39 | 40 | this.state = { 41 | hasIcon: settingsStore.props.systemSettings.load_contact_icons, 42 | }; 43 | } 44 | 45 | checkIcon = (ev) => { 46 | if (ev.target.naturalHeight === 1) { 47 | this.setState({ hasIcon: false }); 48 | } 49 | }; 50 | 51 | render() { 52 | const { address } = this.props; 53 | 54 | let image = null; 55 | if (settingsStore.props.systemSettings.load_contact_icons) { 56 | image = ( 57 | 58 | ); 59 | } 60 | 61 | return ( 62 |
68 | {image} 69 | {this.state.hasIcon || {getInitialsFromAddress(address)}} 70 |
71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /kanmail/client/components/ErrorInformation.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | class ErrorInformation extends React.Component { 5 | static propTypes = { 6 | // error: PropTypes.error.isRequired, 7 | componentStack: PropTypes.string.isRequired, 8 | }; 9 | 10 | render() { 11 | return ( 12 |
13 |

14 | Something broke! 15 |

16 |

17 | window.location.reload()}>Click here to reload! 18 |

19 |

20 | So this is embarrassing - something broke! If this error persists, 21 | please go to:{" "} 22 | 23 | kanmail.io/support 24 | 25 | . 26 |

27 |
28 |           {this.props.componentStack}
29 |         
30 |
31 | ); 32 | } 33 | } 34 | 35 | const showErrorInformation = ({ error, componentStack }) => ( 36 | 37 | ); 38 | export default showErrorInformation; 39 | -------------------------------------------------------------------------------- /kanmail/client/components/HeaderErrors.jsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import React, { Component } from "react"; 3 | import PropTypes from "prop-types"; 4 | 5 | import { SUPPORT_DOC_LINK } from "constants.js"; 6 | import { openLink } from "window.js"; 7 | 8 | import requestStore from "stores/request.js"; 9 | import { subscribe } from "stores/base.jsx"; 10 | 11 | class RequestError extends Component { 12 | static propTypes = { 13 | error: PropTypes.object.isRequired, 14 | }; 15 | 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | copied: false, 21 | }; 22 | } 23 | 24 | copyDebugInformation = () => { 25 | this.textarea.select(); 26 | document.execCommand("copy"); 27 | this.setState({ copied: true }); 28 | }; 29 | 30 | render() { 31 | const { error } = this.props; 32 | const traceback = error.json ? error.json.traceback || null : null; 33 | const debugInfo = `URL: ${error.url} 34 | ErrorName: ${error.errorName} 35 | ErrorMessage: ${error.errorMessage} 36 | Kanmail version: ${window.KANMAIL_VERSION} 37 | Status: ${error.status} 38 | ${traceback}`; 39 | 40 | const copyText = this.state.copied ? "copied!" : "copy error info"; 41 | 42 | return ( 43 |

44 | 45 | {error.status}: {error.url} 46 | 47 | {error.errorName}: {error.errorMessage} 48 | 49 | 127 | 128 | {this.renderSaveButton()} 129 | 130 | 131 | ); 132 | } 133 | 134 | render() { 135 | return ( 136 |

137 |
138 |

Manage License

139 |
140 | 141 |
142 |

Kanmail License

143 | {this.renderContent()} 144 |
145 |
146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /kanmail/client/components/license/main.js: -------------------------------------------------------------------------------- 1 | import bootApp from "boot.jsx"; 2 | import LicenseApp from "components/license/LicenseApp.jsx"; 3 | 4 | bootApp(LicenseApp, "license"); 5 | -------------------------------------------------------------------------------- /kanmail/client/components/meta/MetaApp.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import img from "icon.png"; 4 | import keyboard from "keyboard.js"; 5 | import { openLink, openWindow, makeDragElement } from "window.js"; 6 | 7 | export default class MetaApp extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | keyboard.disable(); 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 |
17 |

18 | Kanmail 19 |

20 |

21 | This is{" "} 22 | openLink(window.KANMAIL_WEBSITE_URL)}> 23 | Kanmail v{window.KANMAIL_VERSION} 24 | 25 | . 26 |

27 |

28 | openWindow("/meta-file/CHANGELOG.md")}> 29 | Changelog 30 | 31 |  •  32 | openWindow("/meta-file/LICENSE.md")}>License 33 |

34 |
35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /kanmail/client/components/meta/main.js: -------------------------------------------------------------------------------- 1 | import bootApp from "boot.jsx"; 2 | import MetaApp from "components/meta/MetaApp.jsx"; 3 | 4 | bootApp(MetaApp, "meta"); 5 | -------------------------------------------------------------------------------- /kanmail/client/components/metaFile/MetaFileApp.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import img from "icon.png"; 5 | import { makeDragElement } from "window.js"; 6 | 7 | export default class MetaApp extends React.Component { 8 | static propTypes = { 9 | fileTitle: PropTypes.string.isRequired, 10 | fileData: PropTypes.string.isRequired, 11 | }; 12 | 13 | render() { 14 | return ( 15 |
16 |
17 |

18 | {this.props.fileTitle} 19 |

20 |
21 | 22 |
23 |
28 |
29 |
30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /kanmail/client/components/metaFile/main.js: -------------------------------------------------------------------------------- 1 | import bootApp from "boot.jsx"; 2 | import MetaFileApp from "components/metaFile/MetaFileApp.jsx"; 3 | 4 | bootApp(MetaFileApp, "meta-file", (rootElement) => ({ 5 | fileTitle: rootElement.getAttribute("data-file-title"), 6 | fileData: rootElement.getAttribute("data-file"), 7 | })); 8 | -------------------------------------------------------------------------------- /kanmail/client/components/send/main.js: -------------------------------------------------------------------------------- 1 | import bootApp from "boot.jsx"; 2 | import SendApp from "components/send/SendApp.jsx"; 3 | 4 | bootApp(SendApp, "send", (rootElement) => ({ 5 | contacts: JSON.parse(rootElement.getAttribute("data-contacts")), 6 | message: JSON.parse(rootElement.getAttribute("data-reply")), 7 | })); 8 | -------------------------------------------------------------------------------- /kanmail/client/components/settings/SignatureForm.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Editor from "components/Editor.jsx"; 5 | 6 | const getInitialState = (props) => { 7 | const state = { 8 | editingTab: props.isAddingNewSignature ? "imap" : "address", 9 | deleteConfirm: false, 10 | 11 | error: props.error, 12 | errorType: props.errorType, 13 | 14 | isSaving: false, 15 | 16 | accountId: props.accountId, 17 | 18 | name: "", 19 | }; 20 | 21 | if (props.itemData) { 22 | state.name = props.itemData.name; 23 | } 24 | 25 | return state; 26 | }; 27 | 28 | export default class SignatureForm extends React.Component { 29 | static propTypes = { 30 | itemData: PropTypes.object, 31 | itemIndex: PropTypes.number, 32 | updateItem: PropTypes.func, 33 | addItem: PropTypes.func, 34 | closeForm: PropTypes.func, 35 | }; 36 | 37 | constructor(props) { 38 | super(props); 39 | this.state = getInitialState(props); 40 | } 41 | 42 | handleSubmit = (ev) => { 43 | ev.preventDefault(); 44 | 45 | if (!this.state.name) { 46 | this.setState({ error: "Please input a name for this signature." }); 47 | return; 48 | } 49 | 50 | const newItemData = { 51 | name: this.state.name, 52 | text: this.editor.getText(), 53 | html: this.editor.getHtml(), 54 | }; 55 | 56 | if (this.props.itemData) { 57 | this.props.updateItem(this.props.itemIndex, newItemData); 58 | } else { 59 | this.props.addItem(newItemData); 60 | } 61 | 62 | this.props.closeForm(); 63 | }; 64 | 65 | render() { 66 | const formClasses = ["account"]; 67 | if (this.state.editing) formClasses.push("active"); 68 | 69 | const saveButtonClasses = ["submit"]; 70 | if (this.state.isSaving) { 71 | saveButtonClasses.push("disabled"); 72 | } 73 | 74 | return ( 75 |
76 |
77 |
78 | 85 |   86 | 89 |
90 | this.setState({ name: ev.target.value })} 96 | /> 97 |   98 |
{this.state.error}
99 |
100 | 101 |
102 | (this.editor = editor)} 105 | /> 106 |
107 |
108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /kanmail/client/components/settings/SignatureList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import SignatureForm from "components/settings/SignatureForm.jsx"; 5 | import OverlayItemList from "components/settings/OverlayItemList.jsx"; 6 | 7 | class SignatureListItem extends React.Component { 8 | static propTypes = { 9 | itemData: PropTypes.object.isRequired, 10 | deleteItem: PropTypes.func.isRequired, 11 | editItem: PropTypes.func.isRequired, 12 | itemIndex: PropTypes.number.isRequired, 13 | moveUp: PropTypes.func.isRequired, 14 | moveDown: PropTypes.func.isRequired, 15 | }; 16 | 17 | constructor(props) { 18 | super(props); 19 | this.state = { 20 | deleteConfirm: false, 21 | }; 22 | } 23 | 24 | handleClickCancel = (ev) => { 25 | ev.preventDefault(); 26 | 27 | this.setState({ 28 | deleteConfirm: false, 29 | }); 30 | }; 31 | 32 | handleClickEdit = (ev) => { 33 | ev.preventDefault(); 34 | this.props.editItem(this.props.itemIndex); 35 | }; 36 | 37 | handleClickDelete = (ev) => { 38 | ev.preventDefault(); 39 | 40 | if (!this.state.deleteConfirm) { 41 | this.setState({ 42 | deleteConfirm: true, 43 | }); 44 | return; 45 | } 46 | 47 | this.props.deleteItem(this.props.itemIndex); 48 | return; 49 | }; 50 | 51 | renderViewButtons() { 52 | if (this.state.deleteConfirm) { 53 | return ( 54 |
55 | 56 |   57 | 60 |
61 | ); 62 | } 63 | 64 | return ( 65 |
66 | 69 |   70 | 73 |   74 | 75 |   76 | 79 |
80 | ); 81 | } 82 | 83 | render() { 84 | return ( 85 |
86 |
87 | {this.renderViewButtons()} 88 | {this.props.itemData.name} 89 |
94 |
95 |
96 | ); 97 | } 98 | } 99 | 100 | export default class SignatureList extends React.Component { 101 | static propTypes = { 102 | signatures: PropTypes.array.isRequired, 103 | addSignature: PropTypes.func.isRequired, 104 | deleteSignature: PropTypes.func.isRequired, 105 | updateSignature: PropTypes.func.isRequired, 106 | moveSignature: PropTypes.func.isRequired, 107 | newSignatureFormProps: PropTypes.object, 108 | }; 109 | 110 | render() { 111 | return ( 112 | 123 | ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /kanmail/client/components/settings/main.js: -------------------------------------------------------------------------------- 1 | import bootApp from "boot.jsx"; 2 | import SettingsApp from "components/settings/SettingsApp.jsx"; 3 | import settingsStore from "stores/settings.js"; 4 | 5 | bootApp(SettingsApp, "settings", () => ({ 6 | settings: settingsStore.props.originalSettings, 7 | accountNameToConnected: settingsStore.props.accountNameToConnected, 8 | })); 9 | -------------------------------------------------------------------------------- /kanmail/client/constants.js: -------------------------------------------------------------------------------- 1 | export const INBOX = "inbox"; 2 | 3 | export const ALWAYS_SYNC_FOLDERS = [INBOX, "archive", "sent", "trash"]; 4 | 5 | export const ALIAS_FOLDERS = [ 6 | // this defines the display order 7 | INBOX, 8 | "sent", 9 | "drafts", 10 | "archive", 11 | "spam", 12 | "trash", 13 | ]; 14 | 15 | export const ALIAS_TO_ICON = { 16 | [INBOX]: "inbox", 17 | sent: "paper-plane", 18 | drafts: "pencil", 19 | archive: "archive", 20 | trash: "trash", 21 | spam: "exclamation-triangle", 22 | }; 23 | 24 | export const PROVIDERS_DOC_LINK = "https://kanmail.io/docs/email-providers"; 25 | export const SUPPORT_DOC_LINK = "https://kanmail.io/support"; 26 | 27 | export const THEME_NAMES = ["default", "default-dark", "default-light"]; 28 | 29 | export const APPLE_APP_PASSWORD_LINK = 30 | "https://support.apple.com/en-gb/HT204397"; 31 | -------------------------------------------------------------------------------- /kanmail/client/fonts/fontawesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/fontawesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/fontawesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/css/open-sans.css: -------------------------------------------------------------------------------- 1 | /* open-sans-regular - latin */ 2 | @font-face { 3 | font-family: "Open Sans"; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local(""), 7 | url("../fonts/open-sans-v18-latin-regular.woff2") format("woff2"), 8 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 9 | url("../fonts/open-sans-v18-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 10 | } 11 | 12 | /* open-sans-600 - latin */ 13 | @font-face { 14 | font-family: "Open Sans"; 15 | font-style: normal; 16 | font-weight: 600; 17 | src: local(""), url("../fonts/open-sans-v18-latin-600.woff2") format("woff2"), 18 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 19 | url("../fonts/open-sans-v18-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 20 | } 21 | 22 | /* open-sans-700 - latin */ 23 | @font-face { 24 | font-family: "Open Sans"; 25 | font-style: normal; 26 | font-weight: 700; 27 | src: local(""), url("../fonts/open-sans-v18-latin-700.woff2") format("woff2"), 28 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 29 | url("../fonts/open-sans-v18-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 30 | } 31 | -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-600.woff -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-600.woff2 -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-700.woff -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-700.woff2 -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-regular.woff -------------------------------------------------------------------------------- /kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/fonts/open-sans/fonts/open-sans-v18-latin-regular.woff2 -------------------------------------------------------------------------------- /kanmail/client/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/icon.png -------------------------------------------------------------------------------- /kanmail/client/images/providers/gmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/images/providers/gmail.png -------------------------------------------------------------------------------- /kanmail/client/images/providers/icloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/images/providers/icloud.png -------------------------------------------------------------------------------- /kanmail/client/images/providers/outlook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/images/providers/outlook.png -------------------------------------------------------------------------------- /kanmail/client/images/providers/yahoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/client/images/providers/yahoo.png -------------------------------------------------------------------------------- /kanmail/client/stores/base.jsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import React from "react"; 3 | 4 | import { lowercaseFirstLetter } from "util/string.js"; 5 | 6 | const getStorePropNames = (store) => { 7 | let propNames = _.keys(store.props); 8 | 9 | if (_.isArray(store)) { 10 | [store, propNames] = store; 11 | } 12 | 13 | return [store, propNames]; 14 | }; 15 | 16 | export function subscribe(...stores) { 17 | return (Component) => 18 | class Connect extends React.Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | const state = {}; 23 | 24 | // Attach the store with it's name 25 | _.each(stores, (storeConfig) => { 26 | const [store, propNames] = getStorePropNames(storeConfig); 27 | 28 | state[lowercaseFirstLetter(store.constructor.storeKey)] = store; 29 | 30 | // Extend by the store's provided properties 31 | _.extend(state, _.pick(store.props, propNames)); 32 | }); 33 | 34 | this.state = state; 35 | } 36 | 37 | componentDidMount() { 38 | _.each(stores, (storeConfig) => { 39 | const [store, propNames] = getStorePropNames(storeConfig); 40 | store.subscribe(this, propNames); 41 | }); 42 | } 43 | 44 | componentWillUnmount() { 45 | _.each(stores, (storeConfig) => { 46 | const [store, propNames] = getStorePropNames(storeConfig); 47 | store.unsubscribe(this, propNames); 48 | }); 49 | } 50 | 51 | render() { 52 | return ( 53 | (this.wrappedComponent = ref)} 58 | /> 59 | ); 60 | } 61 | }; 62 | } 63 | 64 | export class BaseStore { 65 | constructor() { 66 | this.apps = []; 67 | this.props = {}; 68 | } 69 | 70 | subscribe(app, propNames) { 71 | this.apps.push([app, propNames]); 72 | } 73 | 74 | unsubscribe(app) { 75 | this.apps = _.filter(this.apps, ([component]) => component !== app); 76 | } 77 | 78 | triggerUpdate(updatedPropNames) { 79 | if (!updatedPropNames) { 80 | updatedPropNames = _.keys(this.props); 81 | } 82 | 83 | // For each wrapped app, set it's state with the stores props 84 | _.each(this.apps, (app) => { 85 | const [component, propNames] = app; 86 | const intersection = _.intersection(updatedPropNames, propNames); 87 | if (intersection.length > 0) { 88 | component.setState(_.pick(this.props, propNames)); 89 | } 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /kanmail/client/stores/control.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | 3 | function makeDefaults() { 4 | return { 5 | open: false, 6 | inputHandler: null, 7 | extraProps: {}, 8 | }; 9 | } 10 | 11 | class ControlStore extends BaseStore { 12 | /* 13 | Global store of the users app settings. 14 | */ 15 | 16 | static storeKey = "controlStore"; 17 | 18 | constructor() { 19 | super(); 20 | 21 | this.props = makeDefaults(); 22 | } 23 | 24 | open = (inputHandler, extraProps = {}) => { 25 | this.props = { 26 | open: true, 27 | inputHandler: inputHandler, 28 | extraProps: extraProps, 29 | }; 30 | this.triggerUpdate(); 31 | }; 32 | 33 | close = (triggerInputHandler = true) => { 34 | if (this.props.open) { 35 | if (triggerInputHandler) { 36 | this.props.inputHandler(null); 37 | } 38 | this.props = makeDefaults(); 39 | this.triggerUpdate(); 40 | } 41 | }; 42 | } 43 | 44 | const controlStore = new ControlStore(); 45 | export default controlStore; 46 | -------------------------------------------------------------------------------- /kanmail/client/stores/emails/controller.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | import settingsStore from "stores/settings.js"; 4 | import mainEmailStore from "stores/emails/main.js"; 5 | import searchEmailStore from "stores/emails/search.js"; 6 | 7 | class EmailStoreController { 8 | /* 9 | A hacky wrapper around the "main" (normal, date based) and search email 10 | stores. We keep them separate such that we essentially switch between 11 | the two modes. This is a bit of a mess but it currently handles 12 | switching between the two modes and kicking off the search requests. 13 | 14 | TODO: make this more React-y by having all the columns wrapped by some 15 | state and a store so we re-render the column area of the app when 16 | switching between modes. Currently the columns remain and we simply 17 | alter their data. 18 | */ 19 | 20 | constructor(...stores) { 21 | this.stores = stores; 22 | this.activeStore = stores[0]; 23 | } 24 | 25 | setActiveStore(activeStore) { 26 | this.activeStore = activeStore; 27 | 28 | _.each(this.stores, (store) => { 29 | store.active = store === activeStore; 30 | }); 31 | } 32 | 33 | search(searchValue) { 34 | searchEmailStore.setSearchValue(searchValue); 35 | 36 | // Kick off the search requests for columns first 37 | const requests = _.map(settingsStore.props.columns, (folderName) => 38 | searchEmailStore.getFolderEmails(folderName, { query: { reset: true } }) 39 | ); 40 | 41 | // Then once complete do inbox/archive 42 | Promise.all(requests).then( 43 | _.each(["inbox", "archive"], (folderName) => 44 | searchEmailStore.getFolderEmails(folderName, { query: { reset: true } }) 45 | ) 46 | ); 47 | } 48 | 49 | startSearching() { 50 | this.setActiveStore(searchEmailStore); 51 | searchEmailStore.processEmailChanges({ forceUpdate: true }); 52 | } 53 | 54 | stopSearching() { 55 | this.setActiveStore(mainEmailStore); 56 | 57 | // As above; set the mode and (re)process everything 58 | this.searchMode = false; 59 | mainEmailStore.processEmailChanges({ forceUpdate: true }); 60 | } 61 | 62 | getCurrentEmailStore() { 63 | return this.activeStore; 64 | } 65 | } 66 | 67 | const emailStoreController = new EmailStoreController( 68 | mainEmailStore, 69 | searchEmailStore 70 | ); 71 | export default emailStoreController; 72 | 73 | export function getEmailStore() { 74 | return emailStoreController.getCurrentEmailStore(); 75 | } 76 | -------------------------------------------------------------------------------- /kanmail/client/stores/emails/search.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | import requestStore from "stores/request.js"; 4 | import { getColumnMetaStore } from "stores/columns.js"; 5 | import BaseEmails from "stores/emails/base.js"; 6 | import { encodeFolderName } from "util/string.js"; 7 | import { getSidebarFolderLinkStore } from "stores/sidebarFolderLinks.js"; 8 | 9 | class SearchEmails extends BaseEmails { 10 | setSearchValue(value) { 11 | // Ignore if we've updated for another reason! 12 | if (this.searchValue === value) { 13 | return; 14 | } 15 | 16 | // Reset the email list if the search value has changed 17 | this.reset(); 18 | this.processEmailChanges({ forceUpdate: true }); 19 | 20 | // Set the value 21 | this.searchValue = value; 22 | } 23 | 24 | onProcessColumnHook = (columnName, threads) => { 25 | getSidebarFolderLinkStore(columnName).setUnreadCount(threads.length); 26 | }; 27 | 28 | syncFolderEmails = (folderName, options = {}) => { 29 | // Nowt 30 | console.warn("Sync on search email store is a no-op!"); 31 | 32 | return this.getFolderEmails(folderName, options); 33 | }; 34 | 35 | getFolderEmails = (folderName, options = {}) => { 36 | const columnMetaStore = getColumnMetaStore(folderName); 37 | columnMetaStore.setLoading(true); 38 | 39 | const requests = []; 40 | 41 | // For each account, search for matching emails 42 | _.each(this.getAccountKeys(), (accountKey) => { 43 | requests.push(this.searchEmails(accountKey, folderName, options)); 44 | }); 45 | 46 | const finishLoading = () => columnMetaStore.setLoading(false); 47 | return Promise.all(requests).then(finishLoading).catch(finishLoading); 48 | }; 49 | 50 | searchEmails(accountKey, folderName, options = {}) { 51 | const url = `/api/emails/${accountKey}/${encodeFolderName(folderName)}`; 52 | const query = options.query || {}; 53 | query.query = this.searchValue; 54 | 55 | requestStore 56 | .get(`Search & fetch emails from ${accountKey}/${folderName}`, url, query) 57 | .then((data) => { 58 | this.setMetaForAccountFolder(accountKey, folderName, data.meta); 59 | 60 | let changed = false; 61 | 62 | if (data.emails.length > 0) { 63 | this.addEmailsToAccountFolder(accountKey, folderName, data.emails); 64 | 65 | changed = true; 66 | } 67 | 68 | if (changed || options.forceProcess) { 69 | this.processEmailChanges(); 70 | } 71 | }); 72 | } 73 | } 74 | 75 | const searchEmailStore = new SearchEmails(); 76 | export default searchEmailStore; 77 | -------------------------------------------------------------------------------- /kanmail/client/stores/filters.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | 3 | class FilterStore extends BaseStore { 4 | /* 5 | Global store to fetch/hold any filters. 6 | */ 7 | 8 | static storeKey = "filterStore"; 9 | 10 | constructor() { 11 | super(); 12 | 13 | this.props = { 14 | accountName: null, 15 | mainColumn: "inbox", 16 | }; 17 | } 18 | 19 | setAccountFilter(accountName) { 20 | this.props.accountName = accountName; 21 | this.triggerUpdate(["accountName"]); 22 | } 23 | 24 | setMainColumn(columnName) { 25 | if (this.props.mainColumn !== columnName) { 26 | this.props.mainColumn = columnName; 27 | this.triggerUpdate(["mainColumn"]); 28 | } 29 | } 30 | } 31 | 32 | const filterStore = new FilterStore(); 33 | export default filterStore; 34 | -------------------------------------------------------------------------------- /kanmail/client/stores/folders.js: -------------------------------------------------------------------------------- 1 | import requestStore from "stores/request.js"; 2 | import { BaseStore } from "stores/base.jsx"; 3 | 4 | class FolderStore extends BaseStore { 5 | /* 6 | Global store to fetch/hold the list of folder names across accounts. 7 | */ 8 | 9 | static storeKey = "folderStore"; 10 | 11 | constructor() { 12 | super(); 13 | 14 | this.props = { 15 | folders: [], 16 | }; 17 | } 18 | 19 | getFolderNames() { 20 | requestStore.get("Load folders", "/api/folders").then((data) => { 21 | this.props.folders = data.folders; 22 | this.triggerUpdate(); 23 | }); 24 | } 25 | } 26 | 27 | const folderStore = new FolderStore(); 28 | export default folderStore; 29 | -------------------------------------------------------------------------------- /kanmail/client/stores/search.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | import emailStoreController from "stores/emails/controller.js"; 3 | 4 | function makeDefaults() { 5 | return { 6 | isSearching: false, 7 | }; 8 | } 9 | 10 | class SearchStore extends BaseStore { 11 | static storeKey = "searchStore"; 12 | 13 | constructor() { 14 | super(); 15 | 16 | this.props = makeDefaults(); 17 | } 18 | 19 | open = () => { 20 | this.props.isSearching = true; 21 | this.triggerUpdate(); 22 | emailStoreController.startSearching(); 23 | }; 24 | 25 | close = () => { 26 | if (this.props.isSearching) { 27 | this.props = makeDefaults(); 28 | this.triggerUpdate(); 29 | emailStoreController.stopSearching(); 30 | } 31 | }; 32 | 33 | toggle = () => { 34 | if (this.props.isSearching) { 35 | this.close(); 36 | } else { 37 | this.open(); 38 | } 39 | }; 40 | } 41 | 42 | const searchStore = new SearchStore(); 43 | export default searchStore; 44 | -------------------------------------------------------------------------------- /kanmail/client/stores/settings.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | import { BaseStore } from "stores/base.jsx"; 4 | 5 | import { get, post } from "util/requests.js"; 6 | import { arrayMove } from "util/array.js"; 7 | 8 | class SettingsStore extends BaseStore { 9 | /* 10 | Global store of the users app settings. 11 | */ 12 | 13 | static storeKey = "settingsStore"; 14 | 15 | constructor() { 16 | super(); 17 | 18 | this.props = { 19 | columns: [], 20 | accounts: [], 21 | signatures: [], 22 | accountNameToConnected: {}, 23 | systemSettings: {}, 24 | styleSettings: {}, 25 | }; 26 | } 27 | 28 | updateColumnsTriggerState() { 29 | // Save the new list of columns via the API before updating 30 | return post("/api/settings", { 31 | columns: this.props.columns, 32 | }).then(() => { 33 | this.triggerUpdate(["columns"]); 34 | }); 35 | } 36 | 37 | addColumn(name) { 38 | this.props.columns.push(name); 39 | this.updateColumnsTriggerState(); 40 | } 41 | 42 | removeColumn(name) { 43 | this.props.columns = _.without(this.props.columns, name); 44 | this.updateColumnsTriggerState(); 45 | } 46 | 47 | moveColumn(name, position) { 48 | const index = this.props.columns.indexOf(name); 49 | arrayMove(this.props.columns, index, index + position); 50 | this.updateColumnsTriggerState(); 51 | } 52 | 53 | moveColumnLeft(name) { 54 | this.moveColumn(name, -1); 55 | } 56 | 57 | moveColumnRight(name) { 58 | this.moveColumn(name, 1); 59 | } 60 | 61 | updateSidebarFoldersTriggerState() { 62 | // Save the new style settings via the API before updating 63 | return post("/api/settings", { 64 | style: this.props.styleSettings, 65 | }).then(() => { 66 | this.triggerUpdate(["styleSettings"]); 67 | }); 68 | } 69 | 70 | addSidebarFolder(name) { 71 | if (this.props.styleSettings.sidebar_folders.indexOf(name) > -1) { 72 | return; 73 | } 74 | 75 | this.props.styleSettings.sidebar_folders.push(name); 76 | this.updateSidebarFoldersTriggerState(); 77 | } 78 | 79 | removeSidebarFolder(name) { 80 | if (this.props.styleSettings.sidebar_folders.indexOf(name) < 0) { 81 | return; 82 | } 83 | 84 | this.props.styleSettings.sidebar_folders = _.without( 85 | this.props.styleSettings.sidebar_folders, 86 | name 87 | ); 88 | this.updateSidebarFoldersTriggerState(); 89 | } 90 | 91 | getSettings() { 92 | return get("/api/settings").then((data) => { 93 | this.props.columns = data.settings.columns || []; 94 | this.props.accounts = data.settings.accounts || []; 95 | this.props.signatures = data.settings.signatures || []; 96 | this.props.accountNameToConnected = data.account_name_to_connected || {}; 97 | 98 | this.props.systemSettings = data.settings.system || {}; 99 | this.props.styleSettings = data.settings.style || {}; 100 | 101 | // Store the original for the settings "app" 102 | this.props.originalSettings = data.settings; 103 | 104 | const accountEmails = new Set(); 105 | _.each(this.props.accounts, (account) => { 106 | _.each(account.contacts, (contact) => accountEmails.add(contact[1])); 107 | }); 108 | this.props.accountEmails = accountEmails; 109 | 110 | this.triggerUpdate(); 111 | return this.props; 112 | }); 113 | } 114 | 115 | getAccountSettings(accountName) { 116 | return _.find( 117 | this.props.accounts, 118 | (account) => account.name === accountName 119 | ); 120 | } 121 | } 122 | 123 | const settingsStore = new SettingsStore(); 124 | 125 | window.settingsStore = settingsStore; 126 | export default settingsStore; 127 | -------------------------------------------------------------------------------- /kanmail/client/stores/sidebarFolderLinks.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | 3 | class SidebarFolderLinkStore extends BaseStore { 4 | /* 5 | Per-sidebar folder link store to show unread/highlight counts. 6 | */ 7 | 8 | static storeKey = "sidebarFolderLinkStore"; 9 | 10 | constructor() { 11 | super(); 12 | 13 | this.props = { 14 | unreadCount: 0, 15 | }; 16 | } 17 | 18 | setUnreadCount(count) { 19 | if (count !== this.props.unreadCount) { 20 | this.props.unreadCount = count; 21 | this.triggerUpdate(["unreadCount"]); 22 | } 23 | } 24 | } 25 | 26 | // Export the sidebar folder link store factory/cache 27 | // 28 | 29 | const sidebarFolderLinkStores = {}; 30 | 31 | export function getSidebarFolderLinkStore(name) { 32 | if (!sidebarFolderLinkStores[name]) { 33 | console.debug(`Creating new sidebar folder link store: ${name}.`); 34 | 35 | sidebarFolderLinkStores[name] = new SidebarFolderLinkStore(name); 36 | } 37 | 38 | return sidebarFolderLinkStores[name]; 39 | } 40 | -------------------------------------------------------------------------------- /kanmail/client/stores/tooltip.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | 3 | function makeDefaults() { 4 | return { 5 | visible: false, 6 | text: null, 7 | targetElement: null, 8 | extraTop: 0, 9 | }; 10 | } 11 | 12 | class TooltipStore extends BaseStore { 13 | static storeKey = "tooltipStore"; 14 | 15 | constructor() { 16 | super(); 17 | this.props = makeDefaults(); 18 | } 19 | 20 | show(text, targetElement, extraTop = 0) { 21 | this.props.visible = true; 22 | this.props.text = text; 23 | this.props.targetElement = targetElement; 24 | this.props.extraTop = extraTop; 25 | this.triggerUpdate(); 26 | } 27 | 28 | hide() { 29 | if (!this.props.visible) { 30 | return; 31 | } 32 | 33 | this.props = makeDefaults(); 34 | this.triggerUpdate(); 35 | } 36 | } 37 | 38 | const tooltipStore = new TooltipStore(); 39 | export default tooltipStore; 40 | -------------------------------------------------------------------------------- /kanmail/client/stores/update.js: -------------------------------------------------------------------------------- 1 | import { BaseStore } from "stores/base.jsx"; 2 | 3 | import { get, post } from "util/requests.js"; 4 | 5 | class UpdateStore extends BaseStore { 6 | /* 7 | Global store of the users app settings. 8 | */ 9 | 10 | static storeKey = "updateStore"; 11 | 12 | constructor() { 13 | super(); 14 | 15 | this.props = { 16 | updateVersion: null, 17 | updateReady: false, 18 | updateDownloading: false, 19 | updateError: null, 20 | }; 21 | } 22 | 23 | checkUpdate() { 24 | return get("/api/update").then((data) => { 25 | if (data.update) { 26 | this.props.updateVersion = data.update; 27 | } 28 | this.triggerUpdate(); 29 | }); 30 | } 31 | 32 | doUpdate() { 33 | this.props.updateDownloading = true; 34 | this.triggerUpdate(); 35 | 36 | // Just do the post - it'll nuke the window! 37 | return post("/api/update") 38 | .then(() => { 39 | this.props.updateReady = true; 40 | this.triggerUpdate(); 41 | }) 42 | .catch((e) => { 43 | this.props.updateDownloading = false; 44 | this.props.updateError = e.data ? e.data.errorMessage : "unknown"; 45 | this.triggerUpdate(); 46 | }); 47 | } 48 | } 49 | 50 | const updateStore = new UpdateStore(); 51 | export default updateStore; 52 | -------------------------------------------------------------------------------- /kanmail/client/style.less: -------------------------------------------------------------------------------- 1 | @import "styles/base.less"; 2 | @import "styles/columns.less"; 3 | @import "styles/send.less"; 4 | @import "styles/settings.less"; 5 | @import "styles/contacts.less"; 6 | @import "styles/header.less"; 7 | @import "styles/sidebar.less"; 8 | @import "styles/thread.less"; 9 | @import "styles/platforms.less"; 10 | @import "styles/select.less"; 11 | @import "styles/meta.less"; 12 | @import "styles/control.less"; 13 | 14 | & { 15 | @import "styles/themes/default.less"; 16 | } 17 | & { 18 | @import "styles/themes/default-dark.less"; 19 | } 20 | & { 21 | @import "styles/themes/default-light.less"; 22 | } 23 | -------------------------------------------------------------------------------- /kanmail/client/styles/base.less: -------------------------------------------------------------------------------- 1 | /* 2 | Colours */ 3 | 4 | @white: #ffffff; 5 | @black: #000000; 6 | 7 | @darkestGrey: #232526; 8 | @darkGrey: #343637; 9 | @midGrey: #a6a8a9; 10 | @lightGrey: #e6e8e9; 11 | @lightestGrey: #f6f8f9; 12 | 13 | @pink: #f2318d; 14 | @blue: #008dd5; 15 | @red: #fb6107; 16 | @green: #47d966; 17 | @yellow: #ecc30b; 18 | 19 | @formBorderGrey: @lightGrey; 20 | @formBorderGreyHover: #d6d8d0; 21 | @formBorderGreyActive: #a6a8a9; 22 | 23 | @textGrey: #333536; 24 | @lightTextGrey: #666869; 25 | @lightestTextGrey: #999b9c; 26 | 27 | /* 28 | Variables */ 29 | 30 | @topHeight: 53px; 31 | 32 | @sidebarWidth: 175px; 33 | @threadSidebarWidth: 275px; 34 | @borderRadius: 5px; 35 | 36 | @titleTextSize: 17px; 37 | @bigTextSize: 15px; 38 | @textSize: 13px; 39 | @smallTextSize: 11px; 40 | 41 | /* 42 | Custom scrollbar */ 43 | 44 | .scrollbar { 45 | overflow-y: scroll; 46 | } 47 | 48 | .no-select { 49 | cursor: default; 50 | user-select: none; 51 | -webkit-user-select: none; 52 | -moz-user-select: none; 53 | } 54 | 55 | .no-transition { 56 | transition: none !important; 57 | } 58 | 59 | /* 60 | Basics */ 61 | 62 | * { 63 | box-sizing: border-box; 64 | } 65 | 66 | body { 67 | font-family: "Open Sans"; 68 | font-size: @textSize; 69 | line-height: 24px; 70 | height: 100%; 71 | width: 100%; 72 | position: relative; 73 | margin: 0; 74 | } 75 | 76 | a { 77 | text-decoration: none; 78 | cursor: pointer; 79 | } 80 | 81 | h1, 82 | h2, 83 | h3 { 84 | font-weight: normal; 85 | } 86 | 87 | input { 88 | font-family: "Open Sans"; 89 | } 90 | 91 | /* 92 | Layout */ 93 | 94 | .wide { 95 | width: 100%; 96 | } 97 | 98 | .third { 99 | width: 33%; 100 | } 101 | 102 | .two-third { 103 | width: 66%; 104 | } 105 | 106 | .three-quarter { 107 | width: 74%; 108 | } 109 | 110 | .quarter { 111 | width: 24%; 112 | } 113 | 114 | .half { 115 | width: 49%; 116 | } 117 | 118 | .flex { 119 | display: flex; 120 | flex-wrap: wrap; 121 | justify-content: space-between; 122 | 123 | &.justify-left { 124 | justify-content: left; 125 | } 126 | 127 | &.flex-vertical { 128 | flex-direction: column; 129 | } 130 | 131 | &.flex-nowrap { 132 | flex-wrap: nowrap; 133 | } 134 | } 135 | 136 | /* 137 | Forms */ 138 | 139 | button { 140 | padding: 5px; 141 | font-weight: 700; 142 | font-size: @textSize; 143 | border: none; 144 | border-radius: @borderRadius; 145 | color: white; 146 | cursor: pointer; 147 | 148 | &.disabled, 149 | &.disabled:hover { 150 | cursor: auto; 151 | } 152 | 153 | &.main-button { 154 | padding: 15px; 155 | font-size: 16px; 156 | } 157 | } 158 | 159 | input, 160 | textarea { 161 | width: 100%; 162 | // border-radius: @borderRadius; 163 | padding: 5px; 164 | border: 0px solid; 165 | border-bottom-width: 1px; 166 | font-size: @textSize; 167 | 168 | &:hover, 169 | &:focus { 170 | outline: none; 171 | } 172 | 173 | &.inline, 174 | &[type="checkbox"] { 175 | width: auto; 176 | } 177 | } 178 | 179 | /* 180 | Other bits ??? */ 181 | 182 | span.multi-subject { 183 | margin-right: 4px; 184 | border-radius: 24px; 185 | width: 24px; 186 | height: 24px; 187 | float: left; 188 | text-align: center; 189 | display: inline-block; 190 | font-size: @smallTextSize; 191 | } 192 | 193 | .tooltip-wrapper { 194 | display: inline-block; 195 | } 196 | 197 | .tooltip { 198 | z-index: 1100; 199 | position: absolute; 200 | font-size: @smallTextSize; 201 | border-radius: @borderRadius; 202 | padding: 3px 5px; 203 | background: black; 204 | color: white; 205 | 206 | i.fa-keyboard-o { 207 | font-size: @bigTextSize; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /kanmail/client/styles/contacts.less: -------------------------------------------------------------------------------- 1 | header.contacts { 2 | padding-right: 20px; 3 | 4 | div.search { 5 | flex-grow: 1; 6 | margin: 11px 20px 0 20px; 7 | } 8 | } 9 | 10 | section#contacts { 11 | button.add-contact { 12 | float: right; 13 | margin-right: 20px; 14 | padding: 6.5px 5px; 15 | margin-top: 20px; 16 | } 17 | 18 | form.add-contact { 19 | margin: 65px 20px 0 20px; 20 | 21 | div { 22 | width: 30%; 23 | display: inline-block; 24 | margin-right: 20px; 25 | } 26 | } 27 | 28 | div#contact-list { 29 | overflow-y: auto; 30 | position: absolute; 31 | top: 52px; 32 | bottom: 0; 33 | left: 0; 34 | right: 0; 35 | border-top: 1px solid; 36 | 37 | &.form-open { 38 | top: 72px; 39 | } 40 | } 41 | 42 | div.contact { 43 | padding: 5px 20px; 44 | height: 40px; 45 | overflow: hidden; 46 | display: flex; 47 | 48 | input { 49 | width: 48%; 50 | margin-right: 2%; 51 | } 52 | 53 | div { 54 | display: inline-block; 55 | vertical-align: top; 56 | } 57 | 58 | div.avatar { 59 | position: relative; 60 | border-radius: 4px; 61 | margin-right: 10px; 62 | margin-top: 2px; 63 | overflow: hidden; 64 | min-width: 28px; 65 | 66 | img { 67 | width: 22px; 68 | border-radius: 4px; 69 | } 70 | 71 | span { 72 | position: absolute; 73 | left: 0; 74 | right: 0; 75 | text-align: center; 76 | color: white; 77 | margin-top: 1px; 78 | } 79 | } 80 | 81 | div.form, 82 | div.text { 83 | flex-grow: 1; 84 | } 85 | 86 | div.text { 87 | margin-top: 3px; 88 | word-break: break-all; 89 | } 90 | 91 | div.buttons { 92 | width: 180px; 93 | min-width: 170px; 94 | text-align: right; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /kanmail/client/styles/control.less: -------------------------------------------------------------------------------- 1 | /* 2 | Control input overlay */ 3 | 4 | section#control-background { 5 | position: absolute; 6 | z-index: 9; 7 | left: 0; 8 | right: 0; 9 | top: 0; 10 | bottom: 0; 11 | background: rgba(100, 100, 100, 0.6); 12 | } 13 | 14 | section#control { 15 | margin: 200px auto 0 auto; 16 | width: 600px; 17 | background: white; 18 | border-radius: @borderRadius @borderRadius 0 0; 19 | font-size: @bigTextSize; 20 | 21 | p { 22 | padding: 20px 20px 0 20px; 23 | margin: 0; 24 | } 25 | 26 | div.react-select__control { 27 | margin-top: 10px; 28 | border: none; 29 | font-size: @bigTextSize; 30 | padding: 0 20px; 31 | } 32 | 33 | div.react-select__menu { 34 | border: none; 35 | border-radius: 0 0 @borderRadius @borderRadius; 36 | } 37 | 38 | div.react-select__option { 39 | padding: 4px 20px; 40 | } 41 | 42 | div.react-select__indicators { 43 | display: none; 44 | } 45 | 46 | input { 47 | margin: 10px 0; 48 | font-size: @bigTextSize; 49 | padding: 0 20px; 50 | border: none; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /kanmail/client/styles/header.less: -------------------------------------------------------------------------------- 1 | header { 2 | height: @topHeight; 3 | position: relative; 4 | 5 | h1#logo { 6 | float: left; 7 | line-height: 22px; 8 | display: inline-block; 9 | margin: 0; 10 | padding: 0 5px 0 5px; 11 | font-size: 16px; 12 | 13 | span, 14 | i { 15 | float: left; 16 | } 17 | 18 | i { 19 | line-height: 22px; 20 | font-size: 15px; 21 | } 22 | } 23 | 24 | div.header-errors { 25 | text-align: right; 26 | position: absolute; 27 | z-index: 1000; 28 | top: 0; 29 | right: 0; 30 | 31 | i { 32 | line-height: 16px; 33 | } 34 | 35 | div.icon-wrapper { 36 | padding-right: 10px; 37 | display: inline-block; 38 | position: relative; 39 | 40 | a { 41 | color: white; 42 | } 43 | } 44 | 45 | div.icon-contents { 46 | display: none; 47 | position: absolute; 48 | width: 600px; 49 | border: 5px solid; 50 | max-height: 500px; 51 | overflow-y: auto; 52 | background: white; 53 | padding: 5px; 54 | color: black; 55 | left: 100%; 56 | text-align: left; 57 | pointer-events: all; 58 | user-select: text; 59 | 60 | p { 61 | margin: 15px 0 10px 0; 62 | padding-bottom: 10px; 63 | border-bottom: 1px solid; 64 | line-height: 24px; 65 | 66 | &:last-child { 67 | border-bottom: none; 68 | } 69 | 70 | span.meta { 71 | display: block; 72 | font-size: 12px; 73 | } 74 | 75 | textarea { 76 | font-size: @textSize; 77 | line-height: 20px; 78 | resize: none; 79 | height: 100px; 80 | } 81 | 82 | button { 83 | float: right; 84 | } 85 | } 86 | } 87 | 88 | div.icon-contents:hover, 89 | div.icon-wrapper:hover div.icon-contents { 90 | display: block; 91 | } 92 | } 93 | 94 | &.header-bar { 95 | position: fixed; 96 | top: 0; 97 | left: 0; 98 | right: 0; 99 | z-index: 1000; 100 | border-bottom: 1px solid; 101 | padding-right: 20px; 102 | 103 | &.flex { 104 | display: flex; 105 | justify-content: space-between; 106 | } 107 | 108 | h2 { 109 | margin-top: 15px; 110 | margin-left: 20px; 111 | font-size: @titleTextSize; 112 | 113 | .no-select; 114 | } 115 | 116 | button { 117 | padding: 8px; 118 | margin: 11px 0 11px 11px; 119 | 120 | i { 121 | margin-right: 4px; 122 | font-size: @bigTextSize; 123 | } 124 | } 125 | 126 | .button-set { 127 | button { 128 | margin-left: @borderRadius; 129 | } 130 | } 131 | 132 | div.header-errors { 133 | margin-top: 20px; 134 | position: absolute; 135 | 136 | div.icon-contents { 137 | margin-left: -600px; 138 | left: 0; 139 | } 140 | } 141 | } 142 | } 143 | 144 | /* Make the logo float right on MacOS, as the control buttons live on the left */ 145 | section.Darwin header h1#logo { 146 | float: right; 147 | } 148 | -------------------------------------------------------------------------------- /kanmail/client/styles/meta.less: -------------------------------------------------------------------------------- 1 | section#license { 2 | margin: 20px 20px; 3 | 4 | textarea { 5 | display: block; 6 | width: 100%; 7 | height: 64px; 8 | margin-bottom: 20px; 9 | font-family: Courier; 10 | line-height: 26px; 11 | resize: none; 12 | } 13 | } 14 | 15 | header.meta { 16 | h2 { 17 | img { 18 | float: right; 19 | margin-top: -6px; 20 | margin-right: -10px; 21 | } 22 | } 23 | } 24 | 25 | section#meta { 26 | text-align: center; 27 | height: 100%; 28 | font-size: @bigTextSize; 29 | 30 | h2 { 31 | margin-top: @topHeight + 14; 32 | 33 | img { 34 | vertical-align: middle; 35 | margin-right: 10px; 36 | } 37 | } 38 | } 39 | 40 | section#meta-file { 41 | top: @topHeight; 42 | bottom: 0; 43 | position: fixed; 44 | overflow: auto; 45 | padding: 0 20px; 46 | font-size: @bigTextSize; 47 | 48 | pre, 49 | code { 50 | font-size: 16px; 51 | font-family: Courier; 52 | } 53 | code { 54 | padding: 1px; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /kanmail/client/styles/platforms.less: -------------------------------------------------------------------------------- 1 | section.frameless { 2 | section#sidebar header div.logo * { 3 | display: none; 4 | } 5 | 6 | &.Darwin { 7 | header h2 { 8 | margin-left: 90px; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /kanmail/client/styles/select.less: -------------------------------------------------------------------------------- 1 | /* 2 | React select overrides */ 3 | 4 | div.react-select__control { 5 | background: transparent; 6 | border: 0px solid; 7 | border-bottom-width: 1px; 8 | border-radius: 0; 9 | min-height: 0; 10 | box-shadow: none; 11 | font-size: 14px; 12 | padding: 0 7px; 13 | } 14 | 15 | span.react-select__indicator-separator { 16 | margin-right: 5px; 17 | } 18 | 19 | div.react-select__menu { 20 | border-radius: 0; 21 | border: 1px solid; 22 | box-shadow: none; 23 | margin: 0; 24 | } 25 | 26 | div.react-select__option { 27 | padding: 0 8px; 28 | } 29 | 30 | div.react-select__input { 31 | margin-left: -2px; 32 | } 33 | 34 | div.react-select__value-container, 35 | div.react-select__indicator, 36 | div.react-select__menu-notice { 37 | padding: 0; 38 | } 39 | 40 | div.react-select__multi-value__label { 41 | margin: 1px; 42 | padding: 0; 43 | } 44 | -------------------------------------------------------------------------------- /kanmail/client/styles/send.less: -------------------------------------------------------------------------------- 1 | /* 2 | New email (send app) form */ 3 | 4 | section#new-email { 5 | padding-top: @topHeight; 6 | height: 100%; 7 | position: relative; 8 | 9 | form#send-form { 10 | padding: 10px 10px 0 10px; 11 | position: absolute; 12 | width: 100%; 13 | top: @topHeight; 14 | bottom: 0; 15 | justify-content: flex-start; 16 | margin: 0; 17 | 18 | label { 19 | // width: 55px; 20 | padding-left: 10px; 21 | text-align: right; 22 | padding-right: 2px; 23 | font-size: 14px; 24 | margin-bottom: 10px; 25 | position: absolute; 26 | margin-top: 4px; 27 | } 28 | 29 | div input { 30 | padding: 2px 9px; 31 | margin: 0 2px; 32 | font-size: 14px; 33 | width: 100%; 34 | outline: none; 35 | } 36 | 37 | input#subject { 38 | height: 32px; 39 | } 40 | 41 | div.wide, 42 | div.wide, 43 | div.two-third, 44 | div.third, 45 | div.half { 46 | margin-bottom: 5px; 47 | } 48 | 49 | div#account, 50 | div#to, 51 | div#cc, 52 | div#bcc { 53 | margin-left: 50px; 54 | } 55 | 56 | div.subject input { 57 | font-weight: bold; 58 | margin-left: 0px; 59 | } 60 | 61 | label#message-label { 62 | margin-top: 5px; 63 | } 64 | 65 | div.form-top { 66 | position: relative; 67 | z-index: 3; 68 | } 69 | 70 | div.form-content { 71 | overflow: auto; 72 | } 73 | 74 | div#textarea-body { 75 | .draftail-editor { 76 | border: none; 77 | height: 100%; 78 | 79 | .draftail-toolbar { 80 | position: fixed; 81 | top: auto; 82 | margin-top: -40px; 83 | left: 10px; 84 | right: 10px; 85 | z-index: 2; 86 | 87 | div.signature-select { 88 | position: absolute; 89 | right: 0; 90 | top: 7px; 91 | width: 175px; 92 | 93 | div.react-select__control { 94 | border-bottom-width: 0px; 95 | } 96 | } 97 | } 98 | 99 | .drafteditor-root { 100 | margin-top: 40px; 101 | } 102 | 103 | .public-drafteditor-content { 104 | padding-bottom: 0; 105 | } 106 | } 107 | } 108 | 109 | blockquote#reply-quote { 110 | margin: 0; 111 | // max-height: 100px; 112 | overflow-y: auto; 113 | border: 1px solid @formBorderGrey; 114 | 115 | * { 116 | pointer-events: none; 117 | } 118 | } 119 | 120 | #include-quote { 121 | label { 122 | width: auto; 123 | padding-left: 1px; 124 | position: relative; 125 | } 126 | input { 127 | width: auto; 128 | } 129 | } 130 | 131 | div.buttons { 132 | left: 60px; 133 | padding: 5px 10px; 134 | font-size: 16px; 135 | border: none; 136 | position: absolute; 137 | bottom: 10px; 138 | right: 10px; 139 | 140 | button.send.submit { 141 | border-radius: @borderRadius 0 0 @borderRadius; 142 | } 143 | 144 | button.save { 145 | border-radius: 0 @borderRadius @borderRadius 0; 146 | } 147 | 148 | button.attach { 149 | left: auto; 150 | right: 20px; 151 | bottom: 10px; 152 | float: right; 153 | } 154 | } 155 | 156 | div.signature, 157 | div.quote { 158 | margin: 13px 10px 0 10px; 159 | } 160 | 161 | div.signature { 162 | margin-bottom: 10px; 163 | 164 | p { 165 | margin: 0; 166 | } 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /kanmail/client/styles/sidebar.less: -------------------------------------------------------------------------------- 1 | /* 2 | Folders/filters (left column) */ 3 | 4 | section#sidebar { 5 | width: @sidebarWidth; 6 | float: left; 7 | height: 100%; 8 | 9 | .no-select; 10 | 11 | header { 12 | height: @topHeight; 13 | 14 | div.buttons { 15 | padding-top: 8px; 16 | margin: 0 10px; 17 | overflow: hidden; 18 | height: @topHeight; 19 | display: flex; 20 | justify-content: space-between; 21 | } 22 | 23 | div.logo { 24 | margin-top: 7px; 25 | margin-left: 10px; 26 | font-size: 16px; 27 | } 28 | 29 | .tooltip-wrapper { 30 | vertical-align: top; 31 | margin-top: 11px; 32 | margin-left: 10px; 33 | } 34 | 35 | a.compose { 36 | font-size: 18px; 37 | } 38 | 39 | a.search { 40 | font-size: 15px; 41 | } 42 | } 43 | 44 | div#filters { 45 | overflow: auto; 46 | position: fixed; 47 | width: @sidebarWidth; 48 | left: 0; 49 | top: @topHeight + 4; 50 | bottom: 78px; 51 | } 52 | 53 | ul { 54 | margin: 0 0 20px 0; 55 | padding: 0; 56 | 57 | li { 58 | list-style: none; 59 | margin: 5px 10px; 60 | 61 | a, 62 | span { 63 | padding: 5px 0 5px 10px; 64 | display: block; 65 | border-radius: @borderRadius; 66 | } 67 | 68 | span.update { 69 | display: block; 70 | } 71 | 72 | i { 73 | margin-right: 7px; 74 | margin-top: -13.5px; 75 | font-size: 12px; 76 | } 77 | 78 | i.pin-button { 79 | position: absolute; 80 | margin-top: 2px; 81 | padding: 5px; 82 | right: 8px; 83 | } 84 | 85 | span.unread-count { 86 | position: absolute; 87 | right: 18px; 88 | margin-top: -22px; 89 | background: rgba(255, 255, 255, 0.1); 90 | padding: 1.5px; 91 | line-height: 18px; 92 | } 93 | 94 | &.small { 95 | font-size: 12px; 96 | 97 | a { 98 | padding: 2px 0 2px 10px; 99 | } 100 | } 101 | } 102 | } 103 | 104 | footer { 105 | position: absolute; 106 | bottom: 0; 107 | font-size: @smallTextSize; 108 | line-height: 16px; 109 | padding: 0px 0 10px 10px; 110 | width: @sidebarWidth; 111 | 112 | div#footer-status { 113 | margin-top: 10px; 114 | position: relative; 115 | width: 100%; 116 | 117 | span { 118 | opacity: 0.5; 119 | display: inline-block; 120 | width: 30px; 121 | } 122 | 123 | i { 124 | margin-right: 2px; 125 | } 126 | 127 | span.toggle { 128 | width: 16px; 129 | float: right; 130 | margin: 0 5px 0 0; 131 | 132 | i { 133 | cursor: pointer; 134 | font-size: 14px; 135 | line-height: 14px; 136 | } 137 | } 138 | } 139 | 140 | section#status { 141 | position: fixed; 142 | bottom: 0; 143 | right: 0; 144 | left: @sidebarWidth; 145 | height: 100px; 146 | border-top: 5px solid; 147 | z-index: 9; 148 | display: flex; 149 | justify-content: space-between; 150 | 151 | div { 152 | width: 32%; 153 | padding: 5px; 154 | overflow-y: auto; 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /kanmail/client/styles/themes/base-default-variables.less: -------------------------------------------------------------------------------- 1 | @bodyBackgroundColor: @white; 2 | @bodyTextColor: @textGrey; 3 | 4 | @inputColor: @textGrey; 5 | @inputBackgroundColor: @white; 6 | @inputBorderColor: @formBorderGrey; 7 | @inputBorderHoverColor: @formBorderGreyHover; 8 | 9 | @headerBackgroundColor: @lightestGrey; 10 | @headerBorderColor: @lightGrey; 11 | @headerButtonHoverColor: @lightGrey; 12 | 13 | @sidebarBackgroundColor: @darkGrey; 14 | @sidebarTextColor: @white; 15 | @sidebarHeaderIconColor: @white; 16 | @sidebarLinkTextColor: @formBorderGreyActive; 17 | @sidebarLinkHoverBackgroundColor: @darkestGrey; 18 | @sidebarLinkHoverTextColor: @white; 19 | 20 | @threadBackgroundColor: @white; 21 | @threadBorderColor: @lightGrey; 22 | @threadTitleBackgroundColor: @white; 23 | @threadTitleBorderColor: @lightestGrey; 24 | @threadMetaBackgroundColor: @lightestGrey; 25 | @threadMetaBorderColor: @lightGrey; 26 | @threadMessageBorderColor: @lightestGrey; 27 | 28 | @columnBorderColor: @lightGrey; 29 | @columnTitleBorderColor: @lightestGrey; 30 | @columnEmailBackgroundColor: @white; 31 | @columnEmailHoverBackgroundColor: @lightestGrey; 32 | 33 | @contactListBorderColor: @lightestGrey; 34 | @contactHoverBackgroundColor: @lightestGrey; 35 | 36 | @settingsAccountBackgroundColor: @lightestGrey; 37 | @settingsAccountFormOverlayBackgroundColor: rgba(0, 0, 0, 0.1); 38 | @settingsNewAccountButtonHoverBackgroundColor: @white; 39 | 40 | @searchFormBackgroundColor: @lightestGrey; 41 | -------------------------------------------------------------------------------- /kanmail/client/styles/themes/default-dark.less: -------------------------------------------------------------------------------- 1 | @bodyBackgroundColor: @darkGrey; 2 | @bodyTextColor: @lightestTextGrey; 3 | 4 | @inputColor: @lightestTextGrey; 5 | @inputBackgroundColor: @darkGrey; 6 | @inputBorderColor: @midGrey; 7 | @inputBorderHoverColor: @formBorderGreyHover; 8 | 9 | @headerBackgroundColor: @darkestGrey; 10 | @headerBorderColor: @black; 11 | @headerButtonHoverColor: @black; 12 | 13 | @sidebarBackgroundColor: @darkestGrey; 14 | @sidebarTextColor: @bodyTextColor; 15 | @sidebarHeaderIconColor: @lightTextGrey; 16 | @sidebarLinkTextColor: @lightestTextGrey; 17 | @sidebarLinkHoverBackgroundColor: @black; 18 | @sidebarLinkHoverTextColor: @white; 19 | 20 | @threadBackgroundColor: @darkGrey; 21 | @threadBorderColor: @darkestGrey; 22 | @threadTitleBackgroundColor: @darkGrey; 23 | @threadTitleBorderColor: @darkestGrey; 24 | @threadMetaBackgroundColor: @darkestGrey; 25 | @threadMetaBorderColor: @black; 26 | @threadMessageBorderColor: @darkestGrey; 27 | 28 | @columnBorderColor: @darkestGrey; 29 | @columnTitleBorderColor: @darkestGrey; 30 | @columnEmailBackgroundColor: @darkGrey; 31 | @columnEmailHoverBackgroundColor: @darkestGrey; 32 | 33 | @contactListBorderColor: @darkestGrey; 34 | @contactHoverBackgroundColor: @darkestGrey; 35 | 36 | @settingsAccountBackgroundColor: @darkestGrey; 37 | @settingsAccountFormOverlayBackgroundColor: rgba(255, 255, 255, 0.1); 38 | @settingsNewAccountButtonHoverBackgroundColor: @darkGrey; 39 | 40 | @searchFormBackgroundColor: @darkestGrey; 41 | 42 | body.theme-default-dark { 43 | @import (multiple) "styles/themes/base.less"; 44 | } 45 | -------------------------------------------------------------------------------- /kanmail/client/styles/themes/default-light.less: -------------------------------------------------------------------------------- 1 | @import (multiple) "styles/themes/base-default-variables.less"; 2 | 3 | @sidebarBackgroundColor: @lightGrey; 4 | @sidebarTextColor: @lightTextGrey; 5 | @sidebarHeaderIconColor: @lightTextGrey; 6 | @sidebarLinkTextColor: @lightTextGrey; 7 | @sidebarLinkHoverBackgroundColor: @midGrey; 8 | @sidebarLinkHoverTextColor: @white; 9 | 10 | body.theme-default-light { 11 | @import (multiple) "styles/themes/base.less"; 12 | } 13 | -------------------------------------------------------------------------------- /kanmail/client/styles/themes/default.less: -------------------------------------------------------------------------------- 1 | @import (multiple) "styles/themes/base-default-variables.less"; 2 | 3 | body.theme-default { 4 | @import (multiple) "styles/themes/base.less"; 5 | } 6 | -------------------------------------------------------------------------------- /kanmail/client/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Kanmail {% block page_title %}{% endblock %} 4 | 24 | 25 | 26 |
27 |

Kanmail is loading...

28 |

29 | If nothing happens please go to: 30 | kanmail.io/support 33 |

34 |
35 | 36 | {% block app_container %}{% endblock %} 37 | 38 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /kanmail/client/templates/contacts.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block page_title %}Contacts{% endblock %} {% block 2 | js_file %}contacts{% endblock %} {% block app_container %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /kanmail/client/templates/emails.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block js_file %}emails{% endblock %} {% block 2 | app_container %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /kanmail/client/templates/license.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block js_file %}license{% endblock %} {% block 2 | app_container %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /kanmail/client/templates/meta.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block page_title %}Meta{% endblock %} {% block 2 | js_file %}meta{% endblock %} {% block app_container %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /kanmail/client/templates/meta_file.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block page_title %}Meta: {{ filename }}{% endblock 2 | %} {% block js_file %}metaFile{% endblock %} {% block app_container %} 3 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /kanmail/client/templates/oauth_complete.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Kanmail Authentication 4 | 5 | 6 |
7 |

Kanmail

8 |

9 | Authentication complete, please close this window & return to the 10 | Kanmail app! 11 |

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /kanmail/client/templates/oauth_error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Kanmail Authentication Error 4 | 5 | 6 |
14 |

Kanmail

15 |

Authentication error: {{ error }}

16 |

Please close this window & retry in the Kanmail app.

17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /kanmail/client/templates/send.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block page_title %}Send{% endblock %} {% block 2 | js_file %}send{% endblock %} {% block app_container %} {% if is_app and version 3 | != '0.0.0dev' %} 4 | 9 | {% else %} 10 | 11 | {% endif %} 12 | 13 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /kanmail/client/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block page_title %}Settings{% endblock %} {% block 2 | js_file %}settings{% endblock %} {% block app_container %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /kanmail/client/theme.js: -------------------------------------------------------------------------------- 1 | export function setupThemes(styleSettings) { 2 | const darkModeMedia = window.matchMedia("(prefers-color-scheme: dark)"); 3 | 4 | const setTheme = (ev) => { 5 | let targetThemeName = styleSettings.theme_light; 6 | let otherThemeName = styleSettings.theme_dark; 7 | 8 | if (ev.matches) { 9 | targetThemeName = styleSettings.theme_dark; 10 | otherThemeName = styleSettings.theme_light; 11 | } 12 | document.body.classList.remove(`theme-${otherThemeName}`); 13 | document.body.classList.add(`theme-${targetThemeName}`); 14 | }; 15 | 16 | setTheme(darkModeMedia); 17 | if (darkModeMedia.addEventListener) { 18 | darkModeMedia.addEventListener("change", setTheme); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /kanmail/client/util/accounts.js: -------------------------------------------------------------------------------- 1 | export function getAccountIconName(account) { 2 | if (account.imap_connection.host === "imap.gmail.com") { 3 | return "google"; 4 | } 5 | 6 | if (account.imap_connection.host === "imap.mail.me.com") { 7 | return "apple"; 8 | } 9 | 10 | if (account.imap_connection.host === "imap-mail.outlook.com") { 11 | return "windows"; 12 | } 13 | 14 | return "envelope"; 15 | } 16 | -------------------------------------------------------------------------------- /kanmail/client/util/array.js: -------------------------------------------------------------------------------- 1 | export function arrayMove(arr, fromIndex, toIndex) { 2 | const element = arr[fromIndex]; 3 | arr.splice(fromIndex, 1); 4 | arr.splice(toIndex, 0, element); 5 | } 6 | -------------------------------------------------------------------------------- /kanmail/client/util/element.js: -------------------------------------------------------------------------------- 1 | function isInViewport(element) { 2 | var rect = element.getBoundingClientRect(); 3 | var html = document.documentElement; 4 | return ( 5 | rect.top >= 0 && 6 | rect.left >= 0 && 7 | rect.bottom <= (window.innerHeight || html.clientHeight) && 8 | rect.right <= (window.innerWidth || html.clientWidth) 9 | ); 10 | } 11 | 12 | export function ensureInView(element, alignToTop) { 13 | if (!isInViewport(element)) { 14 | element.scrollIntoView(alignToTop); 15 | } 16 | } 17 | 18 | export function stopEventPropagation(ev) { 19 | ev.stopPropagation(); 20 | } 21 | -------------------------------------------------------------------------------- /kanmail/client/util/html.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | export function documentFromHtml(html) { 4 | const parser = new DOMParser(); 5 | return parser.parseFromString(html, "text/html"); 6 | } 7 | 8 | export function popElementFromDocument(doc, selector) { 9 | const element = doc.querySelector(selector); 10 | if (!element) { 11 | return; 12 | } 13 | element.parentNode.removeChild(element); 14 | return element; 15 | } 16 | 17 | export function cleanHtml(html, returnElement = false) { 18 | const tempDocument = documentFromHtml(html); 19 | 20 | // Strip crappy tags 21 | _.each( 22 | tempDocument.body.querySelectorAll("link,meta,style,title,script"), 23 | (element) => { 24 | element.parentNode.removeChild(element); 25 | } 26 | ); 27 | 28 | // Remove image src attributes to stop them loading immediately 29 | _.each(tempDocument.body.querySelectorAll("img,image"), (img) => { 30 | // Attached images are OK! 31 | if (_.startsWith(img.src, "cid:")) { 32 | return; 33 | } 34 | 35 | // Swap src for original-src, remove any srcset 36 | img.setAttribute("original-src", img.src); 37 | img.setAttribute("src", "about:blank"); 38 | img.removeAttribute("srcset"); 39 | }); 40 | 41 | // Remove any background images (currently cannot be restored!) 42 | _.each(tempDocument.body.querySelectorAll("*[background]"), (element) => { 43 | const background = element.getAttribute("background"); 44 | 45 | if (_.startsWith(background, "http")) { 46 | element.removeAttribute("background"); 47 | element.setAttribute("original-background", background); 48 | } 49 | }); 50 | 51 | _.each(tempDocument.body.querySelectorAll("*[style]"), (element) => { 52 | const style = element.getAttribute("style"); 53 | 54 | if (_.includes(style, "background-image")) { 55 | element.removeAttribute("style"); 56 | // element.style.backgroundImage = 'about:blank'; 57 | } 58 | }); 59 | 60 | if (returnElement) { 61 | return tempDocument.body; 62 | } 63 | return tempDocument.body.innerHTML; 64 | } 65 | -------------------------------------------------------------------------------- /kanmail/client/util/message.js: -------------------------------------------------------------------------------- 1 | import { openWindow } from "window.js"; 2 | import { post } from "util/requests.js"; 3 | 4 | export function openReplyToMessageWindow(message, options = {}) { 5 | options.message = message; 6 | 7 | post("/create-send", options).then((data) => 8 | openWindow(data.endpoint, { 9 | title: `Kanmail: reply to ${message.subject}`, 10 | }) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /kanmail/client/util/requests.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import "whatwg-fetch"; 3 | import URI from "urijs"; 4 | 5 | import requestStore from "stores/request.js"; 6 | 7 | let currentCriticalRequestNonce = 0; 8 | 9 | export function newCriticalRequestNonce() { 10 | currentCriticalRequestNonce += 1; 11 | return currentCriticalRequestNonce; 12 | } 13 | 14 | class RequestError extends Error { 15 | name = "RequestError"; 16 | isInternalError = true; 17 | } 18 | 19 | function handleReponse(response, method, options) { 20 | const criticalRequestNonce = options.criticalRequestNonce; 21 | if ( 22 | criticalRequestNonce && 23 | criticalRequestNonce !== currentCriticalRequestNonce 24 | ) { 25 | const error = new RequestError( 26 | `Blocked due to old critical request nonce (current=${currentCriticalRequestNonce}, response=${criticalRequestNonce}, url=${response.url})!` 27 | ); 28 | error.isCriticalRequestNonceFailure = true; 29 | throw error; 30 | } 31 | 32 | if (!response.ok) { 33 | // Read the body and pass to the requestStore 34 | return response.text().then((body) => { 35 | let data = { 36 | url: response.url, 37 | status: response.status, 38 | errorName: "unknown", 39 | errorMessage: body, 40 | }; 41 | 42 | // If possible parse out the JSON error - but expect that sometimes 43 | // we might not even have that if the server *really* broke. 44 | try { 45 | body = JSON.parse(body); 46 | data.errorMessage = body.error_message; 47 | data.errorName = body.error_name; 48 | data.json = body; 49 | } catch (e) { 50 | data.jsonError = e; 51 | } 52 | 53 | const error = new RequestError( 54 | `Error fetching: ${method} ${response.url}` 55 | ); 56 | error.data = data; 57 | 58 | if (response.status == 503) { 59 | error.isNetworkResponseFailure = true; 60 | requestStore.addNetworkError(data); 61 | } else if ( 62 | !options.ignoreStatus || 63 | !_.includes(options.ignoreStatus, response.status) 64 | ) { 65 | requestStore.addRequestError(data); 66 | } 67 | 68 | throw error; 69 | }); 70 | } 71 | 72 | if (response.status == 204) { 73 | return; 74 | } 75 | 76 | return response.json(); 77 | } 78 | 79 | /* 80 | Handle unexpected network request errors (timeout, etc) 81 | */ 82 | function handleError(url, error) { 83 | if (error.isInternalError) { 84 | throw error; 85 | } 86 | 87 | requestStore.addNetworkError({ 88 | url: url, 89 | status: "unknown", 90 | errorName: "unknown", 91 | errorMessage: error.message, 92 | }); 93 | error.isNetworkResponseFailure = true; 94 | throw error; 95 | } 96 | 97 | function get_or_delete(method, url, query = {}, options = {}) { 98 | const uri = URI(url); 99 | 100 | return fetch(uri.query(query), { 101 | method, 102 | headers: { 103 | "Kanmail-Session-Token": window.KANMAIL_SESSION_TOKEN, 104 | }, 105 | }) 106 | .then((response) => handleReponse(response, method, options)) 107 | .catch((error) => handleError(url, error)); 108 | } 109 | 110 | export const get = _.partial(get_or_delete, "GET"); 111 | export const delete_ = _.partial(get_or_delete, "DELETE"); 112 | 113 | function post_or_put(method, url, data, options = {}) { 114 | const uri = URI(url); 115 | 116 | return fetch(uri, { 117 | method, 118 | body: JSON.stringify(data), 119 | headers: { 120 | "Content-Type": "application/json", 121 | "Kanmail-Session-Token": window.KANMAIL_SESSION_TOKEN, 122 | }, 123 | }) 124 | .then((response) => handleReponse(response, method, options)) 125 | .catch((error) => handleError(url, error)); 126 | } 127 | 128 | export const post = _.partial(post_or_put, "POST"); 129 | export const put = _.partial(post_or_put, "PUT"); 130 | -------------------------------------------------------------------------------- /kanmail/client/util/string.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import moment from "moment"; 3 | 4 | export function encodeFolderName(name) { 5 | return encodeURIComponent(encodeURIComponent(name)); 6 | } 7 | 8 | export function capitalizeFirstLetter(string) { 9 | return string.charAt(0).toUpperCase() + string.slice(1); 10 | } 11 | 12 | export function lowercaseFirstLetter(string) { 13 | return string.charAt(0).toLowerCase() + string.slice(1); 14 | } 15 | 16 | export function formatDate(date) { 17 | return moment(date).calendar(null, { 18 | sameDay: "HH:mm A", 19 | lastDay: "[Yesterday]", 20 | lastWeek: "dddd", 21 | nextWeek: "[Next] dddd,", // should never happen (future) 22 | sameElse: function (now) { 23 | if (this.isSame(now, "year")) { 24 | return "MMM DD"; 25 | } 26 | return "MMM DD YY"; 27 | }, 28 | }); 29 | } 30 | 31 | export function formatAddress(address, short = false) { 32 | if (short) { 33 | let name = address[1]; 34 | 35 | if (address[0]) { 36 | const nameBits = address[0].split(" "); 37 | if (nameBits[0] === "The") { 38 | return (name = address[0]); 39 | } 40 | name = nameBits[0]; 41 | } 42 | 43 | return _.trim(name, ","); 44 | } 45 | 46 | if (address[0]) { 47 | return `${address[0]} (${address[1]})`; 48 | } 49 | 50 | return address[1]; 51 | } 52 | 53 | export function formatBytes(bytes, decimals = 2) { 54 | if (bytes === 0) return "0 Bytes"; 55 | 56 | const k = 1024; 57 | const dm = decimals < 0 ? 0 : decimals; 58 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 59 | 60 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 61 | 62 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 63 | } 64 | -------------------------------------------------------------------------------- /kanmail/client/window.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | import { get, post } from "util/requests.js"; 4 | 5 | export function makeDragElement(element) { 6 | // Note this is based on my original drag code merged into `pywebview`: 7 | // https://github.com/r0x0r/pywebview/blob/master/webview/js/drag.py 8 | if (!window.KANMAIL_FRAMELESS || !element) { 9 | return; 10 | } 11 | 12 | var initialX = 0; 13 | var initialY = 0; 14 | 15 | function onMouseMove(ev) { 16 | var x = ev.screenX - initialX; 17 | var y = ev.screenY - initialY; 18 | window.pywebview._bridge.call("moveWindow", [x, y], null); 19 | } 20 | 21 | function onMouseUp() { 22 | window.removeEventListener("mousemove", onMouseMove); 23 | } 24 | 25 | function onMouseDown(ev) { 26 | initialX = ev.clientX; 27 | initialY = ev.clientY; 28 | window.addEventListener("mouseup", onMouseUp); 29 | window.addEventListener("mousemove", onMouseMove); 30 | } 31 | 32 | element.addEventListener("mousedown", onMouseDown); 33 | } 34 | 35 | export function makeNoDragElement(element) { 36 | if (!window.KANMAIL_FRAMELESS || !element) { 37 | return; 38 | } 39 | 40 | element.addEventListener("mousedown", (ev) => ev.stopPropagation()); 41 | } 42 | 43 | function saveWindowPosition() { 44 | const windowSettings = { 45 | // Unused (Python/backend provides these currently) 46 | left: window.screenX, 47 | top: window.screenY, 48 | width: window.innerWidth, 49 | height: window.innerHeight, 50 | // Used to cap the width/height to the screen size 51 | screen_width: window.screen.width, 52 | screen_height: window.screen.height, 53 | }; 54 | post("/api/settings/window", windowSettings); 55 | } 56 | 57 | export function createWindowPositionHandlers() { 58 | window.addEventListener("resize", _.debounce(saveWindowPosition, 100)); 59 | } 60 | 61 | function getWindowId() { 62 | const url = new URL(window.location.href); 63 | return url.searchParams.get("window_id"); 64 | } 65 | 66 | export function closeWindow() { 67 | get("/window/close", { window_id: getWindowId() }); 68 | } 69 | 70 | export function resizeWindow(width, height) { 71 | get("/window/resize", { window_id: getWindowId(), width, height }); 72 | } 73 | 74 | export function openWindow(path, options = {}) { 75 | if (!options.width) { 76 | options.width = 600; 77 | } 78 | if (!options.height) { 79 | options.height = 800; 80 | } 81 | 82 | if (window.KANMAIL_IS_APP) { 83 | get("/window/open", { 84 | url: path, 85 | ...options, 86 | }); 87 | } else { 88 | window.open( 89 | `${path}?Kanmail-Session-Token=${window.KANMAIL_SESSION_TOKEN}`, 90 | options.title, 91 | `width=${options.width},height=${options.height}` 92 | ); 93 | } 94 | } 95 | 96 | export function openSettings() { 97 | openWindow("/settings", { 98 | unique_key: "settings", 99 | confirm_close: true, 100 | title: "Kanmail Settings", 101 | }); 102 | } 103 | 104 | export function openContacts() { 105 | openWindow("/contacts", { 106 | unique_key: "contacts", 107 | title: "Kanmail Contacts", 108 | }); 109 | } 110 | 111 | export function openLicense() { 112 | const height = window.KANMAIL_LICENSED ? 230 : 320; 113 | 114 | openWindow("/license", { 115 | unique_key: "license", 116 | title: "Kanmail License", 117 | width: 540, 118 | height: height, 119 | }); 120 | } 121 | 122 | export function openMeta() { 123 | openWindow("/meta", { 124 | unique_key: "meta", 125 | resizable: false, 126 | title: "Kanmail Meta", 127 | width: 300, 128 | height: 230, 129 | }); 130 | } 131 | 132 | export function openSend() { 133 | openWindow("/send", { title: "Kanmail: compose email" }); 134 | } 135 | 136 | export function openLink(link) { 137 | if (window.KANMAIL_IS_APP) { 138 | get("/window/open-link", { 139 | url: link, 140 | }); 141 | } else { 142 | window.open(link); 143 | } 144 | } 145 | 146 | export function openFile(filename) { 147 | get("/window/open-link", { 148 | url: `file://${filename}`, 149 | }); 150 | } 151 | -------------------------------------------------------------------------------- /kanmail/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from datetime import datetime 4 | from logging.handlers import TimedRotatingFileHandler 5 | 6 | import click 7 | 8 | # Get the logger 9 | logger = logging.getLogger("Kanmail") 10 | # Don't push messages from this Process -> main 11 | logger.propagate = False 12 | 13 | 14 | class LogFormatter(logging.Formatter): 15 | level_to_format = { 16 | logging.DEBUG: lambda s: click.style(s, "green"), 17 | logging.WARNING: lambda s: click.style(s, "yellow"), 18 | logging.ERROR: lambda s: click.style(s, "red"), 19 | logging.CRITICAL: lambda s: click.style(s, "red", bold=True), 20 | } 21 | 22 | def format(self, record): 23 | message = super(LogFormatter, self).format(record) 24 | 25 | # Add path/module info for debug 26 | if record.levelno is logging.DEBUG: 27 | path_start = record.pathname.rfind("kanmail") 28 | 29 | if path_start: 30 | pyinfra_path = record.pathname[path_start:-3] # -3 removes `.py` 31 | module_name = pyinfra_path.replace("/", ".") 32 | message = f"[{module_name}] {message}" 33 | 34 | if record.levelno in self.level_to_format: 35 | message = self.level_to_format[record.levelno](message) 36 | 37 | now = datetime.now().replace(microsecond=0).isoformat() 38 | return f"{now} {record.levelname} {message}" 39 | 40 | 41 | def setup_logging(debug: bool, log_file: str) -> int: 42 | # Figure out the log level 43 | log_level = logging.WARNING 44 | 45 | if debug: 46 | log_level = logging.DEBUG 47 | 48 | # Set the log level 49 | logger.setLevel(log_level) 50 | 51 | # Setup a new handler for stdout & stderr 52 | stderr_handler = logging.StreamHandler(sys.stderr) 53 | 54 | # Setup a formatter 55 | formatter = LogFormatter() 56 | stderr_handler.setFormatter(formatter) 57 | 58 | # Add the handlers 59 | logger.addHandler(stderr_handler) 60 | 61 | # Setup the file handler 62 | file_handler = TimedRotatingFileHandler( 63 | log_file, 64 | when="D", 65 | backupCount=7, 66 | ) 67 | file_handler.setFormatter(formatter) 68 | logger.addHandler(file_handler) 69 | 70 | logger.debug(f"Debug level set to: {log_level}") 71 | return log_level 72 | -------------------------------------------------------------------------------- /kanmail/notifications/__init__.py: -------------------------------------------------------------------------------- 1 | from kanmail.log import logger 2 | from kanmail.settings.constants import IS_APP 3 | 4 | 5 | def import_macos(): 6 | try: 7 | from kanmail.notifications import macos 8 | 9 | return macos 10 | except ImportError: 11 | pass 12 | 13 | 14 | class Dummy: 15 | def init(): 16 | pass 17 | 18 | def send_notification(*args, **kwargs): 19 | logger.debug(f"Sending dummy notification args={args} kwargs={kwargs}") 20 | 21 | def set_notification_count(*args, **kwargs): 22 | logger.debug(f"Setting dummy notification count args={args} kwargs={kwargs}") 23 | 24 | 25 | if IS_APP: 26 | for loader in (import_macos,): 27 | module = loader() 28 | if module: 29 | break 30 | else: 31 | module = Dummy 32 | else: 33 | module = Dummy 34 | 35 | 36 | module.init() 37 | 38 | 39 | def send_notification(*args, **kwargs): 40 | return module.send_notification(*args, **kwargs) 41 | 42 | 43 | def set_notification_count(*args, **kwargs): 44 | return module.set_notification_count(*args, **kwargs) 45 | -------------------------------------------------------------------------------- /kanmail/notifications/macos.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from uuid import uuid4 5 | 6 | import UserNotifications 7 | 8 | from kanmail.log import logger 9 | 10 | 11 | def _notif_callback(err): 12 | if err: 13 | logger.error(f"Error in notification callback: {err}") 14 | 15 | 16 | def _auth_callback(granted, err): 17 | if not granted: 18 | logger.error(f"Error in authorization request: {err}") 19 | 20 | 21 | @lru_cache(maxsize=1) 22 | def _get_notification_center(): 23 | notification_center = UserNotifications.UNUserNotificationCenter.currentNotificationCenter() 24 | notification_center.requestAuthorizationWithOptions_completionHandler_( 25 | UserNotifications.UNAuthorizationOptionBadge, 26 | _auth_callback, 27 | ) 28 | return notification_center 29 | 30 | 31 | def init(): 32 | # Send an empty notification with 0 count to load the notification center 33 | # and reset any previous count. 34 | _send_notification(count=0) 35 | 36 | 37 | def _send_notification( 38 | title: str | None = None, 39 | subtitle: str | None = None, 40 | body: str | None = None, 41 | count: int | None = None, 42 | ): 43 | content = UserNotifications.UNMutableNotificationContent.alloc().init() 44 | 45 | if title: 46 | content.setTitle_(title) 47 | 48 | if subtitle: 49 | content.setSubtitle_(subtitle) 50 | 51 | if body: 52 | content.setBody_(body) 53 | 54 | if count is not None: 55 | content.setBadge_(count) 56 | 57 | request = UserNotifications.UNNotificationRequest.requestWithIdentifier_content_trigger_( 58 | str(uuid4()), 59 | content, 60 | None, 61 | ) 62 | 63 | _get_notification_center().addNotificationRequest_withCompletionHandler_( 64 | request, 65 | _notif_callback, 66 | ) 67 | 68 | 69 | def send_notification( 70 | title: str, 71 | subtitle: str | None = None, 72 | body: str | None = None, 73 | ): 74 | logger.debug(f"Sending notification title={title}") 75 | return _send_notification(title=title, subtitle=subtitle, body=body) 76 | 77 | 78 | def set_notification_count(count: int): 79 | logger.debug(f"Setting notification count to {count}") 80 | return _send_notification(count=count) 81 | -------------------------------------------------------------------------------- /kanmail/secrets.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from typing import Optional 3 | 4 | import keyring 5 | 6 | from kanmail.settings.constants import CACHE_DIR, PLATFORM 7 | 8 | if PLATFORM == "Windows": 9 | from keyring.backends.Windows import WinVaultKeyring as KeyringClass 10 | elif PLATFORM == "Darwin": 11 | from keyring.backends.OS_X import Keyring as KeyringClass 12 | else: 13 | from keyrings.alt.file import PlaintextKeyring 14 | 15 | class KeyringClass(PlaintextKeyring): # type: ignore 16 | file_path = path.join(CACHE_DIR, ".secrets") 17 | 18 | 19 | keyring.set_keyring(KeyringClass()) 20 | 21 | 22 | def _make_password_name(section, host) -> str: 23 | return f"Kanmail {section}: {host}" 24 | 25 | 26 | def set_password(section, host, username, password) -> bool: 27 | name = _make_password_name(section, host) 28 | return keyring.set_password(name, username, password) 29 | 30 | 31 | def delete_password(section, host, username) -> bool: 32 | name = _make_password_name(section, host) 33 | return keyring.delete_password(name, username) 34 | 35 | 36 | def get_password(section, host, username) -> Optional[str]: 37 | name = _make_password_name(section, host) 38 | password = keyring.get_password(name, username) 39 | if password: 40 | return password 41 | 42 | legacy_password = keyring.get_password(host, username) 43 | if legacy_password: 44 | set_password(section, host, username, legacy_password) 45 | keyring.delete_password(host, username) 46 | return legacy_password 47 | 48 | return None 49 | -------------------------------------------------------------------------------- /kanmail/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/kanmail/server/__init__.py -------------------------------------------------------------------------------- /kanmail/server/mail/allowed_images.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from kanmail.server.app import db 4 | 5 | 6 | class AllowedImage(db.Model): 7 | __bind_key__ = "contacts" 8 | __tablename__ = "allowed_images" 9 | __table_args__ = (db.UniqueConstraint("email"),) 10 | 11 | id = db.Column(db.Integer, primary_key=True) 12 | email = db.Column(db.String(300)) 13 | 14 | 15 | @lru_cache(maxsize=1) 16 | def get_allowed_image_emails(): 17 | return [image.email for image in AllowedImage.query.all()] 18 | 19 | 20 | def is_email_allowed_images(email): 21 | return email in get_allowed_image_emails() 22 | 23 | 24 | def allow_images_for_email(email): 25 | if email in get_allowed_image_emails(): 26 | return 27 | 28 | image = AllowedImage(email=email) 29 | 30 | db.session.add(image) 31 | db.session.commit() 32 | 33 | get_allowed_image_emails.cache_clear() 34 | 35 | 36 | def disallow_images_for_email(email): 37 | if email not in get_allowed_image_emails(): 38 | return 39 | 40 | image = AllowedImage.query.filter_by(email=email).one() 41 | 42 | db.session.delete(image) 43 | db.session.commit() 44 | 45 | get_allowed_image_emails.cache_clear() 46 | -------------------------------------------------------------------------------- /kanmail/server/mail/autoconf.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from defusedxml.ElementTree import fromstring as parse_xml 3 | from dns import resolver 4 | from tld import get_fld 5 | 6 | from kanmail.log import logger 7 | 8 | from .autoconf_settings import COMMON_ISPDB_DOMAINS 9 | 10 | ISPDB_URL_FORMATTER = "https://ispdb.kanmail.io/{domain}/v1.1/config.xml" 11 | 12 | 13 | def get_ispdb_confg(domain: str) -> [dict, dict]: 14 | if domain in COMMON_ISPDB_DOMAINS: 15 | logger.debug(f"Got hardcoded autoconfig for {domain}") 16 | return ( 17 | COMMON_ISPDB_DOMAINS[domain]["imap_connection"], 18 | COMMON_ISPDB_DOMAINS[domain]["smtp_connection"], 19 | ) 20 | 21 | logger.debug(f"Looking up thunderbird autoconfig for {domain}") 22 | 23 | ispdb_url = ISPDB_URL_FORMATTER.format(domain=domain) 24 | 25 | try: 26 | response = requests.get(ispdb_url) 27 | except requests.RequestException as e: 28 | logger.warning(f"Failed to fetch ISPDB settings for domain: {domain}: {e}") 29 | return 30 | 31 | if response.status_code == 200: 32 | imap_settings = {} 33 | smtp_settings = {} 34 | 35 | # Parse the XML 36 | et = parse_xml(response.content) 37 | provider = et.find("emailProvider") 38 | 39 | for incoming in provider.findall("incomingServer"): 40 | if incoming.get("type") != "imap": 41 | continue 42 | 43 | imap_settings["host"] = incoming.find("hostname").text 44 | imap_settings["port"] = int(incoming.find("port").text) 45 | imap_settings["ssl"] = incoming.find("socketType").text == "SSL" 46 | break 47 | 48 | for outgoing in provider.findall("outgoingServer"): 49 | if outgoing.get("type") != "smtp": 50 | continue 51 | 52 | smtp_settings["host"] = outgoing.find("hostname").text 53 | smtp_settings["port"] = int(outgoing.find("port").text) 54 | 55 | socket_type = outgoing.find("socketType").text 56 | smtp_settings["ssl"] = socket_type == "SSL" 57 | smtp_settings["tls"] = socket_type == "STARTTLS" 58 | break 59 | 60 | logger.debug( 61 | (f"Autoconf settings for {domain}: " f"imap={imap_settings}, smtp={smtp_settings}") 62 | ) 63 | return imap_settings, smtp_settings 64 | 65 | 66 | def get_mx_record_domain(domain: str) -> list: 67 | logger.debug(f"Fetching MX records for {domain}") 68 | 69 | name_to_preference = {} 70 | names = set() 71 | 72 | try: 73 | for answer in resolver.query(domain, "MX"): 74 | name = get_fld(f"{answer.exchange}".rstrip("."), fix_protocol=True) 75 | name_to_preference[name] = answer.preference 76 | names.add(name) 77 | except (resolver.NoAnswer, resolver.NXDOMAIN): 78 | return [] 79 | 80 | return sorted( 81 | list(names), 82 | key=lambda name: name_to_preference[name], 83 | ) 84 | 85 | 86 | def get_autoconf_settings(username: str, domain: str = None) -> [bool, dict]: 87 | settings = { 88 | "imap_connection": { 89 | "username": username, 90 | "ssl": True, 91 | "ssl_verify_hostname": True, 92 | }, 93 | "smtp_connection": { 94 | "username": username, 95 | "ssl": True, 96 | "ssl_verify_hostname": True, 97 | }, 98 | } 99 | 100 | did_autoconf = False 101 | 102 | if not domain: 103 | domain = username.rsplit("@", 1)[-1] 104 | 105 | config = get_ispdb_confg(domain) 106 | 107 | if not config: 108 | mx_domains = get_mx_record_domain(domain) 109 | for mx_domain in mx_domains: 110 | if mx_domain == domain: # don't re-attempt the original domain 111 | continue 112 | config = get_ispdb_confg(mx_domain) 113 | if config: 114 | break 115 | 116 | if config: 117 | imap, smtp = config 118 | settings["imap_connection"].update(imap) 119 | settings["smtp_connection"].update(smtp) 120 | did_autoconf = True 121 | 122 | return did_autoconf, settings 123 | -------------------------------------------------------------------------------- /kanmail/server/mail/autoconf_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hardcoded settings for the most common providers. 3 | """ 4 | 5 | COMMON_ISPDB_DOMAINS = { 6 | "gmail.com": { 7 | "imap_connection": { 8 | "host": "imap.gmail.com", 9 | "port": 993, 10 | "ssl": True, 11 | }, 12 | "smtp_connection": { 13 | "host": "smtp.gmail.com", 14 | "port": 465, 15 | "tls": False, 16 | "ssl": True, 17 | }, 18 | }, 19 | "icloud.com": { 20 | "imap_connection": { 21 | "host": "imap.mail.me.com", 22 | "port": 993, 23 | "ssl": True, 24 | }, 25 | "smtp_connection": { 26 | "host": "smtp.mail.me.com", 27 | "port": 587, 28 | "tls": True, 29 | "ssl": False, 30 | }, 31 | }, 32 | "outlook.com": { 33 | "imap_connection": { 34 | "host": "outlook.office365.com", 35 | "port": 993, 36 | "ssl": True, 37 | }, 38 | "smtp_connection": { 39 | "host": "smtp.office365.com", 40 | "port": 587, 41 | "tls": True, 42 | "ssl": False, 43 | }, 44 | }, 45 | "yahoo.com": { 46 | "imap_connection": { 47 | "host": "imap.mail.yahoo.com", 48 | "port": 993, 49 | "ssl": True, 50 | }, 51 | "smtp_connection": { 52 | "host": "smtp.mail.yahoo.com", 53 | "port": 465, 54 | "tls": False, 55 | "ssl": True, 56 | }, 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /kanmail/server/mail/contacts.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from kanmail.log import logger 4 | from kanmail.server.app import db 5 | from kanmail.server.util import lock_function 6 | 7 | 8 | class Contact(db.Model): 9 | __bind_key__ = "contacts" 10 | __tablename__ = "contacts" 11 | __table_args__ = (db.UniqueConstraint("email", "name"),) 12 | 13 | id = db.Column(db.Integer, primary_key=True) 14 | 15 | name = db.Column(db.String(300)) 16 | email = db.Column(db.String(300)) 17 | 18 | def to_dict(self): 19 | return { 20 | "id": self.id, 21 | "name": self.name, 22 | "email": self.email, 23 | } 24 | 25 | 26 | @lru_cache(maxsize=1) 27 | def get_contacts(): 28 | contacts = list(Contact.query.all()) 29 | for contact in contacts: 30 | db.session.expunge(contact) # detach from session (request) 31 | return contacts 32 | 33 | 34 | def get_contact_dicts(): 35 | return [contact.to_dict() for contact in Contact.query.all()] 36 | 37 | 38 | def get_contact_tuple_to_contact(): 39 | return {(contact.name, contact.email): contact for contact in get_contacts()} 40 | 41 | 42 | def save_contact(contact): 43 | logger.debug(f"Saving contact: {contact}") 44 | 45 | db.session.add(contact) 46 | db.session.commit() 47 | 48 | get_contacts.cache_clear() 49 | 50 | 51 | def save_contacts(*contacts): 52 | logger.debug(f"Saving {len(contacts)} contacts") 53 | 54 | for contact in contacts: 55 | db.session.add(contact) 56 | db.session.commit() 57 | 58 | get_contacts.cache_clear() 59 | 60 | 61 | def delete_contact(contact): 62 | logger.debug(f"Deleting contact: {contact}") 63 | 64 | db.session.delete(contact) 65 | db.session.commit() 66 | 67 | get_contacts.cache_clear() 68 | 69 | 70 | def is_valid_contact(name, email): 71 | # TODO: improve detection of auto-generated/invalid emails 72 | 73 | if not name: 74 | return False 75 | 76 | if any(s in email for s in ("noreply", "no-reply", "donotreply")): 77 | return False 78 | 79 | if email.startswith("reply"): 80 | return False 81 | 82 | if email.startswith("bounce"): 83 | return False 84 | 85 | if " via " in name: 86 | return False 87 | 88 | return True 89 | 90 | 91 | @lock_function 92 | def add_contacts(contacts): 93 | existing_contacts = get_contact_tuple_to_contact() 94 | contacts_to_save = [] 95 | 96 | for name, email in contacts: 97 | if not is_valid_contact(name, email): 98 | logger.debug(f"Not saving invalid contact: ({name} {email})") 99 | continue 100 | 101 | if (name, email) in existing_contacts: 102 | logger.debug(f"Already have contact: ({name} {email})") 103 | continue 104 | 105 | new_contact = Contact(name=name, email=email) 106 | contacts_to_save.append(new_contact) 107 | 108 | save_contacts(*contacts_to_save) 109 | -------------------------------------------------------------------------------- /kanmail/server/mail/fixes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fixes for IMAP issues (specifically encountered with Gmail, elsewhere untested 3 | currently). Basically: Gmail's IMAP implementation is shit. 4 | 5 | This StackOverflow question: https://stackoverflow.com/questions/46936646 6 | """ 7 | 8 | from kanmail.log import logger 9 | from kanmail.settings.constants import DEBUG 10 | 11 | 12 | def fix_missing_uids(expected_uid_count, uids): 13 | """ 14 | This fixes missing UIDs - when moving multiple emails into a new folder, 15 | the next search request sometimes returns just the latest UID. Because they 16 | are sequential we can infer the previous UIDs. 17 | 18 | When passed to fetch these then generally have to be remapped (see below) 19 | as Gmail will return incorrect UIDs for each message - but the messages 20 | *are* correct. 21 | """ 22 | 23 | uid_count = len(uids) 24 | 25 | if uid_count and uid_count < expected_uid_count: 26 | diff = expected_uid_count - uid_count 27 | lowest_uid = min(uids) 28 | 29 | for i in range(diff): 30 | uids.add(lowest_uid - (i + 1)) 31 | 32 | logger.warning( 33 | f"Corrected {uid_count} missing UIDs {expected_uid_count} -> {uids}", 34 | ) 35 | 36 | return uids 37 | 38 | 39 | def fix_email_uids(email_uids, emails): 40 | """ 41 | After moving emails around in IMAP, Gmail sometimes returns stale/old UIDs 42 | for messages. This attempts to fix this by re-mapping any invalid returned 43 | UIDs to the correct UIDs. 44 | """ 45 | 46 | # First, get the list of returned UIDs 47 | returned_uids = [] 48 | for uid, data in emails.items(): 49 | returned_uids.append(uid) 50 | 51 | missing_uids = set(email_uids) - set(returned_uids) 52 | 53 | if missing_uids: 54 | error = ValueError( 55 | ( 56 | "Incorrect UIDs returned by server, " 57 | f"requested {len(email_uids)} but got {len(returned_uids)}, " 58 | f"missing={missing_uids} ({email_uids} - {returned_uids})" 59 | ) 60 | ) 61 | 62 | # If not the same length, we're probably missing some UIDs, this could 63 | # be due to another IMAP client or server issue. We can't attempt any 64 | # fix here, so return the partial response (unless debugging). 65 | if len(returned_uids) != len(email_uids): 66 | if DEBUG: 67 | raise error 68 | return emails 69 | 70 | # Build map of returned UID -> correct UID 71 | corrected_uid_map = {} 72 | 73 | # First pass - pull out any that exist in our wanted and returned 74 | for uid in email_uids: 75 | if uid in returned_uids: 76 | corrected_uid_map[uid] = uid 77 | email_uids.remove(uid) 78 | returned_uids.remove(uid) 79 | 80 | # Second pass - map any remaining ones in order 81 | email_uids = sorted(email_uids) 82 | returned_uids = sorted(returned_uids) 83 | 84 | for i, uid in enumerate(email_uids): 85 | corrected_uid_map[returned_uids[i]] = uid 86 | 87 | logger.warning(f"Corrected broken server UIDs: {corrected_uid_map}") 88 | 89 | # Overwrite our returned UID -> email map with our corrected UIDs 90 | emails = {corrected_uid_map[uid]: email for uid, email in emails.items()} 91 | 92 | return emails 93 | -------------------------------------------------------------------------------- /kanmail/server/mail/icon.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64decode, b64encode 3 | from hashlib import md5 4 | from os import path 5 | 6 | import requests 7 | 8 | from kanmail.log import logger 9 | from kanmail.settings.constants import ICON_CACHE_DIR 10 | 11 | # This is a transparent 1x1px gif 12 | DEFAULT_ICON_DATA = b64decode("R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") 13 | DEFAULT_ICON_MIMETYPE = "image/gif" 14 | 15 | 16 | def get_icon_for_email(email): 17 | email = email.lower().strip() 18 | 19 | hasher = md5() 20 | hasher.update(email.encode()) 21 | email_hash = hasher.hexdigest() 22 | 23 | cached_icon_filename = path.join(ICON_CACHE_DIR, f"{email_hash}.json") 24 | if path.exists(cached_icon_filename): 25 | with open(cached_icon_filename, "r") as f: 26 | base64_data, mimetype = json.load(f) 27 | return b64decode(base64_data), mimetype 28 | 29 | requests_to_attempt = [ 30 | (f"https://www.gravatar.com/avatar/{email_hash}", {"d": "404"}), 31 | ] 32 | 33 | if "@" in email: 34 | email_domain = email.rsplit("@", 1)[1] 35 | email_domain_parts = list(reversed(email_domain.split("."))) 36 | email_domains = [ 37 | ".".join(reversed(email_domain_parts[: i + 1])) for i in range(len(email_domain_parts)) 38 | ] 39 | for domain in reversed(email_domains[1:]): 40 | requests_to_attempt.append(f"https://icons.duckduckgo.com/ip3/{domain}.ico") 41 | 42 | for url in requests_to_attempt: 43 | params = None 44 | if isinstance(url, tuple): 45 | url, params = url 46 | 47 | try: 48 | response = requests.get(url, params=params) 49 | except requests.RequestException as e: 50 | logger.warning(f"Could not fetch icon: {e}") 51 | else: 52 | if response.status_code == 200: 53 | data, mimetype = response.content, response.headers.get("Content-Type") 54 | with open(cached_icon_filename, "w") as f: 55 | json.dump([b64encode(data).decode(), mimetype], f) 56 | return data, mimetype 57 | 58 | return DEFAULT_ICON_DATA, DEFAULT_ICON_MIMETYPE 59 | -------------------------------------------------------------------------------- /kanmail/server/mail/message.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from email.headerregistry import Address 3 | from email.message import EmailMessage 4 | from email.utils import formatdate, make_msgid 5 | from mimetypes import guess_type 6 | from os import path 7 | 8 | from kanmail.version import get_version 9 | 10 | from .util import markdownify 11 | 12 | 13 | def _make_address(obj): 14 | name = "" 15 | email = obj 16 | 17 | if isinstance(obj, (tuple, list)): 18 | name, email = obj 19 | name = name or "" 20 | 21 | username, domain = email.rsplit("@", 1) 22 | return Address(name, username, domain) 23 | 24 | 25 | def _ensure_multiple(item): 26 | if item is None: 27 | return () 28 | 29 | if not isinstance(item, (tuple, list)): 30 | return (item,) 31 | 32 | return item 33 | 34 | 35 | def make_email_message( 36 | from_, 37 | to=None, 38 | cc=None, 39 | bcc=None, 40 | subject=None, 41 | text=None, 42 | html=None, 43 | attachments=None, 44 | attachment_data=None, 45 | # If replying to another message 46 | reply_to_message_id=None, 47 | reply_to_message_references=None, 48 | raise_for_no_recipients=True, 49 | ): 50 | text = text or "" 51 | 52 | to = _ensure_multiple(to) 53 | cc = _ensure_multiple(cc) 54 | bcc = _ensure_multiple(bcc) 55 | 56 | message = EmailMessage() 57 | 58 | message["Message-ID"] = make_msgid(domain="kanmail") 59 | message["X-Mailer"] = f"Kanmail v{get_version()}" 60 | message["Date"] = formatdate() 61 | 62 | message["From"] = _make_address(from_) 63 | 64 | if raise_for_no_recipients and not any((to, cc, bcc)): 65 | raise ValueError("No recipients defined!") 66 | 67 | message["To"] = tuple(_make_address(a) for a in to) 68 | 69 | if cc: 70 | message["Cc"] = tuple(_make_address(a) for a in cc) 71 | 72 | if bcc: 73 | message["Bcc"] = tuple(_make_address(a) for a in bcc) 74 | 75 | if subject: 76 | message["Subject"] = subject 77 | 78 | if reply_to_message_id: 79 | message["In-Reply-To"] = reply_to_message_id 80 | 81 | references = reply_to_message_references or [] 82 | references.append(reply_to_message_id) 83 | message["References"] = " ".join(references) 84 | 85 | # Attach the text part (simples!) 86 | message.set_content(text) 87 | 88 | # Make/attach the HTML part, including any quote 89 | if not html: 90 | html = markdownify(text) 91 | 92 | message.add_alternative(html, subtype="html") 93 | 94 | # Handle attached files 95 | if attachments: 96 | attachment_data = attachment_data or {} 97 | 98 | for attachment in attachments: 99 | mimetype = guess_type(attachment)[0] 100 | maintype, subtype = mimetype.split("/") 101 | 102 | data = attachment_data.get(attachment) 103 | if not data: 104 | raise Exception(f"Missing data in request for attachment: {attachment}") 105 | 106 | data = b64decode(data) 107 | 108 | message.add_attachment( 109 | data, 110 | maintype=maintype, 111 | subtype=subtype, 112 | filename=path.basename(attachment), 113 | ) 114 | 115 | return message 116 | -------------------------------------------------------------------------------- /kanmail/server/mail/oauth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests import Request 3 | 4 | from kanmail.log import logger 5 | from kanmail.server.app import server 6 | from kanmail.settings.constants import DEACTIVATE_OAUTH, SESSION_TOKEN 7 | 8 | if not DEACTIVATE_OAUTH: 9 | from .oauth_settings import OAUTH_PROVIDERS 10 | 11 | 12 | CURRENT_OAUTH_TOKENS = {} 13 | 14 | 15 | def make_redirect_uri(uid): 16 | return ( 17 | f"http://127.0.0.1:{server.get_port()}/api/oauth/respond" 18 | f"?Kanmail-Session-Token={SESSION_TOKEN}&uid={uid}" 19 | ) 20 | 21 | 22 | def get_oauth_settings(provider): 23 | settings = OAUTH_PROVIDERS.get(provider) 24 | if not settings: 25 | raise ValueError(f"Invalid oauth provider: {provider}") 26 | return settings 27 | 28 | 29 | def get_oauth_request_url(provider, uid): 30 | oauth_settings = get_oauth_settings(provider) 31 | 32 | return ( 33 | Request( 34 | "GET", 35 | oauth_settings["auth_endpoint"], 36 | params={ 37 | "client_id": oauth_settings["client_id"], 38 | "scope": oauth_settings["scope"], 39 | "response_type": "code", 40 | "redirect_uri": make_redirect_uri(uid), 41 | }, 42 | ) 43 | .prepare() 44 | .url 45 | ) 46 | 47 | 48 | def get_oauth_tokens_from_code(provider, uid, auth_code): 49 | oauth_settings = get_oauth_settings(provider) 50 | 51 | response = requests.post( 52 | oauth_settings["token_endpoint"], 53 | params={ 54 | "client_id": oauth_settings["client_id"], 55 | "client_secret": oauth_settings["client_secret"], 56 | "code": auth_code, 57 | "grant_type": "authorization_code", 58 | "redirect_uri": make_redirect_uri(uid), 59 | }, 60 | ) 61 | response.raise_for_status() 62 | 63 | oauth_response = response.json() 64 | 65 | profile_request = requests.get( 66 | oauth_settings["profile_endpoint"], 67 | headers={ 68 | "Authorization": f'Bearer {oauth_response["access_token"]}', 69 | }, 70 | ) 71 | profile_request.raise_for_status() 72 | 73 | oauth_response["email"] = profile_request.json()["email"] 74 | 75 | return oauth_response 76 | 77 | 78 | def get_oauth_tokens_from_refresh_token(provider, refresh_token): 79 | access_token = CURRENT_OAUTH_TOKENS.get(refresh_token) 80 | if access_token: 81 | return access_token 82 | 83 | oauth_settings = get_oauth_settings(provider) 84 | 85 | response = requests.post( 86 | oauth_settings["token_endpoint"], 87 | params={ 88 | "client_id": oauth_settings["client_id"], 89 | "client_secret": oauth_settings["client_secret"], 90 | "refresh_token": refresh_token, 91 | "grant_type": "refresh_token", 92 | }, 93 | ) 94 | response.raise_for_status() 95 | 96 | oauth_response = response.json() 97 | 98 | if "refresh_token" not in oauth_response: 99 | oauth_response["refresh_token"] = refresh_token 100 | 101 | # Store the tokens against the returned response token, if this changes 102 | # the connection object will use this to lookup. 103 | CURRENT_OAUTH_TOKENS[refresh_token] = oauth_response 104 | return oauth_response 105 | 106 | 107 | def invalidate_access_token(refresh_token): 108 | tokens = CURRENT_OAUTH_TOKENS.pop(refresh_token, None) 109 | 110 | if tokens is None: 111 | logger.warning("Invalidated non-existent refresh token") 112 | 113 | 114 | def set_oauth_tokens(refresh_token, access_token): 115 | CURRENT_OAUTH_TOKENS[refresh_token] = { 116 | "refresh_token": refresh_token, 117 | "access_token": access_token, 118 | } 119 | -------------------------------------------------------------------------------- /kanmail/server/mail/oauth_settings.py: -------------------------------------------------------------------------------- 1 | from kanmail.settings.hidden import get_hidden_value 2 | 3 | OAUTH_PROVIDERS = { 4 | "gmail": { 5 | "auth_endpoint": "https://accounts.google.com/o/oauth2/auth", 6 | "token_endpoint": "https://accounts.google.com/o/oauth2/token", 7 | "profile_endpoint": "https://www.googleapis.com/userinfo/v2/me", 8 | "scope": "https://mail.google.com https://www.googleapis.com/auth/userinfo.email", 9 | "client_id": get_hidden_value("GOOGLE_OAUTH_CLIENT_ID"), 10 | "client_secret": get_hidden_value("GOOGLE_OAUTH_CLIENT_SECRET"), 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /kanmail/server/mail/smtp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Overrides for Python's builtin SMTP classes which don't handle unicode passwords 3 | correctly, fixed in the below PR pending merge. 4 | 5 | https://github.com/python/cpython/pull/15064 6 | """ 7 | 8 | import base64 9 | import hmac 10 | import smtplib 11 | from smtplib import _MAXCHALLENGE, SMTPAuthenticationError, SMTPException, encode_base64 12 | 13 | 14 | class SMTP(smtplib.SMTP): 15 | def auth(self, mechanism, authobject, *, initial_response_ok=True): 16 | """Authentication command - requires response processing. 17 | 'mechanism' specifies which authentication mechanism is to 18 | be used - the valid values are those listed in the 'auth' 19 | element of 'esmtp_features'. 20 | 'authobject' must be a callable object taking a single argument: 21 | data = authobject(challenge) 22 | It will be called to process the server's challenge response; the 23 | challenge argument it is passed will be a bytes. It should return 24 | an ASCII string that will be base64 encoded and sent to the server. 25 | Keyword arguments: 26 | - initial_response_ok: Allow sending the RFC 4954 initial-response 27 | to the AUTH command, if the authentication methods supports it. 28 | """ 29 | # RFC 4954 allows auth methods to provide an initial response. Not all 30 | # methods support it. By definition, if they return something other 31 | # than None when challenge is None, then they do. See issue #15014. 32 | mechanism = mechanism.upper() 33 | initial_response = authobject() if initial_response_ok else None 34 | if initial_response is not None: 35 | response = encode_base64(initial_response.encode("utf-8"), eol="") 36 | (code, resp) = self.docmd("AUTH", mechanism + " " + response) 37 | self._auth_challenge_count = 1 38 | else: 39 | (code, resp) = self.docmd("AUTH", mechanism) 40 | self._auth_challenge_count = 0 41 | # If server responds with a challenge, send the response. 42 | while code == 334: 43 | self._auth_challenge_count += 1 44 | challenge = base64.decodebytes(resp) 45 | response = encode_base64(authobject(challenge).encode("utf-8"), eol="") 46 | (code, resp) = self.docmd(response) 47 | # If server keeps sending challenges, something is wrong. 48 | if self._auth_challenge_count > _MAXCHALLENGE: 49 | raise SMTPException( 50 | "Server AUTH mechanism infinite loop. Last response: " + repr((code, resp)) 51 | ) 52 | if code in (235, 503): 53 | return (code, resp) 54 | raise SMTPAuthenticationError(code, resp) 55 | 56 | def auth_cram_md5(self, challenge=None): 57 | """Authobject to use with CRAM-MD5 authentication. Requires self.user 58 | and self.password to be set.""" 59 | # CRAM-MD5 does not support initial-response. 60 | if challenge is None: 61 | return None 62 | return ( 63 | self.user + " " + hmac.HMAC(self.password.encode("utf-8"), challenge, "md5").hexdigest() 64 | ) 65 | 66 | 67 | class SMTP_SSL(smtplib.SMTP_SSL, SMTP): 68 | pass 69 | -------------------------------------------------------------------------------- /kanmail/server/util.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from queue import Queue 3 | from threading import RLock, Thread 4 | from typing import Optional, Union 5 | 6 | from flask import abort 7 | from werkzeug.datastructures import ImmutableMultiDict 8 | 9 | from kanmail.log import logger 10 | from kanmail.settings.constants import DEBUG_LOCKS 11 | 12 | 13 | def lock_function(func): 14 | func.lock = RLock() 15 | 16 | @wraps(func) 17 | def wrapper(*args, **kwargs): 18 | with func.lock: 19 | return func(*args, **kwargs) 20 | 21 | return wrapper 22 | 23 | 24 | def lock_class_method(func): 25 | @wraps(func) 26 | def wrapper(self, *args, **kwargs): 27 | if not hasattr(self, "lock"): 28 | self.lock = RLock() 29 | 30 | if DEBUG_LOCKS: 31 | logger.debug(f"Acquire lock for {self}") 32 | with self.lock: 33 | return_value = func(self, *args, **kwargs) 34 | if DEBUG_LOCKS: 35 | logger.debug(f"Release lock for {self}") 36 | return return_value 37 | 38 | return wrapper 39 | 40 | 41 | sentinel = object() 42 | 43 | 44 | def get_or_400(obj: ImmutableMultiDict, key: str) -> Union[None, str, dict]: 45 | data = obj.get(key, sentinel) 46 | 47 | if data is sentinel: 48 | abort(400, f"missing data: {key}") 49 | 50 | return data 51 | 52 | 53 | def pop_or_400(obj: ImmutableMultiDict, key: str) -> Union[None, str, dict]: 54 | data = obj.pop(key, sentinel) 55 | 56 | if data is sentinel: 57 | abort(400, f"missing data: {key}") 58 | 59 | return data 60 | 61 | 62 | def get_list_or_400(obj: ImmutableMultiDict, key: str, **kwargs) -> Optional[list]: 63 | data = obj.getlist(key, **kwargs) 64 | 65 | if not data: 66 | abort(400) 67 | 68 | return data 69 | 70 | 71 | def execute_threaded(func, args_list): 72 | queue = Queue() 73 | 74 | def wrapper(queue, *args): 75 | try: 76 | output = func(*args) 77 | except Exception as e: 78 | output = e 79 | queue.put(output) 80 | 81 | threads = [] 82 | 83 | for args in args_list: 84 | args = (queue,) + args 85 | thread = Thread(target=wrapper, args=args) 86 | threads.append(thread) 87 | thread.start() 88 | 89 | for thread in threads: 90 | thread.join() 91 | 92 | # Grab the queue - not thread safe but after threads :) 93 | items = list(queue.queue) 94 | 95 | # Raise any exceptions (will only raise first) 96 | for item in items: 97 | if isinstance(item, Exception): 98 | raise item 99 | 100 | return items 101 | -------------------------------------------------------------------------------- /kanmail/server/views/contacts_api.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from flask import Response, abort, jsonify, request, send_file 4 | from sqlalchemy.exc import IntegrityError 5 | 6 | from kanmail.server.app import add_public_route, add_route 7 | from kanmail.server.mail.allowed_images import allow_images_for_email, disallow_images_for_email 8 | from kanmail.server.mail.contacts import Contact, delete_contact, get_contacts, save_contact 9 | from kanmail.server.mail.icon import get_icon_for_email 10 | from kanmail.server.util import get_or_400 11 | 12 | 13 | @add_route("/api/contacts", methods=("GET",)) 14 | def api_get_contacts() -> Response: 15 | """ 16 | Get the contacts list. 17 | """ 18 | 19 | contacts = [contact.to_dict() for contact in get_contacts()] 20 | return jsonify(contacts=contacts) 21 | 22 | 23 | @add_route("/api/contacts", methods=("POST",)) 24 | def api_post_contacts() -> Response: 25 | """ 26 | Create a new contact. 27 | """ 28 | 29 | request_data = request.get_json() 30 | 31 | new_contact = Contact( 32 | name=get_or_400(request_data, "name"), 33 | email=get_or_400(request_data, "email"), 34 | ) 35 | 36 | try: 37 | save_contact(new_contact) 38 | except IntegrityError: 39 | abort(400, "This contact already exists") 40 | 41 | return jsonify(added=True, id=new_contact.id) 42 | 43 | 44 | @add_route("/api/contacts/", methods=("PUT",)) 45 | def api_put_contact(contact_id) -> Response: 46 | """ 47 | Update a single contact. 48 | """ 49 | 50 | request_data = request.get_json() 51 | 52 | contact = Contact.query.get_or_404(contact_id) 53 | contact.name = get_or_400(request_data, "name") 54 | contact.email = get_or_400(request_data, "email") 55 | 56 | try: 57 | save_contact(contact) 58 | except IntegrityError: 59 | abort(400, "This contact already exists") 60 | 61 | return jsonify(updated=True) 62 | 63 | 64 | @add_route("/api/contacts/", methods=("DELETE",)) 65 | def api_delete_contact(contact_id) -> Response: 66 | """ 67 | Delete a single contact. 68 | """ 69 | 70 | contact = Contact.query.get_or_404(contact_id) 71 | delete_contact(contact) 72 | 73 | return jsonify(deleted=True) 74 | 75 | 76 | @add_route("/api/contacts/allow-images/", methods=("PUT",)) 77 | def api_put_images_for_email(email): 78 | allow_images_for_email(email) 79 | return jsonify(added=True) 80 | 81 | 82 | @add_route("/api/contacts/allow-images/", methods=("DELETE",)) 83 | def api_delete_images_for_email(email): 84 | disallow_images_for_email(email) 85 | return jsonify(deleted=True) 86 | 87 | 88 | @add_public_route("/contact-icon/", methods=("GET",)) 89 | def api_get_contact_image(email) -> Response: 90 | data, mimetype = get_icon_for_email(email) 91 | return send_file(BytesIO(data), mimetype=mimetype) 92 | -------------------------------------------------------------------------------- /kanmail/server/views/error.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from flask import Response, jsonify, make_response 4 | from werkzeug.exceptions import HTTPException 5 | 6 | from kanmail.log import logger 7 | from kanmail.server.app import app 8 | from kanmail.server.mail import AccountNotFoundError 9 | from kanmail.server.mail.connection import ConnectionSettingsError, ImapConnectionError 10 | 11 | 12 | @app.errorhandler(400) 13 | @app.errorhandler(401) 14 | @app.errorhandler(404) 15 | @app.errorhandler(405) 16 | def error_bad_request(e) -> Response: 17 | return make_response( 18 | jsonify( 19 | status_code=e.code, 20 | error_name=e.name, 21 | error_message=e.description, 22 | ), 23 | e.code, 24 | ) 25 | 26 | 27 | @app.errorhandler(ConnectionSettingsError) 28 | def error_connection_exception(e) -> Response: 29 | error_name = e.__class__.__name__ 30 | message = f"{e} (account={e.account})" 31 | trace = traceback.format_exc().strip() 32 | logger.warning(f"Connection settings error in view: {message}: {trace}") 33 | return make_response( 34 | jsonify( 35 | status_code=400, 36 | error_name=error_name, 37 | error_message=message, 38 | ), 39 | 400, 40 | ) 41 | 42 | 43 | @app.errorhandler(AccountNotFoundError) 44 | def error_account_not_found(e) -> Response: 45 | error_name = e.__class__.__name__ 46 | message = f"{e}" 47 | logger.warning(message) 48 | return make_response( 49 | jsonify( 50 | status_code=404, 51 | error_name=error_name, 52 | error_message=message, 53 | ), 54 | 404, 55 | ) 56 | 57 | 58 | @app.errorhandler(ImapConnectionError) 59 | def error_network_exception(e) -> Response: 60 | error_name = e.__class__.__name__ 61 | message = f"{e} (account={e.account})" 62 | trace = traceback.format_exc().strip() 63 | logger.warning(f"Network error in view: {message}: {trace}") 64 | return make_response( 65 | jsonify( 66 | status_code=503, 67 | error_name=error_name, 68 | error_message=message, 69 | ), 70 | 503, 71 | ) 72 | 73 | 74 | @app.errorhandler(Exception) 75 | def error_unexpected_exception(e) -> Response: 76 | if isinstance(e, HTTPException): 77 | return e 78 | 79 | error_name = e.__class__.__name__ 80 | message = f"{e}" 81 | trace = traceback.format_exc().strip() 82 | logger.exception(f"Unexpected exception in view: {message}: {trace}") 83 | return make_response( 84 | jsonify( 85 | status_code=500, 86 | error_name=error_name, 87 | error_message=message, 88 | traceback=trace, 89 | ), 90 | 500, 91 | ) 92 | -------------------------------------------------------------------------------- /kanmail/server/views/license_api.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union 2 | 3 | from flask import Response, abort, jsonify, request 4 | 5 | from kanmail.license import LicenseActivationError, activate_license, remove_license 6 | from kanmail.server.app import add_route 7 | from kanmail.server.util import get_or_400 8 | from kanmail.window import reload_main_window 9 | 10 | 11 | @add_route("/api/license", methods=("POST",)) 12 | def api_activate_license() -> Union[Response, Tuple[Response, int]]: 13 | request_data = request.get_json() 14 | license_data = get_or_400(request_data, "license") 15 | 16 | # Cleanup any copy/paste issues with the license 17 | license_data = license_data.strip() 18 | license_data = license_data.strip("-") 19 | license_data = license_data.strip() 20 | 21 | try: 22 | email, token = [line.strip() for line in license_data.splitlines()] 23 | except ValueError: 24 | return ( 25 | jsonify( 26 | activated=False, 27 | error_message="Invalid license format, it should include both email and token.", 28 | ), 29 | 400, 30 | ) 31 | 32 | try: 33 | activate_license(email, token) 34 | except LicenseActivationError as e: 35 | abort(400, f"{e}") 36 | 37 | reload_main_window() 38 | return jsonify(activated=True) 39 | 40 | 41 | @add_route("/api/license", methods=("DELETE",)) 42 | def api_delete_license() -> Response: 43 | remove_license() 44 | reload_main_window() 45 | return jsonify(deleted=True) 46 | -------------------------------------------------------------------------------- /kanmail/server/views/notification_api.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | from kanmail.notifications import send_notification, set_notification_count 4 | from kanmail.server.app import add_route 5 | from kanmail.server.util import get_or_400 6 | 7 | 8 | @add_route("/api/notifications/send", methods=("POST",)) 9 | def notification_send(): 10 | request_data = request.get_json() 11 | send_notification( 12 | title=get_or_400(request_data, "title"), 13 | subtitle=request_data.get("subtitle"), 14 | body=request_data.get("body"), 15 | ) 16 | return "", 204 17 | 18 | 19 | @add_route("/api/notifications/set-count", methods=("POST",)) 20 | def notification_set_count(): 21 | request_data = request.get_json() 22 | set_notification_count( 23 | count=get_or_400(request_data, "count"), 24 | ) 25 | return "", 204 26 | -------------------------------------------------------------------------------- /kanmail/server/views/oauth_api.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from flask import abort, jsonify, redirect, render_template, request 4 | from requests import RequestException 5 | 6 | from kanmail.server.app import add_public_route, add_route 7 | from kanmail.server.mail.oauth import get_oauth_request_url, get_oauth_tokens_from_code 8 | 9 | OAUTH_REQUESTS = {} 10 | 11 | 12 | @add_public_route("/oauth-complete") 13 | def get_oauth_complete(): 14 | return render_template("oauth_complete.html") 15 | 16 | 17 | @add_route("/api/oauth/request", methods=("POST",)) 18 | def make_oauth_request(): 19 | request_data = request.get_json() 20 | oauth_provider = request_data["oauth_provider"] 21 | 22 | uid = str(uuid4()) 23 | OAUTH_REQUESTS[uid] = { 24 | "provider": oauth_provider, 25 | } 26 | 27 | auth_url = get_oauth_request_url(oauth_provider, uid) 28 | 29 | return jsonify(auth_url=auth_url, uid=uid) 30 | 31 | 32 | @add_route("/api/oauth/respond") 33 | def handle_oauth_response(): 34 | # Store the OAuth auth code from the server for the app to retrieve 35 | uid = request.args["uid"] 36 | auth_code = request.args["code"] 37 | 38 | oauth_request = OAUTH_REQUESTS.get(uid) 39 | 40 | if not oauth_request: 41 | return ( 42 | render_template( 43 | "oauth_error.html", 44 | error="OAuth request not found!", 45 | ), 46 | 400, 47 | ) 48 | 49 | try: 50 | oauth_response = get_oauth_tokens_from_code( 51 | oauth_request["provider"], 52 | uid, 53 | auth_code, 54 | ) 55 | except RequestException as e: 56 | return render_template( 57 | "oauth_error.html", 58 | error=f"{e}", 59 | ) 60 | 61 | OAUTH_REQUESTS[uid]["response"] = oauth_response 62 | return redirect("/oauth-complete") 63 | 64 | 65 | @add_route("/api/oauth/response/") 66 | def get_oauth_response(uid): 67 | response = OAUTH_REQUESTS.get(uid) 68 | 69 | if not response: 70 | abort(404, "OAuth response not found!") 71 | 72 | return jsonify(response) 73 | 74 | 75 | @add_route("/api/oauth/response/", methods=("DELETE",)) 76 | def delete_oauth_response(uid): 77 | OAUTH_REQUESTS.pop(uid, None) 78 | return "", 204 79 | -------------------------------------------------------------------------------- /kanmail/server/views/update_api.py: -------------------------------------------------------------------------------- 1 | from flask import Response, abort, jsonify 2 | 3 | from kanmail.server.app import add_route 4 | from kanmail.settings.constants import WATCH_UPDATE 5 | from kanmail.update import check_device_update, update_device 6 | from kanmail.version import get_version 7 | 8 | 9 | @add_route("/api/update", methods=("GET",)) 10 | def api_check_update() -> Response: 11 | if WATCH_UPDATE: 12 | update = check_device_update() 13 | else: 14 | update = None 15 | 16 | if update: 17 | return jsonify(update=update.version, current_version=get_version()) 18 | return jsonify(update=None) 19 | 20 | 21 | @add_route("/api/update", methods=("POST",)) 22 | def api_download_overwrite_update() -> Response: 23 | update = check_device_update() 24 | 25 | if not update: 26 | abort(404, "No update found!") 27 | 28 | update_device(update) 29 | return jsonify(update_ready=True) 30 | -------------------------------------------------------------------------------- /kanmail/server/views/window_api.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from os import environ 3 | from typing import Tuple 4 | 5 | from flask import abort, request 6 | 7 | from kanmail.log import logger 8 | from kanmail.server.app import add_route 9 | from kanmail.window import create_window, destroy_window, resize_window 10 | 11 | 12 | @add_route("/window/open-link", methods=("GET",)) 13 | def open_link() -> Tuple[str, int]: 14 | link = request.args["url"] 15 | 16 | # https://github.com/pyinstaller/pyinstaller/issues/3668 17 | xdg_data_dirs = environ.pop("XDG_DATA_DIRS", None) 18 | try: 19 | if webbrowser.open(link): 20 | return "", 204 21 | finally: 22 | if xdg_data_dirs: 23 | environ["XDG_DATA_DIRS"] = xdg_data_dirs 24 | 25 | logger.critical(f"Failed to open browser link: {link}!") 26 | abort(500, "Could not open link!") 27 | 28 | 29 | @add_route("/window/open", methods=("GET",)) 30 | def open_window() -> Tuple[str, int]: 31 | link = request.args["url"] 32 | 33 | if not create_window( 34 | link, 35 | width=int(request.args["width"]), 36 | height=int(request.args["height"]), 37 | unique_key=request.args.get("unique_key"), 38 | confirm_close=request.args.get("confirm_close"), 39 | resizable=not (request.args.get("resizable") == "false"), 40 | ): 41 | abort(500, f"Could not open {link} window") 42 | return "", 204 43 | 44 | 45 | @add_route("/window/close", methods=("GET",)) 46 | def close_window() -> Tuple[str, int]: 47 | destroy_window(request.args["window_id"]) 48 | return "", 204 49 | 50 | 51 | @add_route("/window/resize", methods=("GET",)) 52 | def window_resize() -> Tuple[str, int]: 53 | resize_window( 54 | request.args["window_id"], 55 | width=int(request.args["width"]), 56 | height=int(request.args["height"]), 57 | ) 58 | return "", 204 59 | -------------------------------------------------------------------------------- /kanmail/settings/hidden.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from base64 import b64decode, b64encode 3 | from functools import lru_cache 4 | from os import environ, path 5 | from typing import Dict 6 | 7 | from .constants import CLIENT_ROOT 8 | 9 | VALID_HIDDEN_KEYS = ( 10 | "SENTRY_DSN", 11 | "POSTHOG_API_KEY", 12 | "GOOGLE_OAUTH_CLIENT_ID", 13 | "GOOGLE_OAUTH_CLIENT_SECRET", 14 | ) 15 | 16 | 17 | @lru_cache(maxsize=1) 18 | def get_hidden_data() -> Dict[str, str]: 19 | hidden_data_filename = path.join(CLIENT_ROOT, "static", "dist", "hidden.json") 20 | 21 | if not path.exists(hidden_data_filename): 22 | return {} 23 | 24 | with open(hidden_data_filename, "rb") as f: 25 | hidden_data = f.read() 26 | 27 | return pickle.loads(b64decode(hidden_data)) 28 | 29 | 30 | def get_hidden_value(key: str) -> str: 31 | if key not in VALID_HIDDEN_KEYS: 32 | raise KeyError(f"Invalid hidden value key: {key}") 33 | 34 | data = get_hidden_data() 35 | value = environ.get(key, data.get(key)) 36 | 37 | if value: 38 | return value 39 | 40 | raise KeyError(f"No hidden value available for key: {key}") 41 | 42 | 43 | def generate_hidden_data() -> Dict[str, str]: 44 | hidden_data = {} 45 | 46 | for key in VALID_HIDDEN_KEYS: 47 | hidden_data[key] = environ[key] 48 | 49 | return b64encode(pickle.dumps(hidden_data)) 50 | -------------------------------------------------------------------------------- /kanmail/update.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from pyupdater.client import Client as PyUpdaterClient 4 | 5 | from kanmail.log import logger 6 | from kanmail.settings.constants import APP_NAME, FROZEN, PyUpdaterConfig 7 | from kanmail.version import get_version_data 8 | 9 | 10 | @lru_cache(maxsize=1) 11 | def get_pyupdater_client() -> PyUpdaterClient: 12 | return PyUpdaterClient(PyUpdaterConfig()) 13 | 14 | 15 | def check_device_update(): 16 | version_data = get_version_data() 17 | client = get_pyupdater_client() 18 | 19 | logger.info( 20 | ( 21 | f'Checking for updates (channel={version_data["channel"]}, ' 22 | f'currentVersion={version_data["version"]})...' 23 | ) 24 | ) 25 | 26 | try: 27 | client.refresh() 28 | except AttributeError: 29 | pass 30 | 31 | update = client.update_check( 32 | APP_NAME, 33 | version_data["version"], 34 | channel=version_data["channel"], 35 | ) 36 | 37 | if not update: 38 | logger.info("No update found") 39 | return 40 | 41 | logger.info(f"Update found: {update.version}") 42 | return update 43 | 44 | 45 | def update_device(update) -> None: 46 | """ 47 | Checks for and downloads any updates for Kanmail - after this it tells the 48 | frontend to render a restart icon. 49 | """ 50 | 51 | update = update or check_device_update() 52 | 53 | if not update: 54 | return 55 | 56 | if not FROZEN: 57 | logger.warning("App not frozen, not fetching update") 58 | return 59 | 60 | logger.debug(f"Downloading update: {update.version}") 61 | update.download() 62 | 63 | logger.debug("Download complete, extracting & overwriting") 64 | update.extract_overwrite() 65 | -------------------------------------------------------------------------------- /kanmail/version.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import lru_cache 3 | from os import path 4 | from typing import Dict 5 | 6 | from kanmail.settings.constants import CLIENT_ROOT 7 | 8 | 9 | @lru_cache(maxsize=1) 10 | def get_version_data() -> Dict[str, str]: 11 | version_filename = path.join(CLIENT_ROOT, "static", "dist", "version.json") 12 | 13 | if not path.exists(version_filename): 14 | return { 15 | "version": "0.0.0dev", 16 | "channel": "alpha", 17 | } 18 | 19 | with open(version_filename, "r") as f: 20 | return json.load(f) 21 | 22 | 23 | def get_version() -> str: 24 | version_data = get_version_data() 25 | return version_data["version"] 26 | -------------------------------------------------------------------------------- /kanmail/window/macos.py: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | 4 | def show_traffic_light_buttons(window): 5 | buttons = [ 6 | window.standardWindowButton_(AppKit.NSWindowCloseButton), 7 | window.standardWindowButton_(AppKit.NSWindowZoomButton), 8 | window.standardWindowButton_(AppKit.NSWindowMiniaturizeButton), 9 | ] 10 | 11 | for button in buttons: 12 | button.setHidden_(False) 13 | 14 | 15 | def reposition_traffic_light_buttons(window): 16 | button = window.standardWindowButton_(AppKit.NSWindowCloseButton) 17 | titlebar_container_view = button.superview().superview() 18 | titlebar_container_rect = titlebar_container_view.frame() 19 | titlebar_container_rect.size.height += 22 20 | titlebar_container_rect.origin.y -= 13 21 | titlebar_container_rect.size.width += 22 22 | titlebar_container_rect.origin.x += 13 23 | titlebar_container_view._.frame = AppKit.NSValue.valueWithRect_(titlebar_container_rect) 24 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from threading import Thread 3 | from time import sleep 4 | 5 | import requests 6 | import webview 7 | 8 | from kanmail.license import validate_or_remove_license 9 | from kanmail.log import logger 10 | from kanmail.server.app import boot, server 11 | from kanmail.server.mail.folder_cache import ( 12 | remove_stale_folders, 13 | remove_stale_headers, 14 | vacuum_folder_cache, 15 | ) 16 | from kanmail.settings import get_window_settings 17 | from kanmail.settings.constants import DEBUG, GUI_LIB, SERVER_HOST 18 | from kanmail.version import get_version 19 | from kanmail.window import create_window, destroy_main_window, init_window_hacks 20 | 21 | 22 | def run_cache_cleanup_later(): 23 | sleep(120) # TODO: make this more intelligent? 24 | remove_stale_folders() 25 | remove_stale_headers() 26 | vacuum_folder_cache() 27 | 28 | 29 | def run_server(): 30 | logger.debug(f"Starting server on {SERVER_HOST}:{server.get_port()}") 31 | 32 | try: 33 | server.serve() 34 | except Exception as e: 35 | logger.exception(f"Exception in server thread!: {e}") 36 | 37 | 38 | def monitor_threads(*threads): 39 | while True: 40 | for thread in threads: 41 | if not thread.is_alive(): 42 | logger.critical(f"Thread: {thread} died, exiting!") 43 | destroy_main_window() 44 | server.stop() 45 | sys.exit(2) 46 | else: 47 | sleep(0.5) 48 | 49 | 50 | def run_thread(target): 51 | def wrapper(thread_name): 52 | try: 53 | target() 54 | except Exception as e: 55 | logger.exception(f"Unexpected exception in thread {thread_name}!: {e}") 56 | 57 | thread = Thread( 58 | target=wrapper, 59 | args=(target.__name__,), 60 | ) 61 | thread.daemon = True 62 | thread.start() 63 | 64 | 65 | def main(): 66 | logger.info(f"\n#\n# Booting Kanmail {get_version()}\n#") 67 | 68 | init_window_hacks() 69 | boot() 70 | 71 | server_thread = Thread(name="Server", target=run_server) 72 | server_thread.daemon = True 73 | server_thread.start() 74 | 75 | run_thread(validate_or_remove_license) 76 | run_thread(run_cache_cleanup_later) 77 | 78 | # Ensure the webserver is up & running by polling it 79 | waits = 0 80 | while waits < 10: 81 | try: 82 | response = requests.get(f"http://{SERVER_HOST}:{server.get_port()}/ping") 83 | response.raise_for_status() 84 | except requests.RequestException as e: 85 | logger.warning(f"Waiting for main window: {e}") 86 | sleep(0.1 * waits) 87 | waits += 1 88 | else: 89 | break 90 | else: 91 | logger.critical("Webserver did not start properly!") 92 | sys.exit(2) 93 | 94 | create_window( 95 | unique_key="main", 96 | **get_window_settings(), 97 | ) 98 | 99 | # Let's hope this thread doesn't fail! 100 | monitor_thread = Thread( 101 | name="Thread monitor", 102 | target=monitor_threads, 103 | args=(server_thread,), 104 | ) 105 | monitor_thread.daemon = True 106 | monitor_thread.start() 107 | 108 | if DEBUG: 109 | sleep(1) # give webpack a second to start listening 110 | 111 | # Start the GUI - this will block until the main window is destroyed 112 | webview.start(gui=GUI_LIB, debug=DEBUG) 113 | 114 | logger.debug("Main window closed, shutting down...") 115 | server.stop() 116 | sys.exit() 117 | 118 | 119 | if __name__ == "__main__": 120 | try: 121 | main() 122 | except Exception: 123 | server.stop() 124 | raise 125 | -------------------------------------------------------------------------------- /make/Dockerfile-ubuntu-linux-build: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 ubuntu:18.04 2 | LABEL maintainer="hello@oxygem.com" 3 | 4 | ENV DEBIAN_FRONTEND noninteractive 5 | 6 | # pyenv recommended build setup 7 | RUN apt-get update 8 | RUN apt-get install -y --no-install-recommends make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev git ca-certificates pkg-config libcairo2-dev libgirepository1.0-dev libgtk-3-dev libwebkit2gtk-4.0-37 gir1.2-webkit2-4.0 9 | 10 | # Remove unncessary stuff 11 | RUN rm -rf /usr/share/icons /usr/share/themes 12 | 13 | # pyenv install 14 | RUN git clone https://github.com/pyenv/pyenv.git ~/.pyenv 15 | ENV HOME /root 16 | ENV PYENV_ROOT $HOME/.pyenv 17 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 18 | 19 | # Python install 20 | ADD .python-version /opt/kanmail/.python-version 21 | WORKDIR /opt/kanmail 22 | RUN env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install `cat .python-version` -v 23 | 24 | # Finally, install the linux requirements 25 | RUN pip install pip -U 26 | ADD requirements /opt/kanmail/requirements 27 | RUN pip install -r requirements/linux.txt 28 | -------------------------------------------------------------------------------- /make/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/make/__init__.py -------------------------------------------------------------------------------- /make/build_linux_client_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | 6 | docker build \ 7 | -t kanmail-ubuntu-linux-build \ 8 | -f make/Dockerfile-ubuntu-linux-build \ 9 | . 10 | 11 | docker run \ 12 | -v `pwd`:/opt/kanmail \ 13 | -e SENTRY_DSN=$SENTRY_DSN \ 14 | -e POSTHOG_API_KEY=$POSTHOG_API_KEY \ 15 | -e GOOGLE_OAUTH_CLIENT_ID=$GOOGLE_OAUTH_CLIENT_ID \ 16 | -e GOOGLE_OAUTH_CLIENT_SECRET=$GOOGLE_OAUTH_CLIENT_SECRET \ 17 | kanmail-ubuntu-linux-build \ 18 | python -m make $@ 19 | -------------------------------------------------------------------------------- /make/clean.py: -------------------------------------------------------------------------------- 1 | from os import path, unlink 2 | 3 | import click 4 | 5 | from .settings import TEMP_SPEC_FILENAME 6 | from .util import print_and_run 7 | 8 | if __name__ == "__main__": 9 | for filename in (TEMP_SPEC_FILENAME,): 10 | if path.exists(filename): 11 | click.echo(f"Removing {filename}") 12 | unlink(filename) 13 | 14 | print_and_run("rm -rf dist/* dist/.changelog build/* pyu-data/new/*", shell=True) 15 | print_and_run(("git", "checkout", "--", "CHANGELOG.md")) 16 | 17 | click.echo("Cleaning complete!") 18 | -------------------------------------------------------------------------------- /make/entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.disable-library-validation 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /make/github-config.pyu: -------------------------------------------------------------------------------- 1 | { 2 | "app_config": { 3 | "APP_NAME": "Kanmail" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /make/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/make/icon.icns -------------------------------------------------------------------------------- /make/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/make/icon.ico -------------------------------------------------------------------------------- /make/macos.py: -------------------------------------------------------------------------------- 1 | from os import path, unlink 2 | from shutil import rmtree 3 | 4 | from .settings import CODESIGN_KEY_NAME, NOTARIZE_PASSWORD_KEYCHAIN_NAME 5 | from .util import print_and_check_output, print_and_run 6 | 7 | 8 | def codesign(app_dir): 9 | print_and_run( 10 | ( 11 | "codesign", 12 | "--deep", 13 | "--timestamp", 14 | "--force", 15 | "--options", 16 | "runtime", 17 | "--entitlements", 18 | "make/entitlements.plist", 19 | "--sign", 20 | CODESIGN_KEY_NAME, 21 | app_dir, 22 | ), 23 | ) 24 | print_and_run(("codesign", "--deep", "--verify", app_dir)) 25 | 26 | 27 | def notarize(version, app_dir, zip_filename): 28 | print_and_run(("ditto", "-c", "-k", "--keepParent", app_dir, zip_filename)) 29 | 30 | try: 31 | notarize_response = print_and_check_output( 32 | ( 33 | "xcrun", 34 | "notarytool", 35 | "submit", 36 | zip_filename, 37 | "--keychain-profile", 38 | NOTARIZE_PASSWORD_KEYCHAIN_NAME, 39 | "--wait", 40 | ), 41 | ) 42 | 43 | if "status: Accepted" not in notarize_response: 44 | raise Exception("Failed to notarize app") 45 | 46 | print_and_run(("xcrun", "stapler", "staple", app_dir)) 47 | print_and_run(("xcrun", "stapler", "validate", app_dir)) 48 | finally: 49 | unlink(zip_filename) 50 | 51 | 52 | def codesign_and_notarize(version): 53 | new_builds_dir = path.join("pyu-data", "new") 54 | 55 | filename = path.join(new_builds_dir, f"Kanmail-mac-{version}.tar.gz") 56 | print_and_run(("gtar", "-C", new_builds_dir, "-xzf", filename)) 57 | app_name = "Kanmail.app" 58 | zip_filename = path.join(new_builds_dir, f"{app_name}.zip") 59 | app_dir = path.join(new_builds_dir, app_name) 60 | 61 | codesign(app_dir) 62 | notarize(version, app_dir, zip_filename) 63 | 64 | print_and_run(("gtar", "-C", new_builds_dir, "-zcf", filename, app_name)) 65 | 66 | # Remove the app now we've tar-ed it up 67 | rmtree(app_dir) 68 | -------------------------------------------------------------------------------- /make/settings.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from os import environ, path 3 | 4 | MAJOR_VERSION = 1 5 | 6 | ROOT_DIRNAME = path.normpath(path.join(path.abspath(path.dirname(__file__)), "..")) 7 | DIST_DIRNAME = path.join(ROOT_DIRNAME, "dist") 8 | MAKE_DIRNAME = path.join(ROOT_DIRNAME, "make") 9 | 10 | NEW_BUILDS_DIRNAME = path.join(ROOT_DIRNAME, "pyu-data", "new") 11 | 12 | TEMP_SPEC_FILENAME = path.join(DIST_DIRNAME, ".spec") 13 | TEMP_CHANGELOG_NAME = path.join(DIST_DIRNAME, ".changelog") 14 | 15 | VERSION_DATA_FILENAME = path.join(DIST_DIRNAME, "version.json") 16 | HIDDEN_DATA_FILENAME = path.join(DIST_DIRNAME, "hidden.json") 17 | 18 | DOCKER_NAME = "fizzadar/kanmail" 19 | 20 | GITHUB_API_TOKEN = environ.get("GITHUB_API_TOKEN") 21 | 22 | 23 | # MacOS build settings 24 | # 25 | 26 | CODESIGN_KEY_NAME = environ.get("CODESIGN_KEY_NAME") 27 | 28 | NOTARIZE_TEAM_ID = environ.get("NOTARIZE_TEAM_ID") 29 | NOTARIZE_PASSWORD_KEYCHAIN_NAME = environ.get("NOTARIZE_PASSWORD_KEYCHAIN_NAME") 30 | 31 | MACOSX_DEPLOYMENT_TARGET = "10.9" 32 | 33 | 34 | # Requirements management 35 | # 36 | 37 | REQUIREMENTS_DIRNAME = path.join(ROOT_DIRNAME, "requirements") 38 | 39 | platform = platform.system().lower() 40 | if platform == "darwin": 41 | platform = "macos" 42 | 43 | REQUIREMENTS_FILENAME = path.join(REQUIREMENTS_DIRNAME, f"{platform}.txt") 44 | DEVELOPMENT_REQUIREMENTS_FILENAME = path.join(REQUIREMENTS_DIRNAME, f"{platform}-development.txt") 45 | 46 | BASE_REQUIREMENTS_FILENAME = path.join(REQUIREMENTS_DIRNAME, "base.in") 47 | BASE_DEVELOPMENT_REQUIREMENTS_FILENAME = path.join(REQUIREMENTS_DIRNAME, "base-development.in") 48 | -------------------------------------------------------------------------------- /make/spec.j2.py: -------------------------------------------------------------------------------- 1 | a = Analysis( # noqa: F821 2 | [r'{{ root_dir }}/main.py'], 3 | pathex=[ 4 | r'{{ root_dir }}', 5 | ], 6 | binaries=[], 7 | datas=[ 8 | (r'{{ root_dir }}/LICENSE.md', '.'), 9 | (r'{{ root_dir }}/CHANGELOG.md', '.'), 10 | (r'{{ root_dir }}/kanmail/client/icon.png', '.'), 11 | 12 | (r'{{ root_dir }}/kanmail/client/templates', 'templates'), 13 | 14 | # Generated at build time 15 | (r'{{ root_dir }}/dist/version.json', 'static/dist'), 16 | (r'{{ root_dir }}/dist/hidden.json', 'static/dist'), 17 | (r'{{ root_dir }}/dist/', 'static/dist/{{ version }}'), 18 | 19 | # TLD names 20 | (r'{{ tld_package_dir }}/res/effective_tld_names.dat.txt', 'tld/res'), 21 | ], 22 | hiddenimports=[ 23 | {%- if platform_name == 'win' -%} # noqa 24 | 'win32timezone', 25 | {%- endif -%} # noqa 26 | ], 27 | hookspath=[ 28 | r'{{ pyupdater_package_dir }}/hooks', 29 | ], 30 | runtime_hooks=[], 31 | excludes=[], 32 | win_no_prefer_redirects=False, 33 | win_private_assemblies=False, 34 | cipher=None, 35 | {% if platform_name != 'mac' %} # noqa 36 | noarchive=False, 37 | {% endif %} # noqa 38 | ) 39 | 40 | 41 | {% if platform_name == 'nix64' %} 42 | # Fixup Linux builds 43 | # Here we *remove* a lot of the shared libraries Pyinstaller picks up during the 44 | # analysis. We only include the minimum number of libraries, and rely on the 45 | # underlying system libraries elsewhere (ie GTK + Webkit). This improves portability 46 | # and reduces the Linux build size significantly. 47 | 48 | def _should_include_binary(binary_tuple): 49 | import fnmatch 50 | 51 | dest = binary_tuple[0] 52 | if dest.startswith('lib-dynload'): 53 | return True 54 | 55 | src = binary_tuple[1] 56 | if fnmatch.fnmatch(src, '*python*'): 57 | return True 58 | 59 | if not src.startswith('/lib') and not src.startswith('/usr/lib'): 60 | return True 61 | 62 | print('Skip bundling library: {0} -> {1}'.format(src, dest)) 63 | return False 64 | 65 | a.binaries = list(filter(_should_include_binary, a.binaries)) 66 | {% endif %} 67 | 68 | 69 | # Generate the executable 70 | # 71 | 72 | pyz = PYZ( # noqa: F821 73 | a.pure, a.zipped_data, 74 | cipher=None, 75 | ) 76 | 77 | exe = EXE( # noqa: F821 78 | pyz, a.scripts, 79 | 80 | {% if platform_name == 'mac' or onedir %} # noqa 81 | [], 82 | exclude_binaries=True, 83 | {% elif platform_name in ('nix64', 'win') %} # noqa 84 | a.binaries, a.zipfiles, a.datas, [], 85 | {% endif %} # noqa 86 | 87 | name='{{ platform_name }}', 88 | debug=False, 89 | bootloader_ignore_signals=False, 90 | strip=False, 91 | upx=False, 92 | 93 | {% if platform_name == 'mac' %} # noqa 94 | console=False, 95 | {% elif platform_name == 'nix64' %} # noqa 96 | runtime_tmpdir=None, 97 | console=True, 98 | {% elif platform_name == 'win' %} # noqa 99 | runtime_tmpdir=None, 100 | console=False, 101 | icon=r'{{ root_dir }}/make/icon.ico', 102 | {% endif %} # noqa 103 | ) 104 | 105 | 106 | # Generate the directory (for Mac bundle or if --onedir) 107 | # 108 | 109 | {% if platform_name == 'mac' or onedir %} # noqa 110 | coll = COLLECT( # noqa: F821 111 | exe, a.binaries, a.zipfiles, a.datas, 112 | strip=False, 113 | upx=False, 114 | name='{{ platform_name }}', 115 | ) 116 | {% endif %} # noqa 117 | 118 | 119 | # Build MacOS app bundle 120 | # 121 | 122 | {% if platform_name == 'mac' %} # noqa 123 | app = BUNDLE( # noqa: F821 124 | coll, 125 | name='mac.app', 126 | icon=r'{{ root_dir }}/make/icon.icns', 127 | bundle_identifier=None, 128 | info_plist={ 129 | # Provides retina support 130 | 'NSHighResolutionCapable': 'True', 131 | # Set the app bundle version 132 | 'CFBundleShortVersionString': '{{ version }}', 133 | }, 134 | ) 135 | {% endif %} # noqa 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kanmail", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Nick Barrett ", 6 | "license": "SEE LICENSE IN LICENSE.md", 7 | "dependencies": { 8 | "@sentry/react": "6.x", 9 | "babel-core": "6.x", 10 | "babel-eslint": "8.x", 11 | "babel-loader": "7.x", 12 | "babel-plugin-transform-class-properties": "6.x", 13 | "babel-plugin-transform-decorators-legacy": "1.x", 14 | "babel-plugin-transform-object-rest-spread": "6.x", 15 | "babel-preset-es2015": "6.x", 16 | "babel-preset-react": "6.x", 17 | "css-loader": "4.x", 18 | "draft-convert": "^2.1.12", 19 | "draft-js": "0.10.5", 20 | "draftail": "1.x", 21 | "eslint": "7.x", 22 | "eslint-import-resolver-webpack": "0.x", 23 | "eslint-plugin-babel": "4.x", 24 | "eslint-plugin-import": "2.x", 25 | "eslint-plugin-react": "7.x", 26 | "file-loader": "6.x", 27 | "immutability-helper": "2.x", 28 | "immutable": "3.x", 29 | "less": "3.x", 30 | "less-loader": "7.x", 31 | "lodash": "4.x", 32 | "mini-css-extract-plugin": "1.x", 33 | "moment": "2.x", 34 | "optimize-css-assets-webpack-plugin": "5.x", 35 | "posthog-js": "1.x", 36 | "prop-types": "15.x", 37 | "randomcolor": "0.x", 38 | "react": "16.x", 39 | "react-dnd": "2.x", 40 | "react-dnd-html5-backend": "2.x", 41 | "react-dom": "16.x", 42 | "react-select": "2.x", 43 | "seamless-scroll-polyfill": "1.x", 44 | "terser-webpack-plugin": "4.x", 45 | "urijs": "1.x", 46 | "webpack": "4.x", 47 | "webpack-cli": "4.x", 48 | "webpack-dev-server": "3.x", 49 | "whatwg-fetch": "2.x", 50 | "eslint-plugin-prettier": "4.2.1", 51 | "prettier": "2.7.1" 52 | }, 53 | "scripts": { 54 | "build": "webpack --mode=production", 55 | "dev": "webpack serve --mode=development", 56 | "lint": "eslint --cache --ext .js,.jsx kanmail/client/", 57 | "lint-fix": "eslint --cache --fix --ext .js,.jsx kanmail/client/" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target-version = ["py36"] 4 | 5 | [tool.isort] 6 | profile = "black" 7 | line_length = 100 8 | combine_as_imports = true 9 | include_trailing_comma = true 10 | 11 | [tool.poetry] 12 | name = "Kanmail" 13 | version = "0.0.0" 14 | description = "" 15 | authors = ["Oxygem "] 16 | license = "Kanmail EULA" 17 | 18 | [tool.poetry.dependencies] 19 | python = ">=3.8,<3.10" 20 | 21 | pywebview = "3.6.3" 22 | PyUpdater = "4.0.0" 23 | pyinstaller = "4.10" 24 | dsdev-utils = "1.0.5" 25 | cheroot = "8.3.0" 26 | click = "8.1.3" 27 | Flask = "2.1.3" 28 | Flask-SQLAlchemy = "2.5.1" 29 | SQLAlchemy = "1.3.13" 30 | Markdown = "2.6.9" 31 | mdx-linkify = "1.0" 32 | dnspython = "1.15.0" 33 | defusedxml = "0.7.0" 34 | tld = "0.9.1" 35 | requests = "2.25.1" 36 | keyring = "19.2.0" 37 | "keyrings.alt" = "3.4.0" 38 | IMAPClient = "2.2.0" 39 | sentry-sdk = {version = "1.1.0", extras = ["flask"]} 40 | cffi = "1.15.0" 41 | MarkupSafe = "2.0.1" 42 | pyobjc-core = {version = "8.5", platform = "darwin"} 43 | pyobjc-framework-Cocoa = {version = "8.5", platform = "darwin"} 44 | pyobjc-framework-WebKit = {version = "8.5", platform = "darwin"} 45 | pyobjc-framework-UserNotifications = {version = "8.5", platform = "darwin"} 46 | PyGObject = {version = "3.36.1", platform = "linux"} 47 | pywin32 = {version = "301", platform = "windows"} 48 | 49 | [tool.poetry.dev-dependencies] 50 | honcho = "1.0.1" 51 | PyUpdater-S3-Plugin = "4.1.2" 52 | Faker = "0.9.2" 53 | pytest = "6.2.5" 54 | mypy = "^0.961" 55 | flake8 = "^4.0.1" 56 | flake8-black = "^0.3.3" 57 | flake8-isort = "^4.1.1" 58 | flake8-commas = "^2.1.0" 59 | ipython = "^8.4.0" 60 | 61 | [build-system] 62 | requires = ["poetry-core>=1.0.0"] 63 | build-backend = "poetry.core.masonry.api" 64 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/screenshot.png -------------------------------------------------------------------------------- /scripts/run_cache_cleanup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | sys.path.append('.') # noqa: E402 6 | 7 | from kanmail.server.mail.folder_cache import ( # noqa: E402 8 | remove_stale_folders, 9 | remove_stale_headers, 10 | vacuum_folder_cache, 11 | ) 12 | 13 | 14 | print('--> Removing stale folders...') 15 | remove_stale_folders() 16 | 17 | print('--> Removing stale headers...') 18 | remove_stale_headers() 19 | 20 | print('--> Vacuuming!') 21 | vacuum_folder_cache() 22 | -------------------------------------------------------------------------------- /scripts/run_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.append('.') # noqa: E402 7 | os.environ['KANMAIL_MODE'] = 'server' # noqa: E402 8 | 9 | from kanmail.server.app import app, boot 10 | from kanmail.settings.constants import DEBUG, SERVER_PORT 11 | 12 | 13 | # Bootstrap the server, but don't prep cheroot itself (we'll use Flask devserver) 14 | boot(prepare_server=False) 15 | 16 | # Run the server 17 | app.run( 18 | host='0.0.0.0', 19 | port=SERVER_PORT, 20 | threaded=True, 21 | debug=DEBUG, 22 | ) 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | 4 | [mypy] 5 | ignore_missing_imports = true 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Oxygem/Kanmail/ba80dff0689196fc019e06ab57e4d03e88b90073/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_app_run.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | 4 | from subprocess import PIPE, Popen 5 | from time import sleep 6 | from unittest import TestCase 7 | 8 | import requests 9 | 10 | 11 | SESSION_ID_REGEX = r'App session token is: ([a-z0-9\-]+)' 12 | WINDOW_ID_REGEX = r'Opening window \(([a-z0-9\-]+)\)' 13 | 14 | 15 | class TestAppRun(TestCase): 16 | def test_app_run(self): 17 | p = Popen((sys.executable, 'main.py'), stderr=PIPE) 18 | 19 | try: 20 | sleep(5) 21 | 22 | output = p.stderr.peek().decode().splitlines() 23 | 24 | session_id = None 25 | window_id = None 26 | 27 | for line in output: 28 | session_id_match = re.findall(SESSION_ID_REGEX, line) 29 | if session_id_match: 30 | session_id = session_id_match 31 | 32 | window_id_match = re.findall(WINDOW_ID_REGEX, line) 33 | if window_id_match: 34 | window_id = window_id_match 35 | 36 | assert session_id is not None 37 | assert window_id is not None 38 | 39 | ping_response = requests.get('http://127.0.0.1:4420/ping') 40 | ping_response.raise_for_status() 41 | assert ping_response.json() == {'ping': 'pong'} 42 | 43 | close_window_response = requests.get( 44 | 'http://127.0.0.1:4420/window/close', 45 | params={ 46 | 'Kanmail-Session-Token': session_id, 47 | 'window_id': window_id, 48 | }, 49 | ) 50 | close_window_response.raise_for_status() 51 | 52 | assert close_window_response.status_code == 204 53 | assert p.poll() is None 54 | 55 | finally: 56 | attempts = 0 57 | 58 | while p.poll() is not None: 59 | if attempts > 30: 60 | break 61 | 62 | p.terminate() 63 | sleep(1) 64 | attempts += 1 65 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* global __dirname process */ 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const TerserJSPlugin = require('terser-webpack-plugin'); 7 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 8 | 9 | 10 | const plugins = [ 11 | new MiniCssExtractPlugin(), 12 | // Ignore moment/locale, see: https://github.com/moment/moment/issues/2416 13 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 14 | ]; 15 | let mode = 'development'; 16 | 17 | if (process.env.NODE_ENV == 'production') { 18 | mode = 'production'; 19 | 20 | plugins.push( 21 | new webpack.DefinePlugin({ 22 | 'process.env': { 23 | NODE_ENV: JSON.stringify('production'), 24 | }, 25 | }), 26 | ); 27 | plugins.push(new webpack.optimize.OccurrenceOrderPlugin()); 28 | } 29 | 30 | const modulesDirectory = path.join(__dirname, 'node_modules'); 31 | const getAppMainFile = app => path.join( 32 | __dirname, 'kanmail', 'client', 'components', app, 'main.js', 33 | ); 34 | 35 | module.exports = { 36 | mode: mode, 37 | plugins: plugins, 38 | entry: { 39 | emails: getAppMainFile('emails'), 40 | send: getAppMainFile('send'), 41 | settings: getAppMainFile('settings'), 42 | contacts: getAppMainFile('contacts'), 43 | license: getAppMainFile('license'), 44 | meta: getAppMainFile('meta'), 45 | metaFile: getAppMainFile('metaFile'), 46 | }, 47 | output: { 48 | path: path.join(__dirname, 'dist'), 49 | publicPath: '/static/dist/', 50 | filename: '[name].js', 51 | }, 52 | devServer: { 53 | port: 4421, 54 | contentBase: path.join(__dirname, 'kanmail', 'client', 'static'), 55 | publicPath: '/static/dist/', 56 | headers: { 57 | 'Access-Control-Allow-Origin': '*', 58 | }, 59 | }, 60 | resolve: { 61 | modules: [ 62 | path.join(__dirname, 'kanmail', 'client'), 63 | modulesDirectory, 64 | ], 65 | }, 66 | resolveLoader: { 67 | modules: [ 68 | modulesDirectory, 69 | ], 70 | }, 71 | optimization: { 72 | minimizer: [ 73 | new TerserJSPlugin({ 74 | terserOptions: { 75 | mangle: false, 76 | keep_classnames: true, 77 | keep_fnames: true, 78 | }, 79 | }), 80 | new OptimizeCSSAssetsPlugin({}), 81 | ], 82 | splitChunks: { 83 | cacheGroups: { 84 | shared: { 85 | name: 'shared', 86 | chunks: 'initial', 87 | minChunks: 2, 88 | }, 89 | } 90 | }, 91 | }, 92 | module: { 93 | rules: [ 94 | { 95 | test: /\.(js|jsx)$/, 96 | exclude: /node_modules/, 97 | use: ['babel-loader'], 98 | }, 99 | { 100 | test: /\.(less|css)$/, 101 | use: [ 102 | MiniCssExtractPlugin.loader, 103 | 'css-loader', 104 | 'less-loader', 105 | ], 106 | }, 107 | { 108 | test: /\.(woff|woff2|eot|ttf|svg|png)$/, 109 | use: ['file-loader'], 110 | }, 111 | ], 112 | }, 113 | } 114 | --------------------------------------------------------------------------------