├── .codecov.yml ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── plugin.yml ├── .gitignore ├── .tx └── config ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── install.rst └── setup.rst ├── modoboa_amavis ├── __init__.py ├── apps.py ├── checks │ ├── __init__.py │ └── settings_checks.py ├── constants.py ├── dbrouter.py ├── factories.py ├── forms.py ├── handlers.py ├── lib.py ├── locale │ ├── cs_CZ │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── el_GR │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── nl_NL │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── pl_PL │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── pt_PT │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.po │ │ │ └── djangojs.po │ └── sv │ │ └── LC_MESSAGES │ │ ├── django.po │ │ └── djangojs.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── amnotify.py │ │ └── qcleanup.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── modo_extension.py ├── settings.py ├── sql_connector.py ├── sql_email.py ├── static │ └── modoboa_amavis │ │ ├── css │ │ ├── quarantine.css │ │ └── selfservice.css │ │ └── js │ │ ├── quarantine.js │ │ └── selfservice.js ├── tasks.py ├── templates │ └── modoboa_amavis │ │ ├── _email_display.html │ │ ├── domain_content_filter.html │ │ ├── email_list.html │ │ ├── emails_page.html │ │ ├── empty_quarantine.html │ │ ├── getmailcontent.html │ │ ├── index.html │ │ ├── mailheaders.html │ │ ├── main_action_bar.html │ │ ├── notifications │ │ └── pending_requests.html │ │ ├── quarantine.html │ │ ├── viewheader.html │ │ └── viewmail_selfservice.html ├── templatetags │ ├── __init__.py │ └── amavis_tags.py ├── test_runners.py ├── tests │ ├── __init__.py │ ├── sa-learn │ ├── sample_messages │ │ ├── quarantined-input.txt │ │ └── quarantined-output-plain_nolinks.txt │ ├── spamc │ ├── test_checks.py │ ├── test_handlers.py │ ├── test_lib.py │ ├── test_management_commands.py │ ├── test_sql_email.py │ ├── test_utils.py │ └── test_views.py ├── urls.py ├── utils.py └── views.py ├── pylintrc ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt └── test_project ├── manage.py └── test_project ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | ignore: 3 | - **/migrations/* 4 | - **/tests/* 5 | - **/tests.py 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{css,js,py}] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | 19 | [{.travis.yml,.codecov.yml}] 20 | indent_style = space 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/plugin.yml: -------------------------------------------------------------------------------- 1 | name: Modoboa amavis plugin 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | release: 9 | branches: [ master ] 10 | 11 | env: 12 | POSTGRES_HOST: localhost 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | services: 18 | postgres: 19 | image: postgres:12 20 | env: 21 | POSTGRES_USER: postgres 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_DB: postgres 24 | ports: 25 | # will assign a random free host port 26 | - 5432/tcp 27 | # needed because the postgres container does not provide a healthcheck 28 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 29 | mysql: 30 | image: mysql:8.0 31 | env: 32 | MYSQL_ROOT_PASSWORD: root 33 | MYSQL_USER: modoboa 34 | MYSQL_PASSWORD: modoboa 35 | MYSQL_DATABASE: modoboa 36 | ports: 37 | - 3306/tcp 38 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 39 | redis: 40 | image: redis 41 | ports: 42 | - 6379/tcp 43 | options: >- 44 | --health-cmd "redis-cli ping" 45 | --health-interval 10s 46 | --health-timeout 5s 47 | --health-retries 5 48 | 49 | strategy: 50 | matrix: 51 | database: ['postgres', 'mysql'] 52 | python-version: [3.8, 3.9, '3.10'] 53 | fail-fast: false 54 | 55 | steps: 56 | - uses: actions/checkout@v3 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | - name: Install dependencies 62 | run: | 63 | sudo apt-get update -y && sudo apt-get install -y librrd-dev rrdtool redis-server 64 | python -m pip install --upgrade pip 65 | #pip install -e git+https://github.com/modoboa/modoboa.git#egg=modoboa 66 | pip install -r requirements.txt 67 | pip install -r test-requirements.txt 68 | cd .. 69 | git clone https://github.com/modoboa/modoboa.git 70 | cd modoboa 71 | python setup.py develop 72 | cd ../modoboa-amavis 73 | python setup.py develop 74 | echo "Testing redis connection" 75 | redis-cli -h $REDIS_HOST -p $REDIS_PORT ping 76 | env: 77 | REDIS_HOST: localhost 78 | REDIS_PORT: ${{ job.services.redis.ports[6379] }} 79 | - name: Install postgres requirements 80 | if: ${{ matrix.database == 'postgres' }} 81 | run: | 82 | pip install 'psycopg[binary]>=3.1' 83 | pip install coverage 84 | echo "DB=postgres" >> $GITHUB_ENV 85 | - name: Install mysql requirements 86 | if: ${{ matrix.database == 'mysql' }} 87 | run: | 88 | pip install 'mysqlclient<2.1.2' 89 | echo "DB=mysql" >> $GITHUB_ENV 90 | - name: Test with pytest 91 | if: ${{ matrix.python-version != '3.10' || matrix.database != 'postgres' }} 92 | run: | 93 | cd test_project 94 | python3 manage.py test modoboa_amavis 95 | env: 96 | # use localhost for the host here because we are running the job on the VM. 97 | # If we were running the job on in a container this would be postgres 98 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} # get randomly assigned published port 99 | MYSQL_HOST: 127.0.0.1 100 | MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port 101 | MYSQL_USER: root 102 | REDIS_HOST: localhost 103 | REDIS_PORT: ${{ job.services.redis.ports[6379] }} 104 | - name: Test with pytest and coverage 105 | if: ${{ matrix.python-version == '3.10' && matrix.database == 'postgres' }} 106 | run: | 107 | cd test_project 108 | coverage run --source ../modoboa_amavis manage.py test modoboa_amavis 109 | coverage xml 110 | coverage report 111 | env: 112 | # use localhost for the host here because we are running the job on the VM. 113 | # If we were running the job on in a container this would be postgres 114 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} # get randomly assigned published port 115 | MYSQL_HOST: 127.0.0.1 116 | MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port 117 | MYSQL_USER: root 118 | REDIS_HOST: localhost 119 | REDIS_PORT: ${{ job.services.redis.ports[6379] }} 120 | - name: Upload coverage result 121 | if: ${{ matrix.python-version == '3.10' && matrix.database == 'postgres' }} 122 | uses: actions/upload-artifact@v3 123 | with: 124 | name: coverage-results 125 | path: test_project/coverage.xml 126 | 127 | coverage: 128 | needs: test 129 | runs-on: ubuntu-latest 130 | steps: 131 | - uses: actions/checkout@v3 132 | - name: Download coverage results 133 | uses: actions/download-artifact@v3 134 | with: 135 | name: coverage-results 136 | - name: Upload coverage to Codecov 137 | uses: codecov/codecov-action@v3 138 | with: 139 | files: ./coverage.xml 140 | 141 | release: 142 | if: github.event_name != 'pull_request' 143 | needs: coverage 144 | runs-on: ubuntu-latest 145 | steps: 146 | - uses: actions/checkout@v3 147 | with: 148 | fetch-depth: 0 149 | - name: Set up Python 3.10 150 | uses: actions/setup-python@v4 151 | with: 152 | python-version: '3.10' 153 | - name: Build packages 154 | run: | 155 | sudo apt-get install librrd-dev rrdtool libssl-dev gettext 156 | python -m pip install --upgrade pip setuptools wheel 157 | pip install -r requirements.txt 158 | cd .. 159 | git clone https://github.com/modoboa/modoboa.git 160 | cd modoboa 161 | python setup.py develop 162 | cd ../modoboa-amavis/modoboa_amavis 163 | django-admin compilemessages 164 | cd .. 165 | python setup.py sdist bdist_wheel 166 | - name: Publish to Test PyPI 167 | if: endsWith(github.event.ref, '/master') 168 | uses: pypa/gh-action-pypi-publish@master 169 | with: 170 | user: __token__ 171 | password: ${{ secrets.test_pypi_password }} 172 | repository_url: https://test.pypi.org/legacy/ 173 | skip_existing: true 174 | - name: Publish distribution to PyPI 175 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 176 | uses: pypa/gh-action-pypi-publish@master 177 | with: 178 | user: __token__ 179 | password: ${{ secrets.pypi_password }} 180 | skip_existing: true 181 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | .eggs 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [modoboa.modoboa-amavis-djangopo] 5 | file_filter = modoboa_amavis/locale//LC_MESSAGES/django.po 6 | source_file = modoboa_amavis/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | 10 | [modoboa.modoboa-amavis-djangojspo] 11 | file_filter = modoboa_amavis/locale//LC_MESSAGES/djangojs.po 12 | source_file = modoboa_amavis/locale/en/LC_MESSAGES/djangojs.po 13 | source_lang = en 14 | type = PO 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Antoine Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE requirements.txt 2 | recursive-include modoboa_amavis *.html *.js *.css *.png *.po *.mo 3 | recursive-include doc * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | modoboa-amavis 2 | ============== 3 | 4 | |gha| |codecov| 5 | 6 | The `amavis `_ frontend of Modoboa. 7 | 8 | Installation 9 | ------------ 10 | 11 | Install this extension system-wide or inside a virtual environment by 12 | running the following command:: 13 | 14 | $ pip install modoboa-amavis 15 | 16 | Edit the settings.py file of your modoboa instance and add 17 | ``modoboa_amavis`` inside the ``MODOBOA_APPS`` variable like this:: 18 | 19 | MODOBOA_APPS = ( 20 | 'modoboa', 21 | 'modoboa.core', 22 | 'modoboa.lib', 23 | 'modoboa.admin', 24 | 'modoboa.relaydomains', 25 | 'modoboa.limits', 26 | 'modoboa.parameters', 27 | # Extensions here 28 | # ... 29 | 'modoboa_amavis', 30 | ) 31 | 32 | Then, add the following at the end of the file:: 33 | 34 | from modoboa_amavis import settings as modoboa_amavis_settings 35 | modoboa_amavis_settings.apply(globals()) 36 | 37 | Run the following commands to setup the database tables:: 38 | 39 | $ cd 40 | $ python manage.py migrate 41 | $ python manage.py collectstatic 42 | $ python manage.py load_initial_data 43 | 44 | Finally, restart the python process running modoboa (uwsgi, gunicorn, 45 | apache, whatever). 46 | 47 | Note 48 | ---- 49 | Notice that if you dont configure amavis and its database, Modoboa 50 | won't work. Check `docs/setup` for more information. 51 | 52 | .. |gha| image:: https://github.com/modoboa/modoboa-amavis/actions/workflows/plugin.yml/badge.svg 53 | :target: https://github.com/modoboa/modoboa-amavis/actions/workflows/plugin.yml 54 | 55 | .. |codecov| image:: https://codecov.io/gh/modoboa/modoboa-amavis/branch/master/graph/badge.svg 56 | :target: https://codecov.io/gh/modoboa/modoboa-amavis 57 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/modoboa-amavis.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/modoboa-amavis.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/modoboa-amavis" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/modoboa-amavis" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # modoboa-amavis documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Feb 22 14:35:42 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | from __future__ import unicode_literals 15 | 16 | # import os 17 | # import sys 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = ".rst" 39 | 40 | # The encoding of source files. 41 | # source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = "index" 45 | 46 | # General information about the project. 47 | project = "modoboa-amavis" 48 | copyright = "2017, Antoine Nguyen" # NOQA:A001 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | version = "1.1" 56 | # The full version, including alpha/beta/rc tags. 57 | release = "1.1.3" 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | # language = None 62 | 63 | # There are two options for replacing |today|: either, you set today to some 64 | # non-false value, then it is used: 65 | # today = '' 66 | # Else, today_fmt is used as the format for a strftime call. 67 | # today_fmt = '%B %d, %Y' 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | exclude_patterns = ["_build"] 72 | 73 | # The reST default role (used for this markup: `text`) to use for all 74 | # documents. 75 | # default_role = None 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | # add_function_parentheses = True 79 | 80 | # If true, the current module name will be prepended to all description 81 | # unit titles (such as .. function::). 82 | # add_module_names = True 83 | 84 | # If true, sectionauthor and moduleauthor directives will be shown in the 85 | # output. They are ignored by default. 86 | # show_authors = False 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = "sphinx" 90 | 91 | # A list of ignored prefixes for module index sorting. 92 | # modindex_common_prefix = [] 93 | 94 | # If true, keep warnings as "system message" paragraphs in the built documents. 95 | # keep_warnings = False 96 | 97 | 98 | # -- Options for HTML output ---------------------------------------------- 99 | 100 | # The theme to use for HTML and HTML Help pages. See the documentation for 101 | # a list of builtin themes. 102 | html_theme = "default" 103 | 104 | # Theme options are theme-specific and customize the look and feel of a theme 105 | # further. For a list of options available for each theme, see the 106 | # documentation. 107 | # html_theme_options = {} 108 | 109 | # Add any paths that contain custom themes here, relative to this directory. 110 | # html_theme_path = [] 111 | 112 | # The name for this set of Sphinx documents. If None, it defaults to 113 | # " v documentation". 114 | # html_title = None 115 | 116 | # A shorter title for the navigation bar. Default is the same as html_title. 117 | # html_short_title = None 118 | 119 | # The name of an image file (relative to this directory) to place at the top 120 | # of the sidebar. 121 | # html_logo = None 122 | 123 | # The name of an image file (within the static path) to use as favicon of the 124 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 125 | # pixels large. 126 | # html_favicon = None 127 | 128 | # Add any paths that contain custom static files (such as style sheets) here, 129 | # relative to this directory. They are copied after the builtin static files, 130 | # so a file named "default.css" will overwrite the builtin "default.css". 131 | html_static_path = ["_static"] 132 | 133 | # Add any extra paths that contain custom files (such as robots.txt or 134 | # .htaccess) here, relative to this directory. These files are copied 135 | # directly to the root of the documentation. 136 | # html_extra_path = [] 137 | 138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 139 | # using the given strftime format. 140 | # html_last_updated_fmt = '%b %d, %Y' 141 | 142 | # If true, SmartyPants will be used to convert quotes and dashes to 143 | # typographically correct entities. 144 | # html_use_smartypants = True 145 | 146 | # Custom sidebar templates, maps document names to template names. 147 | # html_sidebars = {} 148 | 149 | # Additional templates that should be rendered to pages, maps page names to 150 | # template names. 151 | # html_additional_pages = {} 152 | 153 | # If false, no module index is generated. 154 | # html_domain_indices = True 155 | 156 | # If false, no index is generated. 157 | # html_use_index = True 158 | 159 | # If true, the index is split into individual pages for each letter. 160 | # html_split_index = False 161 | 162 | # If true, links to the reST sources are added to the pages. 163 | # html_show_sourcelink = True 164 | 165 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 166 | # html_show_sphinx = True 167 | 168 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 169 | # html_show_copyright = True 170 | 171 | # If true, an OpenSearch description file will be output, and all pages will 172 | # contain a tag referring to it. The value of this option must be the 173 | # base URL from which the finished HTML is served. 174 | # html_use_opensearch = '' 175 | 176 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 177 | # html_file_suffix = None 178 | 179 | # Output file base name for HTML help builder. 180 | htmlhelp_basename = "modoboa-amavisdoc" 181 | 182 | 183 | # -- Options for LaTeX output --------------------------------------------- 184 | 185 | latex_elements = { 186 | # The paper size ('letterpaper' or 'a4paper'). 187 | # 'papersize': 'letterpaper', 188 | 189 | # The font size ('10pt', '11pt' or '12pt'). 190 | # 'pointsize': '10pt', 191 | 192 | # Additional stuff for the LaTeX preamble. 193 | # 'preamble': '', 194 | } 195 | 196 | # Grouping the document tree into LaTeX files. List of tuples 197 | # (source start file, target name, title, 198 | # author, documentclass [howto, manual, or own class]). 199 | latex_documents = [ 200 | ("index", "modoboa-amavis.tex", "modoboa-amavis Documentation", 201 | "Antoine Nguyen", "manual"), 202 | ] 203 | 204 | # The name of an image file (relative to this directory) to place at the top of 205 | # the title page. 206 | # latex_logo = None 207 | 208 | # For "manual" documents, if this is true, then toplevel headings are parts, 209 | # not chapters. 210 | # latex_use_parts = False 211 | 212 | # If true, show page references after internal links. 213 | # latex_show_pagerefs = False 214 | 215 | # If true, show URL addresses after external links. 216 | # latex_show_urls = False 217 | 218 | # Documents to append as an appendix to all manuals. 219 | # latex_appendices = [] 220 | 221 | # If false, no module index is generated. 222 | # latex_domain_indices = True 223 | 224 | 225 | # -- Options for manual page output --------------------------------------- 226 | 227 | # One entry per manual page. List of tuples 228 | # (source start file, name, description, authors, manual section). 229 | man_pages = [ 230 | ("index", "modoboa-amavis", "modoboa-amavis Documentation", 231 | ["Antoine Nguyen"], 1) 232 | ] 233 | 234 | # If true, show URL addresses after external links. 235 | # man_show_urls = False 236 | 237 | 238 | # -- Options for Texinfo output ------------------------------------------- 239 | 240 | # Grouping the document tree into Texinfo files. List of tuples 241 | # (source start file, target name, title, author, 242 | # dir menu entry, description, category) 243 | texinfo_documents = [ 244 | ("index", "modoboa-amavis", "modoboa-amavis Documentation", 245 | "Antoine Nguyen", "modoboa-amavis", "One line description of project.", 246 | "Miscellaneous"), 247 | ] 248 | 249 | # Documents to append as an appendix to all manuals. 250 | # texinfo_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | # texinfo_domain_indices = True 254 | 255 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 256 | # texinfo_show_urls = 'footnote' 257 | 258 | # If true, do not generate a @detailmenu in the "Top" node's menu. 259 | # texinfo_no_detailmenu = False 260 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. modoboa-amavis documentation master file, created by 2 | sphinx-quickstart on Sun Feb 22 14:35:42 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to modoboa-amavis's documentation! 7 | ========================================== 8 | 9 | This plugin provides a simple management frontend for `amavisd-new 10 | `_. The supported features are: 11 | 12 | * SQL quarantine management: available to administrators or users, 13 | possibility to delete or release messages 14 | * Per domain customization (using policies): specify how amavisd-new 15 | will handle traffic 16 | * Manual training of `SpamAssassin 17 | `_ using quarantine's content 18 | 19 | .. note:: 20 | 21 | The per-domain policies feature only works for new 22 | installations. Currently, you can't use modoboa with an existing 23 | database (ie. with data in ``users`` and ``policies`` tables). 24 | 25 | .. note:: 26 | 27 | This plugin requires amavisd-new version **2.7.0** or higher. If 28 | you're planning to use the :ref:`selfservice`, you'll need version 29 | **2.8.0**. 30 | 31 | .. note:: 32 | 33 | ``$sql_partition_tag`` should remain undefined in ``amavisd.conf``. Modoboa 34 | does not support the use of ``sql_partition_tag``, setting this value can 35 | result in quarantined messages not showing or the wrong messages being 36 | released or learnt as ham/spam. 37 | 38 | Contents: 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | 43 | install 44 | setup 45 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ####### 2 | Install 3 | ####### 4 | 5 | Install this extension system-wide or inside a virtual environment by 6 | running the following command:: 7 | 8 | $ pip install modoboa-amavis 9 | 10 | Edit the settings.py file of your modoboa instance and add 11 | ``modoboa_amavis`` inside the ``MODOBOA_APPS`` variable like this:: 12 | 13 | MODOBOA_APPS = ( 14 | 'modoboa', 15 | 'modoboa.core', 16 | 'modoboa.lib', 17 | 'modoboa.admin', 18 | 'modoboa.relaydomains', 19 | 'modoboa.limits', 20 | 'modoboa.parameters', 21 | # Extensions here 22 | # ... 23 | 'modoboa_amavis', 24 | ) 25 | 26 | Then, add the following at the end of the file:: 27 | 28 | from modoboa_amavis.settings import * 29 | 30 | Run the following commands to setup the database tables:: 31 | 32 | $ cd 33 | $ python manage.py migrate 34 | $ python manage.py collectstatic 35 | $ python manage.py load_initial_data 36 | $ python manage.py check --deploy 37 | Finally, restart the python process running modoboa (uwsgi, gunicorn, 38 | apache, whatever). 39 | -------------------------------------------------------------------------------- /modoboa_amavis/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """The amavis frontend of Modoboa.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from pkg_resources import DistributionNotFound, get_distribution 8 | 9 | try: 10 | __version__ = get_distribution(__name__).version 11 | except DistributionNotFound: 12 | # package is not installed 13 | pass 14 | 15 | default_app_config = "modoboa_amavis.apps.AmavisConfig" 16 | -------------------------------------------------------------------------------- /modoboa_amavis/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """AppConfig for amavis.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from django.apps import AppConfig 8 | 9 | 10 | class AmavisConfig(AppConfig): 11 | """App configuration.""" 12 | 13 | name = "modoboa_amavis" 14 | verbose_name = "Modoboa amavis frontend" 15 | 16 | def ready(self): 17 | # Import these to force registration of checks and signals 18 | from . import checks # NOQA:F401 19 | from . import handlers # NOQA:F401 20 | -------------------------------------------------------------------------------- /modoboa_amavis/checks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | # Import these to force registration of checks 6 | from . import settings_checks # NOQA:F401 7 | -------------------------------------------------------------------------------- /modoboa_amavis/checks/settings_checks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.core.checks import Warning, register 7 | from django.db import connections 8 | from django.utils.translation import gettext as _ 9 | 10 | W001 = Warning( 11 | _("AMAVIS_DEFAULT_DATABASE_ENCODING does not match the character " 12 | "encoding used by the Amavis database."), 13 | hint=_("Check your database character encoding and set/update " 14 | "AMAVIS_DEFAULT_DATABASE_ENCODING."), 15 | id="modoboa-amavis.W001", 16 | ) 17 | 18 | W002 = Warning( 19 | _("Modoboa Amavis has not been tested using the selected database engine."), 20 | hint=_("Try using PostgreSQL, MySQL or MariaDB."), 21 | id="modoboa-amavis.W002", 22 | ) 23 | 24 | 25 | @register(deploy=True) 26 | def check_amavis_database_encoding(app_configs, **kwargs): 27 | """Ensure AMAVIS_DEFAULT_DATABASE_ENCODING is set to the correct value.""" 28 | errors = [] 29 | db_engine = settings.DATABASES["amavis"]["ENGINE"] 30 | sql_query = None 31 | if "postgresql" in db_engine: 32 | sql_query = "SELECT pg_encoding_to_char(encoding) "\ 33 | "FROM pg_database "\ 34 | "WHERE datname = %s;" 35 | elif "mysql" in db_engine: 36 | sql_query = "SELECT DEFAULT_CHARACTER_SET_NAME "\ 37 | "FROM INFORMATION_SCHEMA.SCHEMATA "\ 38 | "WHERE SCHEMA_NAME = %s;" 39 | elif "sqlite" in db_engine: 40 | sql_query = "PRAGMA encoding;" 41 | else: 42 | errors.append(W002) 43 | return errors 44 | 45 | with connections["amavis"].cursor() as cursor: 46 | if "sqlite" in db_engine: 47 | cursor.execute(sql_query) 48 | else: 49 | cursor.execute(sql_query, [settings.DATABASES["amavis"]["NAME"]]) 50 | encoding = cursor.fetchone()[0] 51 | 52 | if encoding.lower() != settings.AMAVIS_DEFAULT_DATABASE_ENCODING.lower(): 53 | errors.append(W001) 54 | 55 | return errors 56 | -------------------------------------------------------------------------------- /modoboa_amavis/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Modoboa amavis constants.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | import collections 8 | 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | MESSAGE_TYPES = collections.OrderedDict(( 12 | ("C", _("Clean")), 13 | ("S", _("Spam")), 14 | ("Y", _("Spammy")), 15 | ("V", _("Virus")), 16 | ("H", _("Bad Header")), 17 | ("M", _("Bad MIME")), 18 | ("B", _("Banned")), 19 | ("O", _("Over sized")), 20 | ("T", _("MTA error")), 21 | ("U", _("Unchecked")), 22 | )) 23 | 24 | MESSAGE_TYPE_COLORS = { 25 | "C": "success", 26 | "S": "danger", 27 | "Y": "warning", 28 | "V": "danger", 29 | "H": "warning", 30 | "M": "warning", 31 | "B": "warning", 32 | "O": "warning", 33 | "T": "warning", 34 | "U": "default" 35 | } 36 | -------------------------------------------------------------------------------- /modoboa_amavis/dbrouter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | 6 | class AmavisRouter(object): 7 | 8 | """A router to control all database operations on models in 9 | the amavis application""" 10 | 11 | def db_for_read(self, model, **hints): 12 | """Point all operations on amavis models to 'amavis'.""" 13 | if model._meta.app_label == "modoboa_amavis": 14 | return "amavis" 15 | return None 16 | 17 | def db_for_write(self, model, **hints): 18 | """Point all operations on amavis models to 'amavis'.""" 19 | if model._meta.app_label == "modoboa_amavis": 20 | return "amavis" 21 | return None 22 | 23 | def allow_relation(self, obj1, obj2, **hints): 24 | """Allow any relation if a model in amavis is involved.""" 25 | if obj1._meta.app_label == "modoboa_amavis" \ 26 | or obj2._meta.app_label == "modoboa_amavis": 27 | return True 28 | return None 29 | 30 | def allow_migrate(self, db, app_label, model=None, **hints): 31 | """ 32 | Make sure the auth app only appears in the 'amavis' 33 | database. 34 | """ 35 | if app_label == "modoboa_amavis": 36 | # modoboa_amavis migrations should be created in the amavis 37 | # database. 38 | return (db == "amavis") 39 | elif db == "amavis": 40 | # Don't create non modoboa_amavis migrations in the amavis database. 41 | return False 42 | return None 43 | -------------------------------------------------------------------------------- /modoboa_amavis/factories.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Amavis factories.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | import datetime 8 | import time 9 | 10 | import factory 11 | 12 | from . import models 13 | from .utils import smart_bytes 14 | 15 | SPAM_BODY = """X-Envelope-To: <{rcpt}> 16 | X-Envelope-To-Blocked: <{rcpt}> 17 | X-Quarantine-ID: 18 | X-Spam-Flag: YES 19 | X-Spam-Score: 1000.985 20 | X-Spam-Level: **************************************************************** 21 | X-Spam-Status: Yes, score=1000.985 tag=2 tag2=6.31 kill=6.31 22 | tests=[ALL_TRUSTED=-1, GTUBE=1000, PYZOR_CHECK=1.985] 23 | autolearn=no autolearn_force=no 24 | Received: from demo.modoboa.org ([127.0.0.1]) 25 | by localhost (demo.modoboa.org [127.0.0.1]) (amavisd-new, port 10024) 26 | with ESMTP id nq6ekd4wtXZg for ; 27 | Thu, 9 Nov 2017 15:59:52 +0100 (CET) 28 | Received: from demo.modoboa.org (localhost [127.0.0.1]) 29 | by demo.modoboa.org (Postfix) with ESMTP 30 | for ; Thu, 9 Nov 2017 15:59:52 +0100 (CET) 31 | Content-Type: text/plain; charset="utf-8" 32 | MIME-Version: 1.0 33 | Content-Transfer-Encoding: base64 34 | Subject: Sample message 35 | From: {sender} 36 | To: {rcpt} 37 | Message-ID: <151023959268.5550.5713670714483771838@demo.modoboa.org> 38 | Date: Thu, 09 Nov 2017 15:59:52 +0100 39 | 40 | This is the GTUBE, the 41 | Generic 42 | Test for 43 | Unsolicited 44 | Bulk 45 | Email 46 | 47 | If your spam filter supports it, the GTUBE provides a test by which you 48 | can verify that the filter is installed correctly and is detecting incoming 49 | spam. You can send yourself a test mail containing the following string of 50 | characters (in upper case and with no white spaces and line breaks): 51 | 52 | XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X 53 | 54 | You should send this test mail from an account outside of your network. 55 | """ 56 | 57 | VIRUS_BODY = """Subject: Virus Test Message (EICAR) 58 | MIME-Version: 1.0 59 | Content-Type: multipart/mixed; boundary="huq684BweRXVnRxX" 60 | Content-Disposition: inline 61 | Date: Sun, 06 Nov 2011 10:08:18 -0800 62 | 63 | 64 | --huq684BweRXVnRxX 65 | Content-Type: text/plain; charset=us-ascii 66 | Content-Disposition: inline 67 | 68 | 69 | This is a virus test message. It contains an attached file 'eicar.com', 70 | which contains the EICAR virus 71 | test pattern. 72 | 73 | 74 | --huq684BweRXVnRxX 75 | Content-Type: application/x-msdos-program 76 | Content-Disposition: attachment; filename="eicar.com" 77 | Content-Transfer-Encoding: quoted-printable 78 | 79 | X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*=0A 80 | --huq684BweRXVnRxX-- 81 | """ 82 | 83 | 84 | class MaddrFactory(factory.django.DjangoModelFactory): 85 | """Factory for Maddr.""" 86 | 87 | class Meta: 88 | model = models.Maddr 89 | django_get_or_create = ("email", ) 90 | 91 | id = factory.Sequence(lambda n: n) # NOQA:A003 92 | email = factory.Sequence(lambda n: "user_{}@domain.test".format(n)) 93 | domain = "test.domain" 94 | 95 | 96 | class MsgsFactory(factory.django.DjangoModelFactory): 97 | """Factory for Mailaddr.""" 98 | 99 | class Meta: 100 | model = models.Msgs 101 | 102 | mail_id = factory.Sequence(lambda n: "mailid{}".format(n).encode("ascii")) 103 | secret_id = factory.Sequence(lambda n: smart_bytes("id{}".format(n))) 104 | sid = factory.SubFactory(MaddrFactory) 105 | client_addr = "127.0.0.1" 106 | originating = "Y" 107 | dsn_sent = "N" 108 | subject = factory.Sequence(lambda n: "Test message {}".format(n)) 109 | time_num = factory.LazyAttribute(lambda o: int(time.time())) 110 | time_iso = factory.LazyAttribute( 111 | lambda o: datetime.datetime.fromtimestamp(o.time_num).isoformat()) 112 | size = 100 113 | 114 | 115 | class MsgrcptFactory(factory.django.DjangoModelFactory): 116 | """Factory for Msgrcpt.""" 117 | 118 | class Meta: 119 | model = models.Msgrcpt 120 | 121 | rseqnum = 1 122 | is_local = "Y" 123 | bl = "N" 124 | wl = "N" 125 | mail = factory.SubFactory(MsgsFactory) 126 | rid = factory.SubFactory(MaddrFactory) 127 | 128 | 129 | class QuarantineFactory(factory.django.DjangoModelFactory): 130 | """Factory for Quarantine.""" 131 | 132 | class Meta: 133 | model = models.Quarantine 134 | 135 | chunk_ind = 1 136 | mail = factory.SubFactory(MsgsFactory) 137 | 138 | 139 | def create_quarantined_msg(rcpt, sender, rs, body, **kwargs): 140 | """Create a quarantined msg.""" 141 | msgrcpt = MsgrcptFactory( 142 | rs=rs, 143 | rid__email=rcpt, 144 | rid__domain="com.test", # FIXME 145 | mail__sid__email=smart_bytes(sender), 146 | mail__sid__domain="", # FIXME 147 | **kwargs 148 | ) 149 | QuarantineFactory( 150 | mail=msgrcpt.mail, 151 | mail_text=smart_bytes(SPAM_BODY.format(rcpt=rcpt, sender=sender)) 152 | ) 153 | return msgrcpt 154 | 155 | 156 | def create_spam(rcpt, sender="spam@evil.corp", rs=" "): 157 | """Create a spam.""" 158 | body = SPAM_BODY.format(rcpt=rcpt, sender=sender) 159 | body += "fóó bár" 160 | return create_quarantined_msg( 161 | rcpt, sender, rs, body, bspam_level=999.0, content="S") 162 | 163 | 164 | def create_virus(rcpt, sender="virus@evil.corp", rs=" "): 165 | """Create a virus.""" 166 | return create_quarantined_msg(rcpt, sender, rs, VIRUS_BODY, content="V") 167 | -------------------------------------------------------------------------------- /modoboa_amavis/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Amavis forms. 5 | """ 6 | 7 | from django import forms 8 | from django.utils.translation import gettext as _, gettext_lazy 9 | 10 | from modoboa.lib import form_utils 11 | from modoboa.parameters import forms as param_forms, tools as param_tools 12 | from .models import Policy, Users 13 | 14 | 15 | class DomainPolicyForm(forms.ModelForm): 16 | 17 | spam_subject_tag2_act = forms.BooleanField() 18 | 19 | class Meta: 20 | model = Policy 21 | fields = ("bypass_virus_checks", "bypass_spam_checks", 22 | "spam_tag2_level", "spam_subject_tag2", 23 | "spam_kill_level", "bypass_banned_checks") 24 | widgets = { 25 | "bypass_virus_checks": form_utils.HorizontalRadioSelect(), 26 | "bypass_spam_checks": form_utils.HorizontalRadioSelect(), 27 | "spam_tag2_level": forms.TextInput( 28 | attrs={"class": "form-control"}), 29 | "spam_kill_level": forms.TextInput( 30 | attrs={"class": "form-control"}), 31 | "spam_subject_tag2": forms.TextInput( 32 | attrs={"class": "form-control"}), 33 | "bypass_banned_checks": form_utils.HorizontalRadioSelect(), 34 | } 35 | 36 | def __init__(self, *args, **kwargs): 37 | if "instance" in kwargs: 38 | self.domain = kwargs["instance"] 39 | try: 40 | policy = Users.objects.get( 41 | email="@%s" % self.domain.name).policy 42 | kwargs["instance"] = policy 43 | except (Users.DoesNotExist, Policy.DoesNotExist): 44 | del kwargs["instance"] 45 | super(DomainPolicyForm, self).__init__(*args, **kwargs) 46 | for field in self.fields.keys(): 47 | self.fields[field].required = False 48 | 49 | def save(self, user, commit=True, **kwargs): 50 | policy = super(DomainPolicyForm, self).save(commit=False) 51 | for field in ["bypass_spam_checks", "bypass_virus_checks", 52 | "bypass_banned_checks"]: 53 | if getattr(policy, field) == "": 54 | setattr(policy, field, None) 55 | 56 | if self.cleaned_data["spam_subject_tag2_act"]: 57 | policy.spam_subject_tag2 = None 58 | 59 | if commit: 60 | policy.save() 61 | try: 62 | u = Users.objects.get(fullname=policy.policy_name) 63 | except Users.DoesNotExist: 64 | u = Users.objects.get(email="@%s" % self.domain.name) 65 | u.policy = policy 66 | policy.save() 67 | return policy 68 | 69 | 70 | class LearningRecipientForm(forms.Form): 71 | """A form to select the recipient of a learning request.""" 72 | 73 | recipient = forms.ChoiceField( 74 | label=None, choices=[] 75 | ) 76 | ltype = forms.ChoiceField( 77 | label="", choices=[("spam", "spam"), ("ham", "ham")], 78 | widget=forms.widgets.HiddenInput 79 | ) 80 | selection = forms.CharField( 81 | label="", widget=forms.widgets.HiddenInput) 82 | 83 | def __init__(self, user, *args, **kwargs): 84 | """Constructor.""" 85 | super(LearningRecipientForm, self).__init__(*args, **kwargs) 86 | choices = [] 87 | if user.role == "SuperAdmins": 88 | choices.append(("global", _("Global database"))) 89 | conf = dict(param_tools.get_global_parameters("modoboa_amavis")) 90 | if conf["domain_level_learning"]: 91 | choices.append(("domain", _("Domain's database"))) 92 | if conf["user_level_learning"]: 93 | choices.append(("user", _("User's database"))) 94 | self.fields["recipient"].choices = choices 95 | 96 | 97 | class ParametersForm(param_forms.AdminParametersForm): 98 | """Extension settings.""" 99 | 100 | app = "modoboa_amavis" 101 | 102 | amavis_settings_sep = form_utils.SeparatorField( 103 | label=gettext_lazy("Amavis settings")) 104 | 105 | localpart_is_case_sensitive = form_utils.YesNoField( 106 | label=gettext_lazy("Localpart is case sensitive"), 107 | initial=False, 108 | help_text=gettext_lazy("Value should match amavisd.conf variable %s" 109 | % "$localpart_is_case_sensitive") 110 | ) 111 | 112 | recipient_delimiter = forms.CharField( 113 | label=gettext_lazy("Recipient delimiter"), 114 | initial="", 115 | help_text=gettext_lazy("Value should match amavisd.conf variable %s" 116 | % "$recipient_delimiter"), 117 | widget=forms.TextInput(attrs={"class": "form-control"}), 118 | required=False 119 | ) 120 | 121 | qsettings_sep = form_utils.SeparatorField( 122 | label=gettext_lazy("Quarantine settings")) 123 | 124 | max_messages_age = forms.IntegerField( 125 | label=gettext_lazy("Maximum message age"), 126 | initial=14, 127 | help_text=gettext_lazy( 128 | "Quarantine messages maximum age (in days) before deletion" 129 | ) 130 | ) 131 | 132 | sep1 = form_utils.SeparatorField(label=gettext_lazy("Messages releasing")) 133 | 134 | released_msgs_cleanup = form_utils.YesNoField( 135 | label=gettext_lazy("Remove released messages"), 136 | initial=False, 137 | help_text=gettext_lazy( 138 | "Remove messages marked as released while cleaning up " 139 | "the database" 140 | ) 141 | ) 142 | 143 | am_pdp_mode = forms.ChoiceField( 144 | label=gettext_lazy("Amavis connection mode"), 145 | choices=[("inet", "inet"), ("unix", "unix")], 146 | initial="unix", 147 | help_text=gettext_lazy("Mode used to access the PDP server"), 148 | widget=form_utils.HorizontalRadioSelect() 149 | ) 150 | 151 | am_pdp_host = forms.CharField( 152 | label=gettext_lazy("PDP server address"), 153 | initial="localhost", 154 | help_text=gettext_lazy("PDP server address (if inet mode)"), 155 | widget=forms.TextInput(attrs={"class": "form-control"}) 156 | ) 157 | 158 | am_pdp_port = forms.IntegerField( 159 | label=gettext_lazy("PDP server port"), 160 | initial=9998, 161 | help_text=gettext_lazy("PDP server port (if inet mode)") 162 | ) 163 | 164 | am_pdp_socket = forms.CharField( 165 | label=gettext_lazy("PDP server socket"), 166 | initial="/var/amavis/amavisd.sock", 167 | help_text=gettext_lazy("Path to the PDP server socket (if unix mode)") 168 | ) 169 | 170 | user_can_release = form_utils.YesNoField( 171 | label=gettext_lazy("Allow direct release"), 172 | initial=False, 173 | help_text=gettext_lazy( 174 | "Allow users to directly release their messages") 175 | ) 176 | 177 | self_service = form_utils.YesNoField( 178 | label=gettext_lazy("Enable self-service mode"), 179 | initial=False, 180 | help_text=gettext_lazy("Activate the 'self-service' mode") 181 | ) 182 | 183 | notifications_sender = forms.EmailField( 184 | label=gettext_lazy("Notifications sender"), 185 | initial="notification@modoboa.org", 186 | help_text=gettext_lazy( 187 | "The e-mail address used to send notitications") 188 | ) 189 | 190 | lsep = form_utils.SeparatorField(label=gettext_lazy("Manual learning")) 191 | 192 | manual_learning = form_utils.YesNoField( 193 | label=gettext_lazy("Enable manual learning"), 194 | initial=True, 195 | help_text=gettext_lazy( 196 | "Allow super administrators to manually train Spamassassin" 197 | ) 198 | ) 199 | 200 | sa_is_local = form_utils.YesNoField( 201 | label=gettext_lazy("Is Spamassassin local?"), 202 | initial=True, 203 | help_text=gettext_lazy( 204 | "Tell if Spamassassin is running on the same server than modoboa" 205 | ) 206 | ) 207 | 208 | default_user = forms.CharField( 209 | label=gettext_lazy("Default user"), 210 | initial="amavis", 211 | help_text=gettext_lazy( 212 | "Name of the user owning the default bayesian database" 213 | ) 214 | ) 215 | 216 | spamd_address = forms.CharField( 217 | label=gettext_lazy("Spamd address"), 218 | initial="127.0.0.1", 219 | help_text=gettext_lazy("The IP address where spamd can be reached") 220 | ) 221 | 222 | spamd_port = forms.IntegerField( 223 | label=gettext_lazy("Spamd port"), 224 | initial=783, 225 | help_text=gettext_lazy("The TCP port spamd is listening on") 226 | ) 227 | 228 | domain_level_learning = form_utils.YesNoField( 229 | label=gettext_lazy("Enable per-domain manual learning"), 230 | initial=False, 231 | help_text=gettext_lazy( 232 | "Allow domain administrators to train Spamassassin " 233 | "(within dedicated per-domain databases)" 234 | ) 235 | ) 236 | 237 | user_level_learning = form_utils.YesNoField( 238 | label=gettext_lazy("Enable per-user manual learning"), 239 | initial=False, 240 | help_text=gettext_lazy( 241 | "Allow simple users to personally train Spamassassin " 242 | "(within a dedicated database)" 243 | ) 244 | ) 245 | 246 | visibility_rules = { 247 | "am_pdp_host": "am_pdp_mode=inet", 248 | "am_pdp_port": "am_pdp_mode=inet", 249 | "am_pdp_socket": "am_pdp_mode=unix", 250 | 251 | "sa_is_local": "manual_learning=True", 252 | "default_user": "manual_learning=True", 253 | "spamd_address": "sa_is_local=False", 254 | "spamd_port": "sa_is_local=False", 255 | "domain_level_learning": "manual_learning=True", 256 | "user_level_learning": "manual_learning=True" 257 | } 258 | 259 | 260 | class UserSettings(param_forms.UserParametersForm): 261 | """Per-user settings.""" 262 | 263 | app = "modoboa_amavis" 264 | 265 | dsep = form_utils.SeparatorField(label=gettext_lazy("Display")) 266 | 267 | messages_per_page = forms.IntegerField( 268 | initial=40, 269 | label=gettext_lazy("Number of displayed emails per page"), 270 | help_text=gettext_lazy( 271 | "Set the maximum number of messages displayed in a page") 272 | ) 273 | -------------------------------------------------------------------------------- /modoboa_amavis/handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Amavis handlers.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from django.db.models import signals 8 | from django.dispatch import receiver 9 | from django.template import Context, Template 10 | from django.urls import reverse 11 | from django.utils.translation import gettext as _ 12 | 13 | from modoboa.admin import models as admin_models, signals as admin_signals 14 | from modoboa.core import signals as core_signals 15 | from modoboa.lib import signals as lib_signals 16 | from modoboa.parameters import tools as param_tools 17 | from . import forms 18 | from .lib import ( 19 | create_user_and_policy, create_user_and_use_policy, delete_user, 20 | delete_user_and_policy, update_user_and_policy 21 | ) 22 | from .models import Policy, Users 23 | from .sql_connector import SQLconnector 24 | 25 | 26 | @receiver(core_signals.extra_user_menu_entries) 27 | def menu(sender, location, user, **kwargs): 28 | """Add extra menu entry.""" 29 | if location == "top_menu": 30 | return [ 31 | {"name": "quarantine", 32 | "label": _("Quarantine"), 33 | "url": reverse("modoboa_amavis:index")} 34 | ] 35 | return [] 36 | 37 | 38 | @receiver(signals.post_save, sender=admin_models.Domain) 39 | def manage_domain_policy(sender, instance, **kwargs): 40 | """Create user and policy when a domain is added.""" 41 | if kwargs.get("created"): 42 | create_user_and_policy("@{0}".format(instance.name)) 43 | else: 44 | update_user_and_policy( 45 | "@{0}".format(instance.oldname), 46 | "@{0}".format(instance.name) 47 | ) 48 | 49 | 50 | @receiver(signals.pre_delete, sender=admin_models.Domain) 51 | def on_domain_deleted(sender, instance, **kwargs): 52 | """Delete user and policy for domain.""" 53 | delete_user_and_policy("@{0}".format(instance.name)) 54 | 55 | 56 | @receiver(signals.post_save, sender=admin_models.DomainAlias) 57 | def on_domain_alias_created(sender, instance, **kwargs): 58 | """Create user and use domain policy for domain alias.""" 59 | if not kwargs.get("created"): 60 | return 61 | create_user_and_use_policy( 62 | "@{0}".format(instance.name), 63 | "@{0}".format(instance.target.name) 64 | ) 65 | 66 | 67 | @receiver(signals.pre_delete, sender=admin_models.DomainAlias) 68 | def on_domain_alias_deleted(sender, instance, **kwargs): 69 | """Delete user for domain alias.""" 70 | delete_user("@{0}".format(instance.name)) 71 | 72 | 73 | @receiver(signals.post_save, sender=admin_models.Mailbox) 74 | def on_mailbox_modified(sender, instance, **kwargs): 75 | """Update amavis records if address has changed.""" 76 | condition = ( 77 | not param_tools.get_global_parameter("manual_learning") or 78 | not hasattr(instance, "old_full_address") or 79 | instance.full_address == instance.old_full_address) 80 | if condition: 81 | return 82 | try: 83 | user = Users.objects.select_related("policy").get( 84 | email=instance.old_full_address) 85 | except Users.DoesNotExist: 86 | return 87 | full_address = instance.full_address 88 | user.email = full_address 89 | user.policy.policy_name = full_address[:32] 90 | user.policy.sa_username = full_address 91 | user.policy.save() 92 | user.save() 93 | 94 | 95 | @receiver(signals.pre_delete, sender=admin_models.Mailbox) 96 | def on_mailbox_deleted(sender, instance, **kwargs): 97 | """Clean amavis database when a mailbox is removed.""" 98 | if not param_tools.get_global_parameter("manual_learning"): 99 | return 100 | delete_user_and_policy("@{0}".format(instance.full_address)) 101 | 102 | 103 | @receiver(signals.post_save, sender=admin_models.AliasRecipient) 104 | def on_aliasrecipient_created(sender, instance, **kwargs): 105 | """Create amavis record for the new alias recipient. 106 | 107 | FIXME: how to deal with distibution lists ? 108 | """ 109 | conf = dict(param_tools.get_global_parameters("modoboa_amavis")) 110 | condition = ( 111 | not conf["manual_learning"] or not conf["user_level_learning"] or 112 | not instance.r_mailbox or 113 | instance.alias.type != "alias") 114 | if condition: 115 | return 116 | policy = Policy.objects.filter( 117 | policy_name=instance.r_mailbox.full_address).first() 118 | if policy: 119 | # Use mailbox policy for this new alias. We update or create 120 | # to handle the case where an account is being replaced by an 121 | # alias (when it is disabled). 122 | email = instance.alias.address 123 | Users.objects.update_or_create( 124 | email=email, 125 | defaults={"policy": policy, "fullname": email, "priority": 7} 126 | ) 127 | 128 | 129 | @receiver(signals.pre_delete, sender=admin_models.Alias) 130 | def on_mailboxalias_deleted(sender, instance, **kwargs): 131 | """Clean amavis database when an alias is removed.""" 132 | if not param_tools.get_global_parameter("manual_learning"): 133 | return 134 | if instance.address.startswith("@"): 135 | # Catchall alias, do not remove domain entry accidentally... 136 | return 137 | aliases = [instance.address] 138 | Users.objects.filter(email__in=aliases).delete() 139 | 140 | 141 | @receiver(core_signals.extra_static_content) 142 | def extra_static_content(sender, caller, st_type, user, **kwargs): 143 | """Send extra javascript.""" 144 | condition = ( 145 | user.role == "SimpleUsers" or 146 | st_type != "js" or 147 | caller != "domains") 148 | if condition: 149 | return "" 150 | tpl = Template(""" 155 | """) 156 | return tpl.render(Context({})) 157 | 158 | 159 | @receiver(core_signals.get_top_notifications) 160 | def check_for_pending_requests(sender, include_all, **kwargs): 161 | """Check if release requests are pending.""" 162 | request = lib_signals.get_request() 163 | condition = ( 164 | param_tools.get_global_parameter("user_can_release") or 165 | request.user.role == "SimpleUsers") 166 | if condition: 167 | return [] 168 | 169 | nbrequests = SQLconnector(user=request.user).get_pending_requests() 170 | if not nbrequests: 171 | return [{"id": "nbrequests", "counter": 0}] if include_all \ 172 | else [] 173 | 174 | url = reverse("modoboa_amavis:index") 175 | url += "#listing/?viewrequests=1" 176 | return [{ 177 | "id": "nbrequests", "url": url, "text": _("Pending requests"), 178 | "counter": nbrequests, "level": "danger" 179 | }] 180 | 181 | 182 | @receiver(admin_signals.extra_domain_forms) 183 | def extra_domain_form(sender, user, domain, **kwargs): 184 | """Return domain config form.""" 185 | if not user.has_perm("admin.view_domain"): 186 | return [] 187 | return [{ 188 | "id": "amavis", "title": _("Content filter"), 189 | "cls": forms.DomainPolicyForm, 190 | "formtpl": "modoboa_amavis/domain_content_filter.html" 191 | }] 192 | 193 | 194 | @receiver(admin_signals.get_domain_form_instances) 195 | def fill_domain_instances(sender, user, domain, **kwargs): 196 | """Return domain instance.""" 197 | if not user.has_perm("admin.view_domain"): 198 | return {} 199 | return {"amavis": domain} 200 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/cs_CZ/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Miroslav Abrahám , 2013,2015 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: Modoboa\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 12 | "PO-Revision-Date: 2015-01-06 10:05+0000\n" 13 | "Last-Translator: Miroslav Abrahám \n" 14 | "Language-Team: Czech (Czech Republic) (http://www.transifex.com/projects/p/" 15 | "modoboa/language/cs_CZ/)\n" 16 | "Language: cs_CZ\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 21 | #: static/modoboa_amavis/js/quarantine.js:19 22 | msgid "No more message to show" 23 | msgstr "Žádná další zpráva k zobrazení" 24 | #: static/modoboa_amavis/js/quarantine.js:270 25 | msgid "Release this selection?" 26 | msgstr "Odblokovat tento výběr?" 27 | #: static/modoboa_amavis/js/quarantine.js:274 28 | msgid "Delete this selection?" 29 | msgstr "Smazat tento výběr?" 30 | #: static/modoboa_amavis/js/quarantine.js:314 31 | msgid "Mark this selection as spam?" 32 | msgstr "Označit výběr jako spam?" 33 | #: static/modoboa_amavis/js/quarantine.js:329 34 | msgid "Mark this selection as non-spam?" 35 | msgstr "Označit výběr jako vyžádané zprávy?" 36 | #: static/modoboa_amavis/js/quarantine.js:354 37 | #: static/modoboa_amavis/js/selfservice.js:17 38 | msgid "Release this message?" 39 | msgstr "Odblokovat tuto zprávu?" 40 | #: static/modoboa_amavis/js/quarantine.js:358 41 | #: static/modoboa_amavis/js/selfservice.js:21 42 | msgid "Delete this message?" 43 | msgstr "Smazat tuto zprávu?" 44 | #: static/modoboa_amavis/js/quarantine.js:373 45 | msgid "Mark as spam?" 46 | msgstr "Označit jako spam?" 47 | #: static/modoboa_amavis/js/quarantine.js:389 48 | msgid "Mark as non-spam?" 49 | msgstr "Označit jako vyžádanou zprávu?" 50 | #: static/modoboa_amavis/js/quarantine.js:407 51 | msgid "View full headers" 52 | msgstr "Zobrazit úplné záhlaví" 53 | #: static/modoboa_amavis/js/quarantine.js:412 54 | msgid "Hide full headers" 55 | msgstr "Skrýt záhlaví" 56 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/de/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Arvedui , 2014 7 | # Patrick Koetter , 2010 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: Modoboa\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 13 | "PO-Revision-Date: 2015-06-04 19:08+0000\n" 14 | "Last-Translator: Antoine Nguyen \n" 15 | "Language-Team: German (http://www.transifex.com/tonio/modoboa/language/de/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: de\n" 20 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 21 | 22 | #: static/modoboa_amavis/js/quarantine.js:19 23 | msgid "No more message to show" 24 | msgstr "Keine weiteren Nachrichten" 25 | 26 | #: static/modoboa_amavis/js/quarantine.js:270 27 | msgid "Release this selection?" 28 | msgstr "Diese Auswahl freigeben?" 29 | 30 | #: static/modoboa_amavis/js/quarantine.js:274 31 | msgid "Delete this selection?" 32 | msgstr "Diese Auswahl löschen?" 33 | 34 | #: static/modoboa_amavis/js/quarantine.js:314 35 | msgid "Mark this selection as spam?" 36 | msgstr "Diese Auswahl als Spam markieren?" 37 | 38 | #: static/modoboa_amavis/js/quarantine.js:329 39 | msgid "Mark this selection as non-spam?" 40 | msgstr "Diese Auswahl als kein Spam markieren?" 41 | 42 | #: static/modoboa_amavis/js/quarantine.js:354 43 | #: static/modoboa_amavis/js/selfservice.js:17 44 | msgid "Release this message?" 45 | msgstr "Diese Nachricht freigeben?" 46 | 47 | #: static/modoboa_amavis/js/quarantine.js:358 48 | #: static/modoboa_amavis/js/selfservice.js:21 49 | msgid "Delete this message?" 50 | msgstr "Diese Nachricht löschen?" 51 | 52 | #: static/modoboa_amavis/js/quarantine.js:373 53 | msgid "Mark as spam?" 54 | msgstr "Als Spam markieren?" 55 | 56 | #: static/modoboa_amavis/js/quarantine.js:389 57 | msgid "Mark as non-spam?" 58 | msgstr "Als kein Spam markieren?" 59 | 60 | #: static/modoboa_amavis/js/quarantine.js:407 61 | msgid "View full headers" 62 | msgstr "Alle Header anzeigen" 63 | 64 | #: static/modoboa_amavis/js/quarantine.js:412 65 | msgid "Hide full headers" 66 | msgstr "Einfache Header anzeigen" 67 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/el_GR/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Giannis Kapetanakis , 2016 7 | # Kostas Moumouris , 2017 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: Modoboa\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 13 | "PO-Revision-Date: 2017-10-12 08:15+0000\n" 14 | "Last-Translator: Kostas Moumouris \n" 15 | "Language-Team: Greek (Greece) (http://www.transifex.com/tonio/modoboa/language/el_GR/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: el_GR\n" 20 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 21 | 22 | #: static/modoboa_amavis/js/quarantine.js:19 23 | msgid "No more message to show" 24 | msgstr "Δεν υπάρχουν άλλα μηνύματα για εμφάνιση" 25 | 26 | #: static/modoboa_amavis/js/quarantine.js:270 27 | msgid "Release this selection?" 28 | msgstr "Απελευθέρωση των επιλεγμένων;" 29 | 30 | #: static/modoboa_amavis/js/quarantine.js:274 31 | msgid "Delete this selection?" 32 | msgstr "Διαγραφή των επιλεγμένων;" 33 | 34 | #: static/modoboa_amavis/js/quarantine.js:314 35 | msgid "Mark this selection as spam?" 36 | msgstr "Να επισημανθεί το επιλεγμένο ως ανεπιθύμητο;" 37 | 38 | #: static/modoboa_amavis/js/quarantine.js:329 39 | msgid "Mark this selection as non-spam?" 40 | msgstr "Χαρακτηρισμός των επιλεγμένων ως επιθυμητά;" 41 | 42 | #: static/modoboa_amavis/js/quarantine.js:354 43 | #: static/modoboa_amavis/js/selfservice.js:17 44 | msgid "Release this message?" 45 | msgstr "Απελευθέρωση αυτού του μηνύματος;" 46 | 47 | #: static/modoboa_amavis/js/quarantine.js:358 48 | #: static/modoboa_amavis/js/selfservice.js:21 49 | msgid "Delete this message?" 50 | msgstr "Διαγραφή αυτού του μηνύματος;" 51 | 52 | #: static/modoboa_amavis/js/quarantine.js:373 53 | msgid "Mark as spam?" 54 | msgstr "Χαρακτηρισμός ως ανεπιθύμητο;" 55 | 56 | #: static/modoboa_amavis/js/quarantine.js:389 57 | msgid "Mark as non-spam?" 58 | msgstr "Χαρακτηρισμός ως επιθυμητό; " 59 | 60 | #: static/modoboa_amavis/js/quarantine.js:407 61 | msgid "View full headers" 62 | msgstr "Εμφάνιση headers" 63 | 64 | #: static/modoboa_amavis/js/quarantine.js:412 65 | msgid "Hide full headers" 66 | msgstr "Απόκρυψη headers" 67 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/en/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | #: static/modoboa_amavis/js/quarantine.js:19 20 | msgid "No more message to show" 21 | msgstr "" 22 | #: static/modoboa_amavis/js/quarantine.js:270 23 | msgid "Release this selection?" 24 | msgstr "" 25 | #: static/modoboa_amavis/js/quarantine.js:274 26 | msgid "Delete this selection?" 27 | msgstr "" 28 | #: static/modoboa_amavis/js/quarantine.js:314 29 | msgid "Mark this selection as spam?" 30 | msgstr "" 31 | #: static/modoboa_amavis/js/quarantine.js:329 32 | msgid "Mark this selection as non-spam?" 33 | msgstr "" 34 | #: static/modoboa_amavis/js/quarantine.js:354 35 | #: static/modoboa_amavis/js/selfservice.js:17 36 | msgid "Release this message?" 37 | msgstr "" 38 | #: static/modoboa_amavis/js/quarantine.js:358 39 | #: static/modoboa_amavis/js/selfservice.js:21 40 | msgid "Delete this message?" 41 | msgstr "" 42 | #: static/modoboa_amavis/js/quarantine.js:373 43 | msgid "Mark as spam?" 44 | msgstr "" 45 | #: static/modoboa_amavis/js/quarantine.js:389 46 | msgid "Mark as non-spam?" 47 | msgstr "" 48 | #: static/modoboa_amavis/js/quarantine.js:407 49 | msgid "View full headers" 50 | msgstr "" 51 | #: static/modoboa_amavis/js/quarantine.js:412 52 | msgid "Hide full headers" 53 | msgstr "" 54 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/es/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Evilham , 2017 7 | # Pedro Gracia , 2011,2013 8 | # Pedro Gracia , 2010-2011 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: Modoboa\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 14 | "PO-Revision-Date: 2017-09-22 09:57+0000\n" 15 | "Last-Translator: Evilham \n" 16 | "Language-Team: Spanish (http://www.transifex.com/tonio/modoboa/language/es/)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Language: es\n" 21 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 22 | 23 | #: static/modoboa_amavis/js/quarantine.js:19 24 | msgid "No more message to show" 25 | msgstr "Sin mensajes para mostrar" 26 | 27 | #: static/modoboa_amavis/js/quarantine.js:270 28 | msgid "Release this selection?" 29 | msgstr "¿Publicar esta selección?" 30 | 31 | #: static/modoboa_amavis/js/quarantine.js:274 32 | msgid "Delete this selection?" 33 | msgstr "¿Eliminar esta selección?" 34 | 35 | #: static/modoboa_amavis/js/quarantine.js:314 36 | msgid "Mark this selection as spam?" 37 | msgstr "¿Marcar la selección como spam?" 38 | 39 | #: static/modoboa_amavis/js/quarantine.js:329 40 | msgid "Mark this selection as non-spam?" 41 | msgstr "¿Marcar la selección como no spam?" 42 | 43 | #: static/modoboa_amavis/js/quarantine.js:354 44 | #: static/modoboa_amavis/js/selfservice.js:17 45 | msgid "Release this message?" 46 | msgstr "¿Publicar este mensaje?" 47 | 48 | #: static/modoboa_amavis/js/quarantine.js:358 49 | #: static/modoboa_amavis/js/selfservice.js:21 50 | msgid "Delete this message?" 51 | msgstr "¿Eliminar este mensaje?" 52 | 53 | #: static/modoboa_amavis/js/quarantine.js:373 54 | msgid "Mark as spam?" 55 | msgstr "¿Marcar como spam?" 56 | 57 | #: static/modoboa_amavis/js/quarantine.js:389 58 | msgid "Mark as non-spam?" 59 | msgstr "¿Marcar como no spam?" 60 | 61 | #: static/modoboa_amavis/js/quarantine.js:407 62 | msgid "View full headers" 63 | msgstr "Ver todas las cabeceras" 64 | 65 | #: static/modoboa_amavis/js/quarantine.js:412 66 | msgid "Hide full headers" 67 | msgstr "Esconder todas las cabeceras" 68 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/fr/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: Modoboa\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 11 | "PO-Revision-Date: 2016-11-09 16:13+0000\n" 12 | "Last-Translator: Antoine Nguyen \n" 13 | "Language-Team: French (http://www.transifex.com/tonio/modoboa/language/fr/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: fr\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | #: static/modoboa_amavis/js/quarantine.js:19 21 | msgid "No more message to show" 22 | msgstr "Plus de message à afficher" 23 | 24 | #: static/modoboa_amavis/js/quarantine.js:270 25 | msgid "Release this selection?" 26 | msgstr "Débloquer cette sélection?" 27 | 28 | #: static/modoboa_amavis/js/quarantine.js:274 29 | msgid "Delete this selection?" 30 | msgstr "Supprimer cette sélection?" 31 | 32 | #: static/modoboa_amavis/js/quarantine.js:314 33 | msgid "Mark this selection as spam?" 34 | msgstr "Marquer cette sélection comme étant du spam?" 35 | 36 | #: static/modoboa_amavis/js/quarantine.js:329 37 | msgid "Mark this selection as non-spam?" 38 | msgstr "Marquer cette sélection comme n'étant pas du spam?" 39 | 40 | #: static/modoboa_amavis/js/quarantine.js:354 41 | #: static/modoboa_amavis/js/selfservice.js:17 42 | msgid "Release this message?" 43 | msgstr "Débloquer ce message?" 44 | 45 | #: static/modoboa_amavis/js/quarantine.js:358 46 | #: static/modoboa_amavis/js/selfservice.js:21 47 | msgid "Delete this message?" 48 | msgstr "Supprimer ce message?" 49 | 50 | #: static/modoboa_amavis/js/quarantine.js:373 51 | msgid "Mark as spam?" 52 | msgstr "Marquer comme spam?" 53 | 54 | #: static/modoboa_amavis/js/quarantine.js:389 55 | msgid "Mark as non-spam?" 56 | msgstr "Marquer comme non-spam?" 57 | 58 | #: static/modoboa_amavis/js/quarantine.js:407 59 | msgid "View full headers" 60 | msgstr "Afficher tous les entêtes" 61 | 62 | #: static/modoboa_amavis/js/quarantine.js:412 63 | msgid "Hide full headers" 64 | msgstr "Cacher les entêtes complets" 65 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/it/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Rocco , 2015 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: Modoboa\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 12 | "PO-Revision-Date: 2015-10-11 11:32+0000\n" 13 | "Last-Translator: Rocco \n" 14 | "Language-Team: Italian (http://www.transifex.com/tonio/modoboa/language/it/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: it\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: static/modoboa_amavis/js/quarantine.js:19 22 | msgid "No more message to show" 23 | msgstr "Non ci sono altri messaggi da mostrare" 24 | 25 | #: static/modoboa_amavis/js/quarantine.js:270 26 | msgid "Release this selection?" 27 | msgstr "Rilasciare questa selezione?" 28 | 29 | #: static/modoboa_amavis/js/quarantine.js:274 30 | msgid "Delete this selection?" 31 | msgstr "Eliminare questa selezione?" 32 | 33 | #: static/modoboa_amavis/js/quarantine.js:314 34 | msgid "Mark this selection as spam?" 35 | msgstr "Marca questa selezione come spam ?" 36 | 37 | #: static/modoboa_amavis/js/quarantine.js:329 38 | msgid "Mark this selection as non-spam?" 39 | msgstr "Marca questa selezione come non- spam?" 40 | 41 | #: static/modoboa_amavis/js/quarantine.js:354 42 | #: static/modoboa_amavis/js/selfservice.js:17 43 | msgid "Release this message?" 44 | msgstr "Rilasciare questo messaggio?" 45 | 46 | #: static/modoboa_amavis/js/quarantine.js:358 47 | #: static/modoboa_amavis/js/selfservice.js:21 48 | msgid "Delete this message?" 49 | msgstr "Eliminare questo messaggio?" 50 | 51 | #: static/modoboa_amavis/js/quarantine.js:373 52 | msgid "Mark as spam?" 53 | msgstr "Marca come spam ?" 54 | 55 | #: static/modoboa_amavis/js/quarantine.js:389 56 | msgid "Mark as non-spam?" 57 | msgstr "Marca come non-spam ?" 58 | 59 | #: static/modoboa_amavis/js/quarantine.js:407 60 | msgid "View full headers" 61 | msgstr "Visualizza tutte le intestazioni" 62 | 63 | #: static/modoboa_amavis/js/quarantine.js:412 64 | msgid "Hide full headers" 65 | msgstr "Nascondi le intestazioni" 66 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/nl_NL/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # TuxBrother , 2014 7 | # TuxBrother , 2014 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: Modoboa\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 13 | "PO-Revision-Date: 2014-12-31 11:42+0000\n" 14 | "Last-Translator: TuxBrother \n" 15 | "Language-Team: Dutch (Netherlands) (http://www.transifex.com/projects/p/" 16 | "modoboa/language/nl_NL/)\n" 17 | "Language: nl_NL\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 22 | #: static/modoboa_amavis/js/quarantine.js:19 23 | msgid "No more message to show" 24 | msgstr "Geen berichten meer om te weergeven" 25 | #: static/modoboa_amavis/js/quarantine.js:270 26 | msgid "Release this selection?" 27 | msgstr "Wilt u deze selectie loslaten?" 28 | #: static/modoboa_amavis/js/quarantine.js:274 29 | msgid "Delete this selection?" 30 | msgstr "Selectie verwijderen?" 31 | #: static/modoboa_amavis/js/quarantine.js:314 32 | msgid "Mark this selection as spam?" 33 | msgstr "Markeer deze selectie als ongewenst?" 34 | #: static/modoboa_amavis/js/quarantine.js:329 35 | msgid "Mark this selection as non-spam?" 36 | msgstr "Markeer deze selectie als gewenst?" 37 | #: static/modoboa_amavis/js/quarantine.js:354 38 | #: static/modoboa_amavis/js/selfservice.js:17 39 | msgid "Release this message?" 40 | msgstr "Dit bericht loslaten?" 41 | #: static/modoboa_amavis/js/quarantine.js:358 42 | #: static/modoboa_amavis/js/selfservice.js:21 43 | msgid "Delete this message?" 44 | msgstr "Dit bericht verwijderen?" 45 | #: static/modoboa_amavis/js/quarantine.js:373 46 | msgid "Mark as spam?" 47 | msgstr "Markeer als ongewenst?" 48 | #: static/modoboa_amavis/js/quarantine.js:389 49 | msgid "Mark as non-spam?" 50 | msgstr "Markeer als gewenst?" 51 | #: static/modoboa_amavis/js/quarantine.js:407 52 | msgid "View full headers" 53 | msgstr "Volledige headers weergeven" 54 | #: static/modoboa_amavis/js/quarantine.js:412 55 | msgid "Hide full headers" 56 | msgstr "Volledige headers verbergen" 57 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/pl_PL/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # sin88 , 2016 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: Modoboa\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 12 | "PO-Revision-Date: 2017-09-22 09:57+0000\n" 13 | "Last-Translator: sin88 \n" 14 | "Language-Team: Polish (Poland) (http://www.transifex.com/tonio/modoboa/language/pl_PL/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: pl_PL\n" 19 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" 20 | 21 | #: static/modoboa_amavis/js/quarantine.js:19 22 | msgid "No more message to show" 23 | msgstr "Brak wiadomości do pokazania" 24 | 25 | #: static/modoboa_amavis/js/quarantine.js:270 26 | msgid "Release this selection?" 27 | msgstr "Odznacz" 28 | 29 | #: static/modoboa_amavis/js/quarantine.js:274 30 | msgid "Delete this selection?" 31 | msgstr "Usuń zaznaczenie" 32 | 33 | #: static/modoboa_amavis/js/quarantine.js:314 34 | msgid "Mark this selection as spam?" 35 | msgstr "Czy oznaczyć jako spam?" 36 | 37 | #: static/modoboa_amavis/js/quarantine.js:329 38 | msgid "Mark this selection as non-spam?" 39 | msgstr "Czy oznaczyć jako nie-spam" 40 | 41 | #: static/modoboa_amavis/js/quarantine.js:354 42 | #: static/modoboa_amavis/js/selfservice.js:17 43 | msgid "Release this message?" 44 | msgstr "Czy uwolnić tą wiadomość?" 45 | 46 | #: static/modoboa_amavis/js/quarantine.js:358 47 | #: static/modoboa_amavis/js/selfservice.js:21 48 | msgid "Delete this message?" 49 | msgstr "Usunąć tą wiadomość?" 50 | 51 | #: static/modoboa_amavis/js/quarantine.js:373 52 | msgid "Mark as spam?" 53 | msgstr "Oznaczyć jako spam?" 54 | 55 | #: static/modoboa_amavis/js/quarantine.js:389 56 | msgid "Mark as non-spam?" 57 | msgstr "Czy oznaczyć jako nie-spam?" 58 | 59 | #: static/modoboa_amavis/js/quarantine.js:407 60 | msgid "View full headers" 61 | msgstr "Pokaż nagłówki" 62 | 63 | #: static/modoboa_amavis/js/quarantine.js:412 64 | msgid "Hide full headers" 65 | msgstr "Ukryj nagłówki" 66 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/pt_BR/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Paulino Michelazzo , 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: Modoboa\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 12 | "PO-Revision-Date: 2015-08-10 20:51+0000\n" 13 | "Last-Translator: Rafael Barretto Alves \n" 14 | "Language-Team: Portuguese (Brazil) (http://www.transifex.com/tonio/modoboa/language/pt_BR/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: pt_BR\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: static/modoboa_amavis/js/quarantine.js:19 22 | msgid "No more message to show" 23 | msgstr "Nenhuma mensagem a mais para mostrar" 24 | 25 | #: static/modoboa_amavis/js/quarantine.js:270 26 | msgid "Release this selection?" 27 | msgstr "Liberar esta seleção?" 28 | 29 | #: static/modoboa_amavis/js/quarantine.js:274 30 | msgid "Delete this selection?" 31 | msgstr "Apagar esta seleção?" 32 | 33 | #: static/modoboa_amavis/js/quarantine.js:314 34 | msgid "Mark this selection as spam?" 35 | msgstr "Marcar esta seleção como spam?" 36 | 37 | #: static/modoboa_amavis/js/quarantine.js:329 38 | msgid "Mark this selection as non-spam?" 39 | msgstr "Marcar esta seleção como não-spam?" 40 | 41 | #: static/modoboa_amavis/js/quarantine.js:354 42 | #: static/modoboa_amavis/js/selfservice.js:17 43 | msgid "Release this message?" 44 | msgstr "Liberar esta mensagem?" 45 | 46 | #: static/modoboa_amavis/js/quarantine.js:358 47 | #: static/modoboa_amavis/js/selfservice.js:21 48 | msgid "Delete this message?" 49 | msgstr "Apagar esta mensagem?" 50 | 51 | #: static/modoboa_amavis/js/quarantine.js:373 52 | msgid "Mark as spam?" 53 | msgstr "Marcar como spam?" 54 | 55 | #: static/modoboa_amavis/js/quarantine.js:389 56 | msgid "Mark as non-spam?" 57 | msgstr "Marcar como não-spam?" 58 | 59 | #: static/modoboa_amavis/js/quarantine.js:407 60 | msgid "View full headers" 61 | msgstr "Exibir cabeçalhos completos" 62 | 63 | #: static/modoboa_amavis/js/quarantine.js:412 64 | msgid "Hide full headers" 65 | msgstr "Esconder cabeçalhos completos" 66 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/pt_PT/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # lusitan , 2013 7 | # lusitan , 2013 8 | # Mike C. , 2012 9 | # Mike C. , 2012 10 | # Mike C. , 2014 11 | # Sandra Ribeiro , 2013 12 | msgid "" 13 | msgstr "" 14 | "Project-Id-Version: Modoboa\n" 15 | "Report-Msgid-Bugs-To: \n" 16 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 17 | "PO-Revision-Date: 2014-12-29 12:08+0000\n" 18 | "Last-Translator: Mike C. \n" 19 | "Language-Team: Portuguese (Portugal) (http://www.transifex.com/projects/p/" 20 | "modoboa/language/pt_PT/)\n" 21 | "Language: pt_PT\n" 22 | "MIME-Version: 1.0\n" 23 | "Content-Type: text/plain; charset=UTF-8\n" 24 | "Content-Transfer-Encoding: 8bit\n" 25 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 26 | #: static/modoboa_amavis/js/quarantine.js:19 27 | msgid "No more message to show" 28 | msgstr "Não existem mais mensagens para mostrar" 29 | #: static/modoboa_amavis/js/quarantine.js:270 30 | msgid "Release this selection?" 31 | msgstr "Libertar itens seleccionados?" 32 | #: static/modoboa_amavis/js/quarantine.js:274 33 | msgid "Delete this selection?" 34 | msgstr "Eliminar itens seleccionados?" 35 | #: static/modoboa_amavis/js/quarantine.js:314 36 | msgid "Mark this selection as spam?" 37 | msgstr "Marcar os items selecionados como spam" 38 | #: static/modoboa_amavis/js/quarantine.js:329 39 | msgid "Mark this selection as non-spam?" 40 | msgstr "Marcar os items selecionados como \"não é spam\"" 41 | #: static/modoboa_amavis/js/quarantine.js:354 42 | #: static/modoboa_amavis/js/selfservice.js:17 43 | msgid "Release this message?" 44 | msgstr "Libertar esta mensagem?" 45 | #: static/modoboa_amavis/js/quarantine.js:358 46 | #: static/modoboa_amavis/js/selfservice.js:21 47 | msgid "Delete this message?" 48 | msgstr "Eliminar esta mensagem?" 49 | #: static/modoboa_amavis/js/quarantine.js:373 50 | msgid "Mark as spam?" 51 | msgstr "Marcar como spam?" 52 | #: static/modoboa_amavis/js/quarantine.js:389 53 | msgid "Mark as non-spam?" 54 | msgstr "Marcar como \"não é spam\"" 55 | #: static/modoboa_amavis/js/quarantine.js:407 56 | msgid "View full headers" 57 | msgstr "Ver cabeçalho completo" 58 | #: static/modoboa_amavis/js/quarantine.js:412 59 | msgid "Hide full headers" 60 | msgstr "Esconder cabeçalho completo" 61 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/ru/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: Modoboa\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 11 | "PO-Revision-Date: 2013-12-03 09:35+0000\n" 12 | "Last-Translator: Antoine Nguyen \n" 13 | "Language-Team: Russian (http://www.transifex.com/projects/p/modoboa/language/" 14 | "ru/)\n" 15 | "Language: ru\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 20 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 21 | #: static/modoboa_amavis/js/quarantine.js:19 22 | msgid "No more message to show" 23 | msgstr "" 24 | #: static/modoboa_amavis/js/quarantine.js:270 25 | msgid "Release this selection?" 26 | msgstr "Деблокировать отмеченные?" 27 | #: static/modoboa_amavis/js/quarantine.js:274 28 | msgid "Delete this selection?" 29 | msgstr "Удалить отмеченные?" 30 | #: static/modoboa_amavis/js/quarantine.js:314 31 | #, fuzzy 32 | msgid "Mark this selection as spam?" 33 | msgstr "Деблокировать отмеченные?" 34 | #: static/modoboa_amavis/js/quarantine.js:329 35 | #, fuzzy 36 | msgid "Mark this selection as non-spam?" 37 | msgstr "Деблокировать отмеченные?" 38 | #: static/modoboa_amavis/js/quarantine.js:354 39 | #: static/modoboa_amavis/js/selfservice.js:17 40 | msgid "Release this message?" 41 | msgstr "Деблокировать сообщение?" 42 | #: static/modoboa_amavis/js/quarantine.js:358 43 | #: static/modoboa_amavis/js/selfservice.js:21 44 | msgid "Delete this message?" 45 | msgstr "Удалить сообщение?" 46 | #: static/modoboa_amavis/js/quarantine.js:373 47 | msgid "Mark as spam?" 48 | msgstr "" 49 | #: static/modoboa_amavis/js/quarantine.js:389 50 | msgid "Mark as non-spam?" 51 | msgstr "" 52 | #: static/modoboa_amavis/js/quarantine.js:407 53 | msgid "View full headers" 54 | msgstr "Просмотр всех заголовков" 55 | #: static/modoboa_amavis/js/quarantine.js:412 56 | msgid "Hide full headers" 57 | msgstr "Скрыть все заголовки" 58 | -------------------------------------------------------------------------------- /modoboa_amavis/locale/sv/LC_MESSAGES/djangojs.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Olle Gustafsson , 2013 7 | # Olle Gustafsson , 2013,2015 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: Modoboa\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2015-02-11 09:51+0100\n" 13 | "PO-Revision-Date: 2015-01-13 15:21+0000\n" 14 | "Last-Translator: Olle Gustafsson \n" 15 | "Language-Team: Swedish (http://www.transifex.com/projects/p/modoboa/language/" 16 | "sv/)\n" 17 | "Language: sv\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 22 | #: static/modoboa_amavis/js/quarantine.js:19 23 | msgid "No more message to show" 24 | msgstr "Inga fler meddelanden att visa" 25 | #: static/modoboa_amavis/js/quarantine.js:270 26 | msgid "Release this selection?" 27 | msgstr "Släpp markerat urval?" 28 | #: static/modoboa_amavis/js/quarantine.js:274 29 | msgid "Delete this selection?" 30 | msgstr "Radera markerat urval?" 31 | #: static/modoboa_amavis/js/quarantine.js:314 32 | msgid "Mark this selection as spam?" 33 | msgstr "Markera detta val som skräppost?" 34 | #: static/modoboa_amavis/js/quarantine.js:329 35 | msgid "Mark this selection as non-spam?" 36 | msgstr "Markera detta val som icke-skräppost?" 37 | #: static/modoboa_amavis/js/quarantine.js:354 38 | #: static/modoboa_amavis/js/selfservice.js:17 39 | msgid "Release this message?" 40 | msgstr "Släpp detta meddelande?" 41 | #: static/modoboa_amavis/js/quarantine.js:358 42 | #: static/modoboa_amavis/js/selfservice.js:21 43 | msgid "Delete this message?" 44 | msgstr "Radera detta meddelande?" 45 | #: static/modoboa_amavis/js/quarantine.js:373 46 | msgid "Mark as spam?" 47 | msgstr "Markera som skräppost?" 48 | #: static/modoboa_amavis/js/quarantine.js:389 49 | msgid "Mark as non-spam?" 50 | msgstr "Markera som icke-skräppost" 51 | #: static/modoboa_amavis/js/quarantine.js:407 52 | msgid "View full headers" 53 | msgstr "Visa meddelandehuvud" 54 | #: static/modoboa_amavis/js/quarantine.js:412 55 | msgid "Hide full headers" 56 | msgstr "Göm meddelandehuvud" 57 | -------------------------------------------------------------------------------- /modoboa_amavis/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/modoboa_amavis/management/__init__.py -------------------------------------------------------------------------------- /modoboa_amavis/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/modoboa_amavis/management/commands/__init__.py -------------------------------------------------------------------------------- /modoboa_amavis/management/commands/amnotify.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, unicode_literals 4 | 5 | from django.contrib.sites import models as sites_models 6 | from django.core import mail 7 | from django.core.management.base import BaseCommand 8 | from django.template.loader import render_to_string 9 | from django.urls import reverse 10 | from django.utils.translation import gettext as _ 11 | 12 | from modoboa.admin.models import Domain 13 | from modoboa.core.models import User 14 | from modoboa.parameters import tools as param_tools 15 | from ...models import Msgrcpt 16 | from ...modo_extension import Amavis 17 | from ...sql_connector import SQLconnector 18 | 19 | 20 | class Command(BaseCommand): 21 | help = "Amavis notification tool" # NOQA:A003 22 | 23 | sender = None 24 | baseurl = None 25 | listingurl = None 26 | 27 | def add_arguments(self, parser): 28 | """Add extra arguments to command line.""" 29 | parser.add_argument( 30 | "--smtp_host", type=str, default="localhost", 31 | help="The address of the SMTP server used to send notifications") 32 | parser.add_argument( 33 | "--smtp_port", type=int, default=25, 34 | help=("The listening port of the SMTP server used to send " 35 | "notifications")) 36 | parser.add_argument("--verbose", action="store_true", 37 | help="Activate verbose mode") 38 | 39 | def handle(self, *args, **options): 40 | Amavis().load() 41 | self.options = options 42 | self.notify_admins_pending_requests() 43 | 44 | def _build_message(self, rcpt, total, reqs): 45 | """Build new EmailMessage instance.""" 46 | if self.options["verbose"]: 47 | print("Sending notification to %s" % rcpt) 48 | context = { 49 | "total": total, 50 | "requests": reqs, 51 | "baseurl": self.baseurl, 52 | "listingurl": self.listingurl 53 | } 54 | content = render_to_string( 55 | "modoboa_amavis/notifications/pending_requests.html", 56 | context 57 | ) 58 | msg = mail.EmailMessage( 59 | _("[modoboa] Pending release requests"), 60 | content, 61 | self.sender, 62 | [rcpt] 63 | ) 64 | return msg 65 | 66 | def notify_admins_pending_requests(self): 67 | self.sender = param_tools.get_global_parameter( 68 | "notifications_sender", app="modoboa_amavis") 69 | self.baseurl = "https://{}".format( 70 | sites_models.Site.objects.get_current().domain) 71 | self.listingurl = "{}{}?viewrequests=1".format( 72 | self.baseurl, reverse("modoboa_amavis:_mail_list")) 73 | 74 | messages = [] 75 | # Check domain administators first. 76 | for da in User.objects.filter(groups__name="DomainAdmins"): 77 | if not hasattr(da, "mailbox"): 78 | continue 79 | rcpt = da.mailbox.full_address 80 | reqs = SQLconnector().get_domains_pending_requests( 81 | Domain.objects.get_for_admin(da).values_list("name", flat=True) 82 | ) 83 | total = reqs.count() 84 | reqs = reqs.all()[:10] 85 | if reqs.count(): 86 | messages.append(self._build_message(rcpt, total, reqs)) 87 | 88 | # Then super administators. 89 | reqs = Msgrcpt.objects.filter(rs="p") 90 | total = reqs.count() 91 | if total: 92 | reqs = reqs.all()[:10] 93 | for su in User.objects.filter(is_superuser=True): 94 | if not hasattr(su, "mailbox"): 95 | continue 96 | rcpt = su.mailbox.full_address 97 | messages.append(self._build_message(rcpt, total, reqs)) 98 | 99 | # Finally, send emails. 100 | if not len(messages): 101 | return 102 | kwargs = { 103 | "host": self.options["smtp_host"], 104 | "port": self.options["smtp_port"] 105 | } 106 | with mail.get_connection(**kwargs) as connection: 107 | connection.send_messages(messages) 108 | -------------------------------------------------------------------------------- /modoboa_amavis/management/commands/qcleanup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, unicode_literals 5 | 6 | import time 7 | 8 | from django.core.management.base import BaseCommand 9 | from django.db.models import Count 10 | 11 | from modoboa.parameters import tools as param_tools 12 | from ...models import Maddr, Msgrcpt, Msgs 13 | from ...modo_extension import Amavis 14 | 15 | 16 | class Command(BaseCommand): 17 | args = "" 18 | help = "Amavis quarantine cleanup" # NOQA:A003 19 | 20 | def add_arguments(self, parser): 21 | """Add extra arguments to command line.""" 22 | parser.add_argument( 23 | "--debug", action="store_true", default=False, 24 | help="Activate debug output") 25 | parser.add_argument( 26 | "--verbose", action="store_true", default=False, 27 | help="Display informational messages") 28 | 29 | def __vprint(self, msg): 30 | if not self.verbose: 31 | return 32 | print(msg) 33 | 34 | def handle(self, *args, **options): 35 | Amavis().load() 36 | if options["debug"]: 37 | import logging 38 | log = logging.getLogger("django.db.backends") 39 | log.setLevel(logging.DEBUG) 40 | log.addHandler(logging.StreamHandler()) 41 | self.verbose = options["verbose"] 42 | 43 | conf = dict(param_tools.get_global_parameters("modoboa_amavis")) 44 | 45 | flags = ["D"] 46 | if conf["released_msgs_cleanup"]: 47 | flags += ["R"] 48 | 49 | self.__vprint("Deleting marked messages...") 50 | ids = Msgrcpt.objects.filter(rs__in=flags).values("mail_id").distinct() 51 | for msg in Msgs.objects.filter(mail_id__in=ids): 52 | if not msg.msgrcpt_set.exclude(rs__in=flags).count(): 53 | msg.delete() 54 | 55 | self.__vprint( 56 | "Deleting messages older than {} days...".format( 57 | conf["max_messages_age"])) 58 | limit = int(time.time()) - (conf["max_messages_age"] * 24 * 3600) 59 | while True: 60 | qset = Msgs.objects.filter( 61 | pk__in=list(Msgs.objects.filter(time_num__lt=limit).values_list("pk", flat=True)[:5000]) 62 | ) 63 | if not qset.exists(): 64 | break 65 | qset.delete() 66 | 67 | self.__vprint("Deleting unreferenced e-mail addresses...") 68 | while True: 69 | res = Maddr.objects.annotate( 70 | msgs_count=Count("msgs"), msgrcpt_count=Count("msgrcpt") 71 | ).filter(msgs_count=0, msgrcpt_count=0).values_list("id", flat=True)[:100000] 72 | if not res.exists(): 73 | break 74 | Maddr.objects.filter(id__in=list(res)).delete() 75 | 76 | self.__vprint("Done.") 77 | -------------------------------------------------------------------------------- /modoboa_amavis/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.db import models, migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Maddr', 16 | fields=[ 17 | ('partition_tag', models.IntegerField(default=0)), 18 | ('id', models.BigIntegerField(serialize=False, primary_key=True)), 19 | ('email', models.CharField(unique=True, max_length=255)), 20 | ('domain', models.CharField(max_length=765)), 21 | ], 22 | options={ 23 | 'db_table': 'maddr', 24 | 'managed': False, 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | migrations.CreateModel( 29 | name='Mailaddr', 30 | fields=[ 31 | ('id', models.IntegerField(serialize=False, primary_key=True)), 32 | ('priority', models.IntegerField()), 33 | ('email', models.CharField(unique=True, max_length=255)), 34 | ], 35 | options={ 36 | 'db_table': 'mailaddr', 37 | 'managed': False, 38 | }, 39 | bases=(models.Model,), 40 | ), 41 | migrations.CreateModel( 42 | name='Msgs', 43 | fields=[ 44 | ('partition_tag', models.IntegerField(default=0)), 45 | ('mail_id', models.CharField(max_length=12, serialize=False, primary_key=True)), 46 | ('secret_id', models.CharField(max_length=12, blank=True)), 47 | ('am_id', models.CharField(max_length=60)), 48 | ('time_num', models.IntegerField()), 49 | ('time_iso', models.CharField(max_length=48)), 50 | ('policy', models.CharField(max_length=765, blank=True)), 51 | ('client_addr', models.CharField(max_length=765, blank=True)), 52 | ('size', models.IntegerField()), 53 | ('originating', models.CharField(max_length=3)), 54 | ('content', models.CharField(max_length=1, blank=True)), 55 | ('quar_type', models.CharField(max_length=1, blank=True)), 56 | ('quar_loc', models.CharField(max_length=255, blank=True)), 57 | ('dsn_sent', models.CharField(max_length=3, blank=True)), 58 | ('spam_level', models.FloatField(null=True, blank=True)), 59 | ('message_id', models.CharField(max_length=765, blank=True)), 60 | ('from_addr', models.CharField(max_length=765, blank=True)), 61 | ('subject', models.CharField(max_length=765, blank=True)), 62 | ('host', models.CharField(max_length=765)), 63 | ], 64 | options={ 65 | 'db_table': 'msgs', 66 | 'managed': False, 67 | }, 68 | bases=(models.Model,), 69 | ), 70 | migrations.CreateModel( 71 | name='Msgrcpt', 72 | fields=[ 73 | ('partition_tag', models.IntegerField(default=0)), 74 | ('mail', models.ForeignKey(primary_key=True, serialize=False, to='modoboa_amavis.Msgs', on_delete=models.CASCADE)), 75 | ('rseqnum', models.IntegerField(default=0)), 76 | ('is_local', models.CharField(max_length=3)), 77 | ('content', models.CharField(max_length=3)), 78 | ('ds', models.CharField(max_length=3)), 79 | ('rs', models.CharField(max_length=3)), 80 | ('bl', models.CharField(max_length=3, blank=True)), 81 | ('wl', models.CharField(max_length=3, blank=True)), 82 | ('bspam_level', models.FloatField(null=True, blank=True)), 83 | ('smtp_resp', models.CharField(max_length=765, blank=True)), 84 | ], 85 | options={ 86 | 'db_table': 'msgrcpt', 87 | 'managed': False, 88 | }, 89 | bases=(models.Model,), 90 | ), 91 | migrations.CreateModel( 92 | name='Policy', 93 | fields=[ 94 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 95 | ('policy_name', models.CharField(max_length=32, blank=True)), 96 | ('virus_lover', models.CharField(max_length=3, null=True, blank=True)), 97 | ('spam_lover', models.CharField(max_length=3, null=True, blank=True)), 98 | ('unchecked_lover', models.CharField(max_length=3, null=True, blank=True)), 99 | ('banned_files_lover', models.CharField(max_length=3, null=True, blank=True)), 100 | ('bad_header_lover', models.CharField(max_length=3, null=True, blank=True)), 101 | ('bypass_virus_checks', models.CharField(default=b'', choices=[(b'N', 'yes'), (b'Y', 'no'), (b'', 'default')], max_length=3, help_text="Bypass virus checks or not. Choose 'default' to use global settings.", null=True, verbose_name='Virus filter')), 102 | ('bypass_spam_checks', models.CharField(default=b'', choices=[(b'N', 'yes'), (b'Y', 'no'), (b'', 'default')], max_length=3, help_text="Bypass spam checks or not. Choose 'default' to use global settings.", null=True, verbose_name='Spam filter')), 103 | ('bypass_banned_checks', models.CharField(default=b'', choices=[(b'N', 'yes'), (b'Y', 'no'), (b'', 'default')], max_length=3, help_text="Bypass banned checks or not. Choose 'default' to use global settings.", null=True, verbose_name='Banned filter')), 104 | ('bypass_header_checks', models.CharField(max_length=3, null=True, blank=True)), 105 | ('virus_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 106 | ('spam_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 107 | ('banned_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 108 | ('unchecked_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 109 | ('bad_header_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 110 | ('clean_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 111 | ('archive_quarantine_to', models.CharField(max_length=192, null=True, blank=True)), 112 | ('spam_tag_level', models.FloatField(null=True, blank=True)), 113 | ('spam_tag2_level', models.FloatField(null=True, blank=True)), 114 | ('spam_tag3_level', models.FloatField(null=True, blank=True)), 115 | ('spam_kill_level', models.FloatField(null=True, blank=True)), 116 | ('spam_dsn_cutoff_level', models.FloatField(null=True, blank=True)), 117 | ('spam_quarantine_cutoff_level', models.FloatField(null=True, blank=True)), 118 | ('addr_extension_virus', models.CharField(max_length=192, null=True, blank=True)), 119 | ('addr_extension_spam', models.CharField(max_length=192, null=True, blank=True)), 120 | ('addr_extension_banned', models.CharField(max_length=192, null=True, blank=True)), 121 | ('addr_extension_bad_header', models.CharField(max_length=192, null=True, blank=True)), 122 | ('warnvirusrecip', models.CharField(max_length=3, null=True, blank=True)), 123 | ('warnbannedrecip', models.CharField(max_length=3, null=True, blank=True)), 124 | ('warnbadhrecip', models.CharField(max_length=3, null=True, blank=True)), 125 | ('newvirus_admin', models.CharField(max_length=192, null=True, blank=True)), 126 | ('virus_admin', models.CharField(max_length=192, null=True, blank=True)), 127 | ('banned_admin', models.CharField(max_length=192, null=True, blank=True)), 128 | ('bad_header_admin', models.CharField(max_length=192, null=True, blank=True)), 129 | ('spam_admin', models.CharField(max_length=192, null=True, blank=True)), 130 | ('spam_subject_tag', models.CharField(max_length=192, null=True, blank=True)), 131 | ('spam_subject_tag2', models.CharField(default=None, max_length=192, blank=True, help_text="Modify spam subject using the specified text. Choose 'default' to use global settings.", null=True, verbose_name='Spam marker')), 132 | ('spam_subject_tag3', models.CharField(max_length=192, null=True, blank=True)), 133 | ('message_size_limit', models.IntegerField(null=True, blank=True)), 134 | ('banned_rulenames', models.CharField(max_length=192, null=True, blank=True)), 135 | ('disclaimer_options', models.CharField(max_length=192, null=True, blank=True)), 136 | ('forward_method', models.CharField(max_length=192, null=True, blank=True)), 137 | ('sa_userconf', models.CharField(max_length=192, null=True, blank=True)), 138 | ('sa_username', models.CharField(max_length=192, null=True, blank=True)), 139 | ], 140 | options={ 141 | 'db_table': 'policy', 142 | 'managed': False, 143 | }, 144 | bases=(models.Model,), 145 | ), 146 | migrations.CreateModel( 147 | name='Quarantine', 148 | fields=[ 149 | ('partition_tag', models.IntegerField(default=0)), 150 | ('mail', models.ForeignKey(primary_key=True, serialize=False, to='modoboa_amavis.Msgs', on_delete=models.CASCADE)), 151 | ('chunk_ind', models.IntegerField()), 152 | ('mail_text', models.TextField()), 153 | ], 154 | options={ 155 | 'ordering': ['-mail__time_num'], 156 | 'db_table': 'quarantine', 157 | 'managed': False, 158 | }, 159 | bases=(models.Model,), 160 | ), 161 | migrations.CreateModel( 162 | name='Users', 163 | fields=[ 164 | ('id', models.AutoField(serialize=False, primary_key=True)), 165 | ('priority', models.IntegerField()), 166 | ('email', models.CharField(unique=True, max_length=255)), 167 | ('fullname', models.CharField(max_length=765, blank=True)), 168 | ], 169 | options={ 170 | 'db_table': 'users', 171 | 'managed': False, 172 | }, 173 | bases=(models.Model,), 174 | ), 175 | migrations.CreateModel( 176 | name='Wblist', 177 | fields=[ 178 | ('rid', models.IntegerField(serialize=False, primary_key=True)), 179 | ('sid', models.IntegerField(primary_key=True)), 180 | ('wb', models.CharField(max_length=30)), 181 | ], 182 | options={ 183 | 'db_table': 'wblist', 184 | 'managed': False, 185 | }, 186 | bases=(models.Model,), 187 | ), 188 | ] 189 | -------------------------------------------------------------------------------- /modoboa_amavis/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/modoboa_amavis/migrations/__init__.py -------------------------------------------------------------------------------- /modoboa_amavis/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This is an auto-generated Django model module. 4 | # You'll have to do the following manually to clean this up: 5 | # * Rearrange models' order 6 | # * Make sure each model has one field with primary_key=True 7 | # Feel free to rename the models, but don't rename db_table values 8 | # or field names. 9 | # 10 | # Also note: You'll have to insert the output of 11 | # 'django-admin.py sqlcustom [appname]'into your database. 12 | # 13 | # Original Amavis version : 2.6.2 14 | 15 | from django.db import models 16 | from django.utils.translation import gettext_lazy 17 | 18 | 19 | class Maddr(models.Model): 20 | partition_tag = models.IntegerField(default=0) 21 | id = models.BigIntegerField(primary_key=True) # NOQA:A003 22 | email = models.CharField(max_length=255) 23 | domain = models.CharField(max_length=255) 24 | 25 | class Meta: 26 | db_table = "maddr" 27 | unique_together = [("partition_tag", "email")] 28 | managed = False 29 | 30 | 31 | class Mailaddr(models.Model): 32 | id = models.IntegerField(primary_key=True) # NOQA:A003 33 | priority = models.IntegerField() 34 | email = models.CharField(unique=True, max_length=255) 35 | 36 | class Meta: 37 | db_table = "mailaddr" 38 | managed = False 39 | 40 | 41 | class Msgs(models.Model): 42 | partition_tag = models.IntegerField(default=0) 43 | mail_id = models.BinaryField(max_length=16, primary_key=True) 44 | secret_id = models.BinaryField() 45 | am_id = models.CharField(max_length=60) 46 | time_num = models.IntegerField() 47 | time_iso = models.CharField(max_length=48) 48 | sid = models.ForeignKey(Maddr, db_column="sid", on_delete=models.CASCADE) 49 | policy = models.CharField(max_length=765, blank=True) 50 | client_addr = models.CharField(max_length=765, blank=True) 51 | size = models.IntegerField() 52 | originating = models.CharField(max_length=3) 53 | content = models.CharField(max_length=1, blank=True) 54 | quar_type = models.CharField(max_length=1, blank=True) 55 | quar_loc = models.CharField(max_length=255, blank=True) 56 | dsn_sent = models.CharField(max_length=3, blank=True) 57 | spam_level = models.FloatField(null=True, blank=True) 58 | message_id = models.CharField(max_length=765, blank=True) 59 | from_addr = models.CharField(max_length=765, blank=True) 60 | subject = models.CharField(max_length=765, blank=True) 61 | host = models.CharField(max_length=765) 62 | 63 | class Meta: 64 | db_table = "msgs" 65 | managed = False 66 | unique_together = ("partition_tag", "mail_id") 67 | 68 | 69 | class Msgrcpt(models.Model): 70 | partition_tag = models.IntegerField(default=0) 71 | mail = models.ForeignKey(Msgs, primary_key=True, on_delete=models.CASCADE) 72 | rid = models.ForeignKey(Maddr, db_column="rid", on_delete=models.CASCADE) 73 | rseqnum = models.IntegerField(default=0) 74 | is_local = models.CharField(max_length=3) 75 | content = models.CharField(max_length=3) 76 | ds = models.CharField(max_length=3) 77 | rs = models.CharField(max_length=3) 78 | bl = models.CharField(max_length=3, blank=True) 79 | wl = models.CharField(max_length=3, blank=True) 80 | bspam_level = models.FloatField(null=True, blank=True) 81 | smtp_resp = models.CharField(max_length=765, blank=True) 82 | 83 | class Meta: 84 | db_table = "msgrcpt" 85 | managed = False 86 | unique_together = ("partition_tag", "mail", "rseqnum") 87 | 88 | 89 | class Policy(models.Model): 90 | policy_name = models.CharField(max_length=32, blank=True) 91 | virus_lover = models.CharField(max_length=3, blank=True, null=True) 92 | spam_lover = models.CharField(max_length=3, blank=True, null=True) 93 | unchecked_lover = models.CharField(max_length=3, blank=True, null=True) 94 | banned_files_lover = models.CharField(max_length=3, blank=True, null=True) 95 | bad_header_lover = models.CharField(max_length=3, blank=True, null=True) 96 | bypass_virus_checks = models.CharField( 97 | gettext_lazy("Virus filter"), default="", null=True, 98 | choices=(("N", gettext_lazy("yes")), 99 | ("Y", gettext_lazy("no")), 100 | ("", gettext_lazy("default"))), 101 | max_length=3, 102 | help_text=gettext_lazy( 103 | "Bypass virus checks or not. Choose 'default' to use global " 104 | "settings." 105 | ) 106 | ) 107 | bypass_spam_checks = models.CharField( 108 | gettext_lazy("Spam filter"), default="", null=True, 109 | choices=(("N", gettext_lazy("yes")), 110 | ("Y", gettext_lazy("no")), 111 | ("", gettext_lazy("default"))), 112 | max_length=3, 113 | help_text=gettext_lazy( 114 | "Bypass spam checks or not. Choose 'default' to use global " 115 | "settings." 116 | ) 117 | ) 118 | bypass_banned_checks = models.CharField( 119 | gettext_lazy("Banned filter"), default="", null=True, 120 | choices=(("N", gettext_lazy("yes")), 121 | ("Y", gettext_lazy("no")), 122 | ("", gettext_lazy("default"))), 123 | max_length=3, 124 | help_text=gettext_lazy( 125 | "Bypass banned checks or not. Choose 'default' to use global " 126 | "settings." 127 | ) 128 | ) 129 | bypass_header_checks = models.CharField( 130 | max_length=3, blank=True, null=True) 131 | virus_quarantine_to = models.CharField( 132 | max_length=192, 133 | blank=True, 134 | null=True) 135 | spam_quarantine_to = models.CharField( 136 | max_length=192, blank=True, null=True) 137 | banned_quarantine_to = models.CharField( 138 | max_length=192, 139 | blank=True, 140 | null=True) 141 | unchecked_quarantine_to = models.CharField( 142 | max_length=192, 143 | blank=True, 144 | null=True) 145 | bad_header_quarantine_to = models.CharField( 146 | max_length=192, 147 | blank=True, 148 | null=True) 149 | clean_quarantine_to = models.CharField( 150 | max_length=192, 151 | blank=True, 152 | null=True) 153 | archive_quarantine_to = models.CharField( 154 | max_length=192, 155 | blank=True, 156 | null=True) 157 | spam_tag_level = models.FloatField(null=True, blank=True) 158 | spam_tag2_level = models.FloatField(null=True, blank=True) 159 | spam_tag3_level = models.FloatField(null=True, blank=True) 160 | spam_kill_level = models.FloatField(null=True, blank=True) 161 | spam_dsn_cutoff_level = models.FloatField(null=True, blank=True) 162 | spam_quarantine_cutoff_level = models.FloatField(null=True, blank=True) 163 | addr_extension_virus = models.CharField( 164 | max_length=192, 165 | blank=True, 166 | null=True) 167 | addr_extension_spam = models.CharField( 168 | max_length=192, 169 | blank=True, 170 | null=True) 171 | addr_extension_banned = models.CharField( 172 | max_length=192, 173 | blank=True, 174 | null=True) 175 | addr_extension_bad_header = models.CharField( 176 | max_length=192, 177 | blank=True, 178 | null=True) 179 | warnvirusrecip = models.CharField(max_length=3, blank=True, null=True) 180 | warnbannedrecip = models.CharField(max_length=3, blank=True, null=True) 181 | warnbadhrecip = models.CharField(max_length=3, blank=True, null=True) 182 | newvirus_admin = models.CharField(max_length=192, blank=True, null=True) 183 | virus_admin = models.CharField(max_length=192, blank=True, null=True) 184 | banned_admin = models.CharField(max_length=192, blank=True, null=True) 185 | bad_header_admin = models.CharField(max_length=192, blank=True, null=True) 186 | spam_admin = models.CharField(max_length=192, blank=True, null=True) 187 | spam_subject_tag = models.CharField(max_length=192, blank=True, null=True) 188 | spam_subject_tag2 = models.CharField( 189 | gettext_lazy("Spam marker"), default=None, 190 | max_length=192, blank=True, null=True, 191 | help_text=gettext_lazy( 192 | "Modify spam subject using the specified text. " 193 | "Choose 'default' to use global settings." 194 | ) 195 | ) 196 | spam_subject_tag3 = models.CharField(max_length=192, blank=True, null=True) 197 | message_size_limit = models.IntegerField(null=True, blank=True) 198 | banned_rulenames = models.CharField(max_length=192, blank=True, null=True) 199 | disclaimer_options = models.CharField( 200 | max_length=192, blank=True, null=True) 201 | forward_method = models.CharField(max_length=192, blank=True, null=True) 202 | sa_userconf = models.CharField(max_length=192, blank=True, null=True) 203 | sa_username = models.CharField(max_length=192, blank=True, null=True) 204 | 205 | class Meta: 206 | db_table = "policy" 207 | managed = False 208 | 209 | 210 | class Quarantine(models.Model): 211 | partition_tag = models.IntegerField(default=0) 212 | mail = models.ForeignKey(Msgs, primary_key=True, on_delete=models.CASCADE) 213 | chunk_ind = models.IntegerField() 214 | mail_text = models.BinaryField() 215 | 216 | class Meta: 217 | db_table = "quarantine" 218 | managed = False 219 | ordering = ["-mail__time_num"] 220 | unique_together = ("partition_tag", "mail", "chunk_ind") 221 | 222 | 223 | class Users(models.Model): 224 | id = models.AutoField(primary_key=True) # NOQA:A003 225 | priority = models.IntegerField() 226 | policy = models.ForeignKey(Policy, on_delete=models.CASCADE) 227 | email = models.CharField(unique=True, max_length=255) 228 | fullname = models.CharField(max_length=765, blank=True) 229 | 230 | class Meta: 231 | db_table = "users" 232 | managed = False 233 | 234 | 235 | class Wblist(models.Model): 236 | rid = models.IntegerField(primary_key=True) 237 | sid = models.IntegerField() 238 | wb = models.CharField(max_length=30) 239 | 240 | class Meta: 241 | db_table = "wblist" 242 | managed = False 243 | unique_together = [("rid", "sid")] 244 | -------------------------------------------------------------------------------- /modoboa_amavis/modo_extension.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Amavis management frontend. 5 | 6 | Provides: 7 | 8 | * SQL quarantine management 9 | * Per-domain settings 10 | 11 | """ 12 | 13 | from __future__ import unicode_literals 14 | 15 | from django.utils.translation import gettext_lazy 16 | 17 | from modoboa.admin.models import Domain 18 | from modoboa.core.extensions import ModoExtension, exts_pool 19 | from modoboa.parameters import tools as param_tools 20 | from . import __version__, forms 21 | from .lib import create_user_and_policy, create_user_and_use_policy 22 | 23 | 24 | class Amavis(ModoExtension): 25 | """The Amavis extension.""" 26 | 27 | name = "modoboa_amavis" 28 | label = gettext_lazy("Amavis frontend") 29 | version = __version__ 30 | description = gettext_lazy("Simple amavis management frontend") 31 | url = "quarantine" 32 | available_for_topredirection = True 33 | 34 | def load(self): 35 | param_tools.registry.add("global", forms.ParametersForm, "Amavis") 36 | param_tools.registry.add( 37 | "user", forms.UserSettings, gettext_lazy("Quarantine")) 38 | 39 | def load_initial_data(self): 40 | """Create records for existing domains and co.""" 41 | for dom in Domain.objects.all(): 42 | policy = create_user_and_policy("@{0}".format(dom.name)) 43 | for domalias in dom.domainalias_set.all(): 44 | domalias_pattern = "@{0}".format(domalias.name) 45 | create_user_and_use_policy(domalias_pattern, policy) 46 | 47 | 48 | exts_pool.register_extension(Amavis) 49 | -------------------------------------------------------------------------------- /modoboa_amavis/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Amavis frontend default settings.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | 8 | def apply(settings): 9 | """Modify settings.""" 10 | if "DATABASE_ROUTERS" not in settings: 11 | settings["DATABASE_ROUTERS"] = [] 12 | settings["DATABASE_ROUTERS"] += ["modoboa_amavis.dbrouter.AmavisRouter"] 13 | 14 | if "SILENCED_SYSTEM_CHECKS" not in settings: 15 | settings["SILENCED_SYSTEM_CHECKS"] = [] 16 | settings["SILENCED_SYSTEM_CHECKS"] += ["fields.W342"] 17 | 18 | settings["AMAVIS_DEFAULT_DATABASE_ENCODING"] = "LATIN1" 19 | # settings["SA_LOOKUP_PATH"] = ("/usr/bin", ) 20 | -------------------------------------------------------------------------------- /modoboa_amavis/sql_connector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """SQL connector module.""" 4 | 5 | import datetime 6 | 7 | from django.db.models import Q 8 | 9 | from modoboa.admin.models import Domain 10 | from modoboa.lib.email_utils import decode 11 | 12 | from .lib import cleanup_email_address, make_query_args 13 | from .models import Maddr, Msgrcpt, Quarantine 14 | from .utils import ( 15 | ConvertFrom, fix_utf8_encoding, smart_bytes, smart_str 16 | ) 17 | 18 | 19 | def reverse_domain_names(domains): 20 | """Return a list of reversed domain names.""" 21 | return [".".join(reversed(domain.split("."))) for domain in domains] 22 | 23 | 24 | class SQLconnector: 25 | """This class handles all database operations.""" 26 | 27 | ORDER_TRANSLATION_TABLE = { 28 | "type": "content", 29 | "score": "bspam_level", 30 | "date": "mail__time_num", 31 | "subject": "mail__subject", 32 | "from": "mail__from_addr", 33 | "to": "rid__email" 34 | } 35 | 36 | QUARANTINE_FIELDS = [ 37 | "content", 38 | "bspam_level", 39 | "rs", 40 | "rid__email", 41 | "mail__from_addr", 42 | "mail__subject", 43 | "mail__mail_id", 44 | "mail__time_num", 45 | ] 46 | 47 | def __init__(self, user=None, navparams=None): 48 | """Constructor.""" 49 | self.user = user 50 | self.navparams = navparams 51 | self.messages = None 52 | 53 | self._messages_count = None 54 | self._annotations = {} 55 | 56 | def _exec(self, query, args): 57 | """Execute a raw SQL query. 58 | 59 | :param string query: query to execute 60 | :param list args: a list of arguments to replace in :kw:`query` 61 | """ 62 | from django.db import connections, transaction 63 | 64 | with transaction.atomic(): 65 | cursor = connections["amavis"].cursor() 66 | cursor.execute(query, args) 67 | 68 | def _apply_msgrcpt_simpleuser_filter(self, flt): 69 | """Apply specific filter for simple users.""" 70 | if "str_email" not in self._annotations: 71 | self._annotations["str_email"] = ConvertFrom("rid__email") 72 | 73 | rcpts = [self.user.email] 74 | if hasattr(self.user, "mailbox"): 75 | rcpts += self.user.mailbox.alias_addresses 76 | 77 | query_rcpts = [] 78 | for rcpt in rcpts: 79 | query_rcpts += make_query_args(rcpt, exact_extension=False, 80 | wildcard=".*") 81 | 82 | re = "(%s)" % "|".join(query_rcpts) 83 | return flt & Q(str_email__regex=re) 84 | 85 | def _apply_msgrcpt_filters(self, flt): 86 | """Apply filters based on user's role.""" 87 | if self.user.role == "SimpleUsers": 88 | flt = self._apply_msgrcpt_simpleuser_filter(flt) 89 | elif not self.user.is_superuser: 90 | doms = Domain.objects.get_for_admin( 91 | self.user).values_list("name", flat=True) 92 | flt &= Q(rid__domain__in=reverse_domain_names(doms)) 93 | return flt 94 | 95 | def _get_quarantine_content(self): 96 | """Fetch quarantine content. 97 | 98 | Filters: rs, rid, content 99 | """ 100 | flt = ( 101 | Q(rs__in=[" ", "V", "R", "p", "S", "H"]) 102 | if self.navparams.get("viewrequests", "0") != "1" else Q(rs="p") 103 | ) 104 | flt = self._apply_msgrcpt_filters(flt) 105 | pattern = self.navparams.get("pattern", "") 106 | if pattern: 107 | criteria = self.navparams.get("criteria") 108 | if criteria == "both": 109 | criteria = "from_addr,subject,to" 110 | search_flt = None 111 | for crit in criteria.split(","): 112 | if crit == "from_addr": 113 | nfilter = Q(mail__from_addr__icontains=pattern) 114 | elif crit == "subject": 115 | nfilter = Q(mail__subject__icontains=pattern) 116 | elif crit == "to": 117 | if "str_email" not in self._annotations: 118 | self._annotations["str_email"] = ConvertFrom( 119 | "rid__email") 120 | nfilter = Q(str_email__icontains=pattern) 121 | else: 122 | continue 123 | search_flt = ( 124 | nfilter if search_flt is None else search_flt | nfilter 125 | ) 126 | if search_flt: 127 | flt &= search_flt 128 | msgtype = self.navparams.get("msgtype", None) 129 | if msgtype is not None: 130 | flt &= Q(content=msgtype) 131 | 132 | flt &= Q( 133 | mail__in=Quarantine.objects.filter(chunk_ind=1).values("mail_id") 134 | ) 135 | 136 | return ( 137 | Msgrcpt.objects 138 | .annotate(**self._annotations) 139 | .select_related("mail", "rid") 140 | .filter(flt) 141 | ) 142 | 143 | def messages_count(self): 144 | """Return the total number of messages living in the quarantine. 145 | 146 | We also store the built queryset for a later use. 147 | """ 148 | if self.user is None or self.navparams is None: 149 | return None 150 | if self._messages_count is None: 151 | self.messages = self._get_quarantine_content() 152 | self.messages = self.messages.values(*self.QUARANTINE_FIELDS) 153 | 154 | order = self.navparams.get("order") 155 | if order is not None: 156 | sign = "" 157 | if order[0] == "-": 158 | sign = "-" 159 | order = order[1:] 160 | order = self.ORDER_TRANSLATION_TABLE[order] 161 | self.messages = self.messages.order_by(sign + order) 162 | 163 | self._messages_count = len(self.messages) 164 | 165 | return self._messages_count 166 | 167 | def fetch(self, start=None, stop=None): 168 | """Fetch a range of messages from the internal cache.""" 169 | emails = [] 170 | for qm in self.messages[start - 1:stop]: 171 | if qm["rs"] == "D": 172 | continue 173 | m = { 174 | "from": cleanup_email_address( 175 | fix_utf8_encoding(qm["mail__from_addr"]) 176 | ), 177 | "to": smart_str(qm["rid__email"]), 178 | "subject": fix_utf8_encoding(qm["mail__subject"]), 179 | "mailid": smart_str(qm["mail__mail_id"]), 180 | "date": datetime.datetime.fromtimestamp(qm["mail__time_num"]), 181 | "type": qm["content"], 182 | "score": qm["bspam_level"], 183 | "status": qm["rs"] 184 | } 185 | if qm["rs"] in ["", " "]: 186 | m["class"] = "unseen" 187 | elif qm["rs"] == "p": 188 | m["class"] = "pending" 189 | emails.append(m) 190 | return emails 191 | 192 | def get_recipient_message(self, address, mailid): 193 | """Retrieve a message for a given recipient. 194 | """ 195 | assert isinstance(address, str), "address should be of type str" 196 | 197 | return Msgrcpt.objects\ 198 | .annotate(str_email=ConvertFrom("rid__email"))\ 199 | .get(mail=mailid.encode('ascii'), str_email=address) 200 | 201 | def set_msgrcpt_status(self, address, mailid: str, status): 202 | """Change the status (rs field) of a message recipient. 203 | 204 | :param string status: status 205 | """ 206 | assert isinstance(address, str), "address should be of type str" 207 | addr = ( 208 | Maddr.objects 209 | .annotate(str_email=ConvertFrom("email")) 210 | .get(str_email=address) 211 | ) 212 | self._exec( 213 | "UPDATE msgrcpt SET rs=%s WHERE mail_id=%s AND rid=%s", 214 | [status, mailid.encode("ascii"), addr.id] 215 | ) 216 | 217 | def get_domains_pending_requests(self, domains): 218 | """Retrieve pending release requests for a list of domains.""" 219 | return Msgrcpt.objects.filter( 220 | rs="p", rid__domain__in=reverse_domain_names(domains)) 221 | 222 | def get_pending_requests(self): 223 | """Return the number of requests currently pending.""" 224 | rq = Q(rs="p") 225 | if not self.user.is_superuser: 226 | doms = Domain.objects.get_for_admin(self.user) 227 | if not doms.exists(): 228 | return 0 229 | doms_q = Q(rid__domain__in=reverse_domain_names( 230 | doms.values_list("name", flat=True))) 231 | rq &= doms_q 232 | return Msgrcpt.objects.filter(rq).count() 233 | 234 | def get_mail_content(self, mailid): 235 | """Retrieve the content of a message.""" 236 | content_bytes = smart_bytes("").join([ 237 | smart_bytes(qmail.mail_text) 238 | for qmail in Quarantine.objects.filter( 239 | mail=mailid) 240 | ]) 241 | content = decode( 242 | content_bytes, "utf-8", 243 | append_to_error=("; mail_id=%s" % smart_str(mailid)) 244 | ) 245 | return content 246 | -------------------------------------------------------------------------------- /modoboa_amavis/sql_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | An email representation based on a database record. 5 | """ 6 | 7 | from html2text import HTML2Text 8 | 9 | from django.template.loader import render_to_string 10 | 11 | from modoboa.lib.email_utils import Email 12 | from .sql_connector import SQLconnector 13 | from .utils import fix_utf8_encoding, smart_str 14 | 15 | 16 | class SQLemail(Email): 17 | 18 | """The SQL version of the Email class.""" 19 | 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.qtype = "" 23 | self.qreason = "" 24 | 25 | qreason = self.msg["X-Amavis-Alert"] 26 | if qreason: 27 | if "," in qreason: 28 | self.qtype, qreason = qreason.split(",", 1) 29 | elif qreason.startswith("BAD HEADER SECTION "): 30 | # Workaround for amavis <= 2.8.0 :p 31 | self.qtype = "BAD HEADER SECTION" 32 | qreason = qreason[19:] 33 | 34 | qreason = " ".join([x.strip() for x in qreason.splitlines()]) 35 | self.qreason = qreason 36 | 37 | def _fetch_message(self): 38 | return SQLconnector().get_mail_content(self.mailid) 39 | 40 | @property 41 | def body(self): 42 | if self._body is None: 43 | super().body 44 | self._body = fix_utf8_encoding(self._body) 45 | 46 | # if there's no plain text version available attempt to make one by 47 | # sanitising the html version. The output isn't always pretty but it 48 | # is readable, better than a blank screen and helps the user decide 49 | # if the message is spam or ham. 50 | if self.dformat == "plain" and not self.contents["plain"] \ 51 | and self.contents["html"]: 52 | h = HTML2Text() 53 | h.ignore_tables = True 54 | h.images_to_alt = True 55 | mail_text = h.handle(self.contents["html"]) 56 | self.contents["plain"] = self._post_process_plain( 57 | smart_str(mail_text)) 58 | self._body = self.viewmail_plain() 59 | self._body = fix_utf8_encoding(self._body) 60 | 61 | return self._body 62 | 63 | def render_headers(self, **kwargs): 64 | context = { 65 | "qtype": self.qtype, 66 | "qreason": self.qreason, 67 | "headers": self.headers, 68 | } 69 | return render_to_string("modoboa_amavis/mailheaders.html", context) 70 | -------------------------------------------------------------------------------- /modoboa_amavis/static/modoboa_amavis/css/quarantine.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 400px){ 2 | th#rstatus, 3 | td[name='rstatus'], 4 | th#type, 5 | td[name='type']{ 6 | display: none; 7 | } 8 | } 9 | @media (max-width: 600px){ 10 | th#subject, 11 | td[name='subject']{ 12 | display: none; 13 | } 14 | } 15 | @media (max-width: 767px){ 16 | th#to, 17 | td[name='to']{ 18 | display: none; 19 | } 20 | } 21 | 22 | @media (max-width: 990px){ 23 | th#date, 24 | td[name='date']{ 25 | display: none; 26 | } 27 | } 28 | 29 | #menubar { 30 | position: absolute; 31 | top: 51px; 32 | padding-left: 15px; 33 | padding-right: 15px; 34 | padding-top: 20px; 35 | padding-bottom: 10px; 36 | left: 0; 37 | right: 0; 38 | margin: 0; 39 | z-index: 10; 40 | } 41 | 42 | #menubar input[type="checkbox"] { 43 | margin: 0; 44 | } 45 | 46 | #listing { 47 | position: absolute; 48 | top: 125px; 49 | left: 0; 50 | right: 0; 51 | bottom: 0; 52 | margin: 0; 53 | overflow: auto; 54 | padding-left: 15px; 55 | padding-right: 15px; 56 | } 57 | 58 | #mailcontent { 59 | border: 0; 60 | margin: 0; 61 | padding: 0; 62 | width: 100%; 63 | height: 100%; 64 | } 65 | 66 | #emails tbody tr:hover { 67 | color: #0088cc; 68 | } 69 | 70 | thead tr { 71 | position: relative; 72 | z-index: 1; 73 | } 74 | 75 | td[name=type] span { 76 | cursor: pointer; 77 | } 78 | 79 | td.unseen { 80 | font-weight: bold; 81 | } 82 | 83 | td[class*=pending] { 84 | font-style: italic; 85 | } 86 | 87 | td[class*=openable] { 88 | cursor: pointer; 89 | } -------------------------------------------------------------------------------- /modoboa_amavis/static/modoboa_amavis/css/selfservice.css: -------------------------------------------------------------------------------- 1 | #menubar { 2 | padding-left: 15px; 3 | z-index: 10; 4 | } 5 | 6 | #listing { 7 | position: absolute; 8 | padding: 0px; 9 | right: 0; 10 | left: 0; 11 | bottom: 0; 12 | overflow: hidden; 13 | } 14 | 15 | #mailcontent { 16 | border: 0; 17 | margin: 0; 18 | padding: 0; 19 | width: 100%; 20 | height: 100%; 21 | } -------------------------------------------------------------------------------- /modoboa_amavis/static/modoboa_amavis/js/selfservice.js: -------------------------------------------------------------------------------- 1 | function send_action(e, message) { 2 | var $link = $(e.target); 3 | 4 | e.preventDefault(); 5 | if (!confirm(message)) { 6 | return; 7 | } 8 | $.ajax({ 9 | url: $link.attr("href"), 10 | dataType: 'json' 11 | }).done(function(data) { 12 | $("body").notify("success", data, 2000); 13 | }); 14 | } 15 | 16 | function release(e) { 17 | send_action(e, gettext("Release this message?")); 18 | } 19 | 20 | function remove(e) { 21 | send_action(e, gettext("Delete this message?")); 22 | } 23 | 24 | $(document).ready(function() { 25 | $("#listing").css({ 26 | top: $("#menubar").outerHeight() + 10 27 | }); 28 | $(document).on("click", "a[name=release]", release); 29 | $(document).on("click", "a[name=delete]", remove); 30 | }); -------------------------------------------------------------------------------- /modoboa_amavis/tasks.py: -------------------------------------------------------------------------------- 1 | """Async tasks.""" 2 | 3 | from typing import List 4 | 5 | from django.utils.translation import ngettext 6 | 7 | from modoboa.core import models as core_models 8 | 9 | from .lib import SpamassassinClient 10 | from .sql_connector import SQLconnector 11 | 12 | 13 | def manual_learning(user_pk: int, 14 | mtype: str, 15 | selection: List[str], 16 | recipient_db: str): 17 | """Task to trigger manual learning for given selection.""" 18 | user = core_models.User.objects.get(pk=user_pk) 19 | connector = SQLconnector() 20 | saclient = SpamassassinClient(user, recipient_db) 21 | for item in selection: 22 | rcpt, mail_id = item.split() 23 | content = connector.get_mail_content(mail_id.encode("ascii")) 24 | result = saclient.learn_spam(rcpt, content) if mtype == "spam" \ 25 | else saclient.learn_ham(rcpt, content) 26 | if not result: 27 | break 28 | connector.set_msgrcpt_status( 29 | rcpt, mail_id, mtype[0].upper() 30 | ) 31 | if saclient.error is None: 32 | saclient.done() 33 | message = ngettext("%(count)d message processed successfully", 34 | "%(count)d messages processed successfully", 35 | len(selection)) % {"count": len(selection)} 36 | else: 37 | message = saclient.error 38 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/_email_display.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/domain_content_filter.html: -------------------------------------------------------------------------------- 1 | {% load i18n form_tags %} 2 | {% render_field form.bypass_virus_checks %} 3 | {% render_field form.bypass_spam_checks %} 4 |
5 | 6 |
7 | {{ form.spam_tag2_level }} 8 |
9 | {% trans "or more is spam" %} 10 |
11 |
12 |
13 | {{ form.spam_kill_level }} 14 |
15 | {% trans "or more throw spam message away" %} 16 |
17 | {% render_field form.spam_subject_tag2 activator=True activator_label=_("default") activator_size="col-sm-3" %} 18 | {% render_field form.bypass_banned_checks %} 19 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/email_list.html: -------------------------------------------------------------------------------- 1 | {% extends "modoboa_amavis/quarantine.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block main %} 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 19 | 22 | 25 | 28 | 29 | 30 | 31 | {% include "modoboa_amavis/emails_page.html" %} 32 | 33 |
14 | {% trans "Score" %} 15 | 17 | {% trans "To" %} 18 | 20 | {% trans "From" %} 21 | 23 | {% trans "Subject" %} 24 | 26 | {% trans "Date" %} 27 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/emails_page.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load amavis_tags %} 3 | {% load static %} 4 | 5 | {% for email in email_list %} 6 | 7 | 8 | 9 | 10 | 11 | {{ email.type|msgtype_to_html }} 12 | 13 | 14 | {% if email.status == 'R' %} 15 | 16 | {% elif email.status == 'S' %} 17 | 18 | {% elif email.status == 'H' %} 19 | 20 | {% endif %} 21 | 22 | {{ email.score }} 23 | {{ email.to }} 24 | 25 | {{ email.from|truncatechars:30 }} 26 | 27 | 28 | {{ email.subject|truncatechars:30 }} 29 | 30 | 31 | {{ email.date|date:"SHORT_DATETIME_FORMAT" }} 32 | 33 | 34 | {% endfor %} 35 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/empty_quarantine.html: -------------------------------------------------------------------------------- 1 | {% extends "modoboa_amavis/quarantine.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block main %} 6 |
{% trans "Empty quarantine" %}
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/getmailcontent.html: -------------------------------------------------------------------------------- 1 | {% if pre %}
{% endif %}{{ body }}{% if pre %}
{% endif %} -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/index.html: -------------------------------------------------------------------------------- 1 | {% extends "fluid.html" %}{% load i18n amavis_tags %} 2 | {% load static %} 3 | 4 | {% block pagetitle %}{% trans "Quarantine management" %}{% endblock %} 5 | 6 | {% block extra_css %} 7 | 8 | 9 | {% endblock %} 10 | 11 | {% block extra_js %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 30 | {% endblock %} 31 | 32 | {% block container_content %} 33 | 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/mailheaders.html: -------------------------------------------------------------------------------- 1 | {% extends "common/mailheaders.html" %} 2 | 3 | {% block emailheaders %} 4 | 5 | {% if qtype %}
{{ qtype }} {{ qreason }}
{% endif %} 6 | {{ block.super }} 7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/main_action_bar.html: -------------------------------------------------------------------------------- 1 | {% load lib_tags i18n %} 2 |
3 | 4 |
5 |
6 | 7 | 10 | 15 |
16 | 17 | 22 | 27 | {% if manual_learning %} 28 |
29 | 32 | 36 |
37 | {% endif %} 38 | 39 |
40 | 41 |
42 | {% include "common/email_searchbar.html" with extraopts=extraopts %} 43 |
44 | 45 |
46 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/notifications/pending_requests.html: -------------------------------------------------------------------------------- 1 | {% load i18n lib_tags %} 2 | 3 | {% blocktrans trimmed count counter=total %} 4 | {{ counter }} release request is pending for action. 5 | {% plural %} 6 | {{ counter }} release requests are pending for action. 7 | {% endblocktrans %} 8 | {% trans "Sketch:" %} 9 | {% for msg in requests %} 10 | {% trans "From:" %} {{ msg.mail.sid.email }} 11 | {% trans "To:" %} {{ msg.rid.email }} 12 | {% trans "Date:" %} {{ msg.mail.time_num|fromunix|date:"DATETIME_FORMAT" }} 13 | {% trans "Subject:" %} {{ msg.mail.subject }} 14 | {% trans "Act on this message:" %} {{ baseurl }}{% url "modoboa_amavis:index" %}#{{ msg.mail.mail_id }}/?rcpt={{ msg.rid.email }} 15 | {% endfor %} 16 | {% blocktrans %}Please visit {{ listingurl }} for a full listing.{% endblocktrans %} 17 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/quarantine.html: -------------------------------------------------------------------------------- 1 | {% load amavis_tags i18n %} 2 | 3 |
4 |
5 | {% block main %}{% endblock %} 6 |
7 | 8 |
9 | {% block right_menu_bar %} 10 |

{% trans "Display" %}

11 |
12 | 15 | {% for type, label in message_types.items %} 16 | 19 | {% endfor %} 20 |
21 | {% endblock %} 22 |
23 |
24 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/viewheader.html: -------------------------------------------------------------------------------- 1 |
2 | {% for k, v in headers %}{{ k }}: {{ v }}
3 | {% endfor %}
4 | 
5 | -------------------------------------------------------------------------------- /modoboa_amavis/templates/modoboa_amavis/viewmail_selfservice.html: -------------------------------------------------------------------------------- 1 | {% load i18n lib_tags amavis_tags %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | Modoboa 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
{% include "modoboa_amavis/_email_display.html" %}
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | {% url "django.views.i18n.javascript_catalog" as catalog_url %} 26 | 27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /modoboa_amavis/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/modoboa_amavis/templatetags/__init__.py -------------------------------------------------------------------------------- /modoboa_amavis/templatetags/amavis_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Amavis frontend template tags. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | 9 | from django import template 10 | from django.template.loader import render_to_string 11 | from django.urls import reverse 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import gettext as _ 14 | 15 | from .. import constants, lib 16 | 17 | register = template.Library() 18 | 19 | 20 | @register.simple_tag 21 | def viewm_menu(user, mail_id, rcpt): 22 | """Menu displayed within the viewmail action.""" 23 | entries = [ 24 | {"name": "back", 25 | "url": "javascript:history.go(-1);", 26 | "img": "fa fa-arrow-left", 27 | "class": "btn-primary", 28 | "label": _("Back")}, 29 | {"name": "release", 30 | "img": "fa fa-check", 31 | "class": "btn-success", 32 | "url": ( 33 | reverse("modoboa_amavis:mail_release", args=[mail_id]) + 34 | ("?rcpt=%s" % rcpt if rcpt else "")), 35 | "label": _("Release")}, 36 | {"name": "delete", 37 | "class": "btn-danger", 38 | "img": "fa fa-trash", 39 | "url": ( 40 | reverse("modoboa_amavis:mail_delete", args=[mail_id]) + 41 | ("?rcpt=%s" % rcpt if rcpt else "")), 42 | "label": _("Delete")}, 43 | {"name": "headers", 44 | "class": "btn-default", 45 | "url": reverse("modoboa_amavis:headers_detail", args=[mail_id]), 46 | "label": _("View full headers")}, 47 | ] 48 | 49 | if lib.manual_learning_enabled(user): 50 | entries.insert(3, { 51 | "name": "process", 52 | "img": "fa fa-cog", 53 | "menu": [ 54 | {"name": "mark-as-spam", 55 | "label": _("Mark as spam"), 56 | "url": reverse( 57 | "modoboa_amavis:mail_mark_as_spam", args=[mail_id] 58 | ) + ("?rcpt=%s" % rcpt if rcpt else ""), 59 | "extra_attributes": { 60 | "data-mail-id": mail_id 61 | }}, 62 | {"name": "mark-as-ham", 63 | "label": _("Mark as non-spam"), 64 | "url": reverse( 65 | "modoboa_amavis:mail_mark_as_ham", args=[mail_id] 66 | ) + ("?rcpt=%s" % rcpt if rcpt else ""), 67 | "extra_attributes": { 68 | "data-mail-id": mail_id 69 | }} 70 | ] 71 | }) 72 | 73 | menu = render_to_string("common/buttons_list.html", 74 | {"entries": entries, "extraclasses": "pull-left"}) 75 | 76 | entries = [{"name": "close", 77 | "url": "javascript:history.go(-1);", 78 | "img": "icon-remove"}] 79 | menu += render_to_string( 80 | "common/buttons_list.html", 81 | {"entries": entries, "extraclasses": "pull-right"} 82 | ) 83 | 84 | return menu 85 | 86 | 87 | @register.simple_tag 88 | def viewm_menu_simple(user, mail_id, rcpt, secret_id=""): 89 | release_url = "{0}?rcpt={1}".format( 90 | reverse("modoboa_amavis:mail_release", args=[mail_id]), rcpt) 91 | delete_url = "{0}?rcpt={1}".format( 92 | reverse("modoboa_amavis:mail_delete", args=[mail_id]), rcpt) 93 | if secret_id: 94 | release_url += "&secret_id={0}".format(secret_id) 95 | delete_url += "&secret_id={0}".format(secret_id) 96 | entries = [ 97 | {"name": "release", 98 | "img": "fa fa-check", 99 | "class": "btn-success", 100 | "url": release_url, 101 | "label": _("Release")}, 102 | {"name": "delete", 103 | "img": "fa fa-trash", 104 | "class": "btn-danger", 105 | "url": delete_url, 106 | "label": _("Delete")}, 107 | ] 108 | 109 | return render_to_string("common/buttons_list.html", 110 | {"entries": entries}) 111 | 112 | 113 | @register.simple_tag 114 | def quar_menu(user): 115 | """Render the quarantine listing menu. 116 | 117 | :rtype: str 118 | :return: resulting HTML 119 | """ 120 | extraopts = [{"name": "to", "label": _("To")}] 121 | return render_to_string("modoboa_amavis/main_action_bar.html", { 122 | "extraopts": extraopts, 123 | "manual_learning": lib.manual_learning_enabled(user), 124 | "msg_types": constants.MESSAGE_TYPES 125 | }) 126 | 127 | 128 | @register.filter 129 | def msgtype_to_color(msgtype): 130 | """Return corresponding color.""" 131 | return constants.MESSAGE_TYPE_COLORS.get(msgtype, "default") 132 | 133 | 134 | @register.filter 135 | def msgtype_to_html(msgtype): 136 | """Transform a message type to a bootstrap label.""" 137 | color = constants.MESSAGE_TYPE_COLORS.get(msgtype, "default") 138 | return mark_safe( 139 | "{}".format( 140 | color, constants.MESSAGE_TYPES[msgtype], msgtype)) 141 | -------------------------------------------------------------------------------- /modoboa_amavis/test_runners.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Custom test runner.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from django.apps import apps 8 | from django.test.runner import DiscoverRunner 9 | 10 | 11 | class UnManagedModelTestRunner(DiscoverRunner): 12 | """ 13 | Test runner that automatically makes all unmanaged models in your Django 14 | project managed for the duration of the test run. 15 | Many thanks to the Caktus Group: http://bit.ly/1N8TcHW 16 | """ 17 | 18 | unmanaged_models = [] 19 | 20 | def setup_test_environment(self, *args, **kwargs): 21 | """Mark modoboa_amavis models as managed during testing 22 | During database setup migrations are only run for managed models""" 23 | for m in apps.get_models(): 24 | if m._meta.app_label == "modoboa_amavis": 25 | self.unmanaged_models.append(m) 26 | m._meta.managed = True 27 | super(UnManagedModelTestRunner, self).setup_test_environment( 28 | *args, **kwargs) 29 | 30 | def teardown_test_environment(self, *args, **kwargs): 31 | """Revert modoboa_amavis models to unmanaged""" 32 | super(UnManagedModelTestRunner, self).teardown_test_environment( 33 | *args, **kwargs) 34 | for m in self.unmanaged_models: 35 | m._meta.managed = False 36 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/modoboa_amavis/tests/__init__.py -------------------------------------------------------------------------------- /modoboa_amavis/tests/sa-learn: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/sample_messages/quarantined-input.txt: -------------------------------------------------------------------------------- 1 | X-Envelope-From: 2 | X-Envelope-To: 3 | X-Envelope-To-Blocked: 4 | X-Quarantine-ID: 5 | X-Amavis-Alert: BAD HEADER SECTION, Non-encoded non-ASCII data (and not UTF-8) 6 | (char 85 hex): Subject: I think I saw you in my dreams\x{85} 7 | X-Spam-Flag: YES 8 | X-Spam-Score: 8.178 9 | X-Spam-Level: ******** 10 | X-Spam-Status: Yes, score=8.178 tag=-999 tag2=3.9 kill=5 11 | tests=[HEADER_FROM_DIFFERENT_DOMAINS=0.25, HTML_IMAGE_ONLY_28=0.726, 12 | HTML_MESSAGE=0.001, MIME_HTML_ONLY=1.105, RCVD_IN_MSPIKE_H2=-0.001, 13 | TVD_PH_BODY_ACCOUNTS_PRE=0.001, T_REMOTE_IMAGE=0.01, 14 | T_RP_MATCHES_RCVD=-0.01, URIBL_BLACK=1.7, URIBL_DBL_ABUSE_SPAM=2, 15 | URI_WPADMIN=2.396] autolearn=no autolearn_force=no 16 | Received: from mail.example.net ([127.0.0.1]) 17 | by mail.example.net (mail.example.net [127.0.0.1]) (amavisd-new, port 10024) 18 | with LMTP id nZkKQ_MUuyil for ; 19 | Sat, 16 Dec 2017 13:09:49 +0000 (GMT) 20 | Received: from evil.example.net (mail.evil.example.net [10.0.0.1]) 21 | by mail.example.net (Postfix) with ESMTPS id 62B2E1AF12D 22 | for ; Sat, 16 Dec 2017 13:09:49 +0000 (GMT) 23 | Received: from it-edu by evil.example.net with local (Exim 4.82) 24 | (envelope-from ) 25 | id 1eQCDr-0005NE-IT 26 | for me@example.net; Sat, 16 Dec 2017 16:09:47 +0300 27 | To: me@example.net 28 | Subject: =?UTF-8?Q?Account_Suspension_Notification_=21?= 29 | X-PHP-Originating-Script: 520:maill.php 30 | Date: Sat, 16 Dec 2017 16:09:47 +0300 31 | From: Notice 32 | Reply-To: Notice@fake.example.net 33 | Message-ID: <6619a5295cefcf484a58944fc280eb90@evil.example.net> 34 | X-Priority: 1 35 | X-Mailer: PHPMailer 5.2.10 (https://github.com/PHPMailer/PHPMailer/) 36 | MIME-Version: 1.0 37 | Content-Type: text/html; charset=iso-8859-1 38 | Content-Transfer-Encoding: 8bit 39 | 40 | 41 | 42 | 54 | 55 | 56 | 57 |

58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 72 | 73 | 75 | 76 | 77 | 78 |
This is an automated email, please do not reply
68 |

Dear Client

69 |

We've noticed that some of your account information appears to be missing or incorrect 70 | We need to verify your account information in order to continue using your Apple ID, Please Verify your account information by clicking on the link below

Click here to Verify your ID

74 |


Thanks for choosing Apple,
Apple Team
79 |

© 2017 Apple. All rights reserved.

80 |

Email ID: 163327

81 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/sample_messages/quarantined-output-plain_nolinks.txt: -------------------------------------------------------------------------------- 1 |
This is an automated email, please do not reply  
 2 | 
 3 | # Dear Client
 4 | 
 5 | We've noticed that some of your account information appears to be missing or
 6 | incorrect We need to verify your account information in order to continue
 7 | using your Apple ID, Please Verify your account information by clicking on the
 8 | link below  
 9 | 
10 |  **Click here to Verify your ID**  
11 | 
12 | Thanks for choosing Apple,  
13 | Apple Team  
14 | 
15 | © 2017 Apple. All rights reserved.
16 | 
17 | Email ID: 163327
-------------------------------------------------------------------------------- /modoboa_amavis/tests/spamc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 5 4 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_checks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | from django.test.utils import override_settings 5 | 6 | from ..checks import settings_checks 7 | 8 | 9 | class CheckSessionCookieSecureTest(TestCase): 10 | 11 | databases = '__all__' 12 | 13 | @override_settings(AMAVIS_DEFAULT_DATABASE_ENCODING="LATIN-1") 14 | def test_amavis_database_encoding_incorrect(self): 15 | """ 16 | If AMAVIS_DEFAULT_DATABASE_ENCODING is incorrect provide one warning. 17 | """ 18 | self.assertEqual( 19 | settings_checks.check_amavis_database_encoding(None), 20 | [settings_checks.W001] 21 | ) 22 | 23 | @override_settings(AMAVIS_DEFAULT_DATABASE_ENCODING="UTF-8") 24 | def test_amavis_database_encoding_correct(self): 25 | """ 26 | If AMAVIS_DEFAULT_DATABASE_ENCODING is correct, there's no warning. 27 | """ 28 | self.assertEqual( 29 | settings_checks.check_amavis_database_encoding(None), 30 | [] 31 | ) 32 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Amavis tests.""" 4 | 5 | import os 6 | 7 | from django.test import override_settings 8 | from django.urls import reverse 9 | 10 | from modoboa.admin import factories as admin_factories, models as admin_models 11 | from modoboa.core import models as core_models 12 | from modoboa.lib.tests import ModoTestCase 13 | from modoboa.transport import factories as tr_factories 14 | from .. import factories, lib, models 15 | 16 | 17 | class DomainTestCase(ModoTestCase): 18 | """Check that database is populated.""" 19 | 20 | databases = '__all__' 21 | 22 | def setUp(self): 23 | """Initiate test context.""" 24 | self.admin = core_models.User.objects.get(username="admin") 25 | 26 | def test_create_domain(self): 27 | """Test domain creation.""" 28 | domain = admin_factories.DomainFactory(name="domain.test") 29 | name = "@{}".format(domain.name) 30 | policy = models.Policy.objects.get(policy_name=name) 31 | user = models.Users.objects.filter(policy=policy).first() 32 | self.assertIsNot(user, None) 33 | self.assertEqual(user.email, name) 34 | 35 | # Create a domain alias 36 | self.client.force_login(self.admin) 37 | data = { 38 | "name": domain.name, 39 | "type": "domain", 40 | "enabled": domain.enabled, 41 | "quota": domain.quota, 42 | "default_mailbox_quota": domain.default_mailbox_quota, 43 | "aliases_1": "dalias.test" 44 | } 45 | self.ajax_post( 46 | reverse("admin:domain_change", args=[domain.pk]), data) 47 | name = "@dalias.test" 48 | self.assertFalse( 49 | models.Policy.objects.filter(policy_name=name).exists()) 50 | user = models.Users.objects.get(email=name) 51 | self.assertEqual(user.policy, policy) 52 | 53 | # Delete domain alias 54 | del data["aliases_1"] 55 | self.ajax_post( 56 | reverse("admin:domain_change", args=[domain.pk]), data) 57 | self.assertFalse( 58 | models.Users.objects.filter(email=name).exists()) 59 | 60 | def test_rename_domain(self): 61 | """Test domain rename.""" 62 | domain = admin_factories.DomainFactory(name="domain.test") 63 | domain.name = "domain1.test" 64 | domain.save() 65 | name = "@{}".format(domain.name) 66 | self.assertTrue( 67 | models.Users.objects.filter(email=name).exists()) 68 | self.assertTrue( 69 | models.Policy.objects.filter(policy_name=name).exists()) 70 | 71 | # Now from form 72 | self.client.force_login(self.admin) 73 | rdomain = admin_factories.DomainFactory( 74 | name="domain.relay", type="relaydomain") 75 | rdomain.transport = tr_factories.TransportFactory( 76 | pattern=rdomain.name, service="relay", 77 | _settings={ 78 | "relay_target_host": "external.host.tld", 79 | "relay_target_port": "25", 80 | "relay_verify_recipients": False 81 | } 82 | ) 83 | rdomain.save() 84 | values = { 85 | "name": "domain2.relay", 86 | "quota": rdomain.quota, 87 | "default_mailbox_quota": rdomain.default_mailbox_quota, 88 | "type": "relaydomain", 89 | "enabled": rdomain.enabled, 90 | "service": rdomain.transport.service, 91 | "relay_target_host": "127.0.0.1", 92 | "relay_target_port": 25, 93 | } 94 | self.ajax_post( 95 | reverse("admin:domain_change", args=[rdomain.pk]), 96 | values 97 | ) 98 | 99 | def test_delete_domain(self): 100 | """Test domain removal.""" 101 | domain = admin_factories.DomainFactory(name="domain.test") 102 | domain.delete(None) 103 | name = "@{}".format(domain.name) 104 | self.assertFalse( 105 | models.Users.objects.filter(email=name).exists()) 106 | self.assertFalse( 107 | models.Policy.objects.filter(policy_name=name).exists()) 108 | 109 | def test_update_domain_policy(self): 110 | """Check domain policy edition.""" 111 | self.client.force_login(self.admin) 112 | domain = admin_factories.DomainFactory(name="domain.test") 113 | url = reverse("admin:domain_change", args=[domain.pk]) 114 | # response = self.client.get(url) 115 | # self.assertContains(response, "Content filter") 116 | custom_title = "This is SPAM!" 117 | data = { 118 | "name": domain.name, 119 | "type": "domain", 120 | "enabled": domain.enabled, 121 | "quota": domain.quota, 122 | "default_mailbox_quota": domain.default_mailbox_quota, 123 | "bypass_virus_checks": "Y", 124 | "spam_subject_tag2_act": False, 125 | "spam_subject_tag2": custom_title 126 | } 127 | self.ajax_post(url, data) 128 | name = "@{}".format(domain.name) 129 | policy = models.Policy.objects.get(policy_name=name) 130 | self.assertEqual(policy.spam_subject_tag2, custom_title) 131 | 132 | 133 | @override_settings(SA_LOOKUP_PATH=(os.path.dirname(__file__), )) 134 | class ManualLearningTestCase(ModoTestCase): 135 | """Check manual learning mode.""" 136 | 137 | databases = "__all__" 138 | 139 | @classmethod 140 | def setUpTestData(cls): # NOQA:N802 141 | """Create test data.""" 142 | super(ManualLearningTestCase, cls).setUpTestData() 143 | admin_factories.populate_database() 144 | 145 | def test_alias_creation(self): 146 | """Check alias creation.""" 147 | self.set_global_parameter("user_level_learning", True) 148 | 149 | # Fake activation because we don't have test data yet for 150 | # amavis... 151 | lib.setup_manual_learning_for_mbox( 152 | admin_models.Mailbox.objects.get( 153 | address="user", domain__name="test.com")) 154 | lib.setup_manual_learning_for_mbox( 155 | admin_models.Mailbox.objects.get( 156 | address="admin", domain__name="test.com")) 157 | 158 | values = { 159 | "address": "alias1000@test.com", 160 | "recipients": "admin@test.com", 161 | "enabled": True 162 | } 163 | self.ajax_post(reverse("admin:alias_add"), values) 164 | policy = models.Policy.objects.get( 165 | policy_name=values["recipients"]) 166 | user = models.Users.objects.get(email=values["address"]) 167 | self.assertEqual(user.policy, policy) 168 | 169 | values = { 170 | "address": "user@test.com", 171 | "recipients": "admin@test.com", 172 | "enabled": True 173 | } 174 | self.ajax_post(reverse("admin:alias_add"), values) 175 | policy = models.Policy.objects.get( 176 | policy_name=values["recipients"]) 177 | user = models.Users.objects.get(email=values["address"]) 178 | self.assertEqual(user.policy, policy) 179 | 180 | def test_mailbox_rename(self): 181 | """Check rename case.""" 182 | self.set_global_parameter("user_level_learning", True) 183 | 184 | lib.setup_manual_learning_for_mbox( 185 | admin_models.Mailbox.objects.get( 186 | address="user", domain__name="test.com")) 187 | 188 | user = core_models.User.objects.get(username="user@test.com") 189 | values = { 190 | "username": "user2@test.com", "role": "SimpleUsers", 191 | "quota_act": True, "is_active": True, "email": "user2@test.com", 192 | "language": "en" 193 | } 194 | url = reverse("admin:account_change", args=[user.pk]) 195 | self.ajax_post(url, values) 196 | self.assertTrue( 197 | models.Users.objects.filter(email=values["email"]).exists() 198 | ) 199 | 200 | def test_learn_alias_spam_as_admin(self): 201 | """Check learning spam for an alias address as admin user.""" 202 | user = core_models.User.objects.get(username="admin") 203 | recipient_db = "user" 204 | rcpt = "alias@test.com" 205 | sender = "spam@evil.corp" 206 | content = factories.SPAM_BODY.format(rcpt=rcpt, sender=sender) 207 | 208 | saclient = lib.SpamassassinClient(user, recipient_db) 209 | result = saclient.learn_spam(rcpt, content) 210 | self.assertTrue(result) 211 | 212 | def test_delete_catchall_alias(self): 213 | """Check that Users record is not deleted.""" 214 | self.set_global_parameter("user_level_learning", True) 215 | 216 | # Fake activation because we don't have test data yet for 217 | # amavis... 218 | lib.setup_manual_learning_for_mbox( 219 | admin_models.Mailbox.objects.get( 220 | address="admin", domain__name="test.com")) 221 | 222 | values = { 223 | "address": "@test.com", 224 | "recipients": "admin@test.com", 225 | "enabled": True 226 | } 227 | self.ajax_post(reverse("admin:alias_add"), values) 228 | 229 | alias = admin_models.Alias.objects.get(address="@test.com") 230 | self.ajax_delete( 231 | reverse("admin:alias_delete") + "?selection={}".format(alias.id) 232 | ) 233 | self.assertTrue(models.Users.objects.get(email="@test.com")) 234 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_lib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.test import SimpleTestCase 6 | 7 | from modoboa.lib.tests import ModoTestCase 8 | from modoboa_amavis.lib import cleanup_email_address, make_query_args 9 | 10 | 11 | class MakeQueryArgsTests(ModoTestCase): 12 | 13 | """Tests for modoboa_amavis.lib.make_query_args().""" 14 | 15 | def test_simple_email_address_001(self): 16 | """Check case insensitive email address without recipient delimiter.""" 17 | self.set_global_parameter("localpart_is_case_sensitive", False) 18 | self.set_global_parameter("recipient_delimiter", "") 19 | address = "User+Foo@sub.exAMPLE.COM" 20 | expected_output = [ 21 | "user+foo@sub.example.com", 22 | ] 23 | output = make_query_args(address) 24 | self.assertEqual(output, expected_output) 25 | 26 | def test_simple_email_address_002(self): 27 | """Check case sensitive email address with recipient delimiter.""" 28 | self.set_global_parameter("localpart_is_case_sensitive", True) 29 | self.set_global_parameter("recipient_delimiter", "+") 30 | address = "User+Foo@sub.exAMPLE.COM" 31 | expected_output = [ 32 | "User+Foo@sub.exAMPLE.COM", 33 | "User+Foo@sub.example.com", 34 | "User@sub.example.com", 35 | ] 36 | output = make_query_args(address) 37 | self.assertEqual(output, expected_output) 38 | 39 | def test_simple_email_address_003(self): 40 | """Check email address with recipient delimiter wildcard.""" 41 | self.set_global_parameter("localpart_is_case_sensitive", False) 42 | self.set_global_parameter("recipient_delimiter", "+") 43 | address = "User+Foo@sub.exAMPLE.COM" 44 | expected_output = [ 45 | "user+foo@sub.example.com", 46 | "user+.*@sub.example.com", 47 | "user@sub.example.com", 48 | ] 49 | output = make_query_args(address, exact_extension=False, wildcard=".*") 50 | self.assertEqual(output, expected_output) 51 | 52 | def test_simple_email_address_004(self): 53 | """Check domain search.""" 54 | self.set_global_parameter("localpart_is_case_sensitive", False) 55 | self.set_global_parameter("recipient_delimiter", "+") 56 | address = "User+Foo@sub.exAMPLE.COM" 57 | expected_output = [ 58 | "user+foo@sub.example.com", 59 | "user+.*@sub.example.com", 60 | "user@sub.example.com", 61 | "@sub.example.com", 62 | "@.", 63 | ] 64 | output = make_query_args( 65 | address, exact_extension=False, wildcard=".*", domain_search=True) 66 | self.assertEqual(output, expected_output) 67 | 68 | def test_simple_email_address_idn(self): 69 | """Check email address with international domain name.""" 70 | self.set_global_parameter("localpart_is_case_sensitive", False) 71 | self.set_global_parameter("recipient_delimiter", "") 72 | address = "Pingüino@Pájaro.Niño.exAMPLE.COM" 73 | expected_output = [ 74 | "Pingüino@Pájaro.Niño.exAMPLE.COM", 75 | "pingüino@xn--pjaro-xqa.xn--nio-8ma.example.com", 76 | ] 77 | output = make_query_args(address) 78 | self.assertEqual(output, expected_output) 79 | 80 | 81 | class FixUTF8EncodingTests(SimpleTestCase): 82 | 83 | """Tests for modoboa_amavis.lib.cleanup_email_address().""" 84 | 85 | def test_value_with_newline(self): 86 | value = "\"John Smith\" \n" 87 | expected_output = "John Smith " 88 | output = cleanup_email_address(value) 89 | self.assertEqual(output, expected_output) 90 | 91 | def test_no_name(self): 92 | value = "" 93 | expected_output = "john.smith@example.com" 94 | output = cleanup_email_address(value) 95 | self.assertEqual(output, expected_output) 96 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_management_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Management commands tests.""" 4 | 5 | from dateutil.relativedelta import relativedelta 6 | 7 | from django.core.management import call_command 8 | from django.utils import timezone 9 | 10 | from modoboa.lib.tests import ModoTestCase 11 | from .. import factories, models 12 | 13 | 14 | class ManagementCommandTestCase(ModoTestCase): 15 | """Management commands tests.""" 16 | 17 | databases = '__all__' 18 | 19 | def test_qcleanup(self): 20 | """Test qcleanup command.""" 21 | factories.create_spam("user@test.com", rs="D") 22 | call_command("qcleanup") 23 | self.assertEqual(models.Quarantine.objects.count(), 0) 24 | self.assertEqual(models.Msgs.objects.count(), 0) 25 | self.assertEqual(models.Maddr.objects.count(), 0) 26 | self.assertEqual(models.Msgrcpt.objects.count(), 0) 27 | 28 | factories.create_spam("user@test.com", rs="D") 29 | msgrcpt = factories.create_spam("user@test.com", rs="R") 30 | call_command("qcleanup") 31 | # Should not raise anything 32 | msgrcpt.refresh_from_db() 33 | 34 | self.set_global_parameter("released_msgs_cleanup", True) 35 | call_command("qcleanup") 36 | with self.assertRaises(models.Msgrcpt.DoesNotExist): 37 | msgrcpt.refresh_from_db() 38 | 39 | msgrcpt = factories.create_spam("user@test.com") 40 | msgrcpt.mail.time_num = int( 41 | (timezone.now() - relativedelta(days=40)).strftime("%s")) 42 | msgrcpt.mail.save(update_fields=["time_num"]) 43 | self.set_global_parameter("max_messages_age", 30) 44 | call_command("qcleanup") 45 | with self.assertRaises(models.Msgrcpt.DoesNotExist): 46 | msgrcpt.refresh_from_db() 47 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_sql_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for sql_email.""" 4 | 5 | import os 6 | 7 | from django.test import TestCase 8 | from django.utils.encoding import smart_str 9 | 10 | from ..sql_email import SQLemail 11 | 12 | SAMPLES_DIR = os.path.realpath( 13 | os.path.join(os.path.dirname(__file__), "sample_messages")) 14 | 15 | 16 | class EmailTestImplementation(SQLemail): 17 | 18 | def _fetch_message(self): 19 | message_path = os.path.join(SAMPLES_DIR, "%s-input.txt" % self.mailid) 20 | assert os.path.isfile(message_path), "%s does not exist." % message_path 21 | 22 | with open(message_path, "rb") as fp: 23 | mail_text = fp.read() 24 | 25 | return mail_text 26 | 27 | 28 | class EmailTests(TestCase): 29 | """Tests for modoboa_amavis.sql_email.SQLEmail 30 | 31 | When writing new sample messages use the following naming convention for 32 | the sample files stored in sample_messages: 33 | 34 | input: {message_id}-input.txt 35 | output: {message_id}-output-{dformat}_{no,}links.txt 36 | """ 37 | 38 | def _get_expected_output(self, message_id, **kwargs): 39 | ext = kwargs["dformat"] if "dformat" in kwargs else "plain" 40 | ext += "_links" if "links" in kwargs and kwargs["links"] else "_nolinks" 41 | message_path = os.path.join(SAMPLES_DIR, 42 | "%s-output-%s.txt" % (message_id, ext)) 43 | assert os.path.isfile(message_path), "%s does not exist." % message_path 44 | 45 | with open(message_path, "rb") as fp: 46 | # output should always be unicode (py2) or str (py3) 47 | mail_text = smart_str(fp.read()) 48 | 49 | return mail_text 50 | 51 | def _test_email(self, message_id, **kwargs): 52 | """Boiler plate code for testing e-mails.""" 53 | expected_output = self._get_expected_output(message_id, **kwargs) 54 | output = EmailTestImplementation(message_id, **kwargs).body 55 | self.assertEqual(output, expected_output) 56 | 57 | def test_amavis_aleart_header(self): 58 | email = EmailTestImplementation("quarantined") 59 | self.assertEqual(email.qtype, "BAD HEADER SECTION") 60 | self.assertEqual(email.qreason, 61 | "Non-encoded non-ASCII data (and not UTF-8) (char 85 " 62 | "hex): Subject: I think I saw you in my dreams\\x{85}") 63 | 64 | def test_email_multipart_with_no_text(self): 65 | """for a multipart message without a text/plain part convert the 66 | text/html to text/plain""" 67 | self._test_email("quarantined") 68 | -------------------------------------------------------------------------------- /modoboa_amavis/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.test import SimpleTestCase 6 | 7 | from modoboa_amavis.utils import fix_utf8_encoding 8 | 9 | 10 | class FixUTF8EncodingTests(SimpleTestCase): 11 | 12 | """Tests for modoboa_amavis.utils.fix_utf8_encoding().""" 13 | 14 | def test_4_byte_unicode(self): 15 | value = "\xf0\x9f\x99\x88" 16 | expected_output = "\U0001f648" # == See No Evil Moneky 17 | output = fix_utf8_encoding(value) 18 | self.assertEqual(output, expected_output) 19 | 20 | def test_truncated_4_byte_unicode(self): 21 | value = "\xf0\x9f\x99" 22 | expected_output = "\xf0\x9f\x99" 23 | output = fix_utf8_encoding(value) 24 | self.assertEqual(output, expected_output) 25 | -------------------------------------------------------------------------------- /modoboa_amavis/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Amavis urls.""" 4 | 5 | from django.urls import path 6 | 7 | from . import views 8 | 9 | app_name = 'modoboa_amavis' 10 | 11 | urlpatterns = [ 12 | path('', views.index, name="index"), 13 | path('listing/', views._listing, name="_mail_list"), 14 | path('listing/page/', views.listing_page, name="mail_page"), 15 | path('getmailcontent//', views.getmailcontent, 16 | name="mailcontent_get"), 17 | path('process/', views.process, name="mail_process"), 18 | path('delete//', views.delete, name="mail_delete"), 19 | path('release//', views.release, 20 | name="mail_release"), 21 | path('markspam//', views.mark_as_spam, 22 | name="mail_mark_as_spam"), 23 | path('markham//', views.mark_as_ham, 24 | name="mail_mark_as_ham"), 25 | path('learning_recipient/', views.learning_recipient, 26 | name="learning_recipient_set"), 27 | path('/', views.viewmail, name="mail_detail"), 28 | path('/headers/', views.viewheaders, 29 | name="headers_detail"), 30 | ] 31 | -------------------------------------------------------------------------------- /modoboa_amavis/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """A collection of utility functions for working with the Amavis database.""" 4 | 5 | import chardet 6 | 7 | from django.conf import settings 8 | from django.db.models.expressions import Func 9 | from django.utils.encoding import ( 10 | smart_bytes as django_smart_bytes, smart_str as django_smart_str, 11 | smart_str as django_smart_str 12 | ) 13 | 14 | 15 | """ 16 | Byte fields for text data are EVIL. 17 | 18 | MySQL uses `varbyte` fields which mysqlclient client maps to `str` (Py2) or 19 | `bytes` (Py3), Djangos smart_* functions work as expected. 20 | 21 | PostgreSQL uses `bytea` fields which psycopg2 maps to `memoryview`, 22 | Djangos smart_* functions don't work as expected, you must call `tobytes()` on 23 | the memoryview for them to work. 24 | 25 | For convenience use smart_bytes and smart_str from this file in modoboa_amavis 26 | to avoid any headaches. 27 | """ 28 | 29 | 30 | def smart_bytes(value, *args, **kwargs): 31 | if isinstance(value, memoryview): 32 | value = value.tobytes() 33 | return django_smart_bytes(value, *args, **kwargs) 34 | 35 | 36 | def smart_str(value, *args, **kwargs): 37 | if isinstance(value, memoryview): 38 | value = value.tobytes() 39 | return django_smart_str(value, *args, **kwargs) 40 | 41 | 42 | def smart_str(value, *args, **kwargs): 43 | if isinstance(value, memoryview): 44 | value = value.tobytes() 45 | return django_smart_str(value, *args, **kwargs) 46 | 47 | 48 | def fix_utf8_encoding(value): 49 | """Fix utf-8 strings that contain utf-8 escaped characters. 50 | 51 | msgs.from_addr and msgs.subject potentialy contain badly escaped utf-8 52 | characters, this utility function fixes that and should be used anytime 53 | these fields are accesses. 54 | 55 | Didn't even know the raw_unicode_escape encoding existed :) 56 | https://docs.python.org/3/library/codecs.html?highlight=raw_unicode_escape#python-specific-encodings 57 | """ 58 | assert isinstance(value, str), "value should be of type str" 59 | 60 | if len(value) == 0: 61 | # short circuit for empty strings 62 | return "" 63 | 64 | bytes_value = value.encode("raw_unicode_escape") 65 | try: 66 | value = bytes_value.decode("utf-8") 67 | except UnicodeDecodeError: 68 | encoding = chardet.detect(bytes_value) 69 | try: 70 | value = bytes_value.decode(encoding["encoding"], "replace") 71 | except (TypeError, UnicodeDecodeError): 72 | # ??? use the original value, we've done our best to try and 73 | # convert it to a clean utf-8 string. 74 | pass 75 | 76 | return value 77 | 78 | 79 | class ConvertFrom(Func): 80 | """Convert a binary value to a string. 81 | Calls the database specific function to convert a binary value to a string 82 | using the encoding set in AMAVIS_DEFAULT_DATABASE_ENCODING. 83 | """ 84 | 85 | """PostgreSQL implementation. 86 | See https://www.postgresql.org/docs/9.3/static/functions-string.html#FUNCTIONS-STRING-OTHER""" # NOQA:E501 87 | function = "convert_from" 88 | arity = 1 89 | template = "%(function)s(%(expressions)s, '{}')".format( 90 | settings.AMAVIS_DEFAULT_DATABASE_ENCODING) 91 | 92 | def as_mysql(self, compiler, connection): 93 | """MySQL implementation. 94 | See https://dev.mysql.com/doc/refman/5.5/en/cast-functions.html#function_convert""" # NOQA:E501 95 | return super().as_sql( 96 | compiler, connection, 97 | function="CONVERT", 98 | template="%(function)s(%(expressions)s USING {})".format( 99 | settings.AMAVIS_DEFAULT_DATABASE_ENCODING), 100 | arity=1, 101 | ) 102 | 103 | def as_sqlite(self, compiler, connection): 104 | """SQLite implementation. 105 | SQLite has no equivilant function, just return the field.""" 106 | return super().as_sql( 107 | compiler, connection, 108 | template="%(expressions)s", 109 | arity=1, 110 | ) 111 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS,migrations 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | [MESSAGES CONTROL] 25 | 26 | # Enable the message, report, category or checker with the given id(s). You can 27 | # either give multiple identifier separated by comma (,) or put this option 28 | # multiple time. See also the "--disable" option for examples. 29 | #enable= 30 | 31 | # Disable the message, report, category or checker with the given id(s). You 32 | # can either give multiple identifiers separated by comma (,) or put this 33 | # option multiple times (only on the command line, not in the configuration 34 | # file where it should appear only once).You can also use "--disable=all" to 35 | # disable everything first and then reenable specific checks. For example, if 36 | # you want to run only the similarities checker, you can use "--disable=all 37 | # --enable=similarities". If you want to run only the classes checker, but have 38 | # no Warning level messages displayed, use"--disable=all --enable=classes 39 | # --disable=W" 40 | disable=no-init,no-member,locally-disabled,too-few-public-methods,too-many-public-methods,too-many-ancestors 41 | 42 | 43 | [REPORTS] 44 | 45 | # Set the output format. Available formats are text, parseable, colorized, msvs 46 | # (visual studio) and html. You can also give a reporter class, eg 47 | # mypackage.mymodule.MyReporterClass. 48 | output-format=text 49 | 50 | # Put messages in a separate file for each module / package specified on the 51 | # command line instead of printing them on stdout. Reports (if any) will be 52 | # written in a file name "pylint_global.[txt|html]". 53 | files-output=no 54 | 55 | # Tells whether to display a full report or only the messages 56 | reports=yes 57 | 58 | # Python expression which should return a note less than 10 (10 is the highest 59 | # note). You have access to the variables errors warning, statement which 60 | # respectively contain the number of errors / warnings messages and the total 61 | # number of statements analyzed. This is used by the global evaluation report 62 | # (RP0004). 63 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 64 | 65 | # Add a comment according to your evaluation note. This is used by the global 66 | # evaluation report (RP0004). 67 | comment=no 68 | 69 | # Template used to display messages. This is a python new-style format string 70 | # used to format the message information. See doc for all details 71 | #msg-template= 72 | 73 | 74 | [LOGGING] 75 | 76 | # Logging modules to check that the string format arguments are in logging 77 | # function parameter format 78 | logging-modules=logging 79 | 80 | 81 | [FORMAT] 82 | 83 | # Maximum number of characters on a single line. 84 | max-line-length=80 85 | 86 | # Regexp for a line that is allowed to be longer than the limit. 87 | ignore-long-lines=^\s*(# )??$ 88 | 89 | # Allow the body of an if to be on the same line as the test if there is no 90 | # else. 91 | single-line-if-stmt=no 92 | 93 | # List of optional constructs for which whitespace checking is disabled 94 | no-space-check=trailing-comma,dict-separator 95 | 96 | # Maximum number of lines in a module 97 | max-module-lines=1000 98 | 99 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 100 | # tab). 101 | indent-string=' ' 102 | 103 | # Number of spaces of indent required inside a hanging or continued line. 104 | indent-after-paren=4 105 | 106 | 107 | [SIMILARITIES] 108 | 109 | # Minimum lines number of a similarity. 110 | min-similarity-lines=4 111 | 112 | # Ignore comments when computing similarities. 113 | ignore-comments=yes 114 | 115 | # Ignore docstrings when computing similarities. 116 | ignore-docstrings=yes 117 | 118 | # Ignore imports when computing similarities. 119 | ignore-imports=no 120 | 121 | 122 | [MISCELLANEOUS] 123 | 124 | # List of note tags to take in consideration, separated by a comma. 125 | notes=FIXME,XXX,TODO 126 | 127 | 128 | [BASIC] 129 | 130 | # Required attributes for module, separated by a comma 131 | required-attributes= 132 | 133 | # List of builtins function names that should not be used, separated by a comma 134 | bad-functions=map,filter,apply,input,file 135 | 136 | # Good variable names which should always be accepted, separated by a comma 137 | good-names=i,j,k,ex,Run,_,tz,logger,pk,qs 138 | 139 | # Bad variable names which should always be refused, separated by a comma 140 | bad-names=foo,bar,baz,toto,tutu,tata 141 | 142 | # Colon-delimited sets of names that determine each other's naming style when 143 | # the name regexes allow several styles. 144 | name-group= 145 | 146 | # Include a hint for the correct naming format with invalid-name 147 | include-naming-hint=no 148 | 149 | # Regular expression matching correct function names 150 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 151 | 152 | # Naming hint for function names 153 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 154 | 155 | # Regular expression matching correct variable names 156 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 157 | 158 | # Naming hint for variable names 159 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 160 | 161 | # Regular expression matching correct constant names 162 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 163 | 164 | # Naming hint for constant names 165 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 166 | 167 | # Regular expression matching correct attribute names 168 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 169 | 170 | # Naming hint for attribute names 171 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 172 | 173 | # Regular expression matching correct argument names 174 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 175 | 176 | # Naming hint for argument names 177 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 178 | 179 | # Regular expression matching correct class attribute names 180 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 181 | 182 | # Naming hint for class attribute names 183 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 184 | 185 | # Regular expression matching correct inline iteration names 186 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 187 | 188 | # Naming hint for inline iteration names 189 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 190 | 191 | # Regular expression matching correct class names 192 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 193 | 194 | # Naming hint for class names 195 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 196 | 197 | # Regular expression matching correct module names 198 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 199 | 200 | # Naming hint for module names 201 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 202 | 203 | # Regular expression matching correct method names 204 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 205 | 206 | # Naming hint for method names 207 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 208 | 209 | # Regular expression which should only match function or class names that do 210 | # not require a docstring. 211 | no-docstring-rgx=__.*__ 212 | 213 | # Minimum line length for functions/classes that require docstrings, shorter 214 | # ones are exempt. 215 | docstring-min-length=-1 216 | 217 | 218 | [TYPECHECK] 219 | 220 | # Tells whether missing members accessed in mixin class should be ignored. A 221 | # mixin class is detected if its name ends with "mixin" (case insensitive). 222 | ignore-mixin-members=yes 223 | 224 | # List of module names for which member attributes should not be checked 225 | # (useful for modules/projects where namespaces are manipulated during runtime 226 | # and thus existing member attributes cannot be deduced by static analysis 227 | ignored-modules= 228 | 229 | # List of classes names for which member attributes should not be checked 230 | # (useful for classes with attributes dynamically set). 231 | ignored-classes=SQLObject 232 | 233 | # When zope mode is activated, add a predefined set of Zope acquired attributes 234 | # to generated-members. 235 | zope=no 236 | 237 | # List of members which are set dynamically and missed by pylint inference 238 | # system, and so shouldn't trigger E0201 when accessed. Python regular 239 | # expressions are accepted. 240 | generated-members=REQUEST,acl_users,aq_parent 241 | 242 | 243 | [VARIABLES] 244 | 245 | # Tells whether we should check for unused import in __init__ files. 246 | init-import=no 247 | 248 | # A regular expression matching the name of dummy variables (i.e. expectedly 249 | # not used). 250 | dummy-variables-rgx=_$|dummy 251 | 252 | # List of additional names supposed to be defined in builtins. Remember that 253 | # you should avoid to define new builtins when possible. 254 | additional-builtins= 255 | 256 | 257 | [DESIGN] 258 | 259 | # Maximum number of arguments for function / method 260 | max-args=5 261 | 262 | # Argument names that match this expression will be ignored. Default to name 263 | # with leading underscore 264 | ignored-argument-names=_.* 265 | 266 | # Maximum number of locals for function / method body 267 | max-locals=15 268 | 269 | # Maximum number of return / yield for function / method body 270 | max-returns=6 271 | 272 | # Maximum number of branch for function / method body 273 | max-branches=12 274 | 275 | # Maximum number of statements in function / method body 276 | max-statements=50 277 | 278 | # Maximum number of parents for a class (see R0901). 279 | max-parents=7 280 | 281 | # Maximum number of attributes for a class (see R0902). 282 | max-attributes=7 283 | 284 | # Minimum number of public methods for a class (see R0903). 285 | min-public-methods=2 286 | 287 | # Maximum number of public methods for a class (see R0904). 288 | max-public-methods=20 289 | 290 | 291 | [CLASSES] 292 | 293 | # List of interface methods to ignore, separated by a comma. This is used for 294 | # instance to not check methods defines in Zope's Interface base class. 295 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 296 | 297 | # List of method names used to declare (i.e. assign) instance attributes. 298 | defining-attr-methods=__init__,__new__,setUp 299 | 300 | # List of valid names for the first argument in a class method. 301 | valid-classmethod-first-arg=cls 302 | 303 | # List of valid names for the first argument in a metaclass class method. 304 | valid-metaclass-classmethod-first-arg=mcs 305 | 306 | 307 | [IMPORTS] 308 | 309 | # Deprecated modules which should not be used, separated by a comma 310 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 311 | 312 | # Create a graph of every (i.e. internal and external) dependencies in the 313 | # given file (report RP0402 must not be disabled) 314 | import-graph= 315 | 316 | # Create a graph of external dependencies in the given file (report RP0402 must 317 | # not be disabled) 318 | ext-import-graph= 319 | 320 | # Create a graph of internal dependencies in the given file (report RP0402 must 321 | # not be disabled) 322 | int-import-graph= 323 | 324 | 325 | [EXCEPTIONS] 326 | 327 | # Exceptions that will emit a warning when being caught. Defaults to 328 | # "Exception" 329 | overgeneral-exceptions=Exception 330 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | chardet 2 | html2text 3 | idna 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [isort] 5 | atomic = true 6 | combine_as_imports = true 7 | combine_star = true 8 | default_section = THIRDPARTY 9 | indent = ' ' 10 | known_django=django 11 | known_drf=rest_framework 12 | known_first_party = modoboa,test_project,modoboa_amavis 13 | multi_line_output = 5 14 | no_lines_before=LOCALFOLDER 15 | not_skip = __init__.py 16 | sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,DRF,FIRSTPARTY,LOCALFOLDER 17 | skip_glob = **/migrations/*.py 18 | use_parentheses = true 19 | 20 | [pep8] 21 | exclude = migrations 22 | max-line-length = 80 23 | 24 | [flake8] 25 | exclude = migrations 26 | max-line-length = 80 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | A setuptools based setup module. 6 | 7 | See: 8 | https://packaging.python.org/en/latest/distributing.html 9 | """ 10 | 11 | import io 12 | from os import path 13 | 14 | try: 15 | from pip.req import parse_requirements 16 | except ImportError: 17 | # pip >= 10 18 | from pip._internal.req import parse_requirements 19 | from setuptools import find_packages, setup 20 | 21 | 22 | def get_requirements(requirements_file): 23 | """Use pip to parse requirements file.""" 24 | requirements = [] 25 | if path.isfile(requirements_file): 26 | for req in parse_requirements(requirements_file, session="hack"): 27 | try: 28 | # check markers, such as 29 | # 30 | # rope_py3k ; python_version >= '3.0' 31 | # 32 | if req.match_markers(): 33 | requirements.append(str(req.req)) 34 | except AttributeError: 35 | # pip >= 20.0.2 36 | requirements.append(req.requirement) 37 | return requirements 38 | 39 | 40 | if __name__ == "__main__": 41 | HERE = path.abspath(path.dirname(__file__)) 42 | INSTALL_REQUIRES = get_requirements(path.join(HERE, "requirements.txt")) 43 | 44 | with io.open(path.join(HERE, "README.rst"), encoding="utf-8") as readme: 45 | LONG_DESCRIPTION = readme.read() 46 | 47 | def local_scheme(version): 48 | """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) 49 | to be able to upload to Test PyPI""" 50 | return "" 51 | 52 | setup( 53 | name="modoboa-amavis", 54 | description="The amavis frontend of Modoboa", 55 | long_description=LONG_DESCRIPTION, 56 | license="MIT", 57 | url="http://modoboa.org/", 58 | author="Antoine Nguyen", 59 | author_email="tonio@ngyn.org", 60 | classifiers=[ 61 | "Development Status :: 5 - Production/Stable", 62 | "Environment :: Web Environment", 63 | "Framework :: Django :: 2.2", 64 | "Intended Audience :: System Administrators", 65 | "License :: OSI Approved :: MIT License", 66 | "Operating System :: OS Independent", 67 | "Programming Language :: Python :: 3", 68 | "Programming Language :: Python :: 3.6", 69 | "Programming Language :: Python :: 3.7", 70 | "Programming Language :: Python :: 3.8", 71 | "Topic :: Communications :: Email", 72 | "Topic :: Internet :: WWW/HTTP", 73 | ], 74 | keywords="email amavis spamassassin", 75 | packages=find_packages(exclude=["docs", "test_project"]), 76 | include_package_data=True, 77 | zip_safe=False, 78 | install_requires=INSTALL_REQUIRES, 79 | use_scm_version={"local_scheme": local_scheme}, 80 | setup_requires=["setuptools_scm"], 81 | ) 82 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | factory-boy>=2.4 2 | testfixtures==7.2.2 3 | psycopg[binary]>=3.1 4 | mysqlclient<2.2.1 5 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError: 13 | # The above import may fail for some other reason. Ensure that the 14 | # issue is really that Django is missing to avoid masking other 15 | # exceptions on Python 2. 16 | try: 17 | import django # noqa 18 | except ImportError: 19 | raise ImportError( 20 | "Couldn't import Django. Are you sure it's installed and " 21 | "available on your PYTHONPATH environment variable? Did you " 22 | "forget to activate a virtual environment?" 23 | ) 24 | raise 25 | execute_from_command_line(sys.argv) 26 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-amavis/aea9f40111fe02e8862713da24988130e1974038/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Django settings for test_project project. 5 | 6 | Generated by 'django-admin startproject' using Django 1.11.8. 7 | 8 | For more information on this file, see 9 | https://docs.djangoproject.com/en/1.11/topics/settings/ 10 | 11 | For the full list of settings and their values, see 12 | https://docs.djangoproject.com/en/1.11/ref/settings/ 13 | """ 14 | 15 | import os 16 | from logging.handlers import SysLogHandler 17 | 18 | from modoboa.test_settings import * # noqa 19 | 20 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 21 | BASE_DIR = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 26 | 27 | # SECURITY WARNING: keep the secret key used in production secret! 28 | SECRET_KEY = "w537@nm@5n)=+e%-7*z-jxf21a#0k%uv^rbu**+cj4=_u57e(8" 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = "DEBUG" in os.environ 32 | 33 | TEMPLATE_DEBUG = DEBUG 34 | 35 | ALLOWED_HOSTS = [ 36 | "127.0.0.1", 37 | "localhost", 38 | ] 39 | 40 | SITE_ID = 1 41 | 42 | # Security settings 43 | 44 | X_FRAME_OPTIONS = "SAMEORIGIN" 45 | 46 | # Application definition 47 | 48 | INSTALLED_APPS = ( 49 | "django.contrib.auth", 50 | "django.contrib.contenttypes", 51 | "django.contrib.sessions", 52 | "django.contrib.messages", 53 | "django.contrib.sites", 54 | "django.contrib.staticfiles", 55 | "reversion", 56 | "ckeditor", 57 | "ckeditor_uploader", 58 | "rest_framework", 59 | "rest_framework.authtoken", 60 | 'django_otp', 61 | 'django_otp.plugins.otp_totp', 62 | 'django_otp.plugins.otp_static', 63 | ) 64 | 65 | # A dedicated place to register Modoboa applications 66 | # Do not delete it. 67 | # Do not change the order. 68 | MODOBOA_APPS = ( 69 | "modoboa", 70 | "modoboa.core", 71 | "modoboa.lib", 72 | "modoboa.admin", 73 | "modoboa.transport", 74 | "modoboa.relaydomains", 75 | "modoboa.limits", 76 | "modoboa.parameters", 77 | "modoboa.dnstools", 78 | "modoboa.maillog", 79 | "modoboa.pdfcredentials", 80 | "modoboa.dmarc", 81 | "modoboa.imap_migration", 82 | # Modoboa extensions here. 83 | "modoboa_amavis", 84 | ) 85 | 86 | INSTALLED_APPS += MODOBOA_APPS 87 | 88 | AUTH_USER_MODEL = "core.User" 89 | 90 | MIDDLEWARE = ( 91 | "x_forwarded_for.middleware.XForwardedForMiddleware", 92 | "django.contrib.sessions.middleware.SessionMiddleware", 93 | "django.middleware.common.CommonMiddleware", 94 | "django.middleware.csrf.CsrfViewMiddleware", 95 | "django.contrib.auth.middleware.AuthenticationMiddleware", 96 | 'django_otp.middleware.OTPMiddleware', 97 | 'modoboa.core.middleware.TwoFAMiddleware', 98 | "django.contrib.messages.middleware.MessageMiddleware", 99 | "django.middleware.locale.LocaleMiddleware", 100 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 101 | "modoboa.core.middleware.LocalConfigMiddleware", 102 | "modoboa.lib.middleware.AjaxLoginRedirect", 103 | "modoboa.lib.middleware.CommonExceptionCatcher", 104 | "modoboa.lib.middleware.RequestCatcherMiddleware", 105 | ) 106 | 107 | AUTHENTICATION_BACKENDS = ( 108 | # 'modoboa.lib.authbackends.LDAPBackend', 109 | # 'modoboa.lib.authbackends.SMTPBackend', 110 | "django.contrib.auth.backends.ModelBackend", 111 | ) 112 | 113 | # SMTP authentication 114 | # AUTH_SMTP_SERVER_ADDRESS = 'localhost' 115 | # AUTH_SMTP_SERVER_PORT = 25 116 | # AUTH_SMTP_SECURED_MODE = None # 'ssl' or 'starttls' are accepted 117 | 118 | 119 | TEMPLATES = [ 120 | { 121 | "BACKEND": "django.template.backends.django.DjangoTemplates", 122 | "DIRS": [], 123 | "APP_DIRS": True, 124 | "OPTIONS": { 125 | "context_processors": [ 126 | "django.template.context_processors.debug", 127 | "django.template.context_processors.request", 128 | "django.contrib.auth.context_processors.auth", 129 | "django.template.context_processors.i18n", 130 | "django.template.context_processors.media", 131 | "django.template.context_processors.static", 132 | "django.template.context_processors.tz", 133 | "django.contrib.messages.context_processors.messages", 134 | "modoboa.core.context_processors.top_notifications", 135 | ], 136 | "debug": TEMPLATE_DEBUG, 137 | }, 138 | }, 139 | ] 140 | 141 | ROOT_URLCONF = "test_project.urls" 142 | 143 | WSGI_APPLICATION = "test_project.wsgi.application" 144 | 145 | 146 | # Internationalization 147 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 148 | 149 | LANGUAGE_CODE = "en-us" 150 | 151 | TIME_ZONE = "UTC" 152 | 153 | USE_I18N = True 154 | 155 | USE_L10N = True 156 | 157 | USE_TZ = True 158 | 159 | # Default primary key field type 160 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 161 | 162 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 163 | 164 | # Static files (CSS, JavaScript, Images) 165 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 166 | 167 | STATIC_URL = "/sitestatic/" 168 | STATIC_ROOT = os.path.join(BASE_DIR, "sitestatic") 169 | STATICFILES_DIRS = ( 170 | # os.path.join(BASE_DIR, '..', 'modoboa', 'bower_components'), 171 | ) 172 | 173 | MEDIA_URL = "/media/" 174 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 175 | 176 | # Rest framework settings 177 | 178 | REST_FRAMEWORK = { 179 | "DEFAULT_AUTHENTICATION_CLASSES": ( 180 | "rest_framework.authentication.TokenAuthentication", 181 | "rest_framework.authentication.SessionAuthentication", 182 | ), 183 | } 184 | 185 | # Modoboa settings 186 | # MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png") 187 | 188 | # DOVECOT_LOOKUP_PATH = ('/path/to/dovecot', ) 189 | 190 | MODOBOA_API_URL = "https://api.modoboa.org/1/" 191 | 192 | # REDIS 193 | 194 | REDIS_HOST = os.environ.get('REDIS_HOST', '127.0.0.1') 195 | REDIS_PORT = os.environ.get('REDIS_PORT', 6379) 196 | REDIS_QUOTA_DB = 0 197 | REDIS_URL = 'redis://{}:{}/{}'.format(REDIS_HOST, REDIS_PORT, REDIS_QUOTA_DB) 198 | 199 | # RQ 200 | 201 | RQ_QUEUES = { 202 | 'default': { 203 | 'URL': REDIS_URL, 204 | }, 205 | } 206 | 207 | # Password validation 208 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 209 | 210 | AUTH_PASSWORD_VALIDATORS = [ 211 | { 212 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # NOQA:E501 213 | }, 214 | { 215 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # NOQA:E501 216 | }, 217 | { 218 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # NOQA:E501 219 | }, 220 | { 221 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # NOQA:E501 222 | }, 223 | { 224 | "NAME": "modoboa.core.password_validation.ComplexityValidator", 225 | "OPTIONS": { 226 | "upper": 1, 227 | "lower": 1, 228 | "digits": 1, 229 | "specials": 0 230 | } 231 | }, 232 | ] 233 | 234 | # CKeditor 235 | 236 | CKEDITOR_UPLOAD_PATH = "uploads/" 237 | 238 | CKEDITOR_IMAGE_BACKEND = "pillow" 239 | 240 | CKEDITOR_RESTRICT_BY_USER = True 241 | 242 | CKEDITOR_BROWSE_SHOW_DIRS = True 243 | 244 | CKEDITOR_ALLOW_NONIMAGE_FILES = False 245 | 246 | CKEDITOR_CONFIGS = { 247 | "default": { 248 | "allowedContent": True, 249 | "toolbar": "Modoboa", 250 | "width": None, 251 | "toolbar_Modoboa": [ 252 | ["Bold", "Italic", "Underline"], 253 | ["JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyBlock"], 254 | ["BidiLtr", "BidiRtl", "Language"], 255 | ["NumberedList", "BulletedList", "-", "Outdent", "Indent"], 256 | ["Undo", "Redo"], 257 | ["Link", "Unlink", "Anchor", "-", "Smiley"], 258 | ["TextColor", "BGColor", "-", "Source"], 259 | ["Font", "FontSize"], 260 | ["Image", ], 261 | ["SpellChecker"] 262 | ], 263 | }, 264 | } 265 | 266 | # Logging configuration 267 | 268 | LOGGING = { 269 | "version": 1, 270 | "formatters": { 271 | "syslog": { 272 | "format": "%(name)s: %(levelname)s %(message)s" 273 | }, 274 | }, 275 | "handlers": { 276 | "syslog-auth": { 277 | "class": "logging.handlers.SysLogHandler", 278 | "facility": SysLogHandler.LOG_AUTH, 279 | "formatter": "syslog" 280 | }, 281 | "modoboa": { 282 | "class": "modoboa.core.loggers.SQLHandler", 283 | } 284 | }, 285 | "loggers": { 286 | "modoboa.auth": { 287 | "handlers": ["syslog-auth", "modoboa"], 288 | "level": "INFO", 289 | "propagate": False 290 | }, 291 | "modoboa.admin": { 292 | "handlers": ["modoboa"], 293 | "level": "INFO", 294 | "propagate": False 295 | } 296 | } 297 | } 298 | 299 | # Load settings from extensions 300 | try: 301 | from modoboa_amavis import settings as modoboa_amavis_settings 302 | modoboa_amavis_settings.apply(globals()) 303 | except AttributeError: 304 | from modoboa_amavis.settings import * # noqa 305 | 306 | 307 | MIGRATION_MODULES = { 308 | "modoboa_amavis": None 309 | } 310 | 311 | TEST_RUNNER = "modoboa_amavis.test_runners.UnManagedModelTestRunner" 312 | 313 | # We force sqlite backend for tests because the generated database is 314 | # not the same as the one provided by amavis... 315 | DATABASES.update({ # noqa 316 | "amavis": { 317 | "ENGINE": "django.db.backends.sqlite3", 318 | "NAME": "amavis.db", 319 | "PORT": "", 320 | "ATOMIC_REQUESTS": True, 321 | }, 322 | }) 323 | # sqlite defaults to UTF-8 324 | AMAVIS_DEFAULT_DATABASE_ENCODING = "UTF-8" 325 | -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.urls import include 4 | from django.urls import re_path 5 | 6 | urlpatterns = [ 7 | re_path(r"", include("modoboa.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import unicode_literals 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 17 | 18 | application = get_wsgi_application() 19 | --------------------------------------------------------------------------------