├── .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 | 
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 |
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 |
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 |
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 |
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 |
4 |
11 |
12 |
Loading...
13 |
{{error}}
14 |
15 |
16 |
17 |
18 |
19 |
68 |
98 |
99 |
100 |
101 |
102 |
146 |
147 |
{{error}}
148 |
Nothing...
149 |
154 |
155 | {{hidden.length}} hidden
160 |
170 |
178 |
179 |
186 |
187 |
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 |
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 |
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 |
3 |
10 | {{trancated ? tag.short_name : tag.name}}
11 | {{tag.unread}}
12 |
13 |
14 |
15 |
16 |
24 |
25 |
26 |
27 |
28 |
35 | {{tagName(opt)}}
36 |
{{info[opt].unread}}
37 |
38 |
39 |
40 |
41 |
42 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | tag shouldn't start with "#" or "\"
61 |
62 |
63 |
70 | {{_.filter}}
71 |
72 |
73 |
74 |
75 |
81 | {{tagName(opt)}}
82 |
83 |
84 |
85 |
86 |
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 |
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
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 |
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 |
--------------------------------------------------------------------------------