├── .github ├── actions │ └── podman-mlr │ │ └── action.yml └── workflows │ └── check.yaml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── app.html ├── app.js ├── app.less ├── base.less ├── editor.html ├── editor.js ├── editor.less ├── favicon.png ├── filters.html ├── filters.js ├── filters.less ├── font │ ├── LICENSE.txt │ ├── icons.eot │ ├── icons.less │ ├── icons.less.tpl │ ├── icons.svg │ ├── icons.ttf │ ├── icons.woff │ └── selection.json ├── login.html ├── login.js ├── login.less ├── msg.html ├── msg.js ├── msg.less ├── msgs.html ├── msgs.js ├── msgs.less ├── picker.html ├── picker.js ├── picker.less ├── slider.html ├── slider.js ├── slider.less ├── tags.html ├── tags.js ├── tags.less ├── theme-base.less ├── theme-indigo.less ├── theme-mint.less ├── theme-solarized.less ├── utils.js └── webpack.config.js ├── bin ├── activate ├── deploy ├── install ├── install-dovecot ├── install-idle-sync ├── install-on-ubuntu ├── install-services ├── install-test ├── install-users ├── install-vmail ├── manage.py ├── run-lint └── run-web ├── mailur ├── __init__.py ├── cache.py ├── cli.py ├── html.py ├── imap.py ├── imap_utf7.py ├── local.py ├── lock.py ├── message.py ├── remote.py ├── schema.py └── web.py ├── package.json ├── setup.cfg ├── setup.py └── tests ├── conftest.py ├── files ├── msg-attachments-one-gmail.txt ├── msg-attachments-rus-content-id.txt ├── msg-attachments-textfile.txt ├── msg-attachments-two-gmail.txt ├── msg-attachments-two-yandex.txt ├── msg-embeds-one-gmail.txt ├── msg-encoding-cp1251-alias.txt ├── msg-encoding-cp1251-chardet.txt ├── msg-encoding-empty-charset.txt ├── msg-encoding-parts-in-koi8r.txt ├── msg-encoding-saved-in-koi8r.txt ├── msg-encoding-subject-gb2312.txt ├── msg-from-ending-snail.txt ├── msg-from-rss2email.txt ├── msg-header-with-encoding.txt ├── msg-header-with-long-addresses.txt ├── msg-header-with-no-encoding.txt ├── msg-header-with-no-msgid.txt ├── msg-header-with-nospace-in-msgid.txt ├── msg-links.txt ├── msg-lookup-error.txt └── msg-rfc822.txt ├── test_cli.py ├── test_gmail.py ├── test_imap.py ├── test_local.py ├── test_message.py ├── test_sync.py └── test_web.py /.github/actions/podman-mlr/action.yml: -------------------------------------------------------------------------------- 1 | name: mlr container 2 | description: prepare mlr container 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: hash for podman image 7 | id: podman_image_hash 8 | run: | 9 | echo "::set-output name=value::$(/bin/find bin .github -type f -exec md5sum {} \; | sort -k 2 | md5sum)" 10 | shell: bash 11 | - uses: actions/cache@v2 12 | with: 13 | path: podman 14 | key: ${{ runner.os }}-podman-${{ steps.podman_image_hash.outputs.value }} 15 | 16 | - name: prepare mlr image 17 | shell: bash 18 | run: | 19 | set -exuo pipefail 20 | if [ ! -f podman/mlr.tar ]; then 21 | bin/install-on-ubuntu 22 | mkdir -p podman 23 | sudo podman export mlr > podman/mlr.tar 24 | sudo podman commit mlr mlr 25 | sudo podman rm -f mlr 26 | else 27 | sudo podman import podman/mlr.tar localhost/mlr 28 | fi 29 | 30 | - name: prepare mlr container 31 | shell: bash 32 | run: | 33 | sudo podman run -v .:/opt/mailur --name mlr -d mlr /sbin/init 34 | 35 | cat << EOF | sudo podman exec -i -w /opt/mailur mlr /bin/bash 36 | set -exuo pipefail 37 | 38 | systemctl disable dnf-makecache.timer 39 | 40 | bin/install 41 | bin/install-test 42 | EOF 43 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check code 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | 9 | - name: hash for podman image 10 | id: podman_image_hash 11 | run: | 12 | echo "::set-output name=value::$(/bin/find bin .github -type f -exec md5sum {} \; | sort -k 2 | md5sum)" 13 | shell: bash 14 | - uses: actions/cache@v2 15 | with: 16 | path: podman 17 | key: ${{ runner.os }}-podman-${{ steps.podman_image_hash.outputs.value }} 18 | 19 | - name: Install 20 | run: | 21 | bin/install-on-ubuntu 22 | mkdir -p podman 23 | sudo podman export mlr > podman/mlr.tar 24 | 25 | lint: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: ./.github/actions/podman-mlr 30 | 31 | - name: lint 32 | run: | 33 | cat << EOF | sudo podman exec -i -w /opt/mailur mlr /bin/bash 34 | set -exuo pipefail 35 | 36 | . bin/activate 37 | bin/manage.py lint --ci 38 | EOF 39 | 40 | test: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: ./.github/actions/podman-mlr 45 | 46 | - name: test 47 | run: | 48 | cat << EOF | sudo podman exec -i -w /opt/mailur mlr /bin/bash 49 | set -exuo pipefail 50 | 51 | . bin/activate 52 | bin/manage.py test 53 | EOF 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.py[co] 4 | .cache 5 | .coverage* 6 | .eggs 7 | 8 | /assets/dist 9 | /bin/env 10 | /bin/install-local 11 | /env 12 | /var 13 | /node_modules 14 | /yarn.lock 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Mailur] is a lightweight webmail inspired by Gmail 2 | 3 | ## Features 4 | - multiple tags for messages (no folders) 5 | - [manually linking threads](https://pusto.org/mailur/features/#link-threads) 6 | - [Sieve scripts for email filtering](https://pusto.org/mailur/features/#sieve-scripts) 7 | - [composing messages with Markdown](https://pusto.org/mailur/features/#markdown) 8 | - [independent split pane](https://pusto.org/mailur/features/#the-split-pane) 9 | - easy to switch from threads view to messages view 10 | - slim and compact interface with few basic themes 11 | - ... 12 | 13 | Brand-new version uses [Dovecot as main storage][mlr-dovecot], no database required. 14 | 15 | This version is already in use. It has minimal feature set I need on daily basis. I have big plans for this project and I'm still working on it when I have spare time. 16 | 17 | ### Related links 18 | - [public demo][demo] (credentials: demo/demo) 19 | - [installation][install] 20 | 21 | ![Screenshots](https://pusto.org/mailur/features/the-split-pane.gif) 22 | 23 | [Mailur]: https://pusto.org/mailur/ 24 | [demo]: http://demo.pusto.org 25 | [install]: https://pusto.org/mailur/installation/ 26 | [vimeo]: https://vimeo.com/259140545 27 | [mlr-dovecot]: https://pusto.org/mailur/dovecot/ 28 | [mlr-features]: https://pusto.org/mailur/features/ 29 | [Markdown]: https://daringfireball.net/projects/markdown/syntax 30 | 31 | ### Updates 32 | - `[Nowadays]` On pause... 33 | - `[May 2020]` Two-way syncing for five Gmail flags and labels (details here [#13]) 34 | - `[Mar 2019]` [Feature overview.][mlr-features] 35 | - `[Mar 2019]` [Dovecot as main storage.][mlr-dovecot] 36 | - `[Nov 2015]` [`code`][v02code] [The alpha version.][v02post] Postgres based. We used it over 2 years on daily basis. 37 | - `[Apr 2014]` [`code`][v01code] [The first prototype.][v01post] 38 | 39 | [#13]: https://github.com/naspeh/mailur/issues/13 40 | [v02code]: https://github.com/naskoro/mailur-pg 41 | [v02post]: https://pusto.org/mailur/alpha/ 42 | [v01code]: https://github.com/naskoro/mailur-pg/tree/prototype 43 | [v01post]: https://pusto.org/mailur/intro/ 44 | -------------------------------------------------------------------------------- /assets/app.html: -------------------------------------------------------------------------------- 1 |
8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 |
23 |
24 |
25 | Please find to open things in split pane... 26 |
27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import './favicon.png'; 3 | import './picker.js'; 4 | import './tags.js'; 5 | import './editor.js'; 6 | import './filters.js'; 7 | import './msg.js'; 8 | import { call } from './utils.js'; 9 | import msgs from './msgs.js'; 10 | import tpl from './app.html'; 11 | 12 | Vue.component('app', { 13 | template: tpl, 14 | data: function() { 15 | let splitIsPossible = window.innerWidth > 1200; 16 | return { 17 | tags: window.data.tags.info, 18 | tagIds: window.data.tags.ids, 19 | tagIdsEdit: window.data.tags.ids_edit, 20 | addrs: [], 21 | picSize: 20, 22 | tagCount: 5, 23 | opts: { 24 | bigger: false, 25 | fixPrivacy: false, 26 | query: null, 27 | split: splitIsPossible, 28 | splitQuery: ':threads tag:#inbox', 29 | filters: false 30 | }, 31 | optsKey: `${window.data.user}:opts`, 32 | splitIsPossible: splitIsPossible 33 | }; 34 | }, 35 | created: function() { 36 | window.app = this; 37 | }, 38 | mounted: function() { 39 | let opts = window.localStorage.getItem(this.optsKey); 40 | if (opts) { 41 | this.opts = JSON.parse(opts); 42 | this.reloadOpts(); 43 | } 44 | 45 | this.openFromHash(); 46 | 47 | window.onhashchange = () => { 48 | this.openFromHash(); 49 | }; 50 | }, 51 | computed: { 52 | allTags: function() { 53 | let tags = []; 54 | for (let i of this.tagIds) { 55 | let tag = this.tags[i]; 56 | if (tag.unread || tag.pinned) { 57 | tags.push(i); 58 | } 59 | } 60 | return tags; 61 | } 62 | }, 63 | methods: { 64 | refreshData: function() { 65 | call('get', '/index-data').then(res => { 66 | if (res.errors) { 67 | return; 68 | } 69 | window.data = res; 70 | let tags = res.tags; 71 | this.tags = Object.assign({}, tags.info); 72 | this.tagIds = tags.ids; 73 | this.tagIdsEdit = tags.ids_edit; 74 | }); 75 | }, 76 | setOpt: function(name, value) { 77 | this.opts[name] = value; 78 | window.localStorage.setItem(this.optsKey, JSON.stringify(this.opts)); 79 | this.reloadOpts(); 80 | }, 81 | toggleOpt: function(name) { 82 | this.setOpt(name, !this.opts[name]); 83 | }, 84 | reloadOpts: function() { 85 | let html = document.querySelector('html'); 86 | html.classList.toggle('opt--bigger', this.opts.bigger); 87 | html.classList.toggle('opt--fix-privacy', this.opts.fixPrivacy); 88 | if (!this.split && this.opts.split && this.opts.splitQuery) { 89 | this.openInSplit(this.opts.splitQuery); 90 | } 91 | }, 92 | compose: function() { 93 | this.main 94 | .call('get', '/compose') 95 | .then(res => this.openInMain(res.query_edit)); 96 | }, 97 | openFromHash: function() { 98 | let q = decodeURIComponent(location.hash.slice(1)); 99 | if (q) { 100 | // pass 101 | } else if (this.opts.query !== null) { 102 | q = this.opts.query; 103 | } else { 104 | q = ':threads tag:#inbox'; 105 | } 106 | if (!this.main || this.main.query != q) { 107 | this.openInMain(q); 108 | } 109 | }, 110 | openInMain: function(q) { 111 | window.location.hash = q; 112 | this.opts.query == q || this.setOpt('query', q); 113 | 114 | let view = msgs({ 115 | cls: 'main', 116 | query: q, 117 | open: this.openInMain, 118 | pics: this.pics 119 | }); 120 | if (this.main) { 121 | this.main.newQuery(q); 122 | } else { 123 | view.mount(); 124 | } 125 | this.main = view; 126 | }, 127 | openInSplit: function(q) { 128 | this.opts.split || this.setOpt('split', true); 129 | this.opts.splitQuery == q || this.setOpt('splitQuery', q); 130 | 131 | let view = msgs({ 132 | cls: 'split', 133 | query: q, 134 | open: this.openInSplit, 135 | pics: this.pics 136 | }); 137 | if (this.split) { 138 | this.split.newQuery(q); 139 | } else { 140 | view.mount(); 141 | } 142 | this.split = view; 143 | }, 144 | logout: function() { 145 | window.location = '/logout'; 146 | }, 147 | pics: function(msgs) { 148 | let hashes = []; 149 | for (let m in msgs) { 150 | for (let f of msgs[m].from_list) { 151 | if ( 152 | f.hash && 153 | hashes.indexOf(f.hash) == -1 && 154 | this.addrs.indexOf(f.hash) == -1 155 | ) { 156 | hashes.push(f.hash); 157 | } 158 | } 159 | } 160 | if (hashes.length == 0) { 161 | return; 162 | } 163 | this.addrs = this.addrs.concat(hashes); 164 | while (hashes.length > 0) { 165 | let sheet = document.createElement('link'); 166 | let few = encodeURIComponent(hashes.splice(0, 50)); 167 | sheet.href = `/avatars.css?size=${this.picSize}&hashes=${few}`; 168 | sheet.rel = 'stylesheet'; 169 | document.body.appendChild(sheet); 170 | } 171 | } 172 | } 173 | }); 174 | 175 | new Vue({ 176 | el: '#app', 177 | template: '' 178 | }); 179 | -------------------------------------------------------------------------------- /assets/app.less: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | min-width: 350px; 4 | } 5 | 6 | .app { 7 | position: relative; 8 | display: flex; 9 | flex-direction: column; 10 | height: 100%; 11 | } 12 | 13 | .header { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | z-index: 5; 19 | display: flex; 20 | justify-content: space-between; 21 | box-sizing: border-box; 22 | height: 1.7em; 23 | padding: 0.1em 0.2em; 24 | background-color: @--color--grey-light; 25 | } 26 | 27 | .panes { 28 | position: absolute; 29 | top: 0; 30 | display: flex; 31 | flex-flow: row nowrap; 32 | box-sizing: border-box; 33 | height: 100%; 34 | padding-top: 1.7em; 35 | } 36 | 37 | .main, 38 | .split { 39 | position: relative; 40 | box-sizing: border-box; 41 | width: 100vw; 42 | overflow-x: hidden; 43 | overflow-y: auto; 44 | background-color: @--color--white; 45 | 46 | .opt--split-on & { 47 | width: 50vw; 48 | } 49 | } 50 | 51 | .split { 52 | display: none; 53 | border-left: 1px solid @--color--grey-light; 54 | 55 | .opt--split-on & { 56 | display: block; 57 | } 58 | } 59 | 60 | .header__tags { 61 | display: flex; 62 | align-items: stretch; 63 | height: 1.5em; 64 | line-height: 1.5em; 65 | margin-bottom: 0.2em; 66 | font-size: @--font-size--s; 67 | 68 | .tags { 69 | overflow: hidden; 70 | } 71 | 72 | .tags-select { 73 | margin-right: 0.2em; 74 | overflow: unset; 75 | } 76 | 77 | .tags__item { 78 | border: none; 79 | } 80 | 81 | @media (max-width: 600px) { 82 | .tags--quick { 83 | display: none; 84 | } 85 | } 86 | } 87 | 88 | .header__actions { 89 | display: flex; 90 | white-space: nowrap; 91 | 92 | .opt--fix-privacy & .icon--image, 93 | .opt--split-on & .icon--split, 94 | .opt--bigger & .icon--font, 95 | .opt--filters & .icon--sieve { 96 | color: @--color--orange; 97 | } 98 | 99 | .icon--split { 100 | display: none; 101 | } 102 | 103 | @media (min-width: 1200px) { 104 | .icon--split { 105 | display: flex; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /assets/base.less: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-size: @--font-size; 6 | font-family: sans-serif; 7 | line-height: @--line-height--s; 8 | 9 | &.opt--bigger { 10 | font-size: @--font-size * 1.25; 11 | } 12 | } 13 | 14 | a { 15 | color: @--color--green; 16 | outline: 0; 17 | text-decoration: none; 18 | 19 | &:hover { 20 | text-decoration: underline; 21 | } 22 | } 23 | 24 | input:not([type]), 25 | input[type="text"], 26 | input[type="password"], 27 | textarea { 28 | box-sizing: border-box; 29 | border-bottom: 1px solid @--color--grey; 30 | border-width: 0 0 1px 0; 31 | padding: 0.1em 0.2em; 32 | font-size: inherit; 33 | line-height: inherit; 34 | color: @--color--black; 35 | background: @--color--white; 36 | 37 | &:focus { 38 | outline: 0; 39 | border-left: 3px solid @--color--orange; 40 | } 41 | 42 | &::placeholder { 43 | font-size: inherit; 44 | line-height: inherit; 45 | opacity: unset; 46 | font-weight: normal; 47 | } 48 | } 49 | 50 | 51 | .--text() { 52 | img { 53 | max-width: 100%; 54 | } 55 | hr { 56 | border: none; 57 | border-bottom: 1px solid @--color--grey-light; 58 | } 59 | pre { 60 | white-space: pre-wrap; 61 | line-height: @--line-height--s; 62 | } 63 | pre, code { 64 | font-size: 0.9em; 65 | } 66 | blockquote { 67 | border-left: 1px solid #ccc; 68 | padding: 0; 69 | margin: 0; 70 | padding-left: 0.5em; 71 | margin-left: 0.5em; 72 | } 73 | h1 {font-size: 1.2em}; 74 | h2 {font-size: 1.1em}; 75 | h3 {font-size: 1.1em}; 76 | h4 {font-size: 1.0em; font-weight: bold}; 77 | h5 {font-size: 1.0em; font-weight: bold}; 78 | h2 {font-size: 1.0em; font-weight: bold}; 79 | } 80 | -------------------------------------------------------------------------------- /assets/editor.html: -------------------------------------------------------------------------------- 1 |
2 | 12 | 22 | 23 | 24 |
25 |
26 |
27 | {{f.filename}} 33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | Sending in {{countdown}} seconds... 44 | 45 |
46 |
47 | Sending... 48 |
49 |
50 | -------------------------------------------------------------------------------- /assets/editor.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { Slider } from './slider.js'; 3 | import { contains } from './utils.js'; 4 | import tpl from './editor.html'; 5 | 6 | Vue.component('editor', { 7 | template: tpl, 8 | props: { 9 | msg: { type: Object, required: true }, 10 | call: { type: Function, required: true }, 11 | query: { type: Function, required: true }, 12 | refresh: { type: Function, required: true } 13 | }, 14 | data: function() { 15 | return { 16 | uid: this.msg.uid, 17 | saving: false, 18 | editing: true, 19 | countdown: null, 20 | html: '', 21 | from: this.msg.from, 22 | to: this.msg.to, 23 | subject: this.msg.subject, 24 | txt: this.msg.txt, 25 | allFrom: window.data.addrs_from, 26 | allTo: window.data.addrs_to, 27 | addrCurrent: '', 28 | editFields: ['from', 'to', 'subject', 'txt'], 29 | previewUrl: 'mid:' + this.msg.draft_id 30 | }; 31 | }, 32 | created: function() { 33 | let data = window.localStorage.getItem(this.msg.draft_id); 34 | data = (data && JSON.parse(data)) || {}; 35 | if (data && data.time > this.msg.time * 1000) { 36 | Object.assign(this, data); 37 | } 38 | }, 39 | computed: { 40 | values: function() { 41 | let values = {}; 42 | for (let i of this.editFields) { 43 | values[i] = this[i]; 44 | } 45 | values.time = new Date().getTime(); 46 | return values; 47 | }, 48 | changed: function() { 49 | for (let i of this.editFields) { 50 | if (this[i] != this.msg[i]) { 51 | return true; 52 | } 53 | } 54 | return false; 55 | } 56 | }, 57 | methods: { 58 | autosave: function() { 59 | if (!this.changed) { 60 | return; 61 | } 62 | 63 | window.localStorage.setItem( 64 | this.msg.draft_id, 65 | JSON.stringify(this.values) 66 | ); 67 | setTimeout(() => this.saving || this.save(), 3000); 68 | }, 69 | update: function(val, el) { 70 | let addrs = el.__vue__.$refs['input'].value.split(','); 71 | if (!this.addrCurrent) { 72 | this.addrCurrent = addrs.slice(-1).pop(); 73 | } 74 | if (val && val.indexOf(',') == -1) { 75 | for (let i in addrs) { 76 | if (addrs[i] == this.addrCurrent) { 77 | addrs[i] = val; 78 | } 79 | } 80 | } 81 | addrs = addrs.map(i => i.trim()); 82 | addrs = addrs.filter(i => i).join(', '); 83 | if (el.classList.contains('editor__to')) { 84 | if (val) { 85 | addrs += ', '; 86 | } 87 | this.to = addrs; 88 | } else { 89 | this.from = addrs; 90 | } 91 | this.autosave(); 92 | this.addrCurrent = ''; 93 | return addrs; 94 | }, 95 | keyup: function(e) { 96 | if (!e) return; 97 | let length = 1; 98 | for (let i of e.target.value.split(',')) { 99 | this.addrCurrent = i; 100 | length += i.length; 101 | if (length > e.target.selectionStart) { 102 | break; 103 | } 104 | } 105 | }, 106 | filter: function(val) { 107 | return contains(val, this.addrCurrent.trim()); 108 | }, 109 | del: function() { 110 | window.localStorage.removeItem(this.msg.draft_id); 111 | let data = new FormData(); 112 | data.append('draft_id', this.msg.draft_id); 113 | data.append('delete', true); 114 | this.call('post', '/editor', data, {}).then(() => 115 | this.query(this.msg.query_thread) 116 | ); 117 | }, 118 | save: function(refresh = false) { 119 | this.saving = true; 120 | let data = new FormData(); 121 | let values = this.values; 122 | for (let i in values) { 123 | data.append(i, values[i]); 124 | } 125 | for (let file of Array.from(this.$refs.upload.files || [])) { 126 | data.append('files', file, file.name); 127 | } 128 | data.append('draft_id', this.msg.draft_id); 129 | return this.call('post', '/editor', data, {}) 130 | .then(res => { 131 | this.saving = false; 132 | this.uid = res.uid; 133 | if (this.uid) { 134 | if (window.app.main.query == this.previewUrl) { 135 | let main = window.app.main.view; 136 | main.openMsg(this.uid, true); 137 | } 138 | } 139 | if (refresh) { 140 | this.refresh(); 141 | } else { 142 | for (let i of ['from', 'to', 'subject', 'txt']) { 143 | this.msg[i] = this[i]; 144 | } 145 | } 146 | return res; 147 | }) 148 | .catch(() => { 149 | this.countdown = null; 150 | this.saving = false; 151 | refresh && this.refresh(); 152 | }); 153 | }, 154 | slide: function(e, idx) { 155 | e.preventDefault(); 156 | new Slider({ 157 | el: '.slider', 158 | propsData: { 159 | slides: this.msg.files.filter(i => i.image), 160 | index: idx 161 | } 162 | }); 163 | }, 164 | preview: function() { 165 | this.editing = false; 166 | this.call('post', '/markdown', { txt: this.txt }).then( 167 | res => (this.html = res) 168 | ); 169 | }, 170 | previewInMain: function() { 171 | window.app.main.open(this.previewUrl); 172 | }, 173 | send: function() { 174 | this.preview(); 175 | this.countdown = 5; 176 | this.save(false).then(() => this.sending(this.msg.url_send)); 177 | }, 178 | sending: function(url_send) { 179 | if (this.countdown > 0) { 180 | this.countdown = this.countdown - 1; 181 | setTimeout(() => this.sending(url_send), 1000); 182 | } else if (this.countdown == 0) { 183 | this.call('get', url_send) 184 | .then(res => this.query(res.query)) 185 | .catch(() => (this.countdown = null)); 186 | } else { 187 | this.countdown = null; 188 | } 189 | }, 190 | edit: function() { 191 | this.editing = true; 192 | if (this.countdown) { 193 | this.countdown = null; 194 | this.refresh(); 195 | } 196 | } 197 | } 198 | }); 199 | -------------------------------------------------------------------------------- /assets/editor.less: -------------------------------------------------------------------------------- 1 | .editor { 2 | flex: auto; 3 | display: flex; 4 | flex-direction: column; 5 | padding: 0.5em 1em; 6 | border-top: 1px solid @--color--orange; 7 | border-radius: 0.5em; 8 | font-size: @--font-size--s; 9 | 10 | &.editor--preview { 11 | .editor__edit { 12 | display: inline; 13 | } 14 | .editor__html { 15 | display: block; 16 | } 17 | .editor__preview, 18 | .editor__body { 19 | display: none; 20 | } 21 | } 22 | 23 | &.editor--sending { 24 | .editor__buttons { 25 | display: none; 26 | } 27 | .editor__sending { 28 | display: block; 29 | } 30 | } 31 | } 32 | 33 | .editor__sending { 34 | display: none; 35 | color: @--color--orange; 36 | } 37 | 38 | .editor__edit { 39 | display: none; 40 | } 41 | 42 | .editor__html { 43 | display: none; 44 | 45 | .--text; 46 | } 47 | 48 | .editor__files { 49 | display: flex; 50 | flex-direction: column; 51 | padding-bottom: 0.2em; 52 | margin-bottom: 0.2em; 53 | border-bottom: 1px solid @--color--grey; 54 | } 55 | 56 | .editor__buttons { 57 | display: flex; 58 | 59 | button, input { 60 | margin: 0 0.2em; 61 | } 62 | 63 | .editor__side_preview { 64 | display: none; 65 | 66 | .split & { 67 | display: block; 68 | } 69 | } 70 | .editor__send { 71 | padding-left: 0; 72 | font-weight: bold; 73 | 74 | .icon--send; 75 | } 76 | .editor__delete { 77 | padding-left: 0; 78 | align-self: end; 79 | 80 | .icon--trash; 81 | } 82 | } 83 | 84 | .editor__from, 85 | .editor__to, 86 | .editor__subj { 87 | flex: none; 88 | width: 100%; 89 | } 90 | 91 | .editor__quote { 92 | width: 100%; 93 | padding-bottom: 0.2em; 94 | margin-bottom: 0.2em; 95 | border-bottom: 1px solid @--color--grey; 96 | } 97 | 98 | .editor__body { 99 | flex: auto; 100 | width: 100%; 101 | min-height: 200px; 102 | margin-bottom: 0.2em; 103 | } 104 | -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naspeh/mailur/2cebe15534226db60c2049ed814e1983951efdc8/assets/favicon.png -------------------------------------------------------------------------------- /assets/filters.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /assets/filters.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import tpl from './filters.html'; 3 | 4 | Vue.component('filters', { 5 | template: tpl, 6 | props: { 7 | query: { type: String, required: true }, 8 | call: { type: Function, required: true }, 9 | refresh: { type: Function, required: true } 10 | }, 11 | data: function() { 12 | let name = window.localStorage.getItem('filters') || 'auto'; 13 | return { 14 | filters: window.data.filters, 15 | name: name, 16 | body: window.data.filters[name], 17 | running: false 18 | }; 19 | }, 20 | created: function() { 21 | this.update(this.name); 22 | }, 23 | methods: { 24 | update: function(name) { 25 | this.name = name; 26 | window.localStorage.setItem('filters', name); 27 | let autosaved = window.localStorage.getItem(this.storageKey()); 28 | this.body = autosaved || this.filters[name]; 29 | return name; 30 | }, 31 | storageKey: function(name) { 32 | name = name || this.name; 33 | return 'filters:' + name; 34 | }, 35 | autosave: function() { 36 | window.localStorage.setItem(this.storageKey(), this.body); 37 | }, 38 | run: function() { 39 | this.running = true; 40 | let data = { 41 | name: this.name, 42 | body: this.body, 43 | query: this.query, 44 | action: 'run' 45 | }; 46 | this.call('post', '/filters', data) 47 | .then(() => { 48 | this.running = false; 49 | this.refresh(); 50 | }) 51 | .catch(() => (this.running = false)); 52 | }, 53 | save: function() { 54 | this.running = true; 55 | let data = { 56 | name: this.name, 57 | body: this.body, 58 | query: this.query, 59 | action: 'save' 60 | }; 61 | this.call('post', '/filters', data).then(res => { 62 | this.running = false; 63 | window.localStorage.removeItem(this.storageKey()); 64 | this.filters = res; 65 | this.update(this.name); 66 | }); 67 | }, 68 | cancel: function() { 69 | window.localStorage.removeItem(this.storageKey()); 70 | this.update(this.name); 71 | }, 72 | close: function() { 73 | window.app.toggleOpt('filters'); 74 | } 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /assets/filters.less: -------------------------------------------------------------------------------- 1 | .filters { 2 | display: none; 3 | position: relative; 4 | flex-direction: column; 5 | padding: 1em 0.5em 0.5em 0.5em; 6 | border: 1px solid @--color--grey-light; 7 | border-radius: 0.5em; 8 | 9 | .opt--filters .main & { 10 | display: flex; 11 | } 12 | } 13 | .filters__name { 14 | max-width: 10em; 15 | 16 | .picker__input { 17 | border-bottom: none; 18 | } 19 | } 20 | .filters__body { 21 | flex: auto; 22 | width: 100%; 23 | height: 300px; 24 | border-top: 1px solid @--color--grey; 25 | } 26 | .filters__bottom { 27 | flex: none; 28 | display: flex; 29 | padding: 0.2em; 30 | margin-bottom: -0.5em; 31 | } 32 | .filters__run, 33 | .filters__save, 34 | .filters__cancel, 35 | .filters__close { 36 | padding-left: 0; 37 | margin: 0.2em; 38 | } 39 | .filters__run { 40 | .icon--magic; 41 | } 42 | .filters__save { 43 | font-weight: bold; 44 | 45 | .icon--save; 46 | } 47 | .filters__cancel { 48 | .icon--cancel; 49 | } 50 | .filters__close { 51 | position: absolute; 52 | top: 0; 53 | right: -0.2em; 54 | color: @--color--orange; 55 | .icon--remove; 56 | } 57 | .filters__info { 58 | flex: auto; 59 | align-self: center; 60 | text-align: right; 61 | } 62 | -------------------------------------------------------------------------------- /assets/font/LICENSE.txt: -------------------------------------------------------------------------------- 1 | ## Font Awesome 2 | 3 | Author: Dave Gandy 4 | License: SIL 5 | Homepage: http://fontawesome.io/ 6 | -------------------------------------------------------------------------------- /assets/font/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naspeh/mailur/2cebe15534226db60c2049ed814e1983951efdc8/assets/font/icons.eot -------------------------------------------------------------------------------- /assets/font/icons.less: -------------------------------------------------------------------------------- 1 | // Generated by "mailur icons" 2 | @font-face { 3 | font-family: 'icons'; 4 | src: url('./icons.eot?rg4e9b'); 5 | src: 6 | url('./icons.eot?rg4e9b#iefix') format('embedded-opentype'), 7 | url('./icons.ttf?rg4e9b') format('truetype'), 8 | url('./icons.woff?rg4e9b') format('woff'), 9 | url('./icons.svg?rg4e9b#icons') format('svg'); 10 | font-weight: normal; 11 | font-style: normal; 12 | } 13 | 14 | .--icon() { 15 | &::before { 16 | font-family: 'icons', sans-serif; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | speak: none; 21 | text-transform: none; 22 | display: inline-block; 23 | width: 1.5em; 24 | text-align: center; 25 | 26 | // Better Font Rendering 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | } 31 | 32 | .--only-icon() { 33 | box-sizing: content-box; 34 | min-width: 1.5em; 35 | overflow: hidden; 36 | 37 | .opt--only-icons & { 38 | width: 1.5em; 39 | 40 | &::before { 41 | padding-right: 2em; 42 | } 43 | } 44 | } 45 | 46 | .icon--check-full, 47 | .icon--check-empty, 48 | .icon--unlink, 49 | .icon--add, 50 | .icon--search, 51 | .icon--ok, 52 | .icon--remove, 53 | .icon--tags, 54 | .icon--font, 55 | .icon--forward, 56 | .icon--spam, 57 | .icon--unread, 58 | .icon--whitelist, 59 | .icon--blacklist, 60 | .icon--logout, 61 | .icon--pin, 62 | .icon--open-in-split, 63 | .icon--login, 64 | .icon--link, 65 | .icon--split, 66 | .icon--more, 67 | .icon--reply, 68 | .icon--reply-all, 69 | .icon--archive, 70 | .icon--trash, 71 | .icon--reload, 72 | .icon--image, 73 | .icon--draft, 74 | .icon--attachment, 75 | .icon--spinner, 76 | .icon--sieve, 77 | .icon--send, 78 | .icon--cancel, 79 | .icon--save, 80 | .icon--magic { 81 | .--icon; 82 | } 83 | 84 | .--icon--check-full() { 85 | &::before { 86 | content: "\f046"; 87 | } 88 | } 89 | 90 | .icon--check-full { 91 | .--icon--check-full; 92 | } 93 | 94 | .--icon--check-empty() { 95 | &::before { 96 | content: "\f096"; 97 | } 98 | } 99 | 100 | .icon--check-empty { 101 | .--icon--check-empty; 102 | } 103 | 104 | .--icon--unlink() { 105 | &::before { 106 | content: "\f127"; 107 | } 108 | } 109 | 110 | .icon--unlink { 111 | .--icon--unlink; 112 | } 113 | 114 | .--icon--add() { 115 | &::before { 116 | content: "\f067"; 117 | } 118 | } 119 | 120 | .icon--add { 121 | .--icon--add; 122 | } 123 | 124 | .--icon--search() { 125 | &::before { 126 | content: "\f002"; 127 | } 128 | } 129 | 130 | .icon--search { 131 | .--icon--search; 132 | } 133 | 134 | .--icon--ok() { 135 | &::before { 136 | content: "\f00c"; 137 | } 138 | } 139 | 140 | .icon--ok { 141 | .--icon--ok; 142 | } 143 | 144 | .--icon--remove() { 145 | &::before { 146 | content: "\f00d"; 147 | } 148 | } 149 | 150 | .icon--remove { 151 | .--icon--remove; 152 | } 153 | 154 | .--icon--tags() { 155 | &::before { 156 | content: "\f02c"; 157 | } 158 | } 159 | 160 | .icon--tags { 161 | .--icon--tags; 162 | } 163 | 164 | .--icon--font() { 165 | &::before { 166 | content: "\f034"; 167 | } 168 | } 169 | 170 | .icon--font { 171 | .--icon--font; 172 | } 173 | 174 | .--icon--forward() { 175 | &::before { 176 | content: "\f064"; 177 | } 178 | } 179 | 180 | .icon--forward { 181 | .--icon--forward; 182 | } 183 | 184 | .--icon--spam() { 185 | &::before { 186 | content: "\f06a"; 187 | } 188 | } 189 | 190 | .icon--spam { 191 | .--icon--spam; 192 | } 193 | 194 | .--icon--unread() { 195 | &::before { 196 | content: "\f06e"; 197 | } 198 | } 199 | 200 | .icon--unread { 201 | .--icon--unread; 202 | } 203 | 204 | .--icon--whitelist() { 205 | &::before { 206 | content: "\f087"; 207 | } 208 | } 209 | 210 | .icon--whitelist { 211 | .--icon--whitelist; 212 | } 213 | 214 | .--icon--blacklist() { 215 | &::before { 216 | content: "\f088"; 217 | } 218 | } 219 | 220 | .icon--blacklist { 221 | .--icon--blacklist; 222 | } 223 | 224 | .--icon--logout() { 225 | &::before { 226 | content: "\f08b"; 227 | } 228 | } 229 | 230 | .icon--logout { 231 | .--icon--logout; 232 | } 233 | 234 | .--icon--pin() { 235 | &::before { 236 | content: "\f08d"; 237 | } 238 | } 239 | 240 | .icon--pin { 241 | .--icon--pin; 242 | } 243 | 244 | .--icon--open-in-split() { 245 | &::before { 246 | content: "\f08e"; 247 | } 248 | } 249 | 250 | .icon--open-in-split { 251 | .--icon--open-in-split; 252 | } 253 | 254 | .--icon--login() { 255 | &::before { 256 | content: "\f090"; 257 | } 258 | } 259 | 260 | .icon--login { 261 | .--icon--login; 262 | } 263 | 264 | .--icon--link() { 265 | &::before { 266 | content: "\f0c1"; 267 | } 268 | } 269 | 270 | .icon--link { 271 | .--icon--link; 272 | } 273 | 274 | .--icon--split() { 275 | &::before { 276 | content: "\f0db"; 277 | } 278 | } 279 | 280 | .icon--split { 281 | .--icon--split; 282 | } 283 | 284 | .--icon--more() { 285 | &::before { 286 | content: "\f107"; 287 | } 288 | } 289 | 290 | .icon--more { 291 | .--icon--more; 292 | } 293 | 294 | .--icon--reply() { 295 | &::before { 296 | content: "\f112"; 297 | } 298 | } 299 | 300 | .icon--reply { 301 | .--icon--reply; 302 | } 303 | 304 | .--icon--reply-all() { 305 | &::before { 306 | content: "\f122"; 307 | } 308 | } 309 | 310 | .icon--reply-all { 311 | .--icon--reply-all; 312 | } 313 | 314 | .--icon--archive() { 315 | &::before { 316 | content: "\f187"; 317 | } 318 | } 319 | 320 | .icon--archive { 321 | .--icon--archive; 322 | } 323 | 324 | .--icon--trash() { 325 | &::before { 326 | content: "\f1f8"; 327 | } 328 | } 329 | 330 | .icon--trash { 331 | .--icon--trash; 332 | } 333 | 334 | .--icon--reload() { 335 | &::before { 336 | content: "\f01e"; 337 | } 338 | } 339 | 340 | .icon--reload { 341 | .--icon--reload; 342 | } 343 | 344 | .--icon--image() { 345 | &::before { 346 | content: "\f03e"; 347 | } 348 | } 349 | 350 | .icon--image { 351 | .--icon--image; 352 | } 353 | 354 | .--icon--draft() { 355 | &::before { 356 | content: "\f044"; 357 | } 358 | } 359 | 360 | .icon--draft { 361 | .--icon--draft; 362 | } 363 | 364 | .--icon--attachment() { 365 | &::before { 366 | content: "\f0c6"; 367 | } 368 | } 369 | 370 | .icon--attachment { 371 | .--icon--attachment; 372 | } 373 | 374 | .--icon--spinner() { 375 | &::before { 376 | content: "\f110"; 377 | } 378 | } 379 | 380 | .icon--spinner { 381 | .--icon--spinner; 382 | } 383 | 384 | .--icon--sieve() { 385 | &::before { 386 | content: "\f121"; 387 | } 388 | } 389 | 390 | .icon--sieve { 391 | .--icon--sieve; 392 | } 393 | 394 | .--icon--send() { 395 | &::before { 396 | content: "\f1d8"; 397 | } 398 | } 399 | 400 | .icon--send { 401 | .--icon--send; 402 | } 403 | 404 | .--icon--cancel() { 405 | &::before { 406 | content: "\f05e"; 407 | } 408 | } 409 | 410 | .icon--cancel { 411 | .--icon--cancel; 412 | } 413 | 414 | .--icon--save() { 415 | &::before { 416 | content: "\f0c7"; 417 | } 418 | } 419 | 420 | .icon--save { 421 | .--icon--save; 422 | } 423 | 424 | .--icon--magic() { 425 | &::before { 426 | content: "\f0d0"; 427 | } 428 | } 429 | 430 | .icon--magic { 431 | .--icon--magic; 432 | } 433 | -------------------------------------------------------------------------------- /assets/font/icons.less.tpl: -------------------------------------------------------------------------------- 1 | % # Used by "mailur.cli:icons" 2 | % # template syntax: http://bottlepy.org/docs/dev/stpl.html 3 | % # vim: ft=css 4 | // Generated by "mailur icons" 5 | @font-face { 6 | font-family: 'icons'; 7 | src: url('./icons.eot?rg4e9b'); 8 | src: 9 | url('./icons.eot?rg4e9b#iefix') format('embedded-opentype'), 10 | url('./icons.ttf?rg4e9b') format('truetype'), 11 | url('./icons.woff?rg4e9b') format('woff'), 12 | url('./icons.svg?rg4e9b#icons') format('svg'); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | .--icon() { 18 | &::before { 19 | font-family: 'icons', sans-serif; 20 | font-style: normal; 21 | font-weight: normal; 22 | font-variant: normal; 23 | speak: none; 24 | text-transform: none; 25 | display: inline-block; 26 | width: 1.5em; 27 | text-align: center; 28 | 29 | // Better Font Rendering 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | } 33 | } 34 | 35 | .--only-icon() { 36 | box-sizing: content-box; 37 | min-width: 1.5em; 38 | overflow: hidden; 39 | 40 | .opt--only-icons & { 41 | width: 1.5em; 42 | 43 | &::before { 44 | padding-right: 2em; 45 | } 46 | } 47 | } 48 | 49 | % for name, symbol in icons[:-1]: 50 | .icon--{{name}}, 51 | % end 52 | .icon--{{ icons[-1][0] }} { 53 | .--icon; 54 | } 55 | % for name, symbol in icons: 56 | 57 | .--icon--{{name}}() { 58 | &::before { 59 | content: "{{symbol}}"; 60 | } 61 | } 62 | 63 | .icon--{{name}} { 64 | .--icon--{{name}}; 65 | } 66 | % end 67 | -------------------------------------------------------------------------------- /assets/font/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naspeh/mailur/2cebe15534226db60c2049ed814e1983951efdc8/assets/font/icons.ttf -------------------------------------------------------------------------------- /assets/font/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naspeh/mailur/2cebe15534226db60c2049ed814e1983951efdc8/assets/font/icons.woff -------------------------------------------------------------------------------- /assets/login.html: -------------------------------------------------------------------------------- 1 |
6 | 7 | 11 | 15 | 25 | 34 | 35 | 40 | 43 |
44 | -------------------------------------------------------------------------------- /assets/login.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import './picker.js'; 3 | import { call } from './utils.js'; 4 | import tpl from './login.html'; 5 | 6 | Vue.component('Login', { 7 | template: tpl, 8 | data: function() { 9 | return { 10 | params: { 11 | username: '', 12 | password: '', 13 | timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 14 | theme: window.data.current_theme 15 | }, 16 | disabled: false, 17 | error: null, 18 | themes: window.data.themes, 19 | timezones: window.data.timezones 20 | }; 21 | }, 22 | mounted: function() { 23 | this.focus(); 24 | }, 25 | methods: { 26 | focus: function() { 27 | this.$refs.username.focus(); 28 | }, 29 | send: function() { 30 | this.disabled = true; 31 | this.error = null; 32 | call('post', '/login', this.params).then(res => { 33 | if (res.errors) { 34 | this.disabled = false; 35 | this.error = res.errors[0]; 36 | this.$nextTick(() => this.focus()); 37 | return; 38 | } 39 | let index = window.location.pathname.replace('login', ''); 40 | window.location.replace(index); 41 | }); 42 | } 43 | } 44 | }); 45 | 46 | new Vue({ 47 | el: '#app', 48 | template: '' 49 | }); 50 | -------------------------------------------------------------------------------- /assets/login.less: -------------------------------------------------------------------------------- 1 | .login { 2 | position: absolute; 3 | top: 100px; 4 | left: 50%; 5 | width: 300px; 6 | margin-left: -150px; 7 | box-sizing: border-box; 8 | padding: 20px; 9 | background-color: @--color--grey-light; 10 | color: @--color--black; 11 | } 12 | 13 | .login__username, 14 | .login__password { 15 | display: flex; 16 | justify-content: flex-end; 17 | margin-bottom: 0.2em; 18 | 19 | & input { 20 | width: 150px; 21 | margin-left: 0.5em; 22 | } 23 | } 24 | 25 | .login__theme, 26 | .login__timezone { 27 | display: flex; 28 | justify-content: flex-end; 29 | margin-bottom: 0.2em; 30 | 31 | & .picker { 32 | width: 150px; 33 | margin-left: 0.5em; 34 | } 35 | } 36 | 37 | .login__submit { 38 | display: flex; 39 | justify-content: flex-end; 40 | 41 | & .icon--login { 42 | text-transform: lowercase; 43 | padding-left: 0; 44 | } 45 | } 46 | 47 | .login__error { 48 | position: absolute; 49 | top: -50px; 50 | left: 0; 51 | display: flex; 52 | justify-content: center; 53 | width: 100%; 54 | color: red; 55 | } 56 | 57 | .login__powered_by { 58 | position: fixed; 59 | bottom: 0.2em; 60 | left: 0; 61 | width: 100%; 62 | text-align: center; 63 | font-size: @--font-size--xs; 64 | } 65 | -------------------------------------------------------------------------------- /assets/msg.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
8 |
13 |
19 |
25 |
31 |
36 |
41 |
42 | 55 |
{{msg.from.name}}
56 |
57 |
58 |
{{msg.count}}
63 |
{{msg.subject}}
64 |
65 |
66 |
67 | 68 |
73 |
{{msg.time_human}}
74 |
75 |
80 | 81 |
82 | 83 | Archive 84 | Delete 85 | Reply 86 | Forward 87 | 88 | 89 | 90 | Full thread 91 | Related replies 92 | Original message 93 |
94 |
95 |
99 | 100 |
101 |
102 | Subject: 103 | 104 | {{msg.subject}} 105 | 106 |
107 |
108 | From: 109 | 110 | {{msg.from.name}} 111 | 112 | {{msg.from.addr}} 113 | 114 | 115 |
116 | 127 |
128 | Message-ID: 129 | 130 | {{msg.msgid}} 131 | 132 |
133 |
134 |
135 |
136 | 137 |
138 |
139 | {{f.filename}} 145 |
146 |
147 |
148 | -------------------------------------------------------------------------------- /assets/msg.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { Slider } from './slider.js'; 3 | import tpl from './msg.html'; 4 | 5 | Vue.component('msg', { 6 | template: tpl, 7 | props: { 8 | msg: { type: Object, required: true }, 9 | body: { type: String }, 10 | edit: { type: Object }, 11 | thread: { type: Boolean, default: false }, 12 | opened: { type: Boolean, default: false }, 13 | detailed: { type: Boolean, default: false }, 14 | picked: { type: Boolean, default: false }, 15 | pick: { type: Function }, 16 | editTags: { type: Function, required: true } 17 | }, 18 | data: function() { 19 | return { 20 | open: this.$parent.open, 21 | openMsg: this.$parent.openMsg, 22 | details: this.$parent.details, 23 | call: this.$parent.call 24 | }; 25 | }, 26 | methods: { 27 | openInMain: function(q) { 28 | window.app.openInMain(q); 29 | }, 30 | openDefault: function() { 31 | if (this.thread) { 32 | this.openInMain(this.msg.query_thread); 33 | } else { 34 | this.details(this.msg.uid); 35 | } 36 | }, 37 | openInSplit: function() { 38 | let q = this.msg.query_thread; 39 | if (!this.thread) { 40 | q = `${q} uid:${this.msg.uid}`; 41 | } 42 | window.app.openInSplit(q); 43 | }, 44 | archive: function(msg) { 45 | let data = { old: ['#inbox'] }; 46 | return this.editTags(data, [msg.uid]); 47 | }, 48 | del: function(msg) { 49 | let data = { new: ['#trash'] }; 50 | return this.editTags(data, [msg.uid]); 51 | }, 52 | read: function(msg) { 53 | let data = {}; 54 | data[msg.is_unread ? 'new' : 'old'] = ['\\Seen']; 55 | return this.editTags(data, [msg.uid]); 56 | }, 57 | pin: function(msg) { 58 | let data = {}; 59 | data[msg.is_pinned ? 'old' : 'new'] = ['\\Flagged']; 60 | return this.editTags(data, [msg.uid]); 61 | }, 62 | reply: function(msg, forward = null) { 63 | let end = forward ? '?forward=1' : ''; 64 | this.call('get', msg.url_reply + end).then(res => 65 | this.open(res.query_edit) 66 | ); 67 | }, 68 | makeRicher: function() { 69 | for (let i of this.$el.querySelectorAll('img[data-src]')) { 70 | i.src = i.dataset.src; 71 | } 72 | for (let i of this.$el.querySelectorAll('*[data-style]')) { 73 | i.style = i.dataset.style; 74 | } 75 | }, 76 | slide: function(e, idx) { 77 | e.preventDefault(); 78 | new Slider({ 79 | el: '.slider', 80 | propsData: { 81 | slides: this.msg.files.filter(i => i.image), 82 | index: idx 83 | } 84 | }); 85 | } 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /assets/msg.less: -------------------------------------------------------------------------------- 1 | .msg-line { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | line-height: @--line-height--l; 6 | box-sizing: border-box; 7 | padding: 0 0.2em; 8 | overflow: hidden; 9 | border-top: 1px solid @--color--white; 10 | border-radius: 0.5em; 11 | background-color: @--color--grey-light; 12 | color: @--color--grey; 13 | font-size: @--font-size--s; 14 | 15 | &:hover { 16 | cursor: pointer; 17 | text-decoration: none; 18 | } 19 | 20 | .icon--pin, 21 | .icon--unread, 22 | .icon--reply { 23 | display: flex; 24 | color: @--color--grey; 25 | font-size: @--font-size; 26 | 27 | &:hover { 28 | margin-top: 0.1em; 29 | margin-bottom: -0.1em; 30 | cursor: pointer; 31 | } 32 | } 33 | 34 | .icon--attachment { 35 | display: flex; 36 | color: @--color--grey; 37 | font-size: @--font-size; 38 | } 39 | 40 | .icon--draft { 41 | .icon--pin; 42 | color: @--color--orange; 43 | } 44 | 45 | .icon--image { 46 | display: none; 47 | 48 | .opt--fix-privacy .msg--opened.msg--richer & { 49 | .icon--pin; 50 | } 51 | } 52 | 53 | .icon--more { 54 | .icon--pin; 55 | 56 | .msg--detailed & { 57 | color: @--color--green; 58 | } 59 | } 60 | 61 | .icon--open-in-split { 62 | display: none; 63 | 64 | .opt--split-on .main & { 65 | .icon--pin; 66 | } 67 | } 68 | 69 | .msg--picked & { 70 | background-color: @--color--orange-light; 71 | } 72 | 73 | .msg--opened &, 74 | .msg--detailed & { 75 | border-color: @--color--grey; 76 | background-color: @--color--white; 77 | } 78 | 79 | .msg--opened.msg--picked &, 80 | .msg--detailed.msg--picked & { 81 | border-color: @--color--orange; 82 | } 83 | 84 | .msg--unread & { 85 | & .icon--unread { 86 | color: @--color--black; 87 | } 88 | } 89 | 90 | .msg--pinned & { 91 | & .icon--pin { 92 | color: @--color--orange; 93 | } 94 | } 95 | } 96 | 97 | .msg-line__from { 98 | display: flex; 99 | justify-content: flex-start; 100 | align-items: center; 101 | margin-left: 0.2em; 102 | color: inherit; 103 | } 104 | 105 | .msg-line__from__pic { 106 | height: @--pic-height; 107 | width: @--pic-height; 108 | margin-right: 0.2em; 109 | background-position: center center; 110 | background-repeat: no-repeat; 111 | background-size: contain; 112 | background-color: @--color--white; 113 | 114 | &:hover { 115 | cursor: pointer; 116 | } 117 | } 118 | 119 | .msg-line__from__more { 120 | min-width: @--pic-height; 121 | line-height: @--pic-height; 122 | margin-right: 0.2em; 123 | background-color: @--color--white; 124 | text-align: center; 125 | 126 | .msg--opened &, 127 | .msg--detailed & { 128 | background-color: @--color--grey-light; 129 | } 130 | } 131 | 132 | .msg-line__from__name { 133 | margin-right: 0.2em; 134 | max-width: 5em; 135 | white-space: nowrap; 136 | overflow: hidden; 137 | text-overflow: ellipsis; 138 | 139 | .msg--unread & { 140 | font-weight: bold; 141 | } 142 | 143 | .msg--same-subj & { 144 | color: @--color--black; 145 | } 146 | } 147 | 148 | .msg-line__pick { 149 | display: flex; 150 | color: @--color--black; 151 | 152 | .icon--check-empty; 153 | 154 | .thread & { 155 | display: none; 156 | } 157 | 158 | .msg--picked & { 159 | .icon--check-full; 160 | } 161 | } 162 | 163 | .msg-line__count { 164 | padding: 0 0.2em; 165 | margin-right: 0.2em; 166 | height: @--pic-height; 167 | line-height: @--pic-height; 168 | background-color: @--color--white; 169 | text-align: center; 170 | 171 | .msg--opened &, 172 | .msg--detailed & { 173 | background-color: @--color--grey-light; 174 | } 175 | } 176 | 177 | .msg-line__subj { 178 | margin-right: 0.2em; 179 | white-space: nowrap; 180 | color: @--color--black; 181 | 182 | .msg--unread & { 183 | font-weight: bold; 184 | } 185 | .msg--same-subj & { 186 | display: none; 187 | } 188 | } 189 | 190 | .msg-line__text { 191 | margin-right: 0.2em; 192 | white-space: nowrap; 193 | 194 | .msg--opened & { 195 | display: none; 196 | } 197 | } 198 | 199 | .msg-line__time { 200 | padding-right: 0.2em; 201 | } 202 | 203 | .msg-line__tags { 204 | padding-right: 0.2em; 205 | font-size: @--font-size--xs; 206 | 207 | .tags__item { 208 | border-color: @--color--green; 209 | border-radius: 0.2em; 210 | color: @--color--green; 211 | } 212 | } 213 | 214 | .msg-line__insight { 215 | display: flex; 216 | align-items: center; 217 | width: 100%; 218 | overflow: hidden; 219 | } 220 | 221 | .msg-line__end { 222 | position: absolute; 223 | top: 0; 224 | right: 0; 225 | display: flex; 226 | align-items: center; 227 | padding-left: 0.5em; 228 | padding-right: 0.2em; 229 | background-color: inherit; 230 | 231 | .opt--split-on .main & { 232 | right: 1.7em; 233 | } 234 | } 235 | 236 | .msg-hidden { 237 | display: flex; 238 | line-height: @--line-height--l; 239 | box-sizing: border-box; 240 | padding-left: 1em; 241 | border-top: 1px solid @--color--white; 242 | border-radius: 0.5em; 243 | background-color: @--color--grey-light; 244 | 245 | &:hover { 246 | text-decoration: none; 247 | } 248 | 249 | &:before { 250 | content: "---"; 251 | margin-right: 0.2em; 252 | } 253 | 254 | &:after { 255 | content: "---"; 256 | margin-left: 0.2em; 257 | } 258 | } 259 | .msg__details { 260 | display: none; 261 | flex-wrap: nowrap; 262 | padding: 0 0.2em; 263 | margin-bottom: 0.2em; 264 | white-space: nowrap; 265 | font-size: @--font-size--xs; 266 | overflow: hidden; 267 | 268 | .msg--detailed & { 269 | display: flex; 270 | } 271 | 272 | .msg__details__info { 273 | overflow: hidden; 274 | } 275 | 276 | .msg__details__value { 277 | margin-left: 0.5em; 278 | } 279 | 280 | .msg__details__from-pic { 281 | margin-right: 0.2em; 282 | background-position: center center; 283 | background-repeat: no-repeat; 284 | background-size: contain; 285 | background-color: @--color--white; 286 | } 287 | 288 | .msg__details__to { 289 | display: flex; 290 | flex-flow: wrap; 291 | } 292 | } 293 | 294 | .msg__actions { 295 | & [class^="icon--"] { 296 | display: flex; 297 | margin-right: 0.2em; 298 | } 299 | 300 | & .icon--archive, 301 | & .icon--reply, 302 | & .icon--reply-all, 303 | & .icon--forward, 304 | & .icon--trash, 305 | & .icon--blacklist, 306 | & .icon--whitelist { 307 | .--only-icon; 308 | } 309 | } 310 | 311 | .msg__body { 312 | display: none; 313 | padding: 0 0.2em; 314 | margin-top: 0.2em; 315 | 316 | .--text; 317 | 318 | .msg--opened & { 319 | display: block; 320 | } 321 | } 322 | 323 | .msg__body__files { 324 | display: flex; 325 | flex-direction: column; 326 | padding-top: 0.2em; 327 | padding-bottom: 1em; 328 | border-top: 1px solid @--color--grey-light; 329 | } 330 | 331 | @media @--small-screen { 332 | .msg-line { 333 | align-items: flex-start; 334 | flex-flow: wrap; 335 | line-height: 1.7em; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /assets/msgs.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 16 | 17 | 18 | 99 | 100 | 101 | 188 |
189 | -------------------------------------------------------------------------------- /assets/msgs.less: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | to { 3 | transform: rotate(1turn); 4 | } 5 | } 6 | 7 | .msgs__header { 8 | position: sticky; 9 | top: 0; 10 | z-index: 1; 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | height: 1.5em; 15 | line-height: 1.5em; 16 | padding: 0.2em; 17 | padding-top: 0; 18 | background-color: @--color--grey-light; 19 | border-bottom: 1px solid @--color--white; 20 | font-size: @--font-size--s; 21 | white-space: nowrap; 22 | 23 | .icon--open-in-split, 24 | .icon--reload { 25 | display: flex; 26 | cursor: pointer; 27 | color: @--color--green; 28 | } 29 | 30 | .icon--spinner { 31 | display: flex; 32 | color: @--color--orange; 33 | 34 | &::before { 35 | animation: spin 1s infinite; 36 | } 37 | } 38 | 39 | .icon--open-in-split { 40 | display: none; 41 | 42 | .opt--split-on .main & { 43 | display: flex; 44 | } 45 | } 46 | } 47 | 48 | .msgs__body { 49 | display: flex; 50 | flex-direction: column; 51 | min-height: 100%; 52 | box-sizing: border-box; 53 | padding: 1.9em 0.2em 5em 0.2em; 54 | margin-top: -1.9em; 55 | } 56 | 57 | .msgs__error { 58 | position: sticky; 59 | top: 1.7em; 60 | z-index: 1; 61 | padding: 0.2em; 62 | background-color: @--color--white; 63 | color: @--color--orange; 64 | white-space: pre; 65 | 66 | &::before { 67 | content: "Error:"; 68 | font-weight: bold; 69 | text-transform: uppercase; 70 | margin-right: 0.2em; 71 | } 72 | } 73 | 74 | .msgs__expunge { 75 | display: block; 76 | padding: 0.2em; 77 | background-color: @--color--white; 78 | } 79 | 80 | .msgs__search { 81 | flex-grow: 3; 82 | display: flex; 83 | height: 1.5em; 84 | 85 | & input { 86 | flex-grow: 10; 87 | max-width: 30em; 88 | 89 | &:focus { 90 | min-width: 20em 91 | } 92 | } 93 | 94 | @media @--small-screen { 95 | .msgs--picked & { 96 | display: none; 97 | } 98 | } 99 | } 100 | 101 | .msgs__picker { 102 | height: 1.5em; 103 | width: 12em; 104 | margin-left: 0.2em; 105 | text-align: center; 106 | 107 | .thread & { 108 | width: 7em; 109 | } 110 | 111 | .icon--more { 112 | margin-left: -1.2em; 113 | padding-right: 0.2em; 114 | color: @--color--green; 115 | cursor: pointer; 116 | } 117 | 118 | .picker__header { 119 | display: flex; 120 | align-items: center; 121 | background-color: @--color--grey-light; 122 | 123 | &:hover { 124 | background-color: @--color--white; 125 | } 126 | } 127 | 128 | &.picker--active { 129 | .picker__header { 130 | background-color: @--color--white; 131 | } 132 | } 133 | 134 | .picker__opts { 135 | left: unset; 136 | right: 0; 137 | } 138 | 139 | .picker__input { 140 | box-sizing: border-box; 141 | padding-right: 1.2em; 142 | width: 100%; 143 | cursor: pointer; 144 | border-color: transparent; 145 | background-color: inherit; 146 | 147 | &::placeholder { 148 | color: @--color--green; 149 | text-align: right; 150 | } 151 | 152 | } 153 | } 154 | 155 | .msgs__actions { 156 | display: none; 157 | align-items: stretch; 158 | height: 1.5em; 159 | margin-left: 0.2em; 160 | 161 | .msgs--picked &, 162 | .thread & { 163 | display: flex; 164 | } 165 | 166 | .tags-edit { 167 | margin-left: 0.2em; 168 | } 169 | } 170 | 171 | .msgs__tags { 172 | display: none; 173 | margin-left: 0.2em; 174 | overflow: hidden; 175 | white-space: nowrap; 176 | 177 | .msgs--picked &, 178 | .thread & { 179 | display: block; 180 | } 181 | 182 | .tags__item { 183 | border-color: transparent; 184 | } 185 | 186 | @media (max-width: 1400px) { 187 | .opt--split-on & { 188 | display: none; 189 | } 190 | } 191 | 192 | @media (max-width: 700px) { 193 | :not(.opt--split-on) & { 194 | display: none; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /assets/picker.html: -------------------------------------------------------------------------------- 1 |
5 |
6 | 23 | 24 |
25 |
26 | 27 |
28 | nothing... 29 |
30 |
31 | 32 |
{{opt}}
38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /assets/picker.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { contains } from './utils.js'; 3 | import tpl from './picker.html'; 4 | 5 | Vue.component('picker', { 6 | template: tpl, 7 | props: { 8 | opts: { type: Array, required: true }, 9 | value: { type: String, required: true }, 10 | title: { type: String, default: '' }, 11 | filterOff: { type: Boolean, default: false }, 12 | disabled: { type: Boolean, default: false }, 13 | perPage: { type: Number, default: 15 }, 14 | fnUpdate: { type: Function, default: val => val }, 15 | fnFilter: { type: Function, default: contains }, 16 | fnApply: { type: Function }, 17 | fnCancel: { type: Function }, 18 | fnKeyup: { type: Function } 19 | }, 20 | data: function() { 21 | return { 22 | filter: this.value, 23 | selected: this.value, 24 | active: false 25 | }; 26 | }, 27 | mounted: function() { 28 | window.addEventListener('focus', this.focus, true); 29 | window.addEventListener('click', this.focus, true); 30 | }, 31 | destroyed: function() { 32 | window.removeEventListener('focus', this.focus, true); 33 | window.removeEventListener('click', this.focus, true); 34 | }, 35 | computed: { 36 | filtered: function() { 37 | if (this.filter == this.value) { 38 | return this.opts; 39 | } 40 | 41 | let result = this.opts.filter(val => this.fnFilter(val, this.filter)); 42 | if (result.length == 0) { 43 | this.selected = ''; 44 | } 45 | return result; 46 | } 47 | }, 48 | methods: { 49 | focus: function(e) { 50 | if (e.target == window) { 51 | return; 52 | } 53 | if (this.$el.contains(e.target)) { 54 | this.active || this.activate(); 55 | return; 56 | } 57 | if (this.active) { 58 | this.cancel(); 59 | this.$refs.input.blur(); 60 | } 61 | }, 62 | set: function(val) { 63 | val = val === undefined ? this.selected : val; 64 | this.active = this.fnApply ? true : false; 65 | if (this.active) { 66 | this.$refs.input.focus(); 67 | } 68 | val = this.fnUpdate(val, this.$el) || ''; 69 | if (val) { 70 | this.selected = val; 71 | this.filter = val; 72 | } else { 73 | this.filter = this.value; 74 | } 75 | }, 76 | cancel: function() { 77 | this.fnCancel && this.fnCancel(this.$el); 78 | this.$nextTick(() => { 79 | this.set(this.value); 80 | this.active = false; 81 | }); 82 | }, 83 | apply: function() { 84 | this.fnApply ? this.fnApply() : this.set(); 85 | }, 86 | activate: function(e) { 87 | if (this.disabled) { 88 | return; 89 | } 90 | this.$refs.input.focus(); 91 | this.active = true; 92 | this.$nextTick(() => { 93 | let element = this.selectedOpt(); 94 | if (!element) { 95 | return; 96 | } 97 | // make selected option visible if scroll exists 98 | let opts = this.$refs.opts; 99 | if (opts.scrollHeight == opts.clientHeight) { 100 | return; 101 | } 102 | for (let i = 0; i < 3; i++) { 103 | if (element.previousSibling) { 104 | element = element.previousSibling; 105 | } 106 | } 107 | opts.scrollTop = element.offsetTop; 108 | }); 109 | this.fnKeyup && e && this.fnKeyup(e); 110 | }, 111 | clsOpt: function(opt) { 112 | return `picker__opts__item ${ 113 | opt == this.selected ? 'picker__opts__item--active' : '' 114 | }`; 115 | }, 116 | selectedOpt: function() { 117 | return ( 118 | this.$el.querySelector('.picker__opts__item--active') || 119 | this.$el.querySelector('.picker__opts__item') 120 | ); 121 | }, 122 | select: function(key, count = 1) { 123 | let el = this.selectedOpt(); 124 | if (!el) { 125 | return; 126 | } 127 | if (el.classList.contains('picker__opts__item--active')) { 128 | for (let i = 0; i < count; i++) { 129 | if (key == 'up') { 130 | if (el.previousElementSibling) { 131 | el = el.previousElementSibling; 132 | } else { 133 | break; 134 | } 135 | } else { 136 | if (el.nextElementSibling) { 137 | el = el.nextElementSibling; 138 | } 139 | } 140 | } 141 | } 142 | this.selected = el.dataset && el.dataset.value; 143 | this.activate(); 144 | } 145 | } 146 | }); 147 | -------------------------------------------------------------------------------- /assets/picker.less: -------------------------------------------------------------------------------- 1 | .picker { 2 | position: relative; 3 | 4 | &.picker--active { 5 | z-index: 10; 6 | } 7 | } 8 | 9 | .picker__header { 10 | height: 100%; 11 | } 12 | 13 | .picker__input { 14 | display: flex; 15 | height: 100%; 16 | width: 100%; 17 | } 18 | 19 | .picker__opts { 20 | display: none; 21 | position: absolute; 22 | left: 0; 23 | box-sizing: border-box; 24 | margin-top: -1px; 25 | overflow-y: auto; 26 | overflow-x: hidden; 27 | min-width: 100%; 28 | max-height: 300px; 29 | line-height: 20px; 30 | background-color: @--color--white; 31 | border: 1px solid @--color--grey; 32 | box-shadow: 0 0.2em 0.2em 0 @--color--grey; 33 | color: @--color--black; 34 | font-size: @--font-size--s; 35 | white-space: nowrap; 36 | 37 | .picker--active & { 38 | display: block; 39 | } 40 | } 41 | 42 | .picker__opts__empty { 43 | padding: 0.1em 0.2em; 44 | } 45 | 46 | .picker__opts__item { 47 | cursor: pointer; 48 | padding: 0.1em 0.2em; 49 | 50 | &:nth-child(odd) { 51 | background-color: @--color--grey-light; 52 | } 53 | 54 | &.picker__opts__item--active { 55 | background-color: @--color--grey; 56 | color: @--color--white; 57 | } 58 | 59 | &:hover { 60 | background-color: @--color--grey; 61 | color: @--color--white; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /assets/slider.html: -------------------------------------------------------------------------------- 1 |
2 | {{slide.filename}} 3 |
4 |
5 |
6 | 7 |
8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /assets/slider.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import tpl from './slider.html'; 3 | 4 | export let Slider = Vue.extend({ 5 | template: tpl, 6 | props: { 7 | slides: { type: Array, required: true }, 8 | index: { type: Number, default: 0 } 9 | }, 10 | data: function() { 11 | return { 12 | slide: this.slides[this.index], 13 | loading: true 14 | }; 15 | }, 16 | created: function() { 17 | window.addEventListener('keyup', this.keyup); 18 | }, 19 | methods: { 20 | keyup: function(e) { 21 | let fn = { 22 | 32: this.next, 23 | 39: this.next, 24 | 37: this.prev, 25 | 27: this.close 26 | }[e.keyCode]; 27 | fn && fn(); 28 | }, 29 | close: function() { 30 | window.removeEventListener('keyup', this.keyup); 31 | this.slides = []; 32 | }, 33 | prev: function(e, callback) { 34 | callback = callback || (i => i - 1); 35 | let i = this.slides.indexOf(this.slide); 36 | i = callback(i); 37 | if (i < 0) { 38 | this.slide = this.slides.slice(-1)[0]; 39 | } else if (i > this.slides.length - 1) { 40 | this.slide = this.slides[0]; 41 | } else { 42 | this.slide = this.slides[i]; 43 | } 44 | }, 45 | next: function(e) { 46 | this.prev(e, i => i + 1); 47 | }, 48 | fix: function() { 49 | this.loading = false; 50 | let fix = (x, y) => (!y ? 0 : Math.round((x - y) / 2) + 'px'); 51 | let box = this.$refs.img, 52 | img = box.firstElementChild; 53 | img.style['max-width'] = box.clientWidth + 'px'; 54 | img.style['max-height'] = box.clientHeight + 'px'; 55 | img.style.top = fix(box.clientHeight, img.clientHeight); 56 | img.style.left = fix(box.clientWidth, img.clientWidth); 57 | } 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /assets/slider.less: -------------------------------------------------------------------------------- 1 | .slider { 2 | display: none; 3 | position: absolute; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | z-index: 100; 9 | background-color:rgba(0, 0, 0, 0.85); 10 | padding: 2em 0.5em 0.5em 0.5em; 11 | color: @--color--white; 12 | text-align: center; 13 | 14 | &.slider--show { 15 | display: block; 16 | } 17 | .slider__loading { 18 | position: absolute; 19 | top: 50%; 20 | left: 50%; 21 | .icon--spinner(); 22 | 23 | &:before { 24 | animation: spin 1s infinite; 25 | } 26 | } 27 | .slider__img { 28 | position: relative; 29 | height: 100%; 30 | width: 100%; 31 | box-sizing: border-box; 32 | 33 | img { 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | } 38 | } 39 | .slider__title { 40 | display: block; 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | box-sizing: border-box; 45 | width: 100%; 46 | z-index: 110; 47 | padding: 0.2em; 48 | color: @--color--white; 49 | 50 | &:hover { 51 | text-decoration: underline; 52 | } 53 | } 54 | .slider__close { 55 | position: absolute; 56 | top: 0; 57 | right: 0; 58 | z-index: 110; 59 | padding: 0.2em 1em 1em 1em; 60 | cursor: pointer; 61 | 62 | &:after { 63 | content: 'X'; 64 | } 65 | } 66 | .slider__prev { 67 | position: absolute; 68 | top: 0; 69 | bottom: 0; 70 | left: 0; 71 | width: 30%; 72 | cursor: pointer; 73 | } 74 | .slider__next { 75 | position: absolute; 76 | top: 0; 77 | bottom: 0; 78 | right: 0; 79 | width: 70%; 80 | cursor: pointer; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /assets/tags.html: -------------------------------------------------------------------------------- 1 |
2 | 15 | 41 | 87 |
88 | -------------------------------------------------------------------------------- /assets/tags.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { contains, call } from './utils.js'; 3 | import tpl from './tags.html'; 4 | 5 | let Tags = { 6 | template: tpl, 7 | props: { 8 | opts: { type: Array, required: true }, 9 | trancated: { type: Boolean, default: false }, 10 | unread: { type: Boolean, default: false }, 11 | edit: { type: Function }, 12 | open: { type: Function }, 13 | name: { type: String, default: 'tags' } 14 | }, 15 | computed: { 16 | info: function() { 17 | this.opts || true; 18 | return window.app.tags; 19 | }, 20 | optsInfo: function() { 21 | let tags = []; 22 | for (let id of this.opts) { 23 | this.info[id] && tags.push(this.info[id]); 24 | } 25 | return tags; 26 | } 27 | }, 28 | methods: { 29 | openInMain: function(tag) { 30 | tag && window.app.openInMain(tag.query); 31 | }, 32 | remove: function(tag) { 33 | return this.edit({ old: [tag] }); 34 | } 35 | } 36 | }; 37 | 38 | let TagsSelect = { 39 | template: tpl, 40 | mixins: [Tags], 41 | props: { 42 | name: { type: String, default: 'tags-select' } 43 | }, 44 | computed: { 45 | totalUnread: function() { 46 | return this.info['#unread'].unread; 47 | } 48 | }, 49 | methods: { 50 | tagName: function(id) { 51 | if (!this.info[id]) { 52 | return id; 53 | } 54 | return this.trancated ? this.info[id].short_name : this.info[id].name; 55 | }, 56 | update: function(val) { 57 | this.openInMain(this.info[val]); 58 | }, 59 | filter: function(val, filter) { 60 | if (!this.info[val]) { 61 | return false; 62 | } 63 | return contains(this.info[val].name, filter); 64 | } 65 | } 66 | }; 67 | 68 | let TagsEdit = { 69 | template: tpl, 70 | mixins: [TagsSelect], 71 | props: { 72 | name: { type: String, default: 'tags-edit' }, 73 | origin: { type: Array, required: true }, 74 | edit: { type: Function, required: true }, 75 | opts: { type: Array, default: () => window.app.tagIdsEdit } 76 | }, 77 | data: function() { 78 | return { 79 | new: {}, 80 | picked: null, 81 | failed: null 82 | }; 83 | }, 84 | created: function() { 85 | this.cancel(); 86 | }, 87 | watch: { 88 | origin: function() { 89 | this.cancel(); 90 | } 91 | }, 92 | computed: { 93 | info: function() { 94 | this.opts || true; 95 | let info = window.app.tags; 96 | for (let i in this.new) { 97 | if (info[i]) { 98 | delete this.new[i]; 99 | } 100 | } 101 | return Object.assign({}, this.new, info); 102 | }, 103 | noChanges: function() { 104 | if (this.origin.length == this.picked.length) { 105 | let picked = this.picked.sort(); 106 | let origin = this.origin.slice().sort(); 107 | return origin.every((v, i) => v === picked[i]); 108 | } 109 | return false; 110 | }, 111 | sort: function() { 112 | let tags = this.picked.slice(); 113 | tags = tags.concat(this.opts.filter(i => this.picked.indexOf(i) == -1)); 114 | this.$nextTick( 115 | () => this.$refs.picker.active && this.$refs.picker.activate() 116 | ); 117 | return tags; 118 | } 119 | }, 120 | methods: { 121 | tagChecked: function(id) { 122 | return this.picked.indexOf(id) != -1; 123 | }, 124 | cancel: function() { 125 | this.picked = this.origin.slice(); 126 | this.failed = null; 127 | }, 128 | update: function(id) { 129 | if (this.opts.indexOf(id) == -1) { 130 | call('post', '/tag', { name: id }).then(res => { 131 | if (res.errors) { 132 | this.failed = id; 133 | this.$refs.picker.filter = id; 134 | return; 135 | } 136 | this.new[res.id] = res; 137 | this.new = Object.assign({}, this.new); 138 | this.opts.splice(0, 0, res.id); 139 | this.update(res.id); 140 | }); 141 | return; 142 | } 143 | let idx = this.picked.indexOf(id); 144 | if (idx == -1) { 145 | this.picked.push(id); 146 | } else { 147 | this.picked.splice(idx, 1); 148 | } 149 | }, 150 | apply: function() { 151 | if (this.noChanges) return; 152 | this.edit({ old: this.origin, new: this.picked }); 153 | this.$refs.picker.cancel(true); 154 | } 155 | } 156 | }; 157 | 158 | Vue.component('tags', Tags); 159 | Vue.component('tags-select', TagsSelect); 160 | Vue.component('tags-edit', TagsEdit); 161 | -------------------------------------------------------------------------------- /assets/tags.less: -------------------------------------------------------------------------------- 1 | .tags { 2 | display: flex; 3 | 4 | .icon--tags { 5 | position: absolute; 6 | top: 0; 7 | left: 3px; 8 | cursor: pointer; 9 | } 10 | 11 | .picker__header { 12 | color: @--color--green; 13 | background: inherit; 14 | } 15 | 16 | .picker__input { 17 | width: 10em; 18 | padding-left: 1.5em; 19 | border-color: @--color--grey-light; 20 | background-color: @--color--grey-light; 21 | 22 | &::placeholder { 23 | color: @--color--green; 24 | text-align: center; 25 | } 26 | 27 | &:focus, 28 | &:hover { 29 | background-color: @--color--white; 30 | border-color: @--color--grey; 31 | } 32 | 33 | &:focus { 34 | border-left: 3px solid @--color--orange; 35 | } 36 | } 37 | 38 | .picker__opts__item { 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: center; 42 | padding-right: 0; 43 | } 44 | 45 | } 46 | 47 | .tags-edit { 48 | .tags-edit__add { 49 | display: flex; 50 | align-items: stretch; 51 | font-weight: bold; 52 | } 53 | 54 | .tags-edit__failed { 55 | color: @--color--orange; 56 | } 57 | 58 | .icon--ok { 59 | display: none; 60 | position: absolute; 61 | top: 0; 62 | left: 0; 63 | cursor: pointer; 64 | } 65 | 66 | .icon--add { 67 | color: @--color--orange; 68 | } 69 | 70 | .picker__input { 71 | width: 9em; 72 | } 73 | 74 | .tags--edited { 75 | .icon--ok { 76 | display: block; 77 | } 78 | 79 | .icon--tags { 80 | display: none; 81 | } 82 | 83 | .picker__header { 84 | color: @--color--orange; 85 | } 86 | 87 | .picker__input { 88 | &::placeholder { 89 | color: @--color--orange; 90 | } 91 | } 92 | } 93 | } 94 | 95 | .tags__item { 96 | display: flex; 97 | align-items: center; 98 | box-sizing: border-box; 99 | height: 1.5em; 100 | padding: 0 0.1em; 101 | margin-right: 0.2em; 102 | background-color: @--color--grey-light; 103 | border: 1px solid @--color--grey-light; 104 | color: @--color--green; 105 | white-space: nowrap; 106 | 107 | &:last-child { 108 | margin-right: 0; 109 | } 110 | 111 | &:hover { 112 | text-decoration: none; 113 | background-color: @--color--white; 114 | } 115 | 116 | .icon--remove { 117 | display: none; 118 | } 119 | 120 | &.tags__item--edit { 121 | padding-right: 0; 122 | 123 | .icon--remove { 124 | display: block; 125 | font-size: @--font-size--s; 126 | 127 | &:hover { 128 | color: @--color--orange; 129 | } 130 | } 131 | } 132 | 133 | } 134 | 135 | .tags__item--unread { 136 | font-weight: bold; 137 | } 138 | 139 | .tags__item__unread { 140 | display: none; 141 | 142 | &::before { 143 | content: "("; 144 | } 145 | 146 | &::after { 147 | content: ")"; 148 | } 149 | 150 | .tags__item--unread & { 151 | display: inline; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /assets/theme-base.less: -------------------------------------------------------------------------------- 1 | @import './font/icons.less'; 2 | @import './base.less'; 3 | @import './tags.less'; 4 | @import './picker.less'; 5 | @import './slider.less'; 6 | @import './editor.less'; 7 | @import './filters.less'; 8 | @import './msg.less'; 9 | @import './msgs.less'; 10 | @import './login.less'; 11 | @import './app.less'; 12 | 13 | /* Material Design 14 | https://material.io/guidelines/style/color.html#color-color-palette 15 | 16 | Blue Grey 17 | 50 #eceff1 18 | 100 #cfd8dc 19 | 200 #b0bec5 20 | 300 #90a4ae 21 | 400 #78909c 22 | 500 #607d8b 23 | 600 #546e7a 24 | 700 #455a64 25 | 800 #37474f 26 | 900 #263238 27 | 28 | Green 29 | 50 #e8f5e9 30 | 100 #c8e6c9 31 | 200 #a5d6a7 32 | 300 #81c784 33 | 400 #66bb6a 34 | 500 #4caf50 35 | 600 #43a047 36 | 700 #388e3c 37 | 800 #2e7d32 38 | 900 #1b5e20 39 | 40 | Amber 41 | 50 #fff8e1 42 | 100 #ffecb3 43 | 200 #ffe082 44 | 300 #ffd54f 45 | 400 #ffca28 46 | 500 #ffc107 47 | 600 #ffb300 48 | 700 #ffa000 49 | 800 #ff8f00 50 | 900 #ff6f00 51 | */ 52 | @--color--white: #fff; 53 | @--color--black: #263238; 54 | @--color--green: #388e3c; 55 | @--color--grey: #607d8b; 56 | @--color--grey-light: #eceff1; 57 | @--color--orange: #ff6f00; 58 | @--color--orange-light: #ffecb3; 59 | 60 | // Sizes & heights 61 | @--font-size: 100%; 62 | @--font-size--s: 0.9375rem; // 15px 63 | @--font-size--xs: 0.8125rem; // 13px 64 | @--line-height: 1.5rem; 65 | @--line-height--s: 1.2rem; 66 | @--line-height--l: 2rem; 67 | @--pic-height: 20px; 68 | 69 | @--small-screen: ~"only screen and (max-width: 600px)"; 70 | -------------------------------------------------------------------------------- /assets/theme-indigo.less: -------------------------------------------------------------------------------- 1 | @import './theme-base.less'; 2 | 3 | /* Material Design 4 | https://material.io/guidelines/style/color.html#color-color-palette 5 | 6 | Indigo 7 | 50 #e8eaf6 8 | 100 #c5cae9 9 | 200 #9fa8da 10 | 300 #7986cb 11 | 400 #5c6bc0 12 | 500 #3f51b5 13 | 600 #3949ab 14 | 700 #303f9f 15 | 800 #283593 16 | 900 #1a237e 17 | */ 18 | @--color--grey-light: #e8eaf6; 19 | @--color--green: #303f9f; 20 | -------------------------------------------------------------------------------- /assets/theme-mint.less: -------------------------------------------------------------------------------- 1 | @import './theme-base.less'; 2 | 3 | /* Material Design 4 | https://material.io/guidelines/style/color.html#color-color-palette 5 | 6 | Teal 7 | 50 #e0f2f1 8 | 100 #b2dfdb 9 | 200 #80cbc4 10 | 300 #4db6ac 11 | 400 #26a69a 12 | 500 #009688 13 | 600 #00897b 14 | 700 #00796b 15 | 800 #00695c 16 | 900 #004d40 17 | */ 18 | @--color--grey-light: #e0f2f1; 19 | @--color--green: #00796b; 20 | -------------------------------------------------------------------------------- /assets/theme-solarized.less: -------------------------------------------------------------------------------- 1 | @import './theme-base.less'; 2 | 3 | /* http://ethanschoonover.com/solarized 4 | Background tones (dark) 5 | $base03: #002b36; 6 | $base02: #073642; 7 | 8 | Content tones 9 | $base01: #586e75; 10 | $base00: #657b83; 11 | $base0: #839496; 12 | $base1: #93a1a1; 13 | 14 | Background tones (light) 15 | $base2: #eee8d5; 16 | $base3: #fdf6e3; 17 | 18 | Accent colors 19 | $yellow: #b58900; 20 | $orange: #cb4b16; 21 | $red: #dc322f; 22 | $magenta: #d33682; 23 | $violet: #6c71c4; 24 | $blue: #268bd2; 25 | $cyan: #2aa198; 26 | $green: #859900; 27 | */ 28 | @--color--white: #fdf6e3; 29 | @--color--black: #586e75; 30 | @--color--grey: #839496; 31 | @--color--grey-light: #eee8d5; 32 | @--color--green: #2aa198; 33 | @--color--orange: #cb4b16; 34 | @--color--orange-light: #ffecb3; 35 | -------------------------------------------------------------------------------- /assets/utils.js: -------------------------------------------------------------------------------- 1 | export function call(method, url, data, headers = null) { 2 | let params = { 3 | method: method, 4 | credentials: 'same-origin' 5 | }; 6 | if (headers) { 7 | params.headers = headers; 8 | params.body = data; 9 | } else if (method == 'post') { 10 | params.headers = { 'Content-Type': 'application/json' }; 11 | params.body = data && JSON.stringify(data); 12 | } 13 | return fetch(url, params).then(response => { 14 | let res; 15 | if (response.headers.get('Content-Type') != 'application/json') { 16 | res = response.text(); 17 | } else { 18 | res = response.json(); 19 | } 20 | return res.then(res => { 21 | if (!response.ok && !res.errors) { 22 | return { errors: [`${response.status} ${response.statusText}`] }; 23 | } 24 | return res; 25 | }); 26 | }); 27 | } 28 | 29 | export function trancate(value, max = 15, simbol = '…') { 30 | max = max || 15; 31 | if (value.length > max) { 32 | value = value.slice(0, max - 1) + simbol; 33 | } 34 | return value; 35 | } 36 | 37 | export function contains(one, two) { 38 | return one.toLowerCase().indexOf(two.toLowerCase()) != -1; 39 | } 40 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const path = require('path'); 3 | const pkg = require('../package.json'); 4 | const webpack = require('webpack'); 5 | 6 | const CleanPlugin = require('clean-webpack-plugin'); 7 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 8 | 9 | const dist = path.resolve(__dirname, 'dist'); 10 | const prod = process.env.NODE_ENV === 'production'; 11 | 12 | let entries = { 13 | index: __dirname + '/app.js', 14 | login: __dirname + '/login.js', 15 | vendor: ['vue'] 16 | }; 17 | for (let theme of pkg['mailur']['themes']) { 18 | entries[`theme-${theme}`] = __dirname + `/theme-${theme}.less`; 19 | } 20 | 21 | module.exports = { 22 | entry: entries, 23 | plugins: [ 24 | new CleanPlugin([dist]), 25 | new ExtractTextPlugin({ 26 | filename: '[name].css?[hash]' 27 | }), 28 | new webpack.optimize.CommonsChunkPlugin({ 29 | name: 'vendor' 30 | }) 31 | ], 32 | output: { 33 | filename: '[name].js?[hash]', 34 | path: dist 35 | }, 36 | resolve: { 37 | alias: { 38 | vue$: 'vue/dist/vue.esm.js' 39 | } 40 | }, 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.js$/, 45 | exclude: /node_modules/, 46 | loader: 'babel-loader', 47 | options: { 48 | presets: ['@babel/preset-env'] 49 | } 50 | }, 51 | { 52 | test: /\.(png|jpg|gif|svg)$/, 53 | loader: 'file-loader', 54 | options: { 55 | name: '[name].[ext]?[hash]' 56 | } 57 | }, 58 | { 59 | test: /\.(html)$/, 60 | loader: 'html-loader' 61 | }, 62 | { 63 | test: /\.(eot|svg|ttf|woff|woff2)$/, 64 | loader: 'file-loader', 65 | options: { 66 | name: '[name].[ext]?[hash]' 67 | } 68 | }, 69 | { 70 | test: /\.less$/, 71 | use: ExtractTextPlugin.extract({ 72 | fallback: 'style-loader', 73 | use: [ 74 | { loader: 'css-loader', options: { sourceMap: true } }, 75 | { 76 | loader: 'less-loader', 77 | options: { 78 | sourceMap: true, 79 | plugins: !prod 80 | ? [] 81 | : [ 82 | new (require('less-plugin-autoprefix'))(), 83 | new (require('less-plugin-clean-css'))({ advanced: true }) 84 | ] 85 | } 86 | } 87 | ] 88 | }) 89 | } 90 | ] 91 | }, 92 | devtool: '#source-map' 93 | }; 94 | 95 | if (prod) { 96 | module.exports.plugins = (module.exports.plugins || []).concat([ 97 | new webpack.DefinePlugin({ 98 | 'process.env': { 99 | NODE_ENV: '"production"' 100 | } 101 | }), 102 | new webpack.optimize.UglifyJsPlugin({ 103 | sourceMap: true, 104 | compress: { 105 | warnings: false 106 | } 107 | }) 108 | ]); 109 | } 110 | -------------------------------------------------------------------------------- /bin/activate: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ -o xtrace ]]; then 3 | xtrace=1 4 | set +x 5 | else 6 | xtrace= 7 | fi 8 | 9 | PATH=./node_modules/.bin:$PATH 10 | 11 | cd /opt/mailur 12 | envfile=${envfile-bin/env} 13 | [ -n "$envfile" ] && [ -f $envfile ] && . $envfile 14 | 15 | env=${env:-env} 16 | [ -d $env ] && . $env/bin/activate 17 | 18 | [ -z "$xtrace" ] || set -x 19 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | domain=${domain:-} 7 | nginx_domains=${nginx_domains:-"$domain"} 8 | certbot_opts=${certbot_opts-"--webroot -w /var/tmp --agree-tos"} 9 | 10 | [ -n "$domain" ] || ( 11 | echo '"domain" variable is not set' 12 | exit 1 13 | ) 14 | 15 | cat <<"EOF" > /etc/yum.repos.d/nginx.repo 16 | [nginx-stable] 17 | name=nginx stable repo 18 | baseurl=http://nginx.org/packages/centos/$releasever/$basearch/ 19 | gpgcheck=1 20 | enabled=1 21 | gpgkey=https://nginx.org/keys/nginx_signing.key 22 | module_hotfixes=true 23 | 24 | [nginx-mainline] 25 | name=nginx mainline repo 26 | baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/ 27 | gpgcheck=1 28 | enabled=0 29 | gpgkey=https://nginx.org/keys/nginx_signing.key 30 | module_hotfixes=true 31 | EOF 32 | 33 | yum install -y nginx certbot 34 | 35 | 36 | cat < /etc/nginx/nginx.conf 37 | events { 38 | worker_connections 768; 39 | # multi_accept on; 40 | } 41 | 42 | http { 43 | include /etc/nginx/mime.types; 44 | default_type application/octet-stream; 45 | 46 | access_log /var/log/nginx/access.log; 47 | error_log /var/log/nginx/error.log; 48 | 49 | server { 50 | listen 80 default; 51 | location /.well-known { 52 | root /var/tmp; 53 | } 54 | } 55 | } 56 | EOF 57 | 58 | systemctl enable nginx 59 | systemctl restart nginx 60 | 61 | certbot certonly -d $domain $certbot_opts 62 | systemctl enable certbot-renew.timer 63 | 64 | dhparam=/etc/ssl/dhparam.pem 65 | [ -f "$dhparam" ] || openssl dhparam -out $dhparam 2048 66 | 67 | cat < /etc/nginx/nginx.conf 68 | events { 69 | worker_connections 768; 70 | # multi_accept on; 71 | } 72 | 73 | http { 74 | types_hash_max_size 4096; 75 | default_type application/octet-stream; 76 | 77 | access_log /var/log/nginx/access.log; 78 | error_log /var/log/nginx/error.log; 79 | 80 | include /etc/nginx/mime.types; 81 | include /etc/nginx/conf.d/*.conf; 82 | } 83 | 84 | mail { 85 | server_name $domain; 86 | auth_http 0.0.0.0:5000/nginx; 87 | 88 | error_log /var/log/nginx/mail.log; 89 | 90 | proxy_pass_error_message on; 91 | 92 | ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem; 93 | ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem; 94 | 95 | server { 96 | listen 993 ssl; 97 | protocol imap; 98 | } 99 | 100 | server { 101 | listen 465 ssl; 102 | protocol smtp; 103 | smtp_auth plain; 104 | } 105 | } 106 | 107 | stream { 108 | ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem; 109 | ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem; 110 | 111 | server { 112 | listen 12345 ssl; 113 | proxy_pass localhost:12300; 114 | } 115 | } 116 | EOF 117 | 118 | mkdir -p /etc/nginx/conf.d/ 119 | cat < /etc/nginx/conf.d/mailur.conf 120 | server { 121 | listen 80; 122 | listen [::]:80; 123 | server_name $nginx_domains; 124 | 125 | return 301 https://\$host\$request_uri; 126 | } 127 | server { 128 | listen 443 ssl http2; 129 | listen [::]:443 ssl http2; 130 | server_name $nginx_domains; 131 | 132 | ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem; 133 | ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem; 134 | 135 | location / { 136 | proxy_set_header X-Forwarded-For \$remote_addr; 137 | proxy_set_header X-Forwarded-Proto \$scheme; 138 | proxy_set_header Host \$host; 139 | proxy_pass http://localhost:5000/; 140 | } 141 | location /assets { 142 | alias /opt/mailur/assets/dist; 143 | } 144 | location /.proxy { 145 | internal; 146 | proxy_pass \$arg_url; 147 | proxy_set_header Referer "\$host"; 148 | proxy_ssl_server_name on; 149 | } 150 | location /.well-known { 151 | root /var/tmp; 152 | } 153 | } 154 | EOF 155 | 156 | cat < /etc/nginx/conf.d/params.conf 157 | tcp_nopush on; 158 | tcp_nodelay on; 159 | output_buffers 1 256k; 160 | postpone_output 0; 161 | keepalive_requests 210; 162 | reset_timedout_connection on; 163 | ignore_invalid_headers on; 164 | server_tokens off; 165 | client_max_body_size 1024m; 166 | recursive_error_pages on; 167 | server_name_in_redirect off; 168 | 169 | gzip on; 170 | gzip_disable "msie6"; 171 | gzip_vary on; 172 | gzip_proxied any; 173 | gzip_comp_level 1; 174 | gzip_buffers 16 8k; 175 | gzip_http_version 1.1; 176 | gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript; 177 | 178 | proxy_set_header Accept-Encoding ""; 179 | proxy_buffering on; 180 | proxy_ignore_client_abort off; 181 | proxy_intercept_errors on; 182 | proxy_next_upstream error timeout invalid_header; 183 | proxy_redirect off; 184 | proxy_buffer_size 32k; 185 | proxy_buffers 8 32k; 186 | proxy_busy_buffers_size 64k; 187 | proxy_temp_file_write_size 64k; 188 | client_body_buffer_size 128k; 189 | proxy_connect_timeout 1; 190 | proxy_send_timeout 300; 191 | proxy_read_timeout 300; 192 | proxy_cache_min_uses 1; 193 | proxy_temp_path /var/tmp; 194 | 195 | 196 | # https://mozilla.github.io/server-side-tls/ssl-config-generator/ 197 | # https://michael.lustfield.net/nginx/getting-a-perfect-ssl-labs-score 198 | ssl_session_timeout 1d; 199 | ssl_session_cache shared:SSL:50m; 200 | ssl_session_tickets off; 201 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 202 | ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; 203 | ssl_prefer_server_ciphers on; 204 | ssl_stapling on; 205 | ssl_stapling_verify on; 206 | ssl_dhparam $dhparam; 207 | ssl_ecdh_curve secp384r1; 208 | 209 | # HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months) 210 | add_header Strict-Transport-Security "max-age=15768000; includeSubdomains"; 211 | 212 | resolver 8.8.8.8; 213 | EOF 214 | 215 | systemctl restart nginx 216 | -------------------------------------------------------------------------------- /bin/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Pre-requirements: 3 | # - server with CentOS 7 4 | # - code at "/opt/mailur" 5 | # 6 | # Details: https://pusto.org/mailur/installation/ 7 | # 8 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 9 | set -exuo pipefail 10 | 11 | [ -f bin/env ] || cat < bin/env 12 | #!/bin/sh 13 | # used for creation of virtual mailboxes 14 | # use a space separator for multiple users 15 | user=demo 16 | 17 | # comment next line if you modify "/etc/dovecot/passwd.users" 18 | pass={plain}demo 19 | 20 | # used by "bin/deploy" for nginx and certbot 21 | domain=example.com 22 | 23 | # used as password for dovecot master users 24 | # used as "doveadm_password" 25 | secret="$(cat /proc/sys/kernel/random/uuid)" 26 | 27 | # used by cli/web application 28 | export MLR_DOMAIN=\$domain 29 | export MLR_SECRET=\$secret 30 | export MLR_MASTER=root:\$secret 31 | export MLR_SIEVE=sieve:\$secret 32 | export MLR_IMAP_OFF='' 33 | EOF 34 | 35 | . bin/activate 36 | 37 | # TODO: doesn't work inside podman container 38 | #localectl set-locale LANG=en_US.utf8 39 | #timedatectl set-timezone UTC 40 | 41 | yum install -y epel-release 42 | 43 | bin/install-dovecot 44 | 45 | yum install -y python36 python36-devel gcc 46 | env=${env:-/opt/mailur/env} 47 | pip=$env/bin/pip 48 | python3 -m venv $env 49 | 50 | $pip install -U pip wheel 51 | $pip install -U -e . 52 | 53 | yum install -y npm 54 | npm i 55 | npm run build 56 | 57 | bin/install-services 58 | 59 | [ ! -f bin/install-local ] || bin/install-local 60 | -------------------------------------------------------------------------------- /bin/install-dovecot: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | secret=${secret:-root} 7 | 8 | cat <<"EOF" > /etc/yum.repos.d/dovecot.repo 9 | [dovecot-2.3-latest] 10 | name=Dovecot 2.3 CentOS $releasever - $basearch 11 | baseurl=http://repo.dovecot.org/ce-2.3-latest/rhel/$releasever/RPMS/$basearch 12 | gpgkey=https://repo.dovecot.org/DOVECOT-REPO-GPG 13 | gpgcheck=1 14 | enabled=1 15 | EOF 16 | yum install -y dovecot dovecot-pigeonhole 17 | 18 | id -u vmail || ( 19 | groupadd -g 5000 vmail 20 | useradd -m -d /home/vmail -s /bin/nologin -u 5000 -g vmail vmail 21 | ) 22 | 23 | [ -d '/etc/dovecot.bak' ] || ( 24 | mv /etc/dovecot{,.bak} 25 | rm -rf /etc/dovecot 26 | mkdir /etc/dovecot 27 | ) 28 | cat < /etc/dovecot/dovecot.conf 29 | auth_debug=yes 30 | auth_debug_passwords=yes 31 | auth_verbose_passwords=sha1 32 | mail_debug=yes 33 | verbose_ssl=yes 34 | 35 | ssl = no 36 | ssl_client_ca_file = /etc/pki/tls/cert.pem 37 | 38 | # for query like "UID 1,2,...,150000" should be big enough 39 | imap_max_line_length = 1M 40 | 41 | mail_location = sdbox:~ 42 | 43 | auth_master_user_separator = * 44 | passdb { 45 | driver = passwd-file 46 | args = /etc/dovecot/passwd.masters 47 | master = yes 48 | } 49 | passdb { 50 | driver = passwd-file 51 | args = /etc/dovecot/passwd.users 52 | } 53 | userdb { 54 | driver = passwd-file 55 | args = /etc/dovecot/passwd.users 56 | default_fields = uid=vmail gid=vmail home=/home/vmail/%u 57 | } 58 | 59 | namespace mlr { 60 | prefix = mlr/ 61 | separator = / 62 | hidden = yes 63 | list = no 64 | location = sdbox:~/mlr 65 | mailbox { 66 | auto = create 67 | } 68 | mailbox All { 69 | auto = create 70 | } 71 | mailbox Sys { 72 | auto = create 73 | } 74 | mailbox Del { 75 | auto = create 76 | autoexpunge = 30d 77 | } 78 | } 79 | namespace { 80 | inbox = yes 81 | prefix = tags/ 82 | separator = / 83 | location = virtual:%h/tags 84 | mailbox Trash { 85 | auto = subscribe 86 | special_use = \Trash 87 | } 88 | mailbox Spam { 89 | auto = subscribe 90 | special_use = \Junk 91 | } 92 | mailbox Pinned { 93 | auto = subscribe 94 | special_use = \Flagged 95 | } 96 | mailbox All { 97 | auto = subscribe 98 | special_use = \All 99 | } 100 | } 101 | 102 | mail_plugins = \$mail_plugins acl notify mail_log replication fts fts_lucene virtual 103 | plugin { 104 | acl = vfile:/etc/dovecot/acl 105 | acl_globals_only = yes 106 | 107 | mail_log_events = delete undelete expunge copy save flag_change 108 | mail_log_fields = uid box msgid flags 109 | 110 | fts = lucene 111 | fts_lucene = whitespace_chars=@. 112 | fts_autoindex = yes 113 | fts_autoindex_exclude = mlr 114 | fts_autoindex_exclude2 = mlr/Sys 115 | 116 | #sieve_extensions = +vnd.dovecot.debug 117 | } 118 | 119 | mail_attribute_dict = file:%h/dovecot-attributes 120 | protocol imap { 121 | mail_plugins = \$mail_plugins imap_filter_sieve 122 | mail_max_userip_connections = 20 123 | imap_metadata = yes 124 | } 125 | 126 | protocols = imap 127 | service imap-login { 128 | inet_listener imap { 129 | port = 143 130 | address = localhost 131 | } 132 | 133 | process_min_avail = 1 134 | } 135 | service imap { 136 | vsz_limit = 2G 137 | } 138 | service indexer-worker { 139 | vsz_limit = 2G 140 | } 141 | 142 | replication_dsync_parameters = -d -n mlr 143 | service replicator { 144 | process_min_avail = 1 145 | unix_listener replicator-doveadm { 146 | user = vmail 147 | mode = 0600 148 | } 149 | } 150 | service aggregator { 151 | fifo_listener replication-notify-fifo { 152 | user = vmail 153 | } 154 | unix_listener replication-notify { 155 | user = vmail 156 | } 157 | } 158 | service doveadm { 159 | inet_listener { 160 | address = localhost 161 | port = 12300 162 | } 163 | } 164 | # use https port from nginx 165 | doveadm_port = 12345 166 | doveadm_password = $secret 167 | EOF 168 | cat < /etc/dovecot/passwd.masters 169 | root:{plain}$secret 170 | sieve:{plain}$secret 171 | EOF 172 | cat <<"EOF" > /etc/dovecot/acl 173 | * owner lrws 174 | mlr/* owner lr 175 | * user=root lrwstipe 176 | * user=sieve lrwsp 177 | EOF 178 | 179 | names="$user" bin/install-users 180 | 181 | systemctl enable dovecot 182 | systemctl restart dovecot 183 | -------------------------------------------------------------------------------- /bin/install-idle-sync: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | 5 | user=${user} 6 | timeout=${timeout:-600} 7 | 8 | cat < /etc/systemd/system/mailur-$user.service 9 | [Unit] 10 | Description=mailur-$user 11 | Wants=network.target 12 | After=network.target 13 | [Service] 14 | ExecStart=/bin/sh -c '. bin/activate && exec mlr $user sync --timeout=$timeout' 15 | WorkingDirectory=/opt/mailur 16 | Restart=always 17 | RestartSec=10 18 | [Install] 19 | WantedBy=multi-user.target 20 | EOF 21 | systemctl enable mailur-$user 22 | systemctl restart mailur-$user 23 | -------------------------------------------------------------------------------- /bin/install-on-ubuntu: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Installation inside podman container for Ubuntu. 4 | # Used for Github Actions. 5 | # 6 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 7 | set -exuo pipefail 8 | 9 | VERSION_ID=$(lsb_release -sr) 10 | echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list 11 | curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key | sudo apt-key add - 12 | sudo apt-get -y update 13 | sudo apt-get -y install podman 14 | 15 | sudo podman rm -f mlr 2>/dev/null || true 16 | 17 | sudo podman run -v .:/opt/mailur --name mlr -d centos:stream8 /sbin/init 18 | 19 | cat << EOF | sudo podman exec -i -w /opt/mailur mlr /bin/bash 20 | set -exuo pipefail 21 | 22 | systemctl disable dnf-makecache.timer 23 | bin/install 24 | bin/install-test 25 | EOF 26 | -------------------------------------------------------------------------------- /bin/install-services: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | opts=${gunicorn_opts:-} 7 | 8 | cat < /etc/systemd/system/mailur.service 9 | [Unit] 10 | Description=mailur 11 | Wants=network.target 12 | After=network.target 13 | [Service] 14 | ExecStart=/bin/sh -c 'opts="$opts" bin/run-web' 15 | WorkingDirectory=/opt/mailur 16 | Restart=always 17 | RestartSec=10 18 | [Install] 19 | WantedBy=multi-user.target 20 | EOF 21 | systemctl enable mailur 22 | systemctl restart mailur 23 | 24 | 25 | cat <<"EOF" > /etc/systemd/system/mailur-webpack.service 26 | [Unit] 27 | Description=mailur-webpack 28 | Wants=network.target 29 | After=network.target 30 | [Service] 31 | ExecStart=/bin/sh -c 'exec npm run dev' 32 | WorkingDirectory=/opt/mailur 33 | Restart=always 34 | RestartSec=10 35 | [Install] 36 | WantedBy=multi-user.target 37 | EOF 38 | -------------------------------------------------------------------------------- /bin/install-test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | yum install -y git 7 | 8 | cd /opt/mailur 9 | pip install -e .[test] 10 | -------------------------------------------------------------------------------- /bin/install-users: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | home=${home:-"/home/vmail"} 7 | pass=${pass-\{plain\}demo} 8 | names=${names-"demo"} 9 | fields=${fields:-} 10 | append=${append:-} 11 | 12 | [ -n "$names" ] || exit 0 13 | 14 | if [ -n "$pass" ]; then 15 | users=/etc/dovecot/passwd.users 16 | [ -n "$append" ] || : > $users 17 | for user in $names; do 18 | echo "$user:$pass::::$home/$user::$fields" >> $users 19 | done 20 | fi 21 | 22 | # virtual mailboxes 23 | for user in $names; do 24 | home=$home/$user bin/install-vmail 25 | done 26 | chown -R vmail:vmail $home 27 | -------------------------------------------------------------------------------- /bin/install-vmail: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | 5 | if [ -z "${home:-}" ]; then 6 | [ -n "${user:-}" ] || (echo "no \$user and no \$home"; exit 1) 7 | home=$(doveadm user -u $user -f home) 8 | fi 9 | 10 | path=$home/tags 11 | mkdir -p $path/{INBOX,All,Pinned,Trash,Spam} 12 | cat <<"EOF" > $path/INBOX/dovecot-virtual 13 | mlr 14 | KEYWORD #inbox UNKEYWORD #trash UNKEYWORD #spam 15 | EOF 16 | 17 | cat <<"EOF" > $path/Trash/dovecot-virtual 18 | mlr 19 | KEYWORD #trash 20 | EOF 21 | 22 | cat <<"EOF" > $path/Spam/dovecot-virtual 23 | mlr 24 | KEYWORD #spam UNKEYWORD #trash 25 | EOF 26 | 27 | cat <<"EOF" > $path/Pinned/dovecot-virtual 28 | mlr 29 | INTHREAD REFS FLAGGED UNKEYWORD #trash UNKEYWORD #spam 30 | EOF 31 | 32 | cat <<"EOF" > $path/All/dovecot-virtual 33 | mlr 34 | UNKEYWORD #trash UNKEYWORD #spam 35 | EOF 36 | -------------------------------------------------------------------------------- /bin/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import pathlib 4 | import sys 5 | import time 6 | 7 | root = pathlib.Path(__file__).resolve().parent.parent 8 | 9 | 10 | def main(args=None): 11 | parser = argparse.ArgumentParser('Manage CLI') 12 | cmds = parser.add_subparsers(title='commands') 13 | 14 | def cmd(name, **kw): 15 | p = cmds.add_parser(name, **kw) 16 | p.set_defaults(cmd=name) 17 | p.arg = lambda *a, **kw: p.add_argument(*a, **kw) and p 18 | p.exe = lambda f: p.set_defaults(exe=f) or p 19 | return p 20 | 21 | cmd('icons').exe(lambda a: icons()) 22 | cmd('web').exe(lambda a: web()) 23 | cmd('test')\ 24 | .exe(lambda a: run(''' 25 | pytest="pytest -q --cov=mailur" 26 | $pytest -n2 -m "not no_parallel" 27 | $pytest --cov-append --cov-report=term-missing -m "no_parallel" 28 | ''')) 29 | cmd('lint')\ 30 | .exe(lambda a: run('ci=%s bin/run-lint' % (1 if a.ci else '')))\ 31 | .arg('--ci', action='store_true') 32 | 33 | args = parser.parse_args(sys.argv[1:]) 34 | if not hasattr(args, 'cmd'): 35 | parser.print_usage() 36 | exit(2) 37 | elif hasattr(args, 'exe'): 38 | try: 39 | args.exe(args) 40 | except KeyboardInterrupt: 41 | raise SystemExit('^C') 42 | else: 43 | raise ValueError('Wrong subcommand') 44 | 45 | 46 | def web(): 47 | from gevent.pool import Pool 48 | from gevent.subprocess import run 49 | 50 | def api(): 51 | run('bin/run-web', shell=True) 52 | 53 | def webpack(): 54 | run('command -v yarn && yarn run dev || npm run dev', shell=True) 55 | 56 | try: 57 | pool = Pool() 58 | pool.spawn(api) 59 | pool.spawn(webpack) 60 | pool.join() 61 | except KeyboardInterrupt: 62 | time.sleep(1) 63 | 64 | 65 | def run(cmd): 66 | from subprocess import call 67 | from sys import exit 68 | 69 | check = 'command -v pytest' 70 | if call(check, cwd=root, shell=True): 71 | raise SystemExit( 72 | 'Test dependencies must be installed.\n' 73 | '$ pip install -e .[test]' 74 | ) 75 | 76 | cmd = 'cat <<"EOF" | sh -ex\n%s\nEOF' % cmd 77 | exit(call(cmd, cwd=root, shell=True)) 78 | 79 | 80 | def icons(): 81 | import json 82 | 83 | import bottle 84 | 85 | font = root / 'assets/font' 86 | sel = (font / 'selection.json').read_text() 87 | sel = json.loads(sel) 88 | sel_pretty = json.dumps(sel, indent=2, ensure_ascii=False, sort_keys=True) 89 | (font / 'selection.json').write_text(sel_pretty) 90 | icons = [ 91 | (i['properties']['name'], '\\%s' % hex(i['properties']['code'])[2:]) 92 | for i in sel['icons'] 93 | ] 94 | tpl = str((font / 'icons.less.tpl').resolve()) 95 | txt = bottle.template( 96 | tpl, icons=icons, 97 | template_settings={'syntax': '{% %} % {{ }}'} 98 | ) 99 | f = font / 'icons.less' 100 | f.write_text(txt) 101 | print('%s updated' % f) 102 | 103 | 104 | if __name__ == '__main__': 105 | main() 106 | -------------------------------------------------------------------------------- /bin/run-lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | flake8 7 | 8 | prettier --write assets/*.js webpack.config.js 9 | 10 | eslint assets/*.js webpack.config.js 11 | stylelint assets/**/*.less --fix 12 | htmlhint --rules=doctype-html5=0 assets/*.html 13 | 14 | [ ! ${ci-} ] || git diff --exit-code assets/ 15 | -------------------------------------------------------------------------------- /bin/run-web: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -exuo pipefail 4 | . bin/activate 5 | 6 | app=${app:-'mailur.web:app'} 7 | opts=${opts:-'-w 2 --worker-class=meinheld.gmeinheld.MeinheldWorker'} 8 | 9 | exec gunicorn $app $opts -b :5000 --timeout=300 \ 10 | --access-logfile=- --access-logformat="%(r)s %(s)s %(D)sms %(b)sb" 11 | -------------------------------------------------------------------------------- /mailur/__init__.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | import inspect 3 | import logging 4 | import logging.config 5 | import os 6 | import time 7 | import uuid 8 | from contextlib import contextmanager 9 | 10 | import ujson 11 | 12 | json = ujson 13 | conf = { 14 | 'DEBUG': os.environ.get('MLR_DEBUG', True), 15 | 'DEBUG_IMAP': int(os.environ.get('MLR_DEBUG_IMAP', 0)), 16 | 'DEBUG_SMTP': int(os.environ.get('MLR_DEBUG_SMTP', 0)), 17 | 'SECRET': os.environ.get('MLR_SECRET', uuid.uuid4().hex), 18 | 'MASTER': os.environ.get('MLR_MASTER', 'root:root').split(':'), 19 | 'SIEVE': os.environ.get('MLR_SIEVE', 'sieve:root').split(':'), 20 | 'USER': os.environ.get('MLR_USER'), 21 | 'DOMAIN': os.environ.get('MLR_DOMAIN', 'localhost'), 22 | 'USE_PROXY': os.environ.get('MLR_USE_PROXY', False), 23 | 'IMAP_OFF': os.environ.get('MLR_IMAP_OFF', '').split(), 24 | 'GMAIL_TWO_WAY_SYNC': os.environ.get('MLR_GMAIL_TWO_WAY_SYNC', False), 25 | } 26 | 27 | 28 | class UserFilter(logging.Filter): 29 | def filter(self, record): 30 | record.user = conf['USER'] 31 | return True 32 | 33 | 34 | log = logging.getLogger(__name__) 35 | log.addFilter(UserFilter()) 36 | logging.config.dictConfig({ 37 | 'version': 1, 38 | 'disable_existing_loggers': False, 39 | 'formatters': {'f': { 40 | 'datefmt': '%Y-%m-%d %H:%M:%S%Z', 41 | 'format': ( 42 | '[%(asctime)s][%(levelname).3s]' 43 | '[%(process)s][%(user)s][%(funcName)s] %(message)s' 44 | ), 45 | }}, 46 | 'handlers': {'h': { 47 | 'class': 'logging.StreamHandler', 48 | 'level': logging.DEBUG, 49 | 'formatter': 'f', 50 | 'stream': 'ext://sys.stdout', 51 | }}, 52 | 'loggers': { 53 | __name__: { 54 | 'handlers': 'h', 55 | 'level': logging.DEBUG if conf['DEBUG'] else logging.INFO, 56 | 'propagate': False 57 | }, 58 | '': { 59 | 'handlers': 'h', 60 | 'level': logging.INFO, 61 | 'propagate': False 62 | }, 63 | } 64 | }) 65 | 66 | 67 | def fn_name(func): 68 | name = getattr(func, 'name', None) 69 | if not name: 70 | name = getattr(func, '__name__', None) 71 | if not name: 72 | name = str(func) 73 | return name 74 | 75 | 76 | def fn_desc(func, *a, **kw): 77 | args = ', '.join( 78 | [repr(i) for i in a] + 79 | (['**%r' % kw] if kw else []) 80 | ) 81 | maxlen = 80 82 | if len(args) > maxlen: 83 | args = '%s...' % args[:maxlen] 84 | return '%s(%s)' % (fn_name(func), args) 85 | 86 | 87 | def fn_time(func, desc=None): 88 | @contextmanager 89 | def timing(*a, **kw): 90 | start = time.time() 91 | try: 92 | yield 93 | finally: 94 | d = desc if desc else fn_desc(func, *a, **kw) 95 | log.debug('%s: done for %.2fs', d, time.time() - start) 96 | 97 | def inner_fn(*a, **kw): 98 | with timing(*a, **kw): 99 | return func(*a, **kw) 100 | 101 | def inner_gen(*a, **kw): 102 | with timing(*a, **kw): 103 | yield from func(*a, **kw) 104 | 105 | inner = inner_gen if inspect.isgeneratorfunction(func) else inner_fn 106 | return ft.wraps(func)(inner) 107 | -------------------------------------------------------------------------------- /mailur/cache.py: -------------------------------------------------------------------------------- 1 | from . import conf 2 | 3 | store = {} 4 | 5 | 6 | def key(name): 7 | return conf['USER'], name 8 | 9 | 10 | def get(name, default=None): 11 | return store.get(key(name), default) 12 | 13 | 14 | def set(name, value): 15 | store[key(name)] = value 16 | 17 | 18 | def rm(name): 19 | store.pop(key(name), None) 20 | 21 | 22 | def clear(): 23 | for key in list(store.keys()): 24 | if key[0] == conf['USER']: 25 | del store[key] 26 | 27 | 28 | def exists(name): 29 | return key(name) in store 30 | -------------------------------------------------------------------------------- /mailur/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import functools as ft 3 | import pathlib 4 | import sys 5 | import time 6 | 7 | from gevent import joinall, sleep, spawn 8 | 9 | from . import conf, local, log, remote 10 | 11 | root = pathlib.Path(__file__).resolve().parent.parent 12 | 13 | 14 | def main(args=None): 15 | if args is None: 16 | args = sys.argv[1:] 17 | if isinstance(args, str): 18 | args = args.split() 19 | 20 | try: 21 | parser = build_parser(args) 22 | args = parser.parse_args(args) 23 | if not hasattr(args, 'cmd'): 24 | parser.print_usage() 25 | exit(2) 26 | process(args) 27 | except KeyboardInterrupt: 28 | raise SystemExit('^C') 29 | 30 | 31 | def build_parser(args): 32 | parser = argparse.ArgumentParser('Mailur CLI') 33 | parser.add_argument('login', help='local user') 34 | cmds = parser.add_subparsers(title='commands') 35 | 36 | def cmd(name, **kw): 37 | p = cmds.add_parser(name, **kw) 38 | p.set_defaults(cmd=name) 39 | p.arg = lambda *a, **kw: p.add_argument(*a, **kw) and p 40 | p.exe = lambda f: p.set_defaults(exe=f) or p 41 | return p 42 | 43 | cmd('remote-setup-imap')\ 44 | .arg('username')\ 45 | .arg('password')\ 46 | .arg('--imap', required=True)\ 47 | .arg('--imap-port')\ 48 | .arg('--smtp', required=True)\ 49 | .arg('--smtp-port') 50 | 51 | cmd('remote-setup-gmail')\ 52 | .arg('username')\ 53 | .arg('password')\ 54 | .arg('--imap', default='imap.gmail.com')\ 55 | .arg('--smtp', default='smtp.gmail.com') 56 | 57 | cmd('remote')\ 58 | .arg('--tag')\ 59 | .arg('--box')\ 60 | .arg('--parse', action='store_true')\ 61 | .arg('--batch', type=int, default=1000, help='batch size')\ 62 | .arg('--threads', type=int, default=2, help='thread pool size') 63 | 64 | cmd('parse')\ 65 | .arg('criteria', nargs='?')\ 66 | .arg('--batch', type=int, default=1000, help='batch size')\ 67 | .arg('--threads', type=int, default=2, help='thread pool size')\ 68 | .arg('--fix-duplicates', action='store_true') 69 | 70 | cmd('metadata')\ 71 | .arg('uids', nargs='?') 72 | 73 | cmd('sync')\ 74 | .arg('--timeout', type=int, default=1200, help='timeout in seconds')\ 75 | .exe(lambda args: sync(args.timeout)) 76 | 77 | cmd('sync-flags')\ 78 | .arg('--reverse', action='store_true')\ 79 | .exe(lambda args: ( 80 | local.sync_flags_to_src() 81 | if args.reverse 82 | else local.sync_flags_to_all() 83 | )) 84 | 85 | cmd('clean-flags')\ 86 | .arg('flag', nargs='+')\ 87 | .exe(lambda args: local.clean_flags(args.flag)) 88 | 89 | cmd('diagnose')\ 90 | .exe(lambda args: local.diagnose()) 91 | return parser 92 | 93 | 94 | def process(args): 95 | conf['USER'] = args.login 96 | if hasattr(args, 'exe'): 97 | args.exe(args) 98 | elif args.cmd in ('remote-setup-imap', 'remote-setup-gmail'): 99 | remote.data_account({ 100 | 'username': args.username, 101 | 'password': args.password, 102 | 'imap_host': args.imap, 103 | 'imap_port': int(getattr(args, 'imap_port', 993)), 104 | 'smtp_host': args.smtp, 105 | 'smtp_port': int(getattr(args, 'smtp_port', 465)), 106 | }) 107 | elif args.cmd == 'remote': 108 | opts = dict(threads=args.threads, batch=args.batch) 109 | select_opts = dict(tag=args.tag, box=args.box) 110 | fetch_opts = dict(opts, **select_opts) 111 | fetch_opts = {k: v for k, v in fetch_opts.items() if v} 112 | 113 | remote.fetch(**fetch_opts) 114 | if args.parse: 115 | local.parse(**opts) 116 | elif args.cmd == 'parse': 117 | opts = dict(threads=args.threads, batch=args.batch) 118 | if args.fix_duplicates: 119 | local.clean_duplicate_msgs() 120 | local.parse(args.criteria, **opts) 121 | elif args.cmd == 'metadata': 122 | local.update_metadata(args.uids) 123 | 124 | 125 | def run_forever(fn): 126 | # but if it always raises exception, run only 3 times 127 | 128 | @ft.wraps(fn) 129 | def inner(*a, **kw): 130 | count = 3 131 | while count: 132 | try: 133 | fn(*a, **kw) 134 | except Exception as e: 135 | log.exception(e) 136 | sleep(10) 137 | count = -1 138 | return inner 139 | 140 | 141 | def sync(timeout=1200): 142 | @run_forever 143 | def idle_remote(params): 144 | with remote.client(**params) as c: 145 | handlers = { 146 | 'EXISTS': lambda res: remote.sync(), 147 | 'FETCH': lambda res: remote.sync(only_flags=True), 148 | } 149 | c.idle(handlers, timeout=timeout) 150 | 151 | @run_forever 152 | def sync_flags(): 153 | local.sync_flags_to_all() 154 | local.sync_flags( 155 | post_handler=lambda res: remote.sync(only_flags=True), 156 | timeout=timeout 157 | ) 158 | 159 | try: 160 | remote.sync() 161 | jobs = [spawn(sync_flags)] 162 | for params in remote.get_folders(): 163 | jobs.append(spawn(idle_remote, params)) 164 | joinall(jobs, raise_error=True) 165 | except KeyboardInterrupt: 166 | time.sleep(1) 167 | 168 | 169 | if __name__ == '__main__': 170 | main() 171 | -------------------------------------------------------------------------------- /mailur/html.py: -------------------------------------------------------------------------------- 1 | import re 2 | from html import escape 3 | 4 | import mistune 5 | from lxml.html import fromstring, tostring 6 | from lxml.html.clean import Cleaner, autolink 7 | from pygments import highlight 8 | from pygments.formatters import html 9 | from pygments.lexers import get_lexer_by_name 10 | 11 | from . import conf 12 | 13 | 14 | class HighlightRenderer(mistune.Renderer): 15 | def block_code(self, code, lang): 16 | if not lang: 17 | return '\n
%s
\n' % \ 18 | mistune.escape(code) 19 | lexer = get_lexer_by_name(lang, stripall=True) 20 | formatter = html.HtmlFormatter(noclasses=True) 21 | return highlight(code, lexer, formatter) 22 | 23 | 24 | renderer = HighlightRenderer(escape=False, hard_wrap=True) 25 | markdown = mistune.Markdown(renderer=renderer) 26 | 27 | 28 | def clean(htm, embeds=None): 29 | htm = re.sub(r'^\s*<\?xml.*?\?>', '', htm).strip() 30 | if not htm: 31 | return '', {} 32 | 33 | htm = htm.replace('\r\n', '\n') 34 | cleaner = Cleaner( 35 | links=False, 36 | style=True, 37 | inline_style=False, 38 | kill_tags=['head'], 39 | remove_tags=['html', 'base'], 40 | safe_attrs=list(set(Cleaner.safe_attrs) - {'class'}) + ['style'], 41 | ) 42 | htm = fromstring(htm) 43 | htm = cleaner.clean_html(htm) 44 | 45 | ext_images = 0 46 | embeds = embeds or {} 47 | for img in htm.xpath('//img[@src]'): 48 | src = img.attrib.get('src') 49 | cid = re.match('^cid:(.*)', src) 50 | url = cid and embeds.get('<%s>' % cid.group(1)) 51 | if url: 52 | img.attrib['src'] = url 53 | elif re.match('^data:image/.*', src): 54 | pass 55 | elif re.match('^(https?://|//).*', src): 56 | ext_images += 1 57 | else: 58 | del img.attrib['src'] 59 | 60 | styles = False 61 | for el in htm.xpath('//*[@style]'): 62 | styles = True 63 | break 64 | 65 | fix_links(htm) 66 | 67 | richer = (('styles', styles), ('ext_images', ext_images)) 68 | richer = {k: v for k, v in richer if v} 69 | 70 | htm = tostring(htm, encoding='unicode').strip() 71 | htm = re.sub('(^
|
$)', '', htm) 72 | return htm, richer 73 | 74 | 75 | def fix_privacy(htm, only_proxy=False): 76 | if not htm.strip(): 77 | return htm 78 | 79 | use_proxy = conf['USE_PROXY'] 80 | if only_proxy and not use_proxy: 81 | return htm 82 | 83 | htm = fromstring(htm) 84 | for img in htm.xpath('//img[@src]'): 85 | src = img.attrib['src'] 86 | if re.match('^(https?://|//).*', src): 87 | if src.startswith('//'): 88 | src = 'https:' + src 89 | if use_proxy: 90 | src = '/proxy?url=' + src 91 | if only_proxy: 92 | img.attrib['src'] = src 93 | else: 94 | img.attrib['data-src'] = src 95 | del img.attrib['src'] 96 | 97 | if not only_proxy: 98 | # style could contain "background-image", etc. 99 | for el in htm.xpath('//*[@style]'): 100 | el.attrib['data-style'] = el.attrib['style'] 101 | del el.attrib['style'] 102 | 103 | htm = tostring(htm, encoding='unicode').strip() 104 | htm = re.sub('(^
|
$)', '', htm) 105 | return htm 106 | 107 | 108 | def fix_links(doc): 109 | autolink(doc) 110 | for link in doc.xpath('//a[@href]'): 111 | link.attrib['target'] = '_blank' 112 | return doc 113 | 114 | 115 | def from_text(txt): 116 | def replace(match): 117 | txt = match.group() 118 | if '\n' in txt: 119 | return '
' * txt.count('\n') 120 | else: 121 | return ' ' * txt.count(' ') 122 | 123 | tpl = '

%s

' 124 | htm = escape(txt) 125 | htm = fromstring(tpl % htm) 126 | fix_links(htm) 127 | htm = tostring(htm, encoding='unicode') 128 | htm = htm[3:-4] 129 | htm = re.sub('(?m)((\r?\n)+| [ ]+|^ )', replace, htm) 130 | htm = tpl % htm 131 | return htm 132 | 133 | 134 | def to_text(htm): 135 | htm = fromstring(htm) 136 | return '\n'.join(escape(i) for i in htm.xpath('//text()') if i) 137 | 138 | 139 | def to_line(htm, limit=200): 140 | txt = to_text(htm) 141 | txt = re.sub(r'([\s ]| )+', ' ', txt) 142 | return txt[:limit] 143 | -------------------------------------------------------------------------------- /mailur/imap_utf7.py: -------------------------------------------------------------------------------- 1 | # Borrowed from http://imapclient.freshfoo.com/ 2 | 3 | # The contents of this file has been derived code from the Twisted project 4 | # (http://twistedmatrix.com/). The original author is Jp Calderone. 5 | 6 | # Twisted project license follows: 7 | 8 | # Permission is hereby granted, free of charge, to any person obtaining 9 | # a copy of this software and associated documentation files (the 10 | # "Software"), to deal in the Software without restriction, including 11 | # without limitation the rights to use, copy, modify, merge, publish, 12 | # distribute, sublicense, and/or sell copies of the Software, and to 13 | # permit persons to whom the Software is furnished to do so, subject to 14 | # the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be 17 | # included in all copies or substantial portions of the Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | PRINTABLE = set(range(0x20, 0x26)) | set(range(0x27, 0x7f)) 28 | 29 | 30 | def encode(s): 31 | """Encode a folder name using IMAP modified UTF-7 encoding. 32 | 33 | Despite the function's name, the output is still a unicode string. 34 | """ 35 | assert isinstance(s, str) 36 | 37 | r = [] 38 | _in = [] 39 | 40 | def extend_result_if_chars_buffered(): 41 | if _in: 42 | r.extend(['&', modified_utf7(''.join(_in)), '-']) 43 | del _in[:] 44 | 45 | for c in s: 46 | if ord(c) in PRINTABLE: 47 | extend_result_if_chars_buffered() 48 | r.append(c) 49 | elif c == '&': 50 | extend_result_if_chars_buffered() 51 | r.append('&-') 52 | else: 53 | _in.append(c) 54 | 55 | extend_result_if_chars_buffered() 56 | 57 | return ''.join(r) 58 | 59 | 60 | def decode(s): 61 | """Decode a folder name from IMAP modified UTF-7 encoding to unicode. 62 | 63 | Despite the function's name, the input may still be a unicode 64 | string. If the input is bytes, it's first decoded to unicode. 65 | """ 66 | if isinstance(s, bytes): 67 | s = s.decode('latin-1') 68 | assert isinstance(s, str) 69 | 70 | r = [] 71 | _in = [] 72 | for c in s: 73 | if c == '&' and not _in: 74 | _in.append('&') 75 | elif c == '-' and _in: 76 | if len(_in) == 1: 77 | r.append('&') 78 | else: 79 | r.append(modified_deutf7(''.join(_in[1:]))) 80 | _in = [] 81 | elif _in: 82 | _in.append(c) 83 | else: 84 | r.append(c) 85 | if _in: 86 | r.append(modified_deutf7(''.join(_in[1:]))) 87 | 88 | return ''.join(r) 89 | 90 | 91 | def modified_utf7(s): 92 | # encode to utf-7: '\xff' => b'+AP8-', decode from latin-1 => '+AP8-' 93 | s_utf7 = s.encode('utf-7').decode('latin-1') 94 | return s_utf7[1:-1].replace('/', ',') 95 | 96 | 97 | def modified_deutf7(s): 98 | s_utf7 = '+' + s.replace(',', '/') + '-' 99 | # encode to latin-1: '+AP8-' => b'+AP8-', decode from utf-7 => '\xff' 100 | return s_utf7.encode('latin-1').decode('utf-7') 101 | -------------------------------------------------------------------------------- /mailur/lock.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import signal 4 | import time 5 | from contextlib import contextmanager 6 | 7 | from gevent import sleep 8 | 9 | from . import conf 10 | 11 | 12 | class Error(Exception): 13 | pass 14 | 15 | 16 | @contextmanager 17 | def global_scope(target, timeout=180, wait=5, force=False): 18 | path = '/tmp/%s' % (hashlib.md5(target.encode()).hexdigest()) 19 | 20 | def is_locked(): 21 | if not os.path.exists(path): 22 | return 23 | 24 | with open(path) as f: 25 | pid = f.read() 26 | 27 | # Check if process exists 28 | try: 29 | os.kill(int(pid), 0) 30 | except (OSError, ValueError): 31 | os.remove(path) 32 | return 33 | 34 | elapsed = time.time() - os.path.getctime(path) 35 | if elapsed > timeout or force: 36 | try: 37 | os.kill(int(pid), signal.SIGQUIT) 38 | os.remove(path) 39 | except Exception: 40 | pass 41 | return 42 | return elapsed 43 | 44 | locked = True 45 | for i in range(wait): 46 | locked = is_locked() 47 | if not locked: 48 | break 49 | sleep(0.5 * i) 50 | 51 | if locked: 52 | msg = ( 53 | '%r is locked (for %.2f minutes). Remove file %r to run' 54 | % (target, locked / 60, path) 55 | ) 56 | raise Error(msg) 57 | 58 | try: 59 | with open(path, 'w') as f: 60 | f.write(str(os.getpid())) 61 | yield 62 | finally: 63 | os.remove(path) 64 | 65 | 66 | @contextmanager 67 | def user_scope(target, **opts): 68 | target = '%s:%s' % (conf['USER'], target) 69 | with global_scope(target, **opts): 70 | yield 71 | -------------------------------------------------------------------------------- /mailur/schema.py: -------------------------------------------------------------------------------- 1 | from jsonschema import Draft4Validator, FormatChecker, validators 2 | 3 | 4 | # Based on https://python-jsonschema.readthedocs.org/en/latest/faq/ 5 | def fill_defaults(validator_class): 6 | validate_props = validator_class.VALIDATORS['properties'] 7 | 8 | def set_defaults(validator, props, instance, schema): 9 | for prop, subschema in props.items(): 10 | if isinstance(instance, dict) and 'default' in subschema: 11 | instance.setdefault(prop, subschema['default']) 12 | 13 | for error in validate_props(validator, props, instance, schema): 14 | yield error 15 | 16 | return validators.extend(validator_class, {'properties': set_defaults}) 17 | 18 | 19 | Draft4WithDefaults = fill_defaults(Draft4Validator) 20 | 21 | 22 | class Error(Exception): 23 | def __init__(self, errors, schema): 24 | self.schema = schema 25 | self.errors = errors 26 | super().__init__(errors, schema) 27 | 28 | 29 | def validate(value, schema): 30 | """Collect all errors during validation""" 31 | validator = Draft4WithDefaults(schema, format_checker=FormatChecker()) 32 | errs = sorted(validator.iter_errors(value), key=lambda e: e.path) 33 | errs = ['%s: %s' % (list(e.schema_path), e.message) for e in errs] 34 | if errs: 35 | raise Error(errs, schema) 36 | return value 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "GPL-3.0", 3 | "dependencies": { 4 | "vue": "2.5.13" 5 | }, 6 | "devDependencies": { 7 | "@babel/core": "^7.7.2", 8 | "@babel/preset-env": "^7.7.1", 9 | "babel-loader": "8.0.6", 10 | "clean-webpack-plugin": "0.1.18", 11 | "css-loader": "0.28.9", 12 | "eslint": "4.19.1", 13 | "extract-text-webpack-plugin": "3.0.2", 14 | "file-loader": "1.1.6", 15 | "html-loader": "0.5.5", 16 | "htmlhint": "0.9.13", 17 | "less": "3.10.3", 18 | "less-loader": "5.0.0", 19 | "less-plugin-autoprefix": "2.0.0", 20 | "less-plugin-clean-css": "1.5.1", 21 | "prettier": "1.19.1", 22 | "style-loader": "0.20.1", 23 | "stylelint": "8.4.0", 24 | "stylelint-config-standard": "18.0.0", 25 | "webpack": "3.11.0" 26 | }, 27 | "scripts": { 28 | "build": "webpack --config=assets/webpack.config.js", 29 | "dev": "webpack --config=assets/webpack.config.js -w" 30 | }, 31 | "eslintConfig": { 32 | "extends": "eslint:recommended", 33 | "parserOptions": { 34 | "ecmaVersion": 6, 35 | "sourceType": "module" 36 | }, 37 | "env": { 38 | "es6": true, 39 | "browser": true 40 | } 41 | }, 42 | "prettier": { 43 | "singleQuote": true 44 | }, 45 | "stylelint": { 46 | "extends": "stylelint-config-standard" 47 | }, 48 | "mailur": { 49 | "themes": [ 50 | "base", 51 | "indigo", 52 | "mint", 53 | "solarized" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mailur 3 | author = Grisha Kostyuk (aka naspeh) 4 | version = 0.3.0 5 | license = GPLv3 6 | long_description = file: README.md 7 | requires-python = >=3.6 8 | 9 | [options] 10 | include_package_data = True 11 | packages = find: 12 | 13 | install_requires= 14 | bottle 15 | chardet 16 | gevent 17 | gunicorn 18 | itsdangerous 19 | jsonschema 20 | lxml 21 | meinheld==0.6.1 22 | mistune==0.8.4 23 | pygments 24 | python-dateutil 25 | ujson 26 | wsaccel 27 | 28 | [options.extras_require] 29 | test= 30 | flake8 31 | flake8-isort 32 | #flake8-import-order 33 | pytest 34 | pytest-cov 35 | pytest-xdist 36 | webtest 37 | 38 | [options.entry_points] 39 | console_scripts= 40 | mlr = mailur.cli:main 41 | 42 | [tool:pytest] 43 | addopts=-v --tb=short 44 | testpaths=tests 45 | markers = 46 | no_parallel: flaky at parallel execution 47 | 48 | [flake8] 49 | exclude=env,var,.node_modules,.cache,.eggs 50 | #import-order-style=smarkets 51 | #import-order-style=cryptography 52 | 53 | [isort] 54 | skip=env,var 55 | multi_line_output=5 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import setuptools 3 | 4 | # http://setuptools.readthedocs.io/en/latest/setuptools.html 5 | # > Configuring setup() using setup.cfg files 6 | # > Note New in 30.3.0 (8 Dec 2016). 7 | if not setuptools.__version__ > '30.3': 8 | raise SystemExit('"setuptools >= 30.3.0" is required') 9 | 10 | setuptools.setup() 11 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import email 3 | import json 4 | import re 5 | import subprocess 6 | import sys 7 | import time 8 | import uuid 9 | from email.utils import formatdate 10 | from pathlib import Path 11 | from unittest import mock 12 | 13 | import pytest 14 | 15 | root = (Path(__file__).parent / '..').resolve() 16 | sys.path.insert(0, str(root)) 17 | 18 | users = [] 19 | test1 = None 20 | test2 = None 21 | con_local = None 22 | con_gmail = None 23 | 24 | 25 | @pytest.fixture(scope='session', autouse=True) 26 | def init(request): 27 | from mailur import local 28 | 29 | for i in range(len(request.session.items)): 30 | users.append([ 31 | 'test1_%s' % uuid.uuid4().hex, 32 | 'test2_%s' % uuid.uuid4().hex 33 | ]) 34 | 35 | users_str = ' '.join(sum(users, [])) 36 | subprocess.call(''' 37 | path=/home/vmail/test 38 | rm -rf $path 39 | mkdir -p $path 40 | chown vmail:vmail $path 41 | names="%s" home=$path append=1 bin/install-users 42 | systemctl restart dovecot 43 | ''' % users_str, shell=True, cwd=root) 44 | 45 | # try to connect to dovecot 46 | for i in range(5): 47 | try: 48 | username, pwd = local.master_login(username=users[0][0]) 49 | con = local.connect(username, pwd) 50 | con.select(local.SRC) 51 | return 52 | except Exception as e: 53 | err = e 54 | time.sleep(1) 55 | print('Another try to connect to dovecot: %s' % err) 56 | raise err 57 | 58 | 59 | @pytest.fixture 60 | def patch_conf(patch): 61 | conf = {'USER': test1, 'USE_PROXY': True} 62 | with patch.dict('mailur.conf', conf): 63 | yield 64 | 65 | 66 | @pytest.fixture(autouse=True) 67 | def setup(new_users, gm_client, sendmail, patch_conf): 68 | from mailur import imap, local, remote 69 | 70 | global con_local, con_gmail 71 | 72 | con_local = local.client(None) 73 | con_gmail = local.connect(*local.master_login(username=test2)) 74 | 75 | remote.data_account({ 76 | 'username': 'test', 77 | 'password': 'test', 78 | 'imap_host': 'imap.gmail.com', 79 | 'smtp_host': 'smtp.gmail.com', 80 | }) 81 | 82 | yield 83 | 84 | con_local.logout() 85 | con_gmail.logout() 86 | imap.clean_pool(test1) 87 | imap.clean_pool(test2) 88 | 89 | 90 | @pytest.fixture 91 | def sendmail(patch): 92 | with patch('mailur.remote.smtplib.SMTP.sendmail') as m: 93 | with patch('mailur.remote.smtplib.SMTP.login'): 94 | yield m 95 | 96 | 97 | @pytest.fixture 98 | def new_users(): 99 | global test1, test2 100 | 101 | test1, test2 = users.pop() 102 | 103 | 104 | @pytest.fixture 105 | def load_file(): 106 | def inner(name, charset=None): 107 | txt = (root / 'tests/files' / name).read_bytes() 108 | return txt.decode().encode(charset) if charset else txt 109 | return inner 110 | 111 | 112 | @pytest.fixture 113 | def load_email(gm_client, load_file, latest): 114 | def inner(name, charset=None, **opt): 115 | gm_client.add_emails([{'raw': load_file(name, charset=charset)}]) 116 | return latest(**opt) 117 | return inner 118 | 119 | 120 | class Some(object): 121 | """A helper object that compares equal to everything.""" 122 | 123 | def __eq__(self, other): 124 | self.value = other 125 | return True 126 | 127 | def __ne__(self, other): 128 | self.value = other 129 | return False 130 | 131 | def __getitem__(self, name): 132 | return self.value[name] 133 | 134 | def __repr__(self): 135 | return '' 136 | 137 | 138 | @pytest.fixture 139 | def some(): 140 | return Some() 141 | 142 | 143 | @pytest.fixture 144 | def raises(): 145 | return pytest.raises 146 | 147 | 148 | @pytest.fixture 149 | def patch(): 150 | return mock.patch 151 | 152 | 153 | @pytest.fixture 154 | def call(): 155 | return mock.call 156 | 157 | 158 | def gm_fake(): 159 | from mailur import local 160 | 161 | def uid(name, *a, **kw): 162 | func = getattr(gm_client, 'fake_%s' % name.lower(), None) 163 | if func: 164 | return func(con, *a, **kw) 165 | responces = getattr(gm_client, name.lower(), None) 166 | if responces: 167 | return responces.pop() 168 | return con._uid(name, *a, **kw) 169 | 170 | def xlist(*a, **kw): 171 | responces = getattr(gm_client, 'list', None) 172 | if responces: 173 | return responces.pop() 174 | return 'OK', [ 175 | b'(\\HasNoChildren \\All) "/" mlr', 176 | b'(\\HasNoChildren \\Junk) "/" mlr/All', 177 | b'(\\HasNoChildren \\Trash) "/" mlr/All', 178 | b'(\\HasNoChildren \\Draft) "/" mlr/All', 179 | ] 180 | 181 | con = local.connect(*local.master_login(username=test2)) 182 | 183 | con._uid = con.uid 184 | con.uid = uid 185 | con._list = con.list 186 | con.list = xlist 187 | return con 188 | 189 | 190 | @pytest.fixture 191 | def gm_client(): 192 | from mailur import local, message, remote 193 | 194 | remote.SKIP_DRAFTS = False 195 | 196 | def add_email(item, tag): 197 | gm_client.uid += 1 198 | uid = gm_client.uid 199 | gid = item.get('gid', 100 * uid) 200 | raw = item.get('raw') 201 | if raw: 202 | msg = raw 203 | date = gm_client.time + uid 204 | else: 205 | txt = item.get('txt', '42') 206 | msg = message.binary(txt) 207 | 208 | subj = item.get('subj') 209 | if 'subj' not in item: 210 | subj = 'Subj %s' % uid 211 | msg.add_header('Subject', subj) 212 | 213 | date = item.get('date') 214 | if not date: 215 | date = gm_client.time + uid 216 | msg.add_header('Date', formatdate(date, usegmt=True)) 217 | 218 | mid = item.get('mid') 219 | if not mid: 220 | mid = '<%s@mlr>' % uid 221 | msg.add_header('Message-ID', mid) 222 | 223 | draft_id = item.get('draft_id') 224 | if draft_id: 225 | msg.add_header('X-Draft-ID', '<%s>' % draft_id) 226 | 227 | in_reply_to = item.get('in_reply_to') 228 | if in_reply_to: 229 | msg.add_header('In-Reply-To', in_reply_to) 230 | refs = item.get('refs') 231 | if refs: 232 | msg.add_header('References', refs) 233 | fr = item.get('from') 234 | if fr: 235 | msg.add_header('From', fr) 236 | to = item.get('to') 237 | if to: 238 | msg.add_header('To', to) 239 | 240 | msg = msg.as_bytes() 241 | 242 | folder = local.SRC if tag == '\\All' else local.ALL 243 | res = con_gmail.append(folder, item.get('flags'), None, msg) 244 | if res[0] != 'OK': 245 | raise Exception(res) 246 | 247 | arrived = dt.datetime.fromtimestamp(date) 248 | arrived = arrived.strftime('%d-%b-%Y %H:%M:%S %z').encode() 249 | flags = item.get('flags', '').encode() 250 | labels = item.get('labels', '').encode() 251 | 252 | gm_client.fetch[1][1].append( 253 | (b'1 (X-GM-MSGID %d UID %d )' % (gid, uid)) 254 | ) 255 | gm_client.fetch[0][1].extend([ 256 | ( 257 | b'1 (X-GM-MSGID %d X-GM-THRID %d X-GM-LABELS (%s) UID %d ' 258 | b'INTERNALDATE "%s" FLAGS (%s) ' 259 | b'BODY[] {%d}' 260 | % (gid, gid, labels, uid, arrived, flags, len(msg)), 261 | msg 262 | ), 263 | b')' 264 | ]) 265 | 266 | def add_emails(items=None, *, tag='\\All', fetch=True, parse=True): 267 | if items is None: 268 | items = [{}] 269 | gm_client.fetch = [('OK', []), ('OK', [])] 270 | for item in items: 271 | add_email(item, tag) 272 | if fetch: 273 | remote.fetch_folder(tag=tag) 274 | if parse: 275 | local.parse() 276 | 277 | gm_client.add_emails = add_emails 278 | gm_client.uid = 100 279 | gm_client.time = time.time() - 36000 280 | 281 | with mock.patch('mailur.remote.connect', gm_fake): 282 | yield gm_client 283 | 284 | 285 | def _msgs(box=None, uids='1:*', *, parsed=False, raw=False, policy=None): 286 | from mailur import local, message 287 | 288 | def flags(m): 289 | res = re.search(r'FLAGS \(([^)]*)\)', m).group(1).split() 290 | if '\\Recent' in res: 291 | res.remove('\\Recent') 292 | return ' '.join(res) 293 | 294 | def msg(res): 295 | msg = { 296 | 'uid': re.search(r'UID (\d+)', res[0].decode()).group(1), 297 | 'flags': flags(res[0].decode()), 298 | } 299 | if parsed: 300 | body = email.message_from_bytes(res[1], policy=policy) 301 | parts = [p.get_payload() for p in body.get_payload()] 302 | txt = [p.get_payload() for p in parts[1]] 303 | msg['meta'] = json.loads(parts[0]) 304 | msg['body'] = txt[0] 305 | msg['body_txt'] = txt[1] if len(txt) > 1 else None 306 | msg['body_end'] = parts[2] if len(parts) > 2 else None 307 | msg['body_full'] = body 308 | msg['raw'] = res[1] 309 | else: 310 | body = res[1] 311 | if not raw: 312 | body = email.message_from_bytes(res[1], policy=policy) 313 | msg['body'] = body 314 | 315 | return msg 316 | 317 | policy = policy if policy else message.policy 318 | con_local.select(box or local.ALL) 319 | res = con_local.fetch(uids, '(uid flags body[])') 320 | return [msg(res[i]) for i in range(0, len(res), 2)] 321 | 322 | 323 | @pytest.fixture 324 | def msgs(): 325 | return _msgs 326 | 327 | 328 | @pytest.fixture 329 | def latest(): 330 | def inner(box=None, *, parsed=False, raw=False, policy=None): 331 | return _msgs(box, '*', parsed=parsed, raw=raw, policy=policy)[0] 332 | return inner 333 | 334 | 335 | @pytest.fixture 336 | def web(): 337 | from webtest import TestApp 338 | 339 | from mailur.web import app, assets, themes 340 | 341 | app.catchall = False 342 | 343 | class Wrapper(TestApp): 344 | def search(self, data, status=200): 345 | return self.post_json('/search', data, status=status).json 346 | 347 | def flag(self, data, status=200): 348 | return self.post_json('/msgs/flag', data, status=status) 349 | 350 | def body(self, uid, fix_privacy=True): 351 | data = {'uids': [uid], 'fix_privacy': fix_privacy} 352 | res = self.post_json('/msgs/body', data, status=200).json 353 | return res[uid] 354 | 355 | if not assets.exists(): 356 | assets.mkdir() 357 | for i in themes(): 358 | filename = 'theme-%s.css' % i 359 | (assets / filename).write_text('') 360 | for filename in ('login.js', 'index.js'): 361 | (assets / filename).write_text('') 362 | return Wrapper(app) 363 | 364 | 365 | @pytest.fixture 366 | def login(web): 367 | def inner(username=test1, password='demo', tz='Asia/Singapore'): 368 | params = {'username': username, 'password': password, 'timezone': tz} 369 | web.post_json('/login', params, status=200) 370 | return web 371 | 372 | inner.user1 = test1 373 | inner.user2 = test2 374 | return inner 375 | -------------------------------------------------------------------------------- /tests/files/msg-attachments-one-gmail.txt: -------------------------------------------------------------------------------- 1 | Delivered-To: naspeh@gmail.com 2 | Received: by 10.194.172.98 with SMTP id bb2csp53745wjc; 3 | Mon, 3 Mar 2014 08:13:07 -0800 (PST) 4 | Return-Path: 5 | Received-SPF: pass (google.com: domain of negreh@gmail.com designates 10.14.109.201 as permitted sender) client-ip=10.14.109.201 6 | Authentication-Results: mr.google.com; 7 | spf=pass (google.com: domain of negreh@gmail.com designates 10.14.109.201 as permitted sender) smtp.mail=negreh@gmail.com; 8 | dkim=pass header.i=@gmail.com 9 | X-Received: from mr.google.com ([10.14.109.201]) 10 | by 10.14.109.201 with SMTP id s49mr12245318eeg.88.1393863187332 (num_hops = 1); 11 | Mon, 03 Mar 2014 08:13:07 -0800 (PST) 12 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 13 | d=gmail.com; s=20120113; 14 | h=mime-version:in-reply-to:references:date:message-id:subject:from:to 15 | :content-type; 16 | bh=Wb+1O1/Z3wNq1qND9DxdNWWWboisaoe2rM5LB+A+Vn4=; 17 | b=hOO5ZL53dJ923QfnaBvJjMz7jj5LgR4vPW53kcgRqQCFO6fx1+uXOaBzjZ6qt3nzAW 18 | vhe1HB3zDVQzGH/J2ncINRoCQkT944Te3Y/kST3KzPlcwh42VmCWWMQdR8/Wl7GE+kR7 19 | wlrAR9zFO5qAxuxY8a5oPLZg9gIvy1NG5CL56+CsuISSsbtkeQxrkoWwQRuAPe1nr286 20 | gkeUlp5qE3AKq/wUENuZSGJlTqnid5wSk2vwPf4R7Vib7XQOLrtUsHAFxRhdV17xbqTH 21 | 4rTjZAoPJs/hjez48pswdUCNRyt7kDPsDiehsnVPBG5OSHivCdj30dDI3FCd/7NALdTI 22 | hxCw== 23 | MIME-Version: 1.0 24 | X-Received: by 10.14.109.201 with SMTP id s49mr12245318eeg.88.1393863187321; 25 | Mon, 03 Mar 2014 08:13:07 -0800 (PST) 26 | Received: by 10.14.4.73 with HTTP; Mon, 3 Mar 2014 08:13:07 -0800 (PST) 27 | In-Reply-To: 28 | References: 29 | 30 | <243441393863109@web4h.yandex.ru> 31 | 32 | Date: Mon, 3 Mar 2014 18:13:07 +0200 33 | Message-ID: 34 | Subject: =?KOI8-R?B?UmU6INTFzcEgydrNxc7Fzs7B0Q==?= 35 | From: Ne Greh 36 | To: naspeh 37 | Content-Type: multipart/mixed; boundary=001a11c2768c256f8104f3b61088 38 | 39 | --001a11c2768c256f8104f3b61088 40 | Content-Type: text/plain; charset=KOI8-R 41 | Content-Transfer-Encoding: quoted-printable 42 | 43 | On Mon, Mar 3, 2014 at 6:12 PM, Ne Greh wrote: 44 | > =D1.=D2=D5 45 | > 46 | > 2014-03-03 18:11 GMT+02:00 naspeh : 47 | >> =CF=D4=D7=C5=D4 =D3 =D1=CE=C4=C5=CB=D3=C1 48 | >> 49 | >> 03.03.2014, 18:11, "Ne Greh" : 50 | >>> =D4=C5=CD=C1 =D4=C1=D6=C5 51 | >>> 52 | >>> On Mon, Mar 3, 2014 at 6:10 PM, Grisha K. wrote: 53 | >>> 54 | >>>> =CF=D4=D7=C5=D4 =CE=C1 =D4=C5=CC=CF 55 | >>>> 56 | >>>> 2014-03-03 18:09 GMT+02:00 Ne Greh : 57 | >>>>> =D4=C5=CC=CF 58 | 59 | --001a11c2768c256f8104f3b61088 60 | Content-Type: image/png; name="20.png" 61 | Content-Disposition: attachment; filename="20.png" 62 | Content-Transfer-Encoding: base64 63 | X-Attachment-Id: f_hsby8u7m0 64 | 65 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 66 | bWFnZVJlYWR5ccllPAAAAcJJREFUeNpiZC6xMBDg5N3Px84twMLEzMDCzMzAxMjEAGKDaCZGRoZ/ 67 | //8zvPv28cOTj68cmUCKGRgYBAyk1RjYWFgZfvz+xfDrz2+GX39/M/z59wesGKRJjEdIQIpPdD8L 68 | GzOrAFADw7qEThDFADSFYcPlgwwrLuxm+PbrB1gxyCaQASLcAgLMkm56DSBTTj++xvD801sGGQEx 69 | Bi9NawYPDUuGc09uMLz//hmsGIRBgAnkZpB7rRT0GM48uc4QtqiKoWjjBAagnxhmhVYxKAlLQzT8 70 | +QN04l8GJpgHc2xCGRZE1DHsSJsEFGRiyFjTATaxxy+PgYOFHeKnv0ANsJCxmZLG0LBzNkSRbx7Q 71 | k4IMVdumMcjwizH4advCnQW3QUNMHqw4Zmkdw7NPbxiKHKIYtl0/Bg6EEH0ncMhBncQI1MDCMMG/ 72 | iKHBPZXBRtGAYcqRVWCTjWTUGVZd2MOgI6EMVgwKHBaQ6SDOrdePGFREZBiOPbgE5oOAmqgcw723 73 | T8FsLXFFhhuvHjIwyjf7vQdFCsyNoNBAsCHOgGEuVo4PLKDoBlq1n48DkjQYWBigkcUI9hvIGSDF 74 | QM0fvvz65ggQYAD0EtEPIjH5KwAAAABJRU5ErkJggg== 75 | --001a11c2768c256f8104f3b61088-- 76 | -------------------------------------------------------------------------------- /tests/files/msg-attachments-rus-content-id.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Date: Wed, 1 Sep 2021 16:33:12 +0300 3 | References: 4 | 5 | In-Reply-To: 6 | Message-ID: 7 | Subject: Re: Fw: test 8 | From: Grisha Kostyuk 9 | To: "Grisha Kostyuk (naspeh)" 10 | Content-Type: multipart/mixed; boundary="000000000000a6506305caef1b38" 11 | 12 | --000000000000a6506305caef1b38 13 | Content-Type: multipart/alternative; boundary="000000000000a6506005caef1b36" 14 | 15 | --000000000000a6506005caef1b36 16 | Content-Type: text/plain; charset="UTF-8" 17 | Content-Transfer-Encoding: base64 18 | 19 | 0YLQtdGB0YINCg0KPg0K 20 | --000000000000a6506005caef1b36 21 | Content-Type: text/html; charset="UTF-8" 22 | Content-Transfer-Encoding: quoted-printable 23 | 24 |
=D1=82=D0=B5=D1=81=D1=82
27 |
28 | 29 | --000000000000a6506005caef1b36-- 30 | --000000000000a6506305caef1b38 31 | Content-Type: image/png; name="=?UTF-8?B?0YfQtdGA0L3QsNGPINGC0L7Rh9C60LAucG5n?=" 32 | Content-Disposition: attachment; filename="=?UTF-8?B?0YfQtdGA0L3QsNGPINGC0L7Rh9C60LAucG4=?= 33 | =?UTF-8?B?Zw==?=" 34 | Content-Transfer-Encoding: base64 35 | X-Attachment-Id: f_kt1jhzmc0 36 | Content-ID: <черная точка.png> 37 | 38 | iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YA 39 | AAAASUVORK5CYII= 40 | --000000000000a6506305caef1b38-- 41 | -------------------------------------------------------------------------------- /tests/files/msg-attachments-textfile.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/alternative; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: =?utf-8?b?0JPRgNC40YjQsCDQmi4=?= 5 | To: =?utf-8?b?0JrQsNGC0Y8g0Jou?= 6 | Date: Sat, 21 Nov 2015 00:27:59 -0000 7 | Subject: Тестим кодировку KOI8-R 8 | In-Reply-To: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> 9 | References: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> <564fb6cd.444fc20a.f356f.ffffa7d5@mx.google.com> <560af8f1.e2aec20a.d5f35.ffffe779@mx.google.com> <559ed38e.4925980a.04b2.7db1@mx.google.com> <559ed24a.8325980a.f754.7971@mx.google.com> 10 | 11 | --===============2720466624839896923== 12 | Content-Type: text/plain; charset="KOI8-R" 13 | MIME-Version: 1.0 14 | Content-Transfer-Encoding: 7bit 15 | 16 | тест 17 | --===============2720466624839896923== 18 | Content-Type: text/plain; name="=?KOI8-R?B?5M/Qz8zOxc7JxTQudHh0?=";charset=ANSI_X3.4-1968 19 | X-Attachment-Id: 0.1 20 | Content-Disposition: attachment; filename="=?KOI8-R?B?5M/Qz8zOxc7JxTQudHh0?=" 21 | 22 |

тест

23 | --===============2720466624839896923==-- 24 | -------------------------------------------------------------------------------- /tests/files/msg-attachments-two-gmail.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.194.172.98 with HTTP; Mon, 3 Mar 2014 08:10:08 -0800 (PST) 3 | Date: Mon, 3 Mar 2014 18:10:08 +0200 4 | Delivered-To: naspeh@gmail.com 5 | Message-ID: 6 | Subject: =?KOI8-R?B?UmU6INTFzcEgydrNxc7Fzs7B0Q==?= 7 | From: "Grisha K." 8 | To: Ne Greh 9 | Content-Type: multipart/mixed; boundary=bcaec54ee220831fa204f3b6058e 10 | 11 | --bcaec54ee220831fa204f3b6058e 12 | Content-Type: multipart/alternative; boundary=bcaec54ee220831f9e04f3b6058c 13 | 14 | --bcaec54ee220831f9e04f3b6058c 15 | Content-Type: text/plain; charset=KOI8-R 16 | Content-Transfer-Encoding: quoted-printable 17 | 18 | =CF=D4=D7=C5=D4 =CE=C1 =D4=C5=CC=CF 19 | 20 | 21 | 2014-03-03 18:09 GMT+02:00 Ne Greh : 22 | 23 | > =D4=C5=CC=CF 24 | > 25 | 26 | --bcaec54ee220831f9e04f3b6058c 27 | Content-Type: text/html; charset=KOI8-R 28 | Content-Transfer-Encoding: quoted-printable 29 | 30 |
=CF=D4=D7=C5=D4 =CE=C1 =D4=C5=CC=CF


2014-03-03 18:09 GMT+02:00 Ne Greh <= 32 | span dir=3D"ltr"><= 33 | negreh@gmail.com>:
34 |
=D4=C5=CC=CF
36 |

37 | 38 | --bcaec54ee220831f9e04f3b6058c-- 39 | --bcaec54ee220831fa204f3b6058e 40 | Content-Type: image/png; name="08.png" 41 | Content-Disposition: attachment; filename="08.png" 42 | Content-Transfer-Encoding: base64 43 | X-Attachment-Id: f_hsby4xt51 44 | 45 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 46 | bWFnZVJlYWR5ccllPAAAActJREFUeNo8Uj1IW1EYPe++98KLmuRFidRGA0K0VTAEDLS4lNhahw7i 47 | IihCoXUQxIKDUHDJogFXl6CbCKVQC+LkIAFxMEgV26mgNLa0CkHzbGtCkvfj/W4Sh8v9LpzvfOec 48 | 70obU56oq8GXVjWvLskKGJMhSQxU0w06jo3Sbd4o5H/HGYEB6P5QBLKswqqUYFkV2GYZtm0KMDVp 49 | 3oDu1tvSCpNdOm/As5kPdIGz4NfxNrKZT7DKBQGWGBMEWlOLLk/EOxIOZ7nKHqF4c4lGfzuCkWEE 50 | +4Zw/fME5YJRncYPJHDJXDNjCgLhJ7j6cYS91CS+fHwP1e3B09craAp0Vhu4TMcyweoGHw1OY+Dt 51 | Kp7PbXEmCZn1d0Ji/9gSZFWDJTxZvKGWzE7yBb5uLdZASW6yFcebCTT4g2iPvrqXdT/B19YtwPtr 52 | b1A0LtDzchZ/vu2IEEKxUQF2eGoUgfAQG19GZGQBrV0D+L6bEszNoSjODzehB3sF2LFtUqOI6P5e 53 | nqJS/Ifc6QFyZxkxzfMgjP+5rKh9D3tg8pilz/OP87SUukZKw6rXNRlklhKSXW5DoXXzPaRdmqf6 54 | NYiOfJFU/iYZ1MRJDLN0G78TYAACG87quVcdqAAAAABJRU5ErkJggg== 55 | --bcaec54ee220831fa204f3b6058e 56 | Content-Type: image/png; name="09.png" 57 | Content-Disposition: attachment; filename="09.png" 58 | Content-Transfer-Encoding: base64 59 | X-Attachment-Id: f_hsby4xtu2 60 | 61 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 62 | bWFnZVJlYWR5ccllPAAAAapJREFUeNpMUk8og2EY/+39PqNhPpk1f1dC/h1E/rR22YkIkZSIi+TA 63 | ZTlJWTk4oBx2woGDQpIoOaglRSIXShEHYRezxRq2b+N93s/w1NP7vvX8/jzP8+ommqXKpBTFk2gw 64 | KozJYJIMHWPaqWMiv75ieH97Cbw+PzgYFQNQLAWVkBL0UCMfiEbCImNRVRQTKFkxK6kZ2R5ZkvUK 65 | B6B7fJMOcBZcHW/h4mAVkc+QpsIVo2oYBqNJkZprs1zE8nhziuCLF2mmXBTXNKGouhFPt+f4CPpF 66 | MSlSMPJKmV9q46AzrE11YW/BCd4T2kbmkW4p0CyqmkUmGuTN1rUMo8O5hL7JPWFj2z0kGBsHZiDr 67 | k/4AcYXFUTs8Ky5R1MCLUhQz9pfHYOQWS+pbf1UYsRHAlFciijeme/Hme4Kt3Ynr010xhHJ75z9L 68 | P4CmwTk4elywlttxsuMWzFmFVbg8XIfZWvE7YhZfkO/xGp+hV9xfHYmkMOUUw++9E/fM/DJtzLP9 69 | Vj8tJT46cf67E7PImIqERENApnVzKe1rcDWK+JegN9kgAAcHwu9Bx7cAAwCwptCPTr0M1AAAAABJ 70 | RU5ErkJggg== 71 | --bcaec54ee220831fa204f3b6058e-- -------------------------------------------------------------------------------- /tests/files/msg-attachments-two-yandex.txt: -------------------------------------------------------------------------------- 1 | Delivered-To: naspeh@gmail.com 2 | Received: by 10.194.172.98 with SMTP id bb2csp58656wjc; 3 | Mon, 3 Mar 2014 09:30:33 -0800 (PST) 4 | X-Received: by 10.112.201.164 with SMTP id kb4mr22361787lbc.32.1393867833407; 5 | Mon, 03 Mar 2014 09:30:33 -0800 (PST) 6 | Return-Path: 7 | Received: from forward12.mail.yandex.net (forward12.mail.yandex.net. [2a02:6b8:0:801::2]) 8 | by mx.google.com with ESMTPS id z2si22466644lal.1.2014.03.03.09.30.33 9 | for 10 | (version=TLSv1 cipher=RC4-SHA bits=128/128); 11 | Mon, 03 Mar 2014 09:30:33 -0800 (PST) 12 | Received-SPF: pass (google.com: domain of naspeh@yandex.ru designates 2a02:6b8:0:801::2 as permitted sender) client-ip=2a02:6b8:0:801::2; 13 | Authentication-Results: mx.google.com; 14 | spf=pass (google.com: domain of naspeh@yandex.ru designates 2a02:6b8:0:801::2 as permitted sender) smtp.mail=naspeh@yandex.ru; 15 | dmarc=fail (p=NONE dis=NONE) header.from=gmail.com 16 | Received: from web27j.yandex.ru (web27j.yandex.ru [5.45.198.68]) 17 | by forward12.mail.yandex.net (Yandex) with ESMTP id 79924C20CEC; 18 | Mon, 3 Mar 2014 21:30:32 +0400 (MSK) 19 | Received: from 127.0.0.1 (localhost [127.0.0.1]) 20 | by web27j.yandex.ru (Yandex) with ESMTP id 1DF8BDC110F; 21 | Mon, 3 Mar 2014 21:30:32 +0400 (MSK) 22 | Received: from [109.206.32.110] ([109.206.32.110]) by web27j.yandex.ru with HTTP; 23 | Mon, 03 Mar 2014 21:30:31 +0400 24 | From: naspeh 25 | Envelope-From: naspeh@yandex.ru 26 | To: Ne Greh , 27 | naspeh@gmail.com 28 | In-Reply-To: <261151393863456@web4h.yandex.ru> 29 | References: <261151393863456@web4h.yandex.ru> 30 | Subject: =?koi8-r?B?UmU6INTFzcEgydrNxc7Fzs7B0Q==?= 31 | MIME-Version: 1.0 32 | Message-Id: <118161393867831@web27j.yandex.ru> 33 | X-Mailer: Yamail [ http://yandex.ru ] 5.0 34 | Date: Mon, 03 Mar 2014 19:30:31 +0200 35 | Content-Type: multipart/mixed; 36 | boundary="----==--bound.11817.web27j.yandex.ru" 37 | 38 | 39 | ------==--bound.11817.web27j.yandex.ru 40 | Content-Transfer-Encoding: 8bit 41 | Content-Type: text/plain; charset=koi8-r 42 | 43 | 2 png 44 | 45 | 03.03.2014, 18:17, "naspeh" : 46 | > ответ с м.я.ру 47 | > 48 | > 03.03.2014, 18:11, "Ne Greh" : 49 | > 50 | >>  тема таже 51 | >> 52 | >>  On Mon, Mar 3, 2014 at 6:10 PM, Grisha K. wrote: 53 | >>>   ответ на тело 54 | >>> 55 | >>>   2014-03-03 18:09 GMT+02:00 Ne Greh : 56 | >>>>   тело 57 | ------==--bound.11817.web27j.yandex.ru 58 | Content-Disposition: attachment; 59 | filename="49.png" 60 | Content-Transfer-Encoding: base64 61 | Content-Type: image/png; 62 | name="49.png" 63 | 64 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 65 | bWFnZVJlYWR5ccllPAAAAYRJREFUeNpcUruKwlAQPUkURFSChSAogqCINisWgtjYiYWVyoJ+hVYK 66 | Qj5Af8AvsLQSttrORrFdsLDx1ZmgoiDi3TsDNws7cJhJMnPPyZmrGYbxYZrmdygUMj0eDwi6rruZ 67 | 8H6/cT6fnf1+XzHC4fAPALNYLMJxHDweDwghGBSapvFQIBDwyfpTi0aj/OV4PHKDPAWz2QzT6RT3 68 | +91leT6fnA05YBHlcrnE6XRCLBZDrVZDtVrFer2GbdvcTKDQle5SqYTVaoVWq4Vutwv5T5hMJkgm 69 | k+7A6/UCcrmcSKVSQsXhcBC9Xk+0221xvV7FbrcT+XxexONx7nMZyuUyLMti2tFohEgkgsFgwBLr 70 | 9brLoisLM5kMN3c6HTaAZM3nczah0Wj8SSoUCkxJ9BTD4VD0+32um82mGI/HXAeDQUGO6mpBm80G 71 | l8sFi8WCQZFOp7HdbrnOZrNss5ZIJGyp11Qa/4NkKPj9fse43W5ftEGv1+uT18TdLGV6lu/VNXFk 72 | b+VXgAEApYriRnmIEQUAAAAASUVORK5CYII= 73 | ------==--bound.11817.web27j.yandex.ru 74 | Content-Disposition: attachment; 75 | filename="50.png" 76 | Content-Transfer-Encoding: base64 77 | Content-Type: image/png; 78 | name="50.png" 79 | 80 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 81 | bWFnZVJlYWR5ccllPAAAAWpJREFUeNpcUrGKwkAQfS5BRESCZbCysL7Cys42VargF1ilEj9ALKzE 82 | yk8Qi2DlD9iIVQgpgoiFiIiFzQURCxH3Zgayx93A252FmXlvZrbwfr+/sixb3+93m3wwPp+PuRlK 83 | KdRqtaxer3cUBwOwkyTB6/VCqVRCsVgUWJYlwZx0u93s6/W6LtChKQGO4/AFqgLP89DtdlEulw0L 84 | FxC2x+Mh9IvFAkEQoFKpYDabwfd97Pd7E2wYc93b7RatVgthGGI6nYJ6Qq/Xw/F4/JOANE314XBg 85 | WQKSpieTiZ7P55rYNEnUcRzr8/kscYZhs9lgOBxKH4PBgJvEeDzG5XLBarUyLCofIetlo8oyAJbl 86 | uq4MYblc/kqKokgomZ4ljUYjTZXFp350v98Xn3rSPFGVL6jZbKJaraLdbgvYSDMajYb4u91Oxlw4 87 | nU7fvJRc43+wjBzP5zOzZN1Kma/Bli+L37xpDqbkjGR3fgQYAOmc6514TJKvAAAAAElFTkSuQmCC 88 | 89 | ------==--bound.11817.web27j.yandex.ru-- 90 | -------------------------------------------------------------------------------- /tests/files/msg-embeds-one-gmail.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.200.46.154 with HTTP; Tue, 23 Jan 2018 04:08:55 -0800 (PST) 3 | In-Reply-To: 4 | References: <56350de2.50af1c0a.341ed.ffff88b0@mx.google.com> 5 | 6 | 7 | <56350d03.50af1c0a.341ed.ffff879c@mx.google.com> 8 | <56352b64.ea9ec20a.cfb10.379f@mx.google.com> 9 | <563871f6.6a13c20a.9c82.0be0@mx.google.com> 10 | 11 | Date: Tue, 23 Jan 2018 14:08:55 +0200 12 | Delivered-To: naspeh@gmail.com 13 | Message-ID: 14 | Subject: Re: test 15 | From: Grisha Kostyuk 16 | To: Ne Greh 17 | Content-Type: multipart/related; boundary="94eb2c0c7d3e2c7639056370694b" 18 | 19 | --94eb2c0c7d3e2c7639056370694b 20 | Content-Type: multipart/alternative; boundary="94eb2c0c7d3e2c7636056370694a" 21 | 22 | --94eb2c0c7d3e2c7636056370694a 23 | Content-Type: text/plain; charset="UTF-8" 24 | Content-Transfer-Encoding: base64 25 | 26 | cnNzIGljb24NCuKAiw0K 27 | --94eb2c0c7d3e2c7636056370694a 28 | Content-Type: text/html; charset="UTF-8" 29 | Content-Transfer-Encoding: quoted-printable 30 | 31 |
rss icon=C2=A0
=E2=80=8B
34 | 35 | --94eb2c0c7d3e2c7636056370694a-- 36 | --94eb2c0c7d3e2c7639056370694b 37 | Content-Type: image/png; name="50.png" 38 | Content-Disposition: inline; filename="50.png" 39 | Content-Transfer-Encoding: base64 40 | Content-ID: 41 | X-Attachment-Id: ii_jcrlk9sk0_16122eb711c529e8 42 | MIME-Version: 1.0 43 | Date: Tue, 23 Jan 2018 04:08:30 -0800 44 | Message-ID: 45 | Subject: Attachment 46 | From: Grisha Kostyuk 47 | 48 | iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ 49 | bWFnZVJlYWR5ccllPAAAAWpJREFUeNpcUrGKwkAQfS5BRESCZbCysL7Cys42VargF1ilEj9ALKzE 50 | yk8Qi2DlD9iIVQgpgoiFiIiFzQURCxH3Zgayx93A252FmXlvZrbwfr+/sixb3+93m3wwPp+PuRlK 51 | KdRqtaxer3cUBwOwkyTB6/VCqVRCsVgUWJYlwZx0u93s6/W6LtChKQGO4/AFqgLP89DtdlEulw0L 52 | FxC2x+Mh9IvFAkEQoFKpYDabwfd97Pd7E2wYc93b7RatVgthGGI6nYJ6Qq/Xw/F4/JOANE314XBg 53 | WQKSpieTiZ7P55rYNEnUcRzr8/kscYZhs9lgOBxKH4PBgJvEeDzG5XLBarUyLCofIetlo8oyAJbl 54 | uq4MYblc/kqKokgomZ4ljUYjTZXFp350v98Xn3rSPFGVL6jZbKJaraLdbgvYSDMajYb4u91Oxlw4 55 | nU7fvJRc43+wjBzP5zOzZN1Kma/Bli+L37xpDqbkjGR3fgQYAOmc6514TJKvAAAAAElFTkSuQmCC 56 | --94eb2c0c7d3e2c7639056370694b-- -------------------------------------------------------------------------------- /tests/files/msg-encoding-cp1251-alias.txt: -------------------------------------------------------------------------------- 1 | Subject: Обновления музыки на сайте JeTune.ru 2 | From: news@jetune.ru 3 | To: TEST@MAIL.RU 4 | Content-type: text/html; charset=cp-1251 5 | Message-Id: <20070722235555.CE09B2565D6@mail.jetune.ru> 6 | Date: Mon, 23 Jul 2007 03:55:55 +0400 (MSD) 7 | X-Spam: Not detected 8 | 9 | 10 |

Здравствуйте.

11 | -------------------------------------------------------------------------------- /tests/files/msg-encoding-cp1251-chardet.txt: -------------------------------------------------------------------------------- 1 | Date: Sun, 12 Oct 2008 00:38:20 -0400 2 | Message-Id: <200810120438.m9C4cK0s018081@master2.fastbighost.com> 3 | To: naspeh@ya.ru 4 | Subject: Оплатите, пожалуйста, счет 5 | From: "hostpro.com.ua" 6 | 7 | 8 | 9 | Уважаемый Гриша ! 10 | 11 | Данным письмом уведомляем, что компанией HOSTPRO Вам выставлен счет [258121] 12 | 13 | ... 14 | 15 | С наилучшими пожеланиями 16 | 17 | отдел продаж HOSTPRO 18 | -------------------------------------------------------------------------------- /tests/files/msg-encoding-empty-charset.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/alternative; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: =?utf-8?b?0JPRgNC40YjQsCDQmi4=?= 5 | To: =?utf-8?b?0JrQsNGC0Y8g0Jou?= 6 | Date: Sat, 21 Nov 2015 00:27:59 -0000 7 | Subject: Re: Mailur test 8 | In-Reply-To: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> 9 | References: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> <564fb6cd.444fc20a.f356f.ffffa7d5@mx.google.com> <560af8f1.e2aec20a.d5f35.ffffe779@mx.google.com> <559ed38e.4925980a.04b2.7db1@mx.google.com> <559ed24a.8325980a.f754.7971@mx.google.com> 10 | 11 | --===============2720466624839896923== 12 | Content-Type: text/plain; charset 13 | MIME-Version: 1.0 14 | Content-Transfer-Encoding: 7bit 15 | 16 | test 17 | --===============2720466624839896923== 18 | Content-Type: text/html; charset 19 | MIME-Version: 1.0 20 | Content-Transfer-Encoding: 7bit 21 | 22 |

test

23 | --===============2720466624839896923==-- 24 | -------------------------------------------------------------------------------- /tests/files/msg-encoding-parts-in-koi8r.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/alternative; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: =?utf-8?b?0JPRgNC40YjQsCDQmi4=?= 5 | To: =?utf-8?b?0JrQsNGC0Y8g0Jou?= 6 | Date: Sat, 21 Nov 2015 00:27:59 -0000 7 | Subject: Тестим кодировку KOI8-R 8 | In-Reply-To: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> 9 | References: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> <564fb6cd.444fc20a.f356f.ffffa7d5@mx.google.com> <560af8f1.e2aec20a.d5f35.ffffe779@mx.google.com> <559ed38e.4925980a.04b2.7db1@mx.google.com> <559ed24a.8325980a.f754.7971@mx.google.com> 10 | 11 | --===============2720466624839896923== 12 | Content-Type: text/plain; charset="KOI8-R" 13 | MIME-Version: 1.0 14 | Content-Transfer-Encoding: 7bit 15 | 16 | тест 17 | --===============2720466624839896923== 18 | Content-Type: text/html; charset="KOI8-R" 19 | MIME-Version: 1.0 20 | Content-Transfer-Encoding: 7bit 21 | 22 |

тест

23 | --===============2720466624839896923==-- 24 | -------------------------------------------------------------------------------- /tests/files/msg-encoding-saved-in-koi8r.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | From: "Гриша" 3 | To: "Катя" 4 | Date: Sat, 21 Nov 2015 00:27:59 -0000 5 | Subject: Тестим кодировку KOI8-R 6 | 7 | тест 8 | -------------------------------------------------------------------------------- /tests/files/msg-encoding-subject-gb2312.txt: -------------------------------------------------------------------------------- 1 | Received: by 10.140.193.1; Mon, 14 Jan 2008 16:09:51 -0800 (PST) 2 | Message-ID: 3 | Date: Mon, 14 Jan 2008 16:09:51 -0800 4 | From: "=?KOI8-R?B?68/MzMXL1MnXIEdtYWls?=" 5 | To: "Ne Greh" 6 | Subject: =?GB2312?B?p7Gn4Kfpp+Sn0SBHbWFpbCCoQyCn4Kfjp+Cn0qfWp9+n36fRp/Eu?= =?GB2312?B?IKejp+Cn5CCn6afkp+Agp6On0afeIKffp+Wn2Kffp+Agp9mn36fRp+Sn7i4=?= 7 | MIME-Version: 1.0 8 | 9 | 42 10 | -------------------------------------------------------------------------------- /tests/files/msg-from-ending-snail.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/alternative; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: grrr@ 5 | To: katya@ 6 | Reply-To: grrr 7 | Date: Sat, 21 Nov 2015 00:27:59 -0000 8 | Subject: Re: Mailur test 9 | In-Reply-To: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> 10 | References: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> <564fb6cd.444fc20a.f356f.ffffa7d5@mx.google.com> <560af8f1.e2aec20a.d5f35.ffffe779@mx.google.com> <559ed38e.4925980a.04b2.7db1@mx.google.com> <559ed24a.8325980a.f754.7971@mx.google.com> 11 | 12 | --===============2720466624839896923== 13 | Content-Type: text/plain; charset 14 | MIME-Version: 1.0 15 | Content-Transfer-Encoding: 7bit 16 | 17 | test 18 | --===============2720466624839896923== 19 | Content-Type: text/html; charset 20 | MIME-Version: 1.0 21 | Content-Transfer-Encoding: 7bit 22 | 23 |

test

24 | --===============2720466624839896923==-- 25 | -------------------------------------------------------------------------------- /tests/files/msg-from-rss2email.txt: -------------------------------------------------------------------------------- 1 | Sender: feeds@yadro.org 2 | X-Mailgun-Sid: WyI0MjU0MiIsICJuYXNwZWgrbmV3c0BnbWFpbC5jb20iLCAiNGU1ZDgiXQ== 3 | Message-Id: <20160831100635.2569.78376.83235A43@yadro.org> 4 | Mime-Version: 1.0 5 | Content-Type: text/html; charset="utf-8" 6 | To: naspeh+news@gmail.com 7 | Subject: =?utf-8?b?0J3QsCDQsNCy0YLQvtCx0YPRgdC1INC/0L4g0JzQsNGA0YHRgw==?= 8 | X-Rss-Id: https://blognot.co/?p=13008 9 | User-Agent: rss2email 10 | X-Rss-Url: http://feedproxy.google.com/~r/Blognot/~3/qug3pFchXX0/13008 11 | Date: Wed, 31 Aug 2016 10:06:32 -0000 12 | X-Rss-Tags: =?utf-8?b?0JHQtdC3INGA0YPQsdGA0LjQutC4?= 13 | X-Rss-Feed: http://feeds.feedburner.com/Blognot?format=xml 14 | From: =?utf-8?b?0JHQu9C+R9C90L7RgjogR3JheQ==?= 15 | 16 | тест 17 | -------------------------------------------------------------------------------- /tests/files/msg-header-with-encoding.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Transfer-Encoding: 7bit 3 | Content-Type: text/plain; charset="us-ascii"; Format="flowed" 4 | Message-Id: 5 | Date: Wed, 07 Jan 2015 13:23:22 +0000 6 | From: =?utf-8?b?0JrQsNGC0Y8g0Jou?= 7 | To: Grisha 8 | Subject: =?utf-8?b?UmU6INC90LUg0L/QvtGA0LAg0LvQuCDQv9C+0LTQutGA0LXQv9C40YLRjNGB0Y8/?= 9 | 10 | 42 11 | -------------------------------------------------------------------------------- /tests/files/msg-header-with-long-addresses.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Transfer-Encoding: 7bit 3 | Content-Type: text/plain; charset="us-ascii"; Format="flowed" 4 | Date: Wed, 07 Jan 2015 13:23:22 +0000 5 | Message-Id: 6 | To: primary discussion list for use and development of cx_Freeze 7 | , Python , 8 | PyQT mailing list 9 | 10 | 42 11 | -------------------------------------------------------------------------------- /tests/files/msg-header-with-no-encoding.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Transfer-Encoding: 7bit 3 | Content-Type: text/plain; charset="us-ascii"; Format="flowed" 4 | Message-Id: 5 | Date: Wed, 07 Jan 2015 13:23:22 +0000 6 | From: "Катя К." 7 | To: Гриша 8 | Subject: Re: не пора ли подкрепиться? 9 | 10 | 42 11 | -------------------------------------------------------------------------------- /tests/files/msg-header-with-no-msgid.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Transfer-Encoding: 7bit 3 | Content-Type: text/plain; charset="us-ascii"; Format="flowed" 4 | Date: Wed, 07 Jan 2015 13:23:22 +0000 5 | From: "Катя К." 6 | To: Гриша 7 | Subject: Re: не пора ли подкрепиться? 8 | 9 | 42 10 | -------------------------------------------------------------------------------- /tests/files/msg-header-with-nospace-in-msgid.txt: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Transfer-Encoding: 7bit 3 | Content-Type: text/plain; charset="us-ascii"; Format="flowed" 4 | Message-Id: 5 | Date: Wed, 07 Jan 2015 13:23:22 +0000 6 | From: "Катя К." 7 | To: Гриша 8 | Subject: Re: не пора ли подкрепиться? 9 | 10 | 42 11 | -------------------------------------------------------------------------------- /tests/files/msg-links.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/mixed; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: grrr@example.com 5 | To: katya@example.com 6 | Date: Sat, 21 Nov 2015 00:27:59 -0000 7 | Subject: Тестим ссылки 8 | 9 | --===============2720466624839896923== 10 | Content-Type: text/plain; charset="UTF-8" 11 | MIME-Version: 1.0 12 | Content-Transfer-Encoding: 8bit 13 | 14 | https://github.com/naspeh/mailur 15 | http://bottlepy.org/docs/dev/routing.html 16 | --===============2720466624839896923== 17 | Content-Type: text/html; charset="UTF-8" 18 | MIME-Version: 1.0 19 | Content-Transfer-Encoding: 8bit 20 | 21 | mailur 22 | 23 | https://github.com/naspeh/mailur 24 | http://bottlepy.org/docs/dev/routing.html 25 | --===============2720466624839896923==-- 26 | -------------------------------------------------------------------------------- /tests/files/msg-lookup-error.txt: -------------------------------------------------------------------------------- 1 | Message-ID: 2 | Subject: bad symbol? 3 | Date: Wed, 07 Jan 2015 13:23:22 +0000 4 | From: katya@example.com 5 | To: grrr@example.com 6 | Content-type: text/html; charset=iso-2022-int-1 7 | Content-Transfer-Encoding: 8bit 8 | MIME-Version: 1.0 9 | 10 | 11 | test 12 | -------------------------------------------------------------------------------- /tests/files/msg-rfc822.txt: -------------------------------------------------------------------------------- 1 | Message-ID: <564fba90.022d1c0a.8a31f.ffff9bd5@mx.google.com> 2 | Content-Type: multipart/related; boundary="===============2720466624839896923==" 3 | MIME-Version: 1.0 4 | From: =?utf-8?b?0JPRgNC40YjQsCDQmi4=?= 5 | To: =?utf-8?b?0JrQsNGC0Y8g0Jou?= 6 | Date: Sat, 21 Nov 2015 00:27:59 -0000 7 | Subject: Re: Mailur test 8 | In-Reply-To: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> 9 | References: <564fb7d4.61c9c20a.95b95.ffffa8bb@mx.google.com> <564fb6cd.444fc20a.f356f.ffffa7d5@mx.google.com> <560af8f1.e2aec20a.d5f35.ffffe779@mx.google.com> <559ed38e.4925980a.04b2.7db1@mx.google.com> <559ed24a.8325980a.f754.7971@mx.google.com> 10 | 11 | --===============2720466624839896923== 12 | Content-Type: text/plain; charset 13 | MIME-Version: 1.0 14 | Content-Transfer-Encoding: 7bit 15 | 16 | test 17 | --===============2720466624839896923== 18 | Content-Type: message/rfc822 19 | 20 | Received: by 10.140.193.1; Mon, 14 Jan 2008 16:09:51 -0800 (PST) 21 | Message-ID: 22 | Date: Mon, 14 Jan 2008 16:09:51 -0800 23 | From: "=?KOI8-R?B?68/MzMXL1MnXIEdtYWls?=" 24 | To: "Ne Greh" 25 | Subject: =?GB2312?B?p7Gn4Kfpp+Sn0SBHbWFpbCCoQyCn4Kfjp+Cn0qfWp9+n36fRp/Eu?= =?GB2312?B?IKejp+Cn5CCn6afkp+Agp6On0afeIKffp+Wn2Kffp+Agp9mn36fRp+Sn7i4=?= 26 | MIME-Version: 1.0 27 | 28 | 42 29 | --===============2720466624839896923==-- 30 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_output 2 | 3 | from mailur import cli, local 4 | 5 | 6 | def test_general(gm_client, login, msgs, patch, call): 7 | stdout = check_output('mlr -h', shell=True) 8 | assert b'Mailur CLI' in stdout 9 | 10 | gm_client.add_emails([{}, {}], fetch=False, parse=False) 11 | assert [i['uid'] for i in msgs(local.SRC)] == [] 12 | assert [i['uid'] for i in msgs()] == [] 13 | 14 | cli.main('%s remote --parse' % login.user1) 15 | assert [i['uid'] for i in msgs(local.SRC)] == ['1', '2'] 16 | assert [i['uid'] for i in msgs()] == ['1', '2'] 17 | 18 | with patch('mailur.cli.local') as m: 19 | cli.main('%s metadata' % login.user1) 20 | assert m.update_metadata.called 21 | 22 | cli.main('%s metadata' % login.user1) 23 | with local.client() as con: 24 | con.copy(['1', '2'], local.ALL) 25 | puids = con.search('ALL') 26 | con.select(local.SRC) 27 | ouids = con.search('ALL') 28 | assert len(puids) == (2 * len(ouids)) 29 | 30 | cli.main('%s metadata' % login.user1) 31 | con.select(local.ALL) 32 | all_puids = con.search('ALL') 33 | assert len(all_puids) == 4 34 | local.link_threads(ouids) 35 | cli.main('%s parse --fix-duplicates' % login.user1) 36 | cli.main('%s metadata' % login.user1) 37 | 38 | con.select(local.ALL) 39 | puids = con.search('ALL') 40 | con.select(local.SRC) 41 | ouids = con.search('ALL') 42 | assert len(puids) == len(ouids) 43 | puids_from_msgs = sorted(local.data_msgs.get()) 44 | puids_from_pairs = sorted(local.data_uidpairs.get().values()) 45 | assert puids_from_msgs == puids_from_pairs 46 | 47 | with patch('mailur.cli.remote.fetch_folder') as m: 48 | cli.main('%s remote' % login.user1) 49 | opts = {'batch': 1000, 'threads': 2} 50 | assert m.call_args_list == [ 51 | call(tag='\\All', **opts), 52 | call(tag='\\Junk', **opts), 53 | call(tag='\\Trash', **opts), 54 | ] 55 | 56 | m.reset_mock() 57 | cli.main('%s remote --tag=\\All' % login.user1) 58 | assert m.call_args_list == [call(tag='\\All', **opts)] 59 | 60 | m.reset_mock() 61 | cli.main('%s remote --box=All' % login.user1) 62 | assert m.call_args_list == [call(box='All', **opts)] 63 | 64 | 65 | def test_fetch_and_parse(gm_client, login, msgs, patch, raises): 66 | stdout = check_output('mlr %s parse' % login.user1, shell=True) 67 | assert b'## all parsed already' in stdout 68 | 69 | cli.main('%s remote' % login.user1) 70 | assert len(msgs(local.SRC)) == 0 71 | assert len(msgs()) == 0 72 | 73 | gm_client.add_emails([{}], fetch=False, parse=False) 74 | cli.main('%s remote' % login.user1) 75 | assert len(msgs(local.SRC)) == 1 76 | assert len(msgs()) == 0 77 | 78 | cli.main('%s parse' % login.user1) 79 | assert len(msgs(local.SRC)) == 1 80 | assert len(msgs()) == 1 81 | 82 | gm_client.add_emails([{}], fetch=False, parse=False) 83 | cli.main('%s remote --parse' % login.user1) 84 | assert len(msgs(local.SRC)) == 2 85 | assert len(msgs()) == 2 86 | 87 | assert [i['uid'] for i in msgs(local.SRC)] == ['1', '2'] 88 | assert [i['uid'] for i in msgs()] == ['1', '2'] 89 | cli.main('%s parse all' % login.user1) 90 | assert [i['uid'] for i in msgs(local.SRC)] == ['1', '2'] 91 | assert [i['uid'] for i in msgs()] == ['3', '4'] 92 | 93 | with patch('mailur.remote.fetch') as m, raises(SystemExit): 94 | m.side_effect = SystemExit 95 | cli.main('%s remote --parse' % login.user1) 96 | assert len(msgs(local.SRC)) == 2 97 | assert len(msgs()) == 2 98 | -------------------------------------------------------------------------------- /tests/test_gmail.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from mailur import local, remote 4 | 5 | 6 | def test_client(some, patch, call): 7 | with patch('mailur.imap.select') as m: 8 | con = remote.client(tag='\\All') 9 | assert m.call_args == call(some, b'mlr', True) 10 | 11 | assert set(con.__dict__.keys()) == set( 12 | '_con logout list select select_tag status search ' 13 | 'fetch idle copy enable' 14 | .split() 15 | ) 16 | 17 | con.select_tag('\\Junk') 18 | assert m.call_args == call(some, b'mlr/All', True) 19 | 20 | con.select_tag('\\Trash') 21 | assert m.call_args == call(some, b'mlr/All', True) 22 | 23 | con.select_tag('\\Draft') 24 | assert m.call_args == call(some, b'mlr/All', True) 25 | 26 | with patch('mailur.imap.fn_time') as m: 27 | con.list() 28 | assert m.called 29 | 30 | 31 | def test_account(gm_client): 32 | params = { 33 | 'username': 'test', 34 | 'password': 'test', 35 | 'imap_host': 'imap.gmail.com', 36 | 'smtp_host': 'smtp.gmail.com' 37 | } 38 | remote.data_account(params.copy()) 39 | assert remote.data_account.get() == dict( 40 | params, gmail=True, smtp_port=465, imap_port=993, 41 | ) 42 | 43 | params = { 44 | 'username': 'test@test.com', 45 | 'password': 'test', 46 | 'imap_host': 'imap.test.com', 47 | 'smtp_host': 'smtp.test.com' 48 | } 49 | remote.data_account(params.copy()) 50 | assert remote.data_account.get() == dict( 51 | params, smtp_port=465, imap_port=993, 52 | ) 53 | 54 | gm_client.list = [('OK', [ 55 | b'(\\HasNoChildren \\All) "/" mlr/All', 56 | ])] 57 | assert remote.get_folders() == [{'tag': '\\All'}] 58 | 59 | gm_client.list = [('OK', [ 60 | b'(\\HasNoChildren) "/" INBOX', 61 | ])] 62 | assert remote.get_folders() == [{'box': 'INBOX', 'tag': '\\Inbox'}] 63 | 64 | 65 | def test_fetch_and_parse(gm_client, some, raises): 66 | lm = local.client() 67 | remote.fetch() 68 | local.parse() 69 | 70 | def gm_uidnext(): 71 | account = remote.data_account.get() 72 | key = ':'.join((account['imap_host'], account['username'], '\\All')) 73 | res = remote.data_uidnext.key(key) 74 | assert res 75 | return res[1] 76 | 77 | def mlr_uidnext(): 78 | return local.data_uidnext.get() 79 | 80 | assert gm_uidnext() == 1 81 | assert mlr_uidnext() is None 82 | 83 | gm_client.add_emails() 84 | assert gm_uidnext() == 2 85 | assert mlr_uidnext() == 2 86 | assert lm.select(local.SRC) == [b'1'] 87 | assert lm.select(local.ALL) == [b'1'] 88 | 89 | gm_client.add_emails([{'txt': '1'}, {'txt': '2'}]) 90 | assert gm_uidnext() == 4 91 | assert mlr_uidnext() == 4 92 | assert lm.select(local.SRC) == [b'3'] 93 | assert lm.select(local.ALL) == [b'3'] 94 | 95 | remote.fetch() 96 | local.parse('all') 97 | assert gm_uidnext() == 4 98 | assert mlr_uidnext() == 4 99 | assert lm.select(local.SRC) == [b'3'] 100 | assert lm.select(local.ALL) == [b'3'] 101 | assert lm.status(local.ALL, '(UIDNEXT)') == [b'mlr/All (UIDNEXT 7)'] 102 | 103 | 104 | def test_origin_msg(gm_client, latest, login): 105 | gm_client.add_emails(parse=False) 106 | msg = latest(local.SRC)['body'] 107 | # headers 108 | sha256 = msg.get('X-SHA256') 109 | assert sha256 and re.match('<[a-z0-9]{64}>', sha256) 110 | uid = msg.get('X-GM-UID') 111 | assert uid and uid == '<101>' 112 | msgid = msg.get('X-GM-MSGID') 113 | assert msgid and msgid == '<10100>' 114 | thrid = msg.get('X-GM-THRID') 115 | assert thrid and thrid == '<10100>' 116 | user = msg.get('X-GM-Login') 117 | assert user == '<%s*root>' % login.user2 118 | 119 | gm_client.add_emails([ 120 | {'flags': r'\Flagged', 'labels': r'"\\Inbox" "\\Sent" label'} 121 | ], parse=False) 122 | flags = latest(local.SRC)['flags'] 123 | assert r'\Flagged #inbox #sent label' == flags 124 | assert local.data_tags.get() == {} 125 | 126 | gm_client.add_emails([{'labels': r'test/#-.,:;!?/'}], parse=False) 127 | flags = latest(local.SRC)['flags'] 128 | assert r'test/#-.,:;!?/' == flags 129 | assert local.data_tags.get() == {} 130 | 131 | gm_client.add_emails([ 132 | {'labels': 'label "another label" (label)'} 133 | ], parse=False) 134 | flags = latest(local.SRC)['flags'] 135 | assert 'label #12ea23fc #40602c03' == flags 136 | assert local.data_tags.get() == { 137 | '#12ea23fc': {'name': 'another label'}, 138 | '#40602c03': {'name': '(label)'}, 139 | } 140 | 141 | # - "\Important" must be skiped 142 | # - flags must be transformed with imap-utf7 143 | gm_client.add_emails([ 144 | {'labels': r'"\\Important" "\\Sent" "test(&BEIENQRBBEI-)"'} 145 | ], parse=False) 146 | flags = latest(local.SRC)['flags'] 147 | assert '#sent #a058c658' == flags 148 | assert local.data_tags.get() == { 149 | '#12ea23fc': {'name': 'another label'}, 150 | '#40602c03': {'name': '(label)'}, 151 | '#a058c658': {'name': 'test(тест)'}, 152 | } 153 | 154 | gm_client.add_emails([{}], tag='\\Junk', parse=False) 155 | assert latest(local.SRC)['flags'] == '#spam' 156 | 157 | gm_client.add_emails([{}], tag='\\Trash', parse=False) 158 | assert latest(local.SRC)['flags'] == '#trash' 159 | 160 | gm_client.add_emails([{}], tag='\\Draft', parse=False) 161 | assert latest(local.SRC)['flags'] == '\\Draft' 162 | 163 | gm_client.add_emails([{}], tag='\\Inbox', fetch=False, parse=False) 164 | remote.fetch(tag='\\Chats', box='mlr') 165 | assert latest(local.SRC)['flags'] == '#chats' 166 | 167 | 168 | def test_thrid(gm_client, msgs): 169 | gm_client.add_emails([ 170 | {'labels': 'mlr/thrid mlr/thrid/1516806882952089676'}, 171 | {'labels': 'mlr/thrid mlr/thrid/1516806882952089676'} 172 | ], parse=False) 173 | 174 | assert [i['flags'] for i in msgs(local.SRC)] == ['mlr/thrid', 'mlr/thrid'] 175 | assert [i['body']['X-Thread-ID'] for i in msgs(local.SRC)] == [ 176 | '', 177 | '' 178 | ] 179 | 180 | 181 | def test_unique(gm_client, msgs): 182 | gm_client.add_emails([{}], parse=False) 183 | 184 | m = msgs(local.SRC)[-1] 185 | gid = m['body']['X-GM-MSGID'] 186 | gm_client.add_emails([{'gid': int(gid.strip('<>'))}], parse=False) 187 | assert [i['body']['X-GM-THRID'] for i in msgs(local.SRC)] == [gid] 188 | -------------------------------------------------------------------------------- /tests/test_imap.py: -------------------------------------------------------------------------------- 1 | from mailur import imap, local, message 2 | 3 | 4 | def test_batched_uids(gm_client): 5 | con = local.client() 6 | bsize = 25000 7 | assert [] == con.fetch([str(i) for i in range(1, 100, 2)], 'FLAGS') 8 | assert [] == con.fetch([str(i) for i in range(1, bsize, 2)], 'FLAGS') 9 | 10 | con.select(local.ALL, readonly=False) 11 | assert [] == con.store([str(i) for i in range(1, 100, 2)], '+FLAGS', '#') 12 | assert [] == con.store([str(i) for i in range(1, bsize, 2)], '+FLAGS', '#') 13 | 14 | # with one message 15 | msg = message.binary('42') 16 | msg.add_header('Message-Id', message.gen_msgid()) 17 | con.append(local.SRC, None, None, msg.as_bytes()) 18 | con.select(local.SRC, readonly=True) 19 | assert [b'1 (UID 1 FLAGS (\\Recent))'] == ( 20 | con.fetch([str(i) for i in range(1, 100, 2)], 'FLAGS') 21 | ) 22 | assert [b'1 (UID 1 FLAGS (\\Recent))'] == ( 23 | con.fetch([str(i) for i in range(1, bsize, 2)], 'FLAGS') 24 | ) 25 | 26 | con.select(local.SRC, readonly=False) 27 | assert [b'1 (UID 1 MODSEQ (3) FLAGS (\\Recent #1))'] == ( 28 | con.store([str(i) for i in range(1, 100, 2)], '+FLAGS', '#1') 29 | ) 30 | assert [b'1 (UID 1 MODSEQ (4) FLAGS (\\Recent #1 #2))'] == ( 31 | con.store([str(i) for i in range(1, bsize, 2)], '+FLAGS', '#2') 32 | ) 33 | 34 | gm_client.add_emails([{} for i in range(1, 22)], parse=False) 35 | assert local.parse(batch=10) is None 36 | 37 | 38 | def test_fn_parse_thread(): 39 | fn = imap.parse_thread 40 | assert fn('(1)(2 3)') == (['1'], ['2', '3']) 41 | assert fn('(11)(21 31)') == (['11'], ['21', '31']) 42 | assert fn('(1)(2 3 (4 5))') == (['1'], ['2', '3', '4', '5']) 43 | assert fn('(130 131 (132 133 134 (138)(139)(140)))') == ( 44 | ['130', '131', '132', '133', '134', '138', '139', '140'], 45 | ) 46 | assert fn(b'(1)(2)(3)') == (['1'], ['2'], ['3']) 47 | 48 | 49 | def test_fn_pack_uids(): 50 | fn = imap.pack_uids 51 | assert fn(['1', '2', '3', '4']) == '1:4' 52 | assert fn(['1', '3', '4']) == '1,3:4' 53 | assert fn(['100', '1', '4', '3', '10', '9', '8', '7']) == '1,3:4,7:10,100' 54 | 55 | 56 | def test_literal_size_limit(gm_client, raises): 57 | # for query like "UID 1,2,...,150000" should be big enough 58 | gm_client.add_emails([{} for i in range(0, 20)], parse=False) 59 | c = local.client(local.SRC) 60 | all_uids = c.search('ALL') 61 | 62 | uids = ','.join(str(i) for i in range(1, 150000)) 63 | assert all_uids == c.search('UID %s' % uids) 64 | 65 | uid = ',%i' % (10 ** 6) 66 | uids += (uid * 20000) 67 | with raises(imap.Error) as e: 68 | c.search('UID %s' % uids) 69 | assert 'Too long argument' in str(e.value) 70 | 71 | 72 | def test_multiappend(patch, msgs): 73 | new = [ 74 | (None, None, message.binary(str(i)).as_bytes()) 75 | for i in range(0, 10) 76 | ] 77 | con = local.client(None) 78 | with patch('gevent.pool.Pool.spawn') as m: 79 | m.return_value.value = '' 80 | con.multiappend(local.SRC, new) 81 | assert not m.called 82 | assert len(msgs(local.SRC)) == 10 83 | 84 | con.multiappend(local.SRC, new, batch=3) 85 | assert m.called 86 | assert m.call_count 87 | assert m.call_args_list[0][0][2] == new[0:3] 88 | assert m.call_args_list[1][0][2] == new[3:6] 89 | assert m.call_args_list[2][0][2] == new[6:9] 90 | assert m.call_args_list[3][0][2] == new[9:] 91 | 92 | m.reset_mock() 93 | con.multiappend(local.SRC, new, batch=5) 94 | assert m.called 95 | assert m.call_count 96 | assert m.call_args_list[0][0][2] == new[0:5] 97 | assert m.call_args_list[1][0][2] == new[5:] 98 | 99 | con.multiappend(local.SRC, new, batch=3) 100 | assert len(msgs(local.SRC)) == 20 101 | 102 | 103 | def test_idle(): 104 | def handler(res): 105 | if handler.first: 106 | handler.first = False 107 | return 108 | raise ValueError 109 | 110 | handler.first = True 111 | con = local.client() 112 | # just check if timeout works 113 | assert not con.idle({'EXISTS': handler}, timeout=1) 114 | 115 | 116 | def test_sieve(gm_client, msgs, raises, some): 117 | gm_client.add_emails([ 118 | {'from': '"A" '}, 119 | {'from': '"B" '} 120 | ]) 121 | con = local.client(readonly=False) 122 | 123 | with raises(imap.Error) as e: 124 | res = con.sieve('ALL', 'addflag "#0";') 125 | assert e.value.args == (some,) 126 | assert some.value.startswith(b'script: line 1: error: unknown command') 127 | 128 | res = con.sieve('ALL', ''' 129 | require ["imap4flags"]; 130 | 131 | if header :contains "Subject" "Subj" { 132 | setflag "#subj"; 133 | } 134 | ''') 135 | assert [m['flags'] for m in msgs()] == ['#subj', '#subj'] 136 | 137 | res = con.sieve('UID *', 'require ["imap4flags"];addflag "#2";') 138 | assert res == [some] 139 | assert some.value.endswith(b'UID 2 OK') 140 | assert [m['flags'] for m in msgs()] == ['#subj', '#subj #2'] 141 | 142 | res = con.sieve('UID 3', 'require ["imap4flags"];addflag "#2";') 143 | assert res == [] 144 | assert [m['flags'] for m in msgs()] == ['#subj', '#subj #2'] 145 | 146 | res = con.sieve('ALL', ''' 147 | require ["imap4flags"]; 148 | 149 | if address :is :all "from" "a@t.com" { 150 | addflag "#1"; 151 | } 152 | ''') 153 | assert res == [some] 154 | assert [m['flags'] for m in msgs()] == ['#subj #1', '#subj #2'] 155 | 156 | res = con.sieve('ALL', ''' 157 | require ["imap4flags"]; 158 | 159 | if address :is "from" ["a@t.com", "b@t.com"] { 160 | addflag "#ab"; 161 | } 162 | ''') 163 | assert res == [some, some] 164 | assert [m['flags'] for m in msgs()] == ['#subj #1 #ab', '#subj #2 #ab'] 165 | 166 | 167 | def test_select_tag(patch, raises): 168 | con = local.client(readonly=False) 169 | 170 | con.select_tag('\\All') 171 | assert con.box == 'tags/All' 172 | 173 | # delimiter "." 174 | con = local.client(readonly=False) 175 | with patch('mailur.imap.xlist') as m: 176 | m.return_value = [b'* LIST (\\HasNoChildren \\Trash) "." Trash'] 177 | 178 | with raises(imap.Error) as e: 179 | con.select_tag('\\All') 180 | assert 'No folder with tag: \\All' in str(e.value) 181 | 182 | with patch('mailur.imap.select') as m: 183 | con.select_tag('\\Trash') 184 | m.assert_called_once_with(con._con, b'Trash', True) 185 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from gevent import sleep, spawn 4 | from pytest import mark 5 | 6 | from mailur import cli, conf, local, remote 7 | 8 | 9 | def test_local(gm_client, msgs): 10 | gm_client.add_emails([{}] * 5) 11 | assert [i['flags'] for i in msgs(local.SRC)] == [''] * 5 12 | assert [i['flags'] for i in msgs()] == [''] * 5 13 | 14 | con_src = local.client(local.SRC, readonly=False) 15 | con_all = local.client(local.ALL, readonly=False) 16 | 17 | con_src.store('1:*', '+FLAGS', '#1') 18 | con_all.store('1:*', '+FLAGS', '#2') 19 | local.sync_flags_to_all() 20 | assert [i['flags'] for i in msgs(local.SRC)] == ['#1'] * 5 21 | assert [i['flags'] for i in msgs()] == ['#1'] * 5 22 | 23 | con_src.store('1,2', '+FLAGS', '#2') 24 | con_all.store('2,3', '+FLAGS', '#3') 25 | local.sync_flags_to_all() 26 | assert [i['flags'] for i in msgs(local.SRC)] == [ 27 | '#1 #2', '#1 #2', '#1', '#1', '#1' 28 | ] 29 | assert [i['flags'] for i in msgs()] == [ 30 | '#2 #1', '#2 #1', '#1', '#1', '#1' 31 | ] 32 | 33 | con_all.store('1:*', '-FLAGS', '#1 #2') 34 | con_all.store('1:*', '+FLAGS', '#3') 35 | local.sync_flags_to_src() 36 | assert [i['flags'] for i in msgs(local.SRC)] == ['#3'] * 5 37 | assert [i['flags'] for i in msgs()] == ['#3'] * 5 38 | 39 | con_src.store('1,2', '+FLAGS', '#2') 40 | con_all.store('2,3', '+FLAGS', '#4') 41 | local.sync_flags_to_src() 42 | assert [i['flags'] for i in msgs(local.SRC)] == [ 43 | '#3', '#3 #4', '#3 #4', '#3', '#3' 44 | ] 45 | assert [i['flags'] for i in msgs()] == [ 46 | '#3', '#3 #4', '#3 #4', '#3', '#3' 47 | ] 48 | 49 | con_all.store('1:*', '-FLAGS', '#3 #4') 50 | local.sync_flags_to_src() 51 | assert [i['flags'] for i in msgs(local.SRC)] == [''] * 5 52 | assert [i['flags'] for i in msgs()] == [''] * 5 53 | 54 | local.sync_flags_to_src() 55 | con_all.store('1:*', '+FLAGS', '#err') 56 | assert [i['flags'] for i in msgs(local.SRC)] == [''] * 5 57 | assert [i['flags'] for i in msgs()] == ['#err'] * 5 58 | 59 | con_src.store('1:*', '+FLAGS', '#err') 60 | con_all.store('1:*', '-FLAGS', '#err') 61 | local.sync_flags_to_all() 62 | assert [i['flags'] for i in msgs(local.SRC)] == ['#err'] * 5 63 | assert [i['flags'] for i in msgs()] == [''] * 5 64 | 65 | con_src.store('1:*', '-FLAGS', '#err') 66 | assert [i['flags'] for i in msgs(local.SRC)] == [''] * 5 67 | assert [i['flags'] for i in msgs()] == [''] * 5 68 | 69 | # idle 70 | spawn(local.sync_flags) 71 | sleep(1) 72 | 73 | con_src.store('1:*', '+FLAGS', '#1') 74 | for i in range(2, 6): 75 | con_src.store('%s' % i, '+FLAGS', '#%s' % i) 76 | sleep(1) 77 | assert [i['flags'] for i in msgs(local.SRC)] == [ 78 | '#1', '#1 #2', '#1 #3', '#1 #4', '#1 #5' 79 | ] 80 | assert [i['flags'] for i in msgs()] == [ 81 | '#1', '#2 #1', '#1 #3', '#1 #4', '#1 #5' 82 | ] 83 | 84 | 85 | @mark.no_parallel 86 | @patch.dict(conf, {'GMAIL_TWO_WAY_SYNC': '1'}) 87 | def test_cli_idle_gmail(gm_client, msgs, login, patch): 88 | actions = [] 89 | 90 | def fetch(con, uids, fields): 91 | responces = getattr(gm_client, 'fetch', None) 92 | if 'CHANGEDSINCE' in fields: 93 | index = fields.split()[-1] 94 | if index == '5)': 95 | return ('OK', [ 96 | b'4 (X-GM-MSGID 10400 ' 97 | b'X-GM-LABELS ("\\Inbox" "\\Starred" "mlr/thrid/777") ' 98 | b'FLAGS (\\Seen) UID 104 MODSEQ (427368))' 99 | ]) 100 | return ('OK', []) 101 | elif responces: 102 | return responces.pop() 103 | elif 'X-GM-LABELS' in fields: 104 | if con.current_box != local.SRC: 105 | return ('OK', [ 106 | b'1 (X-GM-MSGID 10100 X-GM-LABELS () ' 107 | b'FLAGS (\\Seen) UID 101 MODSEQ (427368))' 108 | ]) 109 | return ('OK', [ 110 | ( 111 | b'2 (X-GM-MSGID 10200 X-GM-LABELS () ' 112 | b'FLAGS (\\Seen) UID 102 MODSEQ (427368))' 113 | ), 114 | ( 115 | b'3 (X-GM-MSGID 10300 X-GM-LABELS () ' 116 | b'FLAGS (\\Seen) UID 103 MODSEQ (427368))' 117 | ), 118 | ( 119 | b'4 (X-GM-MSGID 10400 X-GM-LABELS () ' 120 | b'FLAGS (\\Seen) UID 104 MODSEQ (427368))' 121 | ), 122 | ( 123 | b'6 (X-GM-MSGID 10600 X-GM-LABELS (\\Draft) ' 124 | b'FLAGS (\\Seen) UID 106 MODSEQ (427368))' 125 | ), 126 | ]) 127 | return con._uid('FETCH', uids, fields) 128 | 129 | def search(con, charset, *criteria): 130 | if 'X-GM-MSGID' in criteria[0]: 131 | uid = int(criteria[0].split()[-1]) // 100 132 | return ('OK', [(b'%d' % uid)]) 133 | return con._uid('SEARCH', charset, *criteria) 134 | 135 | def store(con, uids, cmd, flags): 136 | if 'X-GM-LABELS' in cmd: 137 | actions.append((uids, cmd, sorted(flags.split()))) 138 | return ('OK', []) 139 | return con._uid('STORE', uids, cmd, flags) 140 | 141 | gm_client.fake_fetch = fetch 142 | gm_client.fake_search = search 143 | gm_client.fake_store = store 144 | 145 | spawn(lambda: cli.main('%s sync --timeout=300' % login.user1)) 146 | sleep(5) 147 | 148 | gm_client.add_emails([{}] * 4, fetch=False, parse=False) 149 | sleep(3) 150 | assert len(msgs(local.SRC)) == 4 151 | assert len(msgs()) == 4 152 | 153 | local.parse('all') 154 | 155 | gm_client.add_emails([{}], fetch=False, parse=False) 156 | sleep(3) 157 | assert len(msgs(local.SRC)) == 5 158 | assert len(msgs()) == 5 159 | expected_flags = ['', '', '', '\\Flagged \\Seen #inbox', ''] 160 | assert [i['flags'] for i in msgs(local.SRC)] == expected_flags 161 | assert [i['flags'] for i in msgs()] == expected_flags 162 | 163 | con_src = local.client(local.SRC, readonly=False) 164 | con_src.store('1:*', '+FLAGS', '#1') 165 | sleep(3) 166 | expected_flags = [(f + ' #1').strip() for f in expected_flags] 167 | assert [i['flags'] for i in msgs(local.SRC)] == expected_flags 168 | assert [i['flags'] for i in msgs()] == expected_flags 169 | 170 | assert actions == [ 171 | ('101', '-X-GM-LABELS', ['\\Spam']), 172 | ('101', '+X-GM-LABELS', ['\\Inbox']), # move to \\All 173 | ('101', '-X-GM-LABELS', ['\\Trash']), 174 | ('101', '+X-GM-LABELS', ['\\Inbox']), # move to \\All 175 | ('104', '+X-GM-LABELS', ['\\Inbox', '\\Starred']), 176 | ('104', '+X-GM-LABELS', ['\\Inbox', '\\Starred']), 177 | ('101', '-X-GM-LABELS', ['\\Spam']), 178 | ('101', '+X-GM-LABELS', ['\\Inbox']), # move to \\All 179 | ('101', '-X-GM-LABELS', ['\\Trash']), 180 | ('101', '+X-GM-LABELS', ['\\Inbox']), # move to \\All 181 | ] 182 | 183 | actions.clear() 184 | con_src = local.client(local.SRC, readonly=False) 185 | con_src.store('2', '+FLAGS', '#inbox') 186 | sleep(3) 187 | con_src.store('2', '+FLAGS', '#trash') 188 | sleep(3) 189 | expected_flags[1] = '#inbox #1 #trash' 190 | assert [i['flags'] for i in msgs(local.SRC)] == expected_flags 191 | assert [i['flags'] for i in msgs()] == expected_flags 192 | assert actions == [ 193 | ('102', '+X-GM-LABELS', ['\\Inbox']), 194 | ('102', '-X-GM-LABELS', ['\\Inbox']), 195 | ('102', '+X-GM-LABELS', ['\\Trash']), 196 | ] 197 | 198 | 199 | @mark.no_parallel 200 | def test_cli_idle_general_imap(gm_client, msgs, login, patch): 201 | remote.data_account({ 202 | 'username': 'test@test.com', 203 | 'password': 'test', 204 | 'imap_host': 'imap.test.com', 205 | 'smtp_host': 'smtp.test.com' 206 | }) 207 | assert remote.get_folders() == [{'tag': '\\All'}] 208 | 209 | spawn(lambda: cli.main('%s sync --timeout=300' % login.user1)) 210 | sleep(2) 211 | 212 | gm_client.add_emails([{}] * 4, fetch=False, parse=False) 213 | gm_client.fetch = [gm_client.fetch[0]] 214 | sleep(2) 215 | assert len(msgs(local.SRC)) == 4 216 | assert len(msgs()) == 4 217 | 218 | gm_client.list = [] 219 | xlist = [('OK', [b'(\\HasNoChildren) "/" INBOX'])] * 10 220 | with patch.object(gm_client, 'list', xlist): 221 | spawn(lambda: cli.main('%s sync --timeout=300' % login.user1)) 222 | sleep(2) 223 | 224 | gm_client.add_emails([{'flags': '#inbox'}], fetch=False, parse=False) 225 | gm_client.fetch = [gm_client.fetch[0]] 226 | sleep(2) 227 | assert len(msgs(local.SRC)) == 5 228 | assert len(msgs()) == 5 229 | assert len(msgs('INBOX')) == 1 230 | 231 | 232 | def test_cli_all_flags(gm_client, msgs, login): 233 | gm_client.add_emails([{}] * 5) 234 | assert [i['flags'] for i in msgs(local.SRC)] == [''] * 5 235 | assert [i['flags'] for i in msgs()] == [''] * 5 236 | 237 | con_src = local.client(local.SRC, readonly=False) 238 | con_all = local.client(local.ALL, readonly=False) 239 | 240 | con_src.store('1:*', '+FLAGS', '#1') 241 | con_all.store('1:*', '+FLAGS', '#2') 242 | cli.main('%s sync-flags' % login.user1) 243 | assert [i['flags'] for i in msgs(local.SRC)] == ['#1'] * 5 244 | assert [i['flags'] for i in msgs()] == ['#1'] * 5 245 | 246 | con_src.store('1:*', '+FLAGS', '#2') 247 | con_all.store('1:*', '+FLAGS', '#3') 248 | cli.main('%s sync-flags --reverse' % login.user1) 249 | assert [i['flags'] for i in msgs(local.SRC)] == ['#1 #3'] * 5 250 | assert [i['flags'] for i in msgs()] == ['#1 #3'] * 5 251 | 252 | 253 | def test_clean_flags(gm_client, msgs, login): 254 | gm_client.add_emails([{}] * 2) 255 | local.link_threads(['1', '2']) 256 | 257 | assert [i['flags'] for i in msgs(local.SRC)] == ['', ''] 258 | assert [i['flags'] for i in msgs()] == ['', ''] 259 | 260 | con_src = local.client(local.SRC, readonly=False) 261 | con_all = local.client(local.ALL, readonly=False) 262 | 263 | con_src.store('1', '+FLAGS', '#tag1') 264 | con_src.store('2', '+FLAGS', '#tag2 #tag3') 265 | con_all.store('1', '+FLAGS', '#tag1 #tag3') 266 | con_all.store('2', '+FLAGS', '#tag2') 267 | assert [i['flags'] for i in msgs(local.SRC)] == ['#tag1', '#tag2 #tag3'] 268 | assert [i['flags'] for i in msgs()] == ['#tag1 #tag3', '#tag2'] 269 | 270 | cli.main('%s clean-flags #tag1' % login.user1) 271 | assert [i['flags'] for i in msgs(local.SRC)] == ['', '#tag2 #tag3'] 272 | assert [i['flags'] for i in msgs()] == ['#tag3', '#tag2'] 273 | 274 | cli.main('%s clean-flags #tag2 #tag3' % login.user1) 275 | assert [i['flags'] for i in msgs(local.SRC)] == ['', ''] 276 | assert [i['flags'] for i in msgs()] == ['', ''] 277 | --------------------------------------------------------------------------------