├── .drone.yml ├── .github └── dependabot.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Make.rules ├── Makefile ├── README.md ├── TODO.txt ├── asimap ├── __init__.py ├── asimapd.py ├── asimapd_user.py ├── auth.py ├── client.py ├── constants.py ├── db.py ├── exceptions.py ├── fetch.py ├── generator.py ├── hashers.py ├── mbox.py ├── mh.py ├── parse.py ├── search.py ├── server.py ├── set_password.py ├── test │ ├── __init__.py │ ├── asimapd_debug_runner.py │ ├── conftest.py │ ├── debug_imap_client.py │ ├── factories.py │ ├── fixtures │ │ └── mhdir │ │ │ ├── one │ │ │ ├── 1 │ │ │ ├── 2 │ │ │ ├── 3 │ │ │ ├── 4 │ │ │ ├── 5 │ │ │ ├── 6 │ │ │ ├── 7 │ │ │ ├── 8 │ │ │ ├── 9 │ │ │ ├── 10 │ │ │ ├── 11 │ │ │ ├── 12 │ │ │ ├── 13 │ │ │ ├── 14 │ │ │ ├── 15 │ │ │ ├── 16 │ │ │ ├── 17 │ │ │ ├── 18 │ │ │ ├── 19 │ │ │ ├── 20 │ │ │ ├── 21 │ │ │ └── 22 │ │ │ └── problems │ │ │ ├── 1 │ │ │ ├── 2 │ │ │ ├── 3 │ │ │ └── 4 │ ├── test_auth.py │ ├── test_client.py │ ├── test_db.py │ ├── test_fetch.py │ ├── test_generator.py │ ├── test_mbox.py │ ├── test_mh.py │ ├── test_parse.py │ ├── test_search.py │ ├── test_server.py │ ├── test_throttle.py │ ├── test_user_server.py │ └── test_utils.py ├── throttle.py ├── trace.py ├── user_server.py └── utils.py ├── docker-compose.yml ├── docs └── rfcs │ ├── rfc2045.txt │ ├── rfc2088.txt │ ├── rfc2177.txt │ ├── rfc2180.txt │ ├── rfc3348.txt │ ├── rfc3501.txt │ ├── rfc3502.txt │ ├── rfc3691.txt │ ├── rfc4315.txt │ ├── rfc4551.txt │ ├── rfc5162.txt │ ├── rfc5256.txt │ ├── rfc5258.txt │ ├── rfc5267.txt │ ├── rfc5465.txt │ └── rfc9051.txt ├── pyproject.toml ├── requirements ├── Makefile ├── build.in ├── build.txt ├── development.in ├── development.txt ├── lint.in ├── lint.txt ├── production.in └── production.txt └── scripts ├── asimapd.sh ├── imap_client.py ├── message_load_times.py └── move_to_subdir_by_year.py /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | name: AS IMAP Tests 4 | 5 | steps: 6 | - name: test 7 | image: "python:3.12.9" 8 | environment: 9 | PYTHONUNBUFFERED: 1 10 | PYTHONPATH: /drone/src 11 | 12 | DEBUG: true 13 | commands: 14 | - pip install -U pip 15 | - pip install -r ./requirements/development.txt 16 | - pytest 17 | 18 | --- 19 | kind: pipeline 20 | name: Build and Publish Docker Image 21 | 22 | depends_on: 23 | - AS IMAP Tests 24 | 25 | steps: 26 | - name: Build prod 27 | image: plugins/docker 28 | settings: 29 | username: 30 | from_secret: GHCR_USER 31 | password: 32 | from_secret: GHCR_PAT 33 | repo: ghcr.io/scanner/asimap 34 | registry: ghcr.io 35 | auto_tag: true 36 | target: prod 37 | build_args: 38 | - VERSION=${DRONE_TAG} 39 | 40 | - name: Build dev 41 | image: plugins/docker 42 | settings: 43 | username: 44 | from_secret: GHCR_USER 45 | password: 46 | from_secret: GHCR_PAT 47 | repo: ghcr.io/scanner/asimap 48 | registry: ghcr.io 49 | auto_tag: true 50 | auto_tag_suffix: dev 51 | target: dev 52 | build_args: 53 | - VERSION=${DRONE_TAG} 54 | 55 | trigger: 56 | event: 57 | - tag 58 | 59 | --- 60 | kind: pipeline 61 | name: Build Notify 62 | 63 | depends_on: 64 | - AS IMAP Tests 65 | - Build and Publish Docker Image 66 | 67 | trigger: 68 | status: 69 | - success 70 | - failure 71 | 72 | steps: 73 | - name: slack notification 74 | image: plugins/slack 75 | settings: 76 | webhook: 77 | from_secret: slack_notify_webhook 78 | channel: builds 79 | username: drone 80 | template: >- 81 | {{#if build.pull }} 82 | *{{#success build.status }}✔{{ else }}✘{{/success }} {{ uppercasefirst build.status }}*: 83 | {{ else }} 84 | *{{#success build.status }}✔{{ else }}✘{{/success }} {{ uppercasefirst build.status }}: Build {{ build.number }}* (type: `{{ build.event }}`) 85 | {{/if }} 86 | 87 | Repository: 88 | 89 | Commit message: {{ build.message }} 90 | 91 | Commit: 92 | 93 | Branch: 94 | 95 | Author: {{ build.author }} 96 | 97 | Duration: {{ since build.started }} 98 | 99 | <{{ build.link }}|Visit build page ↗> 100 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | debug 3 | .cache 4 | ssl 5 | *.pyc 6 | *~ 7 | *.bak 8 | .pytest_cache 9 | asimap.egg-info 10 | venv* 11 | .tox 12 | .isorted 13 | .blackened 14 | .coverage 15 | .package 16 | dist 17 | asimap_test_dir 18 | .env 19 | *.env 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-added-large-files 10 | args: ["--maxkb=1200"] 11 | - repo: https://github.com/pycqa/isort 12 | rev: 6.0.1 13 | hooks: 14 | - id: isort 15 | name: isort (python) 16 | - repo: https://github.com/psf/black 17 | rev: 25.1.0 18 | hooks: 19 | - id: black 20 | language_version: python3.12 21 | args: [--config=./pyproject.toml] 22 | - repo: https://github.com/astral-sh/ruff-pre-commit 23 | # Ruff version. 24 | rev: v0.9.9 25 | hooks: 26 | - id: ruff 27 | # NOTE: move before 'isort' and 'black' if --fix is enabled 28 | # args: [--fix, --exit-non-zero-on-fix] 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - linting upraded to `ruff` 13 | - updated how we build requirements, structure Makefiles 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ######################### 2 | # 3 | # Builder stage 4 | # 5 | FROM python:3.12.9 AS builder 6 | 7 | ARG APP_HOME=/app 8 | WORKDIR ${APP_HOME} 9 | COPY requirements/build.txt requirements/production.txt /app/requirements/ 10 | COPY README.md LICENSE Makefile Make.rules pyproject.toml /app/ 11 | RUN python -m venv --copies /venv 12 | RUN . /venv/bin/activate && \ 13 | pip install --upgrade pip && \ 14 | pip install --upgrade setuptools && \ 15 | pip install -r /app/requirements/build.txt && \ 16 | pip install -r /app/requirements/production.txt 17 | 18 | COPY asimap /app/asimap 19 | RUN . /venv/bin/activate && python -m build 20 | 21 | ######################### 22 | # 23 | # includes the 'development' requirements 24 | # 25 | FROM builder AS dev 26 | 27 | LABEL org.opencontainers.image.source=https://github.com/scanner/asimap 28 | LABEL org.opencontainers.image.description="Apricot Systematic IMAP Demon" 29 | LABEL org.opencontainers.image.licenses=BSD-3-Clause 30 | 31 | RUN apt update && apt install --assume-yes jove vim nmh && apt clean 32 | 33 | ENV PYTHONUNBUFFERED=1 34 | ENV PYTHONDONTWRITEBYTECODE=1 35 | 36 | ARG APP_HOME=/app 37 | 38 | WORKDIR ${APP_HOME} 39 | ENV PYTHONPATH=${APP_HOME} 40 | 41 | COPY README.md LICENSE Makefile Make.rules pyproject.toml /app/ 42 | COPY requirements/development.txt /app/requirements/development.txt 43 | COPY asimap /app/asimap 44 | RUN . /venv/bin/activate && pip install -r requirements/development.txt 45 | 46 | # Puts the venv's python (and other executables) at the front of the 47 | # PATH so invoking 'python' will activate the venv. 48 | # 49 | ENV PATH=/venv/bin:/usr/bin/mh:$PATH 50 | 51 | WORKDIR ${APP_HOME} 52 | 53 | RUN addgroup --system --gid 900 app \ 54 | && adduser --system --uid 900 --ingroup app app 55 | 56 | USER app 57 | 58 | CMD ["python", "/app/asimap/asimapd.py"] 59 | 60 | ######################### 61 | # 62 | # `app` - The docker image for the django app web service 63 | # 64 | FROM python:3.12.9-slim AS prod 65 | 66 | LABEL org.opencontainers.image.source=https://github.com/scanner/asimap 67 | LABEL org.opencontainers.image.description="Apricot Systematic IMAP Demon" 68 | LABEL org.opencontainers.image.licenses=BSD-3-Clause 69 | 70 | RUN apt update && apt install --assume-yes nmh && apt clean 71 | 72 | ENV PYTHONUNBUFFERED=1 73 | ENV PYTHONDONTWRITEBYTECODE=1 74 | 75 | # We only want the installable dist we created in the builder. 76 | # 77 | COPY --from=builder /app/dist /app/dist 78 | COPY --from=builder /venv /venv 79 | 80 | ARG VERSION 81 | RUN . /venv/bin/activate && \ 82 | pip install /app/dist/asimap-${VERSION}-py3-none-any.whl 83 | 84 | # Puts the venv's python (and other executables) at the front of the 85 | # PATH so invoking 'python' will activate the venv. 86 | # 87 | ENV PATH=/venv/bin:/usr/bin/mh:$PATH 88 | 89 | ARG APP_HOME=/app 90 | WORKDIR ${APP_HOME} 91 | 92 | RUN addgroup --system --gid 900 app \ 93 | && adduser --system --uid 900 --ingroup app app 94 | 95 | USER app 96 | 97 | # NOTE: All the configuration for asimapd, like where the password file is and 98 | # where the SSL files are are passed via env vars. 99 | # 100 | CMD ["/venv/bin/asimapd"] 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright 2011,2012,2023 Eric 'Scanner' Luce. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README 2 | include asimapd.py 3 | recursive-include tests * 4 | -------------------------------------------------------------------------------- /Make.rules: -------------------------------------------------------------------------------- 1 | # -*- Mode: Makefile -*- 2 | 3 | ACTIVATE := source $(ROOT_DIR)/venv/bin/activate && 4 | PYTHON ?= python3.12 5 | VERSION := $(shell PYTHONPATH=`pwd` python -c 'import asimap ; print(asimap.__version__)') 6 | 7 | .git/hooks/pre-commit .git/hooks/pre-push: venv 8 | @$(ACTIVATE) pre-commit install 9 | @echo "pre-commit hooks installed!" 10 | @touch .git/hooks/pre-commit .git/hooks/pre-push 11 | 12 | clean:: ## Swab the decks! Does not touch docker images or volumes. 13 | @find $(ROOT_DIR) -name \*~ -exec rm '{}' + 14 | @find $(ROOT_DIR) -name \*.pyc -exec rm '{}' + 15 | @find $(ROOT_DIR) -name __pycache__ -prune -exec rm -fr '{}' + 16 | @find $(ROOT_DIR) -name .mypy_cache -prune -exec rm -fr '{}' + 17 | @rm -rf build bdist cover dist sdist distribute-* *.egg *.egg-info 18 | @rm -rf node_modules 19 | @rm -rf *.tar.gz junit.xml coverage.xml .cache 20 | @rm -rf .tox .eggs .blackened .isorted .ruff_cache 21 | @rm -rf venv* 22 | @find $(ROOT_DIR) \( -name \*.orig -o -name \*.bak -o -name \*.rej \) -exec rm '{}' + 23 | @make -C requirements/ clean 24 | @mkdir .mypy_cache 25 | 26 | requirements/production.txt: requirements/production.in 27 | @make -C requirements/ production.txt 28 | requirements/lint.txt: requirements/lint.in 29 | @make -C requirements/ lint.txt 30 | requirements/development.txt: requirements/development.in requirements/lint.txt requirements/production.txt 31 | @make -C requirements/ development.txt 32 | 33 | requirements: requirements/development.txt ## Rebuild out of date requirements 34 | 35 | $(ROOT_DIR)/venv: requirements/development.txt 36 | @if [ -d "$@" ] ; then \ 37 | $(ACTIVATE) pip install -U pip ; \ 38 | $(ACTIVATE) pip-sync $(ROOT_DIR)/requirements/development.txt ; \ 39 | else \ 40 | $(PYTHON) -m venv $@ ; \ 41 | $(ACTIVATE) pip install -U pip ; \ 42 | $(ACTIVATE) pip install -U setuptools ; \ 43 | $(ACTIVATE) pip install -r $(ROOT_DIR)/requirements/development.txt ; \ 44 | fi 45 | @touch $@ 46 | 47 | venv:: $(ROOT_DIR)/venv 48 | 49 | # Set the Make variable `VERSION` to the version of our project 50 | version: venv 51 | $(eval VERSION := $(shell $(ACTIVATE) hatch version)) 52 | @echo "Version: $(VERSION)" 53 | 54 | # Squeegee vs lint targets. `lint` is pre-commit, so it does what you 55 | # need done for the pre-commit hook to pass. Squeegee runs the various 56 | # linting and formatting commands directly. It also runs mypy. 57 | # 58 | squeegee: venv isort black mypy ## Manually run isort, black, mypy, and ruff over all project files 59 | @$(ACTIVATE) ruff check $(ROOT_DIR) 60 | 61 | lint: venv .git/hooks/pre-commit ## Run all pre-commit hooks. Note: this only runs over files in the git repo (and staged fiels) 62 | @$(ACTIVATE) pre-commit run -a 63 | 64 | PY_FILES=$(shell find $(ROOT_DIR)/asimap/ $(ROOT_DIR)/scripts/ -type f -name '*.py') 65 | JS_FILES=$(shell find $(ROOT_DIR)/asimap/ $(ROOT_DIR)/scripts/ -type f -name '*.js') 66 | .blackened: $(PY_FILES) venv 67 | @$(ACTIVATE) black $(ROOT_DIR)/asimap/ $(ROOT_DIR)/scripts/ 68 | @touch .blackened 69 | 70 | .isorted: $(PY_FILES) venv 71 | @$(ACTIVATE) isort $(ROOT_DIR)/asimap/ $(ROOT_DIR)/scripts/ 72 | @touch .isorted 73 | 74 | .prettier: $(JS_FILES) 75 | @npx prettier --write $(ROOT_DIR)/asimap 76 | @touch .prettier 77 | 78 | formatting: isort black prettier ## Run `isort`, `black`, `prettier` over all files in project. 79 | isort: .isorted 80 | black: .blackened 81 | prettier: .prettier 82 | 83 | mypy: venv ## Run `mypy` over `app/` and `scripts/` 84 | @$(ACTIVATE) mypy --install-types --non-interactive --explicit-package-bases ./app/ ./scripts/ 85 | 86 | .PHONY: requirements formatting lint squeegee isort black mypy version 87 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # -*- Mode: Makefile -*- 2 | ROOT_DIR := $(shell git rev-parse --show-toplevel) 3 | include $(ROOT_DIR)/Make.rules 4 | DOCKER_BUILDKIT := 1 5 | 6 | .PHONY: clean lint test test-units test-integrations mypy logs shell restart delete down up build dirs help package publish tag publish-tag 7 | 8 | test-integrations: venv 9 | PYTHONPATH=`pwd` $(ACTIVATE) pytest -m integration asimap/ 10 | 11 | test-units: venv 12 | PYTHONPATH=`pwd` $(ACTIVATE) pytest -m "not integration" asimap/ 13 | 14 | test: venv 15 | PYTHONPATH=`pwd` $(ACTIVATE) pytest asimap/ 16 | 17 | coverage: venv 18 | PYTHONPATH=`pwd` $(ACTIVATE) coverage run -m pytest asimap/ 19 | coverage html 20 | open 'htmlcov/index.html' 21 | 22 | build: version requirements/build.txt requirements/development.txt ## `docker build` for both `prod` and `dev` targets 23 | COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker build --build-arg VERSION="$(VERSION)" --target prod --tag asimap:$(VERSION) --tag asimap:prod . 24 | COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker build --build-arg VERSION=$(VERSION) --target dev --tag asimap:$(VERSION)-dev --tag asimap:dev . 25 | 26 | asimap_test_dir: ## Create directory running local development 27 | @mkdir -p $(ROOT_DIR)/asimap_test_dir 28 | 29 | traces_dir: ## Create traces directory for running local development 30 | @mkdir -p $(ROOT_DIR)/asimap_test_dir/traces 31 | 32 | ssl: ## Creates the ssl directory used to hold development ssl cert and key 33 | @mkdir -p $(ROOT_DIR)/asimap_test_dir/ssl 34 | 35 | dirs: asimap_test_dir ssl traces_dir 36 | 37 | # XXX Should we have an option to NOT use certs/mkcert (either just make 38 | # self-signed ourself) in case a developer does not want to go through the 39 | # potential risks associated with mkcert? 40 | # 41 | asimap_test_dir/ssl/ssl_key.pem asimap_test_dir/ssl/ssl_crt.pem: 42 | @mkcert -key-file $(ROOT_DIR)/asimap_test_dir/ssl/ssl_key.pem \ 43 | -cert-file $(ROOT_DIR)/asimap_test_dir/ssl/ssl_crt.pem \ 44 | `hostname` localhost 127.0.0.1 ::1 45 | 46 | certs: ssl asimap_test_dir/ssl/ssl_key.pem asimap_test_dir/ssl/ssl_crt.pem ## uses `mkcert` to create certificates for local development. 47 | 48 | up: build dirs certs ## build and then `docker compose up` for the `dev` profile. Use this to rebuild restart services that have changed. 49 | @docker compose --profile dev up --remove-orphans --detach 50 | 51 | down: ## `docker compose down` for the `dev` profile 52 | @docker compose --profile dev down --remove-orphans 53 | 54 | rebuild-restart: down build up ## docker compose down -> build -> up for the `dev` profile 55 | 56 | delete: clean ## docker compose down for `dev` and `prod` and `make clean`. 57 | @docker compose --profile dev down --remove-orphans 58 | @docker compose --profile prod down --remove-orphans 59 | 60 | restart: ## docker compose restart for the `dev` profile 61 | @docker compose --profile dev restart 62 | 63 | shell: ## Make a bash shell an ephemeral dev container 64 | @docker compose run --rm -ti asimap-dev /bin/bash 65 | 66 | exec-shell: ## Make a bash shell in the docker-compose running imap-dev container 67 | @docker compose exec -ti asimap-dev /bin/bash 68 | 69 | .package: version venv $(PY_FILES) pyproject.toml README.md LICENSE Makefile requirements/build.txt requirements/production.txt 70 | @PYTHONPATH=`pwd` $(ACTIVATE) python -m build 71 | @touch .package 72 | 73 | package: .package ## build python package (.tar.gz and .whl) 74 | 75 | install: version package ## Install asimap via pip install of the package wheel 76 | pip install --force-reinstall -U $(ROOT_DIR)/dist/asimap-$(VERSION)-py3-none-any.whl 77 | 78 | # Should mark the published tag as a release on github 79 | release: package ## Make a release. Tag based on the version. 80 | 81 | publish: package ## Publish the package to pypi 82 | 83 | tag: version ## Tag the git repo with the current version of asimapd. 84 | @if git rev-parse "$(VERSION)" >/dev/null 2>&1; then \ 85 | echo "Tag '$(VERSION)' already exists (skipping 'git tag')" ; \ 86 | else \ 87 | git tag --sign "$(VERSION)" -m "Version $(VERSION)"; \ 88 | echo "Tagged with '$(VERSION)'" ; \ 89 | fi 90 | 91 | publish-tag: version tag ## Tag (if not already tagged) and publish the tag of the current version to git `origin` branch 92 | @git push origin tag $(VERSION) 93 | 94 | help: ## Show this help. 95 | @grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 96 | 97 | clean:: ## Swab the decks! Does not touch docker images or volumes. 98 | @rm -rf $(ROOT_DIR)/asimap_test_dir $(ROOT_DIR)/.package $(ROOT_DIR)/dist 99 | 100 | logs: ## Tail the logs for imap-dev container 101 | @docker compose logs -f imap-dev 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://drone.apricot.com/api/badges/scanner/asimap/status.svg?ref=refs/heads/main)](https://drone.apricot.com/scanner/asimap) 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 3 | 4 | 'asimap' is a python IMAP server that uses python's mailbox module to 5 | provide the backing store. This lets us export things like an MH mail 6 | store via IMAP. Actually the way it is coded right now it ONLY support 7 | 'mailbox.MH' style folders. 8 | 9 | We go to various lengths to actually work alongside currently running 10 | MH clients accessing the same files at the same time. 11 | 12 | It uses a multiprocess model where there is a main server process 13 | running as root and for any logged in user there is a sub-process 14 | running as that user. If a user logs in via more than one mail client 15 | all of their connections will be handled by the same 16 | sub-process. Sub-processes stick around for a short while after a user 17 | disconnects to avoid startup time if they connect again within, say, 30 18 | minutes. 19 | 20 | This is not a high performance server being pure python and it is not 21 | a highly scaleable one requiring a process for every logged in user. 22 | 23 | NOTE: Placeholder instructions 24 | 25 | * How to Build and Install 26 | 27 | `make package` will build the installable package. 28 | `make install` will install that built package 29 | 30 | * How to Run 31 | 32 | ** `asimapd` 33 | 34 | Whether inside a docker container or from the command line `asimapd` tries to 35 | assume reasonable defaults to run without needing to specify any command line 36 | options. 37 | 38 | Usually the most important command line option is `--pwfile` which contains the 39 | location of the account/password file. The format of the password file is a 40 | username, a password hash, and the root directory that contains the maildir for 41 | that user. 42 | 43 | The password hash uses the routines from Django so that a password saved from a 44 | Django app can be used in `asimapd`. See 45 | https://github.com/django/django/blob/main/django/contrib/auth/hashers.py for 46 | details. 47 | 48 | Command line argument for asimapd: 49 | 50 | ``` text 51 | NOTE: For all command line options that can also be specified via an env. var: 52 | the command line option will override the env. var if set. 53 | 54 | Usage: 55 | asimapd [--address=] [--port=

] [--cert=] [--key=] 56 | [--trace] [--trace-dir=] [--debug] [--log-config=] 57 | [--pwfile=] 58 | 59 | Options: 60 | --version 61 | -h, --help Show this text and exit 62 | --address= The address to listen on. Defaults to '0.0.0.0'. 63 | The env. var is `ADDRESS`. 64 | --port=

Port to listen on. Defaults to: 993. 65 | The env. var is `PORT` 66 | --cert= SSL Certificate file. If not set defaults to 67 | `/opt/asimap/ssl/cert.pem`. The env var is SSL_CERT 68 | --key= SSL Certificate key file. If not set defaults to 69 | `/opt/asimap/ssl/key.pem`. The env var is SSL_KEY 70 | 71 | --trace For debugging and generating protocol test data `trace` 72 | can be enabled. When enabled messages will appear on the 73 | `asimap.trace` logger where the `message` part of the log 74 | message is a JSON dump of the message being sent or 75 | received. This only happens for post-authentication IMAP 76 | messages (so nothing about logging in is recorded.) 77 | However the logs are copious! The default logger will dump 78 | trace logs where `--trace-dir` specifies. 79 | 80 | --trace-dir= The directory trace log files are written to. Unless 81 | overriden by specifying a custom log config! Since traces 82 | use the logging system if you supply a custom log config 83 | and turn tracing on that will override this. By default 84 | trace logs will be written to `/opt/asimap/traces/`. By 85 | default the traces will be written using a 86 | RotatingFileHandler with a size of 20mb, and backup count 87 | of 5 using the pythonjsonlogger.jsonlogger.JsonFormatter. 88 | 89 | --debug Will set the default logging level to `DEBUG` thus 90 | enabling all of the debug logging. The env var is `DEBUG` 91 | 92 | --log-config= The log config file. This file may be either a JSON file 93 | that follows the python logging configuration dictionary 94 | schema or a file that coforms to the python logging 95 | configuration file format. If no file is specified it will 96 | check in /opt/asimap, /etc, /usr/local/etc, /opt/local/etc 97 | for a file named `asimapd_log.cfg` or `asimapd_log.json`. 98 | If no valid file can be found or loaded it will defaut to 99 | logging to stdout. The env. var is `LOG_CONFIG` 100 | 101 | --pwfile= The file that contains the users and their hashed passwords 102 | The env. var is `PWFILE`. Defaults to `/opt/asimap/pwfile` 103 | ``` 104 | 105 | ** `asimapd_set_password` 106 | 107 | ``` text 108 | A script to set passwords for asimap accounts (creates the account if it 109 | does not exist.) 110 | 111 | This is primarily used for setting up a test development environment. In the 112 | Apricot Systematic typical deployment the password file is managed by the 113 | `as_email_service` 114 | 115 | If the `password` is not supplied an unuseable password is set effectively 116 | disabling the account. 117 | 118 | If the account does not already exist `maildir` must be specified (as it 119 | indicates the users mail directory root) 120 | 121 | NOTE: `maildir` is in relation to the root when asimapd is running. 122 | 123 | Usage: 124 | set_password [--pwfile=] [] [] 125 | 126 | Options: 127 | --version 128 | -h, --help Show this text and exit 129 | --pwfile= The file that contains the users and their hashed passwords 130 | The env. var is `PWFILE`. Defaults to `/opt/asimap/pwfile` 131 | ``` 132 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | Now that the basic server is working it is time to establish specific 2 | things I need to do. 3 | 4 | * break up resync all in to: 5 | * find new folders 6 | * resync on folders that have not been resync'd in (five?) minutes 7 | * expire active folders that are around past their expiry 8 | 9 | * add continuing and queued work support per mailbox 10 | XXX Still needs tweaking but it basically works now. 11 | o we still need to add proper support for this with 'search' !! Definitely! 12 | o we need to look at our STORE support and see if it needs any tweaking. 13 | 14 | * add 'MailboxInconsistency' exception and handlers at the client.py 15 | that when they get this have a better idea what to do (things like 16 | 'send a BAD to the client, 'be silent and retry command', 17 | 'disconnect client') 18 | 19 | XXX 1/4 implemented. We have the exception and some commands grok it 20 | and restart. 21 | 22 | * Handle mailbox.MH and mailbox.MHMessage exceptions like 'malformed 23 | sequence' and such.. like MailboxInconsistency.. mostly we just need 24 | to retry since the problem wil be fixed by the time we get to it 25 | again.. usually it is because we and some MH command were fighting 26 | over things liek the .sequences file. 27 | 28 | XXX I think I have this.. but really it needs to be tested and I am 29 | not quite happy how I am handling the exception. I almost think 30 | I should be handling it at a lower level. Too much repeating 31 | myself in code. 32 | 33 | * Watching Mail.app if a folder has pending expunges we send back 34 | No's, but it keeps trying to do fetch's.. I wonder if we should do 35 | an unceremonious close on a client if it asks for FETCH's like 10 36 | times in a row while there are pending expunges. 37 | 38 | XXX Yet to see if this actually works. 39 | 40 | * add mulitple port (ssl/non-ssl) support 41 | 42 | XXX have not tested this yet, but then SSL support is there but does 43 | not work properly when Mail.app is talking to us (but 'openssl 44 | s_client' works fine) 45 | 46 | * add UIDPLUS (rfc4315) support 47 | 48 | * write tool to split huge mailboxes in to mailbox + sub-mailbox by 49 | year, keeping the last 1,000 messages in the current folder. 50 | 51 | * fix logging so each subprocess output goes to its own logfile (named 52 | with local and imap user names) 53 | 54 | * daemonize properly. 55 | 56 | * add proper unix account authentication support 57 | * make sure we change to the right user 58 | * Must support throttling so that brute forcing causes lock outs. 59 | untested! (right now it is 3 bad auths in 60 seconds locks out 60 | that IP address, 4 bad auths in 60 seconds for a specific user 61 | locks out that user name for 60 seconds.) 62 | 63 | * fix renaming folders with subfolders 64 | 65 | * add SSL support 66 | 67 | * fix literal+ support 68 | 69 | * implement rfc3348 the 'child mailbox extension' 70 | 71 | * There is a bug with renaming a folder to a new name that has 72 | subfolders (not making it a subfolder of other folders.) 73 | 74 | I think that although my recursion function should work it is a bit 75 | too confusing and that I can replace it with a simple loop over 76 | matching records in the database that will be as effecting and a 77 | clearer to understand. 78 | 79 | * this is a problem when an IMAP tries to copy a message to a folder 80 | that is locked by some other process and the message ends up getting 81 | copied ot the destination folder multiple times. 82 | 83 | This mostly happens when my spam marking has locked the 'junk' 84 | folder and Mail.app tries to put more messages in that folder 85 | 86 | Fixed... it was not about queued commands but the resync on the 87 | destination folder failing due to a mailboxlock and the upper level 88 | basically re-queueing the entire command that had already been 89 | executed.. 90 | 91 | o Apparently we should not even send untagged FETCH's to client unless 92 | they are in these states: 93 | 94 | - as the result of a client command (e.g., FETCH responses to a 95 | FETCH or STORE command), 96 | - as unsolicited responses sent just before the end of a command 97 | (e.g., EXISTS or EXPUNGE) as the result of changes in other 98 | sessions, and 99 | - during an IDLE command. 100 | 101 | Right now we are generating untagged FETCH's to clients when the 102 | state of messages in a mailbox changes. We need to put these on to 103 | things like the expunge queue. 104 | 105 | o Find a way to make 'check all folders' run in the background. Maybe 106 | fork a subprocess for it? and we parse the results when it finishes? 107 | 108 | Or instead how about we get rid of find_all_folders. WHen we resync 109 | a folder we track the last time we found its sub-folders. If it has 110 | been more than an hour or something we get that folder's list of 111 | sub-folders. If any of those folders are NOT in our db, we add them 112 | (and either call resync now or wait until the next 'check all 113 | folders' runs.) 114 | 115 | o when we queue a command for later due to a mailbox lock we should 116 | delay re-running it for like 0.1 seconds to prevent busy spins on 117 | locks. I guess put in a 'delay' or something in the queued job and 118 | 'last time run' if delay is true, then delay running it until we 119 | have passed the delay time. 120 | 121 | o Add support for NOTIFY (rfc5465). I think this implies I need to 122 | support rfc5267 as well. We should also support rfc5258 - LIST-EXTENDED 123 | 124 | o some sort of intelligent fallback for messages with malformed 125 | headers such that we can not find the UID properly. 126 | 127 | o make COPY command queue'able. 128 | 129 | o make SEARCH command queue'able again. 130 | 131 | o add a better test system so we can run the full server but have it 132 | not run as root, and not change to a user upon auth, and have the mail 133 | dir exist in a set well known place. 134 | 135 | o add command/response tracing facility and hook it into the ability 136 | to run regression tests against a running server. 137 | 138 | o write a unit test suite for the components we can test separately 139 | (like IMAP message parsing, search & fetch over a set of test 140 | messages.) 141 | 142 | === 2012.03.23 === 143 | 144 | o 'check_all_folders' is pretty quick... 4.8s on 145 | kamidake.apricot.com for my account - but how about instead of doing 146 | a sweep of folders we know about we basically queueu up a series of 147 | requests to check the status for each folder. It would have the same 148 | effect, but instead of blocking the server for seconds, it would 149 | flow normally, almost like an IMAP client doing CHECK commands 150 | on every folder. 151 | 152 | === 2012.07.23 === 153 | 154 | Suggestions from Bill Janssen (janssen@parc.com) 155 | 156 | Add to the readme or a how to run the server document text that better 157 | text that tells you what you need to bring the server up. He pointed 158 | out: 159 | 160 | * Install pytz first. 161 | 162 | * "sudo mkdir -p /var/log/asimapd/" first. 163 | 164 | * Use utils/asimap_auth to create logins with passwords, before firing 165 | up the server for the first time. 166 | 167 | * Edit asimap/auth.py to use "~/MH-Mail" for my mail root instead of "~/Mail". 168 | 169 | A couple of suggestions: 170 | 171 | * You'd probably like Tornado (http://tornadoweb.org/). Great 172 | replacement for the buggy and poorly maintained async*. -- look in to 173 | tornado to replace async* 174 | 175 | * It would be great if auth.py read ~/.mh_profile to get things like the 176 | maildir. The vars "Path" (the maildir), "MailDrop" (the mbox spool 177 | file to inc from), and "Flist-Order" (an ordering for mailboxes) might 178 | be useful. 179 | -------------------------------------------------------------------------------- /asimap/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apricot Systematic IMAP server 3 | """ 4 | 5 | __version__ = "2.1.30" 6 | __authors__ = ["Scanner Luce "] 7 | -------------------------------------------------------------------------------- /asimap/asimapd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | The AS IMAP Daemon. This is intended to be run as root. It provides an 7 | IMAP service that is typically backed by MH mail folders. 8 | 9 | NOTE: For all command line options that can also be specified via an env. var: 10 | the command line option will override the env. var if set. 11 | 12 | Usage: 13 | asimapd [--address=] [--port=

] [--cert=] [--key=] 14 | [--trace] [--trace-dir=] [--debug] [--log-config=] 15 | [--pwfile=] 16 | 17 | Options: 18 | --version 19 | -h, --help Show this text and exit 20 | --address= The address to listen on. Defaults to '0.0.0.0'. 21 | The env. var is `ADDRESS`. 22 | --port=

Port to listen on. Defaults to: 993. 23 | The env. var is `PORT` 24 | --cert= SSL Certificate file. If not set defaults to 25 | `/opt/asimap/ssl/cert.pem`. The env var is SSL_CERT 26 | --key= SSL Certificate key file. If not set defaults to 27 | `/opt/asimap/ssl/key.pem`. The env var is SSL_KEY 28 | 29 | --trace For debugging and generating protocol test data `trace` 30 | can be enabled. When enabled messages will appear on the 31 | `asimap.trace` logger where the `message` part of the log 32 | message is a JSON dump of the message being sent or 33 | received. This only happens for post-authentication IMAP 34 | messages (so nothing about logging in is recorded.) 35 | However the logs are copious! The default logger will dump 36 | trace logs where `--trace-dir` specifies. 37 | 38 | --trace-dir= The directory trace log files are written to. Unless 39 | overriden by specifying a custom log config! Since traces 40 | use the logging system if you supply a custom log config 41 | and turn tracing on that will override this. By default 42 | trace logs will be written to `/opt/asimap/traces/`. By 43 | default the traces will be written using a 44 | RotatingFileHandler with a size of 20mb, and backup count 45 | of 5 using the pythonjsonlogger.jsonlogger.JsonFormatter. 46 | 47 | --debug Will set the default logging level to `DEBUG` thus 48 | enabling all of the debug logging. The env var is `DEBUG` 49 | 50 | --log-config= The log config file. This file may be either a JSON file 51 | that follows the python logging configuration dictionary 52 | schema or a file that coforms to the python logging 53 | configuration file format. If no file is specified it will 54 | check in /opt/asimap, /etc, /usr/local/etc, /opt/local/etc 55 | for a file named `asimapd_log.cfg` or `asimapd_log.json`. 56 | If no valid file can be found or loaded it will defaut to 57 | logging to stdout. The env. var is `LOG_CONFIG` 58 | 59 | --pwfile= The file that contains the users and their hashed passwords 60 | The env. var is `PWFILE`. Defaults to `/opt/asimap/pwfile` 61 | """ 62 | # system imports 63 | # 64 | import asyncio 65 | import logging 66 | import os 67 | import ssl 68 | from pathlib import Path 69 | 70 | # 3rd party imports 71 | # 72 | from docopt import docopt 73 | from dotenv import load_dotenv 74 | 75 | # Application imports 76 | # 77 | from asimap import __version__ as VERSION 78 | from asimap import auth 79 | from asimap.server import IMAPServer 80 | from asimap.user_server import set_user_server_program 81 | from asimap.utils import setup_asyncio_logging, setup_logging 82 | 83 | logger = logging.getLogger("asimap.asimapd") 84 | 85 | 86 | ############################################################################# 87 | # 88 | def main(): 89 | """ 90 | Our main entry point. Parse the options, set up logging, go in to 91 | daemon mode if necessary, setup the asimap library and start 92 | accepting connections. 93 | """ 94 | args = docopt(__doc__, version=VERSION) 95 | address = args["--address"] 96 | port = args["--port"] 97 | port = int(port) if port else None 98 | ssl_cert_file = args["--cert"] 99 | ssl_key_file = args["--key"] 100 | trace = args["--trace"] 101 | trace_dir = args["--trace-dir"] 102 | debug = args["--debug"] 103 | log_config = args["--log-config"] 104 | pwfile = args["--pwfile"] 105 | 106 | load_dotenv() 107 | 108 | # If docopt is not, see if the option is set in the os.environ. If it not 109 | # set there either, then set it to the default value. 110 | # 111 | if address is None: 112 | address = ( 113 | os.environ["ADDRESS"] if "ADDRESS" in os.environ else "0.0.0.0" 114 | ) 115 | if port is None: 116 | port = os.environ["PORT"] if "PORT" in os.environ else 993 117 | if ssl_cert_file is None: 118 | ssl_cert_file = ( 119 | os.environ["SSL_CERT"] 120 | if "SSL_CERT" in os.environ 121 | else "/opt/asimap/ssl/ssl_crt.pem" 122 | ) 123 | ssl_cert_file = Path(ssl_cert_file) 124 | if ssl_key_file is None: 125 | ssl_key_file = ( 126 | os.environ["SSL_KEY"] 127 | if "SSL_KEY" in os.environ 128 | else "/opt/asimap/ssl/ssl_key.pem" 129 | ) 130 | ssl_key_file = Path(ssl_key_file) 131 | # If debug is not enabled via the command line, see if it is enabled via 132 | # the env var. 133 | # 134 | if not debug: 135 | debug = bool(os.environ["DEBUG"]) if "DEBUG" in os.environ else False 136 | if log_config is None: 137 | log_config = ( 138 | os.environ["LOG_CONFIG"] if "LOG_CONFIG" in os.environ else None 139 | ) 140 | if trace is None: 141 | trace = os.environ["TRACE"] if "TRACE" in os.environ else False 142 | if trace_dir is None: 143 | trace_dir = ( 144 | os.environ["TRACE_DIR"] 145 | if "TRACE_DIR" in os.environ 146 | else Path("/opt/asimap/traces") 147 | ) 148 | if pwfile is None: 149 | pwfile = ( 150 | os.environ["PWFILE"] 151 | if "PWFILE" in os.environ 152 | else "/opt/asimap/pwfile" 153 | ) 154 | 155 | # If a password file was specified overwrote the default location for the 156 | # password file in the asimap.auth module. 157 | # 158 | if pwfile: 159 | setattr(auth, "PW_FILE_LOCATION", pwfile) 160 | 161 | if not ssl_cert_file.exists() or not ssl_key_file.exists(): 162 | raise FileNotFoundError( 163 | f"Both '{ssl_cert_file}' and '{ssl_key_file}' must exist." 164 | ) 165 | 166 | # After we setup our logging handlers and formatters set up for asyncio 167 | # logging so that logging calls do not block the asyncio event loop. 168 | # 169 | setup_logging(log_config, debug, trace_dir=trace_dir) 170 | setup_asyncio_logging() 171 | logger.info("ASIMAPD Starting, version: %s", VERSION) 172 | 173 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 174 | ssl_context.check_hostname = False 175 | ssl_context.load_cert_chain(ssl_cert_file, ssl_key_file) 176 | 177 | logger.info("Binding address: %s:%d", address, port) 178 | 179 | # ASIMap is run internally as a main server that accepts connections and 180 | # authenticates them, and once it authenticates them it passes control to a 181 | # subprocess. One subprocess per authenticated user. 182 | # 183 | # We use the program `asimapd_user.py` as the entry point for this user's 184 | # subprocess. One subprocess per user. Multiple connections from the same 185 | # user go to this one subprocess. 186 | # 187 | # Using the location of the server program determine the location of 188 | # the user server program. 189 | # 190 | user_server_program = Path(__file__).parent / "asimapd_user.py" 191 | user_server_program.resolve(strict=True) 192 | 193 | # Make sure the user server program exists and is executable before we go 194 | # any further.. 195 | # 196 | if not user_server_program.exists() or not user_server_program.is_file(): 197 | logger.error( 198 | "User server program does not exist or is not a file: '%s'", 199 | user_server_program, 200 | ) 201 | exit(-1) 202 | 203 | # Set this as a variable in the asimap.user_server module. 204 | # 205 | logger.debug("user server program is: '%s'", user_server_program) 206 | set_user_server_program(user_server_program) 207 | 208 | server = IMAPServer( 209 | address, 210 | port, 211 | ssl_context, 212 | trace=trace, 213 | trace_dir=trace_dir, 214 | log_config=log_config, 215 | debug=debug, 216 | ) 217 | try: 218 | asyncio.run(server.run()) 219 | except KeyboardInterrupt: 220 | logger.warning("Keyboard interrupt, exiting") 221 | finally: 222 | logging.shutdown() 223 | print("Finally exited asyncio main loop") 224 | 225 | 226 | ############################################################################ 227 | ############################################################################ 228 | # 229 | # Here is where it all starts 230 | # 231 | if __name__ == "__main__": 232 | main() 233 | # 234 | # 235 | ############################################################################ 236 | ############################################################################ 237 | -------------------------------------------------------------------------------- /asimap/asimapd_user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | This is the 'user mail store' agent for the asimpad server. This is invoked 7 | as a subprocess by asimapd when a user has authenticated. It is not intended to 8 | be run directly from the command line by a user. 9 | 10 | It runs as the user whose mailbox is being accessed. 11 | 12 | All IMAP connections authenticated as the same user will all use the same 13 | instance of the asimapd_user.py process. 14 | 15 | It expects to be run within the directory where the user's asimapd db file for 16 | their mail spool is. 17 | 18 | Usage: 19 | asimapd_user.py [--trace] [--trace-dir=] [--log-config=] 20 | [--debug] 21 | 22 | Options: 23 | --version 24 | -h, --help Show this text and exit 25 | 26 | --debug Will set the default logging level to `DEBUG` thus 27 | enabling all of the debuggign logging. 28 | 29 | --log-config= The log config file. This file may be either a JSON file 30 | that follows the python logging configuration dictionary 31 | schema or a file that coforms to the python logging 32 | configuration file format. If no file is specified it will 33 | check in /etc, /usr/local/etc, /opt/local/etc for a file 34 | named `asimapd_log.cfg` or `asimapd_log.json`. If no 35 | valid file can be found or loaded it will defaut to 36 | logging to stdout. 37 | 38 | --trace For debugging and generating protocol test data `trace` 39 | can be enabled. When enabled messages will appear on the 40 | `asimap.trace` logger where the `message` part of the log 41 | message is a JSON dump of the message being sent or 42 | received. This only happens for post-authentication IMAP 43 | messages (so nothing about logging in is recorded.) 44 | However the logs are copious! The default logger will dump 45 | trace logs where `--trace-dir` specifies. 46 | 47 | --trace-dir= The directory trace log files are written to. Unless 48 | overriden by specifying a custom log config! Since traces 49 | use the logging system if you supply a custom log config 50 | and turn tracing on that will override this. By default 51 | trace logs will be written to `/opt/asimap/traces/`. By 52 | default the traces will be written using a 53 | RotatingFileHandler with a size of 20mb, and backup count 54 | of 5 using the pythonjsonlogger.jsonlogger.JsonFormatter. 55 | 56 | XXX We communicate with the server via localhost TCP sockets. We REALLY should 57 | set up some sort of authentication key that the server must use when 58 | connecting to us. Perhaps we will use stdin for that in the 59 | future. Otherwise this is a bit of a nasty security hole. 60 | """ 61 | # system imports 62 | # 63 | import asyncio 64 | import codecs 65 | import logging 66 | import sys 67 | from pathlib import Path 68 | 69 | # 3rd party imports 70 | # 71 | from docopt import docopt 72 | from dotenv import load_dotenv 73 | 74 | # Application imports 75 | # 76 | import asimap.trace 77 | from asimap import __version__ as VERSION 78 | from asimap.user_server import IMAPUserServer 79 | from asimap.utils import ( 80 | encoding_search_fn, 81 | setup_asyncio_logging, 82 | setup_logging, 83 | ) 84 | 85 | logger = logging.getLogger("asimap.asimapd_user") 86 | 87 | 88 | ############################################################################# 89 | # 90 | async def create_and_start_user_server(maildir: Path, debug: bool): 91 | server = await IMAPUserServer.new(maildir, debug=debug) 92 | await server.run() 93 | 94 | 95 | ############################################################################# 96 | # 97 | def main(): 98 | """ 99 | Parse arguments, setup logging, setup tracing, create the user server 100 | object and start the asyncio main event loop on the user server. 101 | """ 102 | load_dotenv() 103 | args = docopt(__doc__, version=VERSION) 104 | trace = args["--trace"] 105 | trace_dir = args["--trace-dir"] 106 | debug = args["--debug"] 107 | log_config = args["--log-config"] 108 | username = args[""] 109 | 110 | # Register the additional codec translations we support. 111 | # 112 | codecs.register(encoding_search_fn) 113 | 114 | # After we setup our logging handlers and formatters set up for asyncio 115 | # logging so that logging calls do not block the asyncio event loop. 116 | # 117 | setup_logging(log_config, debug, username=username, trace_dir=trace_dir) 118 | setup_asyncio_logging() 119 | maildir = Path.cwd() 120 | logger.info( 121 | "Starting new user server for '%s', maildir: '%s'", username, maildir 122 | ) 123 | 124 | if trace: 125 | asimap.trace.toggle_trace(turn_on=True) 126 | 127 | try: 128 | asyncio.run(create_and_start_user_server(maildir, debug)) 129 | except KeyboardInterrupt: 130 | logger.warning("Keyboard interrupt, exiting, user: %s", username) 131 | except Exception as e: 132 | logger.exception( 133 | "For user %s Failed with uncaught exception %s", username, str(e) 134 | ) 135 | sys.exit(1) 136 | 137 | 138 | ############################################################################ 139 | ############################################################################ 140 | # 141 | # Here is where it all starts 142 | # 143 | if __name__ == "__main__": 144 | main() 145 | # 146 | # 147 | ############################################################################ 148 | ############################################################################ 149 | -------------------------------------------------------------------------------- /asimap/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines classes that are used by the main server to 3 | authenticate users. 4 | """ 5 | 6 | # system imports 7 | # 8 | import logging 9 | from datetime import datetime 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING, Dict, Union 12 | 13 | # 3rd party imports 14 | # 15 | import aiofiles.os 16 | 17 | # asimapd imports 18 | # 19 | from asimap.exceptions import BadAuthentication, NoSuchUser 20 | from asimap.hashers import acheck_password 21 | 22 | if TYPE_CHECKING: 23 | from _typeshed import StrPath 24 | 25 | logger = logging.getLogger("asimap.auth") 26 | 27 | # We populate a dict of mappings from username to their user object. 28 | # This is read in from the pw file. 29 | # 30 | USERS: Dict[str, "PWUser"] = {} 31 | 32 | # We keep track of the last time we read the pw file so that if the underlying 33 | # file has its timestamp changed we know to read the users in again. This value 34 | # is floating point number giving the number of seconds since the epoch. 35 | # 36 | PW_FILE_LAST_TIMESTAMP = 0.0 37 | 38 | # This is the default location for the password file. It is expected to be 39 | # modified by the asimapd main module based on passed in parameters. 40 | # 41 | # This file is a text file of the format: 42 | # :: 43 | # 44 | # Lines begining with "#" are comments. whitespace is stripped from each 45 | # element. The acceptable format of the password hash is determine by what the 46 | # `asimap.hashers` module considers valid. 47 | # 48 | PW_FILE_LOCATION = "/var/db/asimapd_passwords.txt" 49 | 50 | 51 | ################################################################## 52 | ################################################################## 53 | # 54 | class PWUser: 55 | """ 56 | The basic user object. The user name, their password hash, and the path 57 | to their Mail dir. 58 | """ 59 | 60 | ################################################################## 61 | # 62 | def __init__(self, username: str, maildir: "StrPath", password_hash: str): 63 | """ """ 64 | self.username = username 65 | self.maildir = Path(maildir) 66 | self.pw_hash = password_hash 67 | 68 | ################################################################## 69 | # 70 | def __str__(self): 71 | return self.username 72 | 73 | 74 | #################################################################### 75 | # 76 | async def authenticate(username: str, password: str) -> PWUser: 77 | """ 78 | Authenticate the given username with the given password against our set 79 | of users. 80 | 81 | If the password file has been a modification time more recent then 82 | PW_FILE_LAST_TIMESTAMP, then before we lookup and authenticate a user we 83 | will re-read all the users into memory. 84 | 85 | NOTE: Obviously this is meant for "small" numbers of users in the hundreds 86 | range. 87 | """ 88 | global PW_FILE_LAST_TIMESTAMP 89 | mtime = await aiofiles.os.path.getmtime(PW_FILE_LOCATION) 90 | if mtime > PW_FILE_LAST_TIMESTAMP: 91 | logger.info( 92 | "Reading password file due to last modified: %s", PW_FILE_LOCATION 93 | ) 94 | await read_users_from_file(PW_FILE_LOCATION) 95 | PW_FILE_LAST_TIMESTAMP = mtime 96 | 97 | if username not in USERS: 98 | raise NoSuchUser(f"No such user '{username}'") 99 | 100 | user = USERS[username] 101 | if not await acheck_password(password, user.pw_hash): 102 | raise BadAuthentication 103 | return user 104 | 105 | 106 | #################################################################### 107 | # 108 | async def read_users_from_file(pw_file_name: "StrPath") -> None: 109 | """ 110 | Reads all the user entries from the password file, construction User 111 | objects for each one. Then updates `USERS` with new dict of User objects. 112 | 113 | NOTE: If the `maildir` path in the password file is not absolute then the 114 | path is considered relative to the location of the password file. 115 | 116 | NOTE: We should put this in to a common "apricot systematic" module that 117 | can be shared by both asimapd and as_email_service. 118 | """ 119 | pw_file_name = Path(pw_file_name) 120 | users = {} 121 | async with aiofiles.open(str(pw_file_name), "r") as f: 122 | async for line in f: 123 | line = line.strip() 124 | if not line or line[0] == "#": 125 | continue 126 | try: 127 | maildir: Union[str | Path] 128 | username, pw_hash, maildir = [ 129 | x.strip() for x in line.split(":") 130 | ] 131 | maildir = Path(maildir) 132 | if not maildir.is_absolute(): 133 | maildir = pw_file_name.parent / maildir 134 | users[username] = PWUser(username, maildir, pw_hash) 135 | except ValueError as exc: 136 | logger.error( 137 | "Unable to unpack password record %s: %s", 138 | line, 139 | exc, 140 | ) 141 | for username, user in users.items(): 142 | USERS[username] = users[username] 143 | 144 | # And delete any records from USER's that were not in the pwfile. 145 | # 146 | existing_users = set(USERS.keys()) 147 | new_users = set(users.keys()) 148 | for username in existing_users - new_users: 149 | del USERS[username] 150 | 151 | 152 | #################################################################### 153 | # 154 | def write_pwfile(pwfile: Path, accounts: Dict[str, PWUser]) -> None: 155 | """ 156 | we support a password file by email account with the password hash and 157 | maildir for that email account. This is for inteegration with other 158 | services (such as the asimap service) 159 | 160 | This will write all the entries in the accounts dict in to the indicated 161 | password file. 162 | """ 163 | new_pwfile = pwfile.with_suffix(".new") 164 | with new_pwfile.open("w") as f: 165 | f.write( 166 | f"# File generated by asimap set_password at {datetime.now()}\n" 167 | ) 168 | for account in sorted(accounts.keys()): 169 | # Maildir is written as a path relative to the location of the 170 | # pwfile. This is because we do not know how these files are rooted 171 | # when other services read them so we them relative to the pwfile. 172 | # 173 | user = accounts[account] 174 | f.write(f"{account}:{user.pw_hash}:{user.maildir}\n") 175 | new_pwfile.rename(pwfile) 176 | -------------------------------------------------------------------------------- /asimap/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | Various global constants. 7 | """ 8 | from collections import defaultdict 9 | from enum import StrEnum 10 | from typing import List, Optional, Set, TypeAlias 11 | 12 | Sequences: TypeAlias = defaultdict[str, Set[int]] 13 | 14 | 15 | # Here we set the list of defined system flags (flags that may be set on a 16 | # message) and the subset of those flags that may not be set by a user. 17 | # 18 | # XXX Convert these to StrEnum's. 19 | # 20 | class SystemFlags(StrEnum): 21 | ANSWERED = r"\Answered" 22 | DELETED = r"\Deleted" 23 | DRAFT = r"\Draft" 24 | FLAGGED = r"\Flagged" 25 | RECENT = r"\Recent" 26 | SEEN = r"\Seen" 27 | 28 | 29 | SYSTEM_FLAGS = ( 30 | r"\Answered", 31 | r"\Deleted", 32 | r"\Draft", 33 | r"\Flagged", 34 | r"\Recent", 35 | r"\Seen", 36 | ) 37 | NON_SETTABLE_FLAGS = r"\Recent" 38 | PERMANENT_FLAGS = ( 39 | r"\Answered", 40 | r"\Deleted", 41 | r"\Draft", 42 | r"\Flagged", 43 | r"\Seen", 44 | r"\*", 45 | ) 46 | 47 | # mh does not allow '\' in sequence names so we have a mapping between 48 | # the actual mh sequence name and the corresponding system flag. 49 | # 50 | SYSTEM_FLAG_MAP = { 51 | "replied": r"\Answered", 52 | "Deleted": r"\Deleted", 53 | "Draft": r"\Draft", 54 | "flagged": r"\Flagged", 55 | "Recent": r"\Recent", 56 | "Seen": r"\Seen", 57 | } 58 | 59 | REV_SYSTEM_FLAG_MAP = {v: k for k, v in SYSTEM_FLAG_MAP.items()} 60 | 61 | 62 | #################################################################### 63 | # 64 | def flags_to_seqs(flags: Optional[List[str]]) -> List[str]: 65 | """ 66 | Converts an array of IMAP flags to MH sequence names. 67 | """ 68 | flags = [] if flags is None else flags 69 | return [flag_to_seq(x) for x in flags] 70 | 71 | 72 | #################################################################### 73 | # 74 | def flag_to_seq(flag): 75 | """ 76 | Map an IMAP flag to an mh sequence name. This basically sees if the flag 77 | is one we need to translate or not. 78 | 79 | Arguments: 80 | - `flag`: The IMAP flag we are going to translate. 81 | """ 82 | return REV_SYSTEM_FLAG_MAP[flag] if flag in REV_SYSTEM_FLAG_MAP else flag 83 | 84 | 85 | #################################################################### 86 | # 87 | def seqs_to_flags(seqs: Optional[List[str]]) -> List[str]: 88 | """ 89 | Converts an array of MH sequence names to IMAP flags 90 | """ 91 | seqs = [] if seqs is None else seqs 92 | return [seq_to_flag(x) for x in seqs] 93 | 94 | 95 | #################################################################### 96 | # 97 | def seq_to_flag(seq: str) -> str: 98 | """ 99 | The reverse of flag to seq - map an MH sequence name to the IMAP flag. 100 | 101 | Arguments: 102 | - `seq`: The MH sequence name 103 | """ 104 | return SYSTEM_FLAG_MAP[seq] if seq in SYSTEM_FLAG_MAP else seq 105 | -------------------------------------------------------------------------------- /asimap/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | Some exceptions need to be generally available to many modules so they are 7 | kept in this module to avoid ciruclar dependencies. 8 | """ 9 | 10 | # system imports 11 | # 12 | 13 | 14 | ####################################################################### 15 | # 16 | # We have some basic exceptions used during the processing of commands 17 | # to determine how we respond in exceptional situations 18 | # 19 | class ProtocolException(Exception): 20 | def __init__(self, value="protocol exception"): 21 | self.value = value 22 | 23 | def __str__(self): 24 | return self.value 25 | 26 | 27 | ################################################################## 28 | ################################################################## 29 | # 30 | class No(ProtocolException): 31 | def __init__(self, value="no"): 32 | self.value = value 33 | 34 | def __str__(self): 35 | return self.value 36 | 37 | 38 | ################################################################## 39 | ################################################################## 40 | # 41 | class Bad(ProtocolException): 42 | def __init__(self, value="bad"): 43 | self.value = value 44 | 45 | def __str__(self): 46 | return self.value 47 | 48 | 49 | ################################################################## 50 | ################################################################## 51 | # 52 | class MailboxInconsistency(ProtocolException): 53 | """ 54 | When processing commands on a mailbox it is possible to hit a 55 | state where what is on disk is not what we expected it to be. 56 | 57 | Frequently the base action in these cases is to punt (because we 58 | are usually in a place where we can not regain consistency and 59 | maintain state). 60 | 61 | The upper layer is expected to catch this, initiate actions to 62 | regain consistent state, and then likely try the command again. 63 | """ 64 | 65 | def __init__( 66 | self, value="mailbox inconsistencey", mbox_name=None, msg_key=None 67 | ): 68 | self.value = value 69 | self.mbox_name = mbox_name 70 | self.msg_key = msg_key 71 | 72 | def __str__(self): 73 | return "%s in mailbox '%s', msg key: %s" % ( 74 | self.value, 75 | self.mbox_name, 76 | str(self.msg_key), 77 | ) 78 | 79 | 80 | ################################################################## 81 | ################################################################## 82 | # 83 | class MailboxLock(ProtocolException): 84 | """ 85 | Raised when we are unable to get a lock on a mailbox 86 | """ 87 | 88 | ################################################################## 89 | # 90 | def __init__(self, value="Mailbox lock", mbox=None): 91 | """ 92 | Arguments: 93 | - `value`: 94 | - `mbox`: the mbox.Mailbox object we had a problem getting a lock on 95 | """ 96 | self.value = value 97 | self.mbox = mbox 98 | 99 | ################################################################## 100 | # 101 | def __str__(self): 102 | if self.mbox is None: 103 | return self.value 104 | else: 105 | return "%s on mailbox %s" % (self.value, self.mbox.name) 106 | 107 | 108 | ############################################################################ 109 | # 110 | # Our authentication system has its own set of exceptions. 111 | # 112 | class AuthenticationException(Exception): 113 | def __init__(self, value="bad!"): 114 | self.value = value 115 | 116 | def __str__(self): 117 | return repr(self.value) 118 | 119 | 120 | ############################################################################ 121 | # 122 | class BadAuthentication(AuthenticationException): 123 | pass 124 | 125 | 126 | ############################################################################ 127 | # 128 | class NoSuchUser(AuthenticationException): 129 | pass 130 | 131 | 132 | ############################################################################ 133 | # 134 | class AuthenticationError(AuthenticationException): 135 | pass 136 | -------------------------------------------------------------------------------- /asimap/mh.py: -------------------------------------------------------------------------------- 1 | """ 2 | Re-implement some of the methods on mailbox.MH using aiofiles for async access 3 | """ 4 | 5 | # System imports 6 | # 7 | import asyncio 8 | import errno 9 | import logging 10 | import mailbox 11 | import os 12 | import stat 13 | from contextlib import asynccontextmanager 14 | from mailbox import NoSuchMailboxError, _lock_file 15 | from pathlib import Path 16 | from typing import TYPE_CHECKING, Union 17 | 18 | # 3rd party imports 19 | # 20 | import aiofiles 21 | import aiofiles.os 22 | 23 | # from charset_normalizer import from_bytes 24 | 25 | if TYPE_CHECKING: 26 | from _typeshed import StrPath 27 | 28 | LINESEP = str(mailbox.linesep, "ascii") 29 | 30 | logger = logging.getLogger("asimap.mh") 31 | 32 | 33 | ######################################################################## 34 | ######################################################################## 35 | # 36 | class MH(mailbox.MH): 37 | """ 38 | Replace some of the mailbox.MH methods with ones that use aiofiles 39 | """ 40 | 41 | #################################################################### 42 | # 43 | def __init__(self, path: "StrPath", factory=None, create=True): 44 | self._locked: bool = False 45 | path = str(path) 46 | super().__init__(path, factory=factory, create=create) 47 | 48 | #################################################################### 49 | # 50 | def get_folder(self, folder: "StrPath"): 51 | """Return an MH instance for the named folder.""" 52 | return MH( 53 | os.path.join(self._path, str(folder)), 54 | factory=self._factory, 55 | create=False, 56 | ) 57 | 58 | #################################################################### 59 | # 60 | def add_folder(self, folder: "StrPath"): 61 | """Create a folder and return an MH instance representing it.""" 62 | return MH(os.path.join(self._path, str(folder)), factory=self._factory) 63 | 64 | #################################################################### 65 | # 66 | def lock(self, dotlock: bool = False): 67 | """ 68 | Lock the mailbox. We turn off dotlock'ing because it updates the 69 | folder's mtime, which will causes unnecessary resyncs. We still expect 70 | whatever is dropping mail in to the folder to use dotlocking, but that 71 | is fine. 72 | """ 73 | if not self._locked: 74 | mh_seq_fname = os.path.join(self._path, ".mh_sequences") 75 | if not os.path.exists(mh_seq_fname): 76 | f = open(mh_seq_fname, "a") 77 | f.close() 78 | os.chmod(mh_seq_fname, stat.S_IRUSR | stat.S_IWUSR) 79 | self._file = open(mh_seq_fname, "rb+") 80 | _lock_file(self._file, dotlock=dotlock) 81 | self._locked = True 82 | 83 | #################################################################### 84 | # 85 | @asynccontextmanager 86 | async def lock_folder( 87 | self, 88 | timeout: Union[int | float] = 2, 89 | fail: bool = False, 90 | ): 91 | """ 92 | Implement an asyncio contextmanager for locking a folder. This 93 | only protects against other _processes_ that obey the advisory locking. 94 | 95 | Use this when you need to modify the MH folder, or guarantee that the 96 | message you are adding to the folder does not conflict with one being 97 | added by another system, or want to make sure that the sequences file 98 | does not change. 99 | 100 | NOTE: Since this also uses dot-locking this will cause the mtime on the 101 | folder to change. 102 | """ 103 | # NOTE: The locking at the process level, so if this process has 104 | # already locked the folder there is nothing for us to do. The 105 | # code that has the folder already locked will properly release 106 | # it when done with it. 107 | # 108 | if not os.path.exists(self._path): 109 | raise NoSuchMailboxError(self._path) 110 | 111 | if self._locked: 112 | yield 113 | else: 114 | while timeout > 0: 115 | try: 116 | self.lock() 117 | break 118 | except mailbox.ExternalClashError: 119 | if fail: 120 | raise 121 | timeout -= 0.1 122 | await asyncio.sleep(0.1) 123 | try: 124 | yield 125 | finally: 126 | self.unlock() 127 | 128 | #################################################################### 129 | # 130 | def get_message_path(self, key: int) -> Path: 131 | return Path(os.path.join(self._path, str(key))) 132 | 133 | #################################################################### 134 | # 135 | async def aclear(self): 136 | for key in [int(x) for x in self.keys()]: 137 | try: 138 | self.remove(str(key)) 139 | await asyncio.sleep(0) 140 | except KeyError: 141 | pass 142 | 143 | #################################################################### 144 | # 145 | async def aremove(self, key: int): 146 | """Remove the keyed message; raise KeyError if it doesn't exist.""" 147 | path = os.path.join(self._path, str(key)) 148 | try: 149 | # Why do calls for "exists", "isfile", and "access" when we can 150 | # just try to open the file for reading. 151 | # 152 | async with aiofiles.open(path, "rb+"): 153 | pass 154 | except OSError as e: 155 | if e.errno == errno.ENOENT: 156 | raise KeyError("No message with key: %s" % key) 157 | else: 158 | raise 159 | else: 160 | await aiofiles.os.remove(path) 161 | -------------------------------------------------------------------------------- /asimap/set_password.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | """ 4 | A script to set passwords for asimap accounts (creates the account if it 5 | does not exist.) 6 | 7 | This is primarily used for setting up a test development environment. In the 8 | Apricot Systematic typical deployment the password file is managed by the 9 | `as_email_service` 10 | 11 | If the `password` is not supplied an unuseable password is set effectively 12 | disabling the account. 13 | 14 | If the account does not already exist `maildir` must be specified (as it 15 | indicates the users mail directory root) 16 | 17 | NOTE: `maildir` is in relation to the root when asimapd is running. 18 | 19 | Usage: 20 | set_password [--pwfile=] [] [] 21 | 22 | Options: 23 | --version 24 | -h, --help Show this text and exit 25 | --pwfile= The file that contains the users and their hashed passwords 26 | The env. var is `PWFILE`. Defaults to `/opt/asimap/pwfile` 27 | """ 28 | 29 | # system imports 30 | # 31 | import asyncio 32 | import getpass 33 | import logging 34 | import os 35 | from pathlib import Path 36 | from typing import TYPE_CHECKING, Optional 37 | 38 | # 3rd party imports 39 | # 40 | from docopt import docopt 41 | from dotenv import load_dotenv 42 | 43 | # asimapd imports 44 | # 45 | from asimap import __version__ as VERSION 46 | from asimap.auth import ( 47 | PW_FILE_LOCATION, 48 | USERS, 49 | PWUser, 50 | read_users_from_file, 51 | write_pwfile, 52 | ) 53 | from asimap.hashers import make_password 54 | 55 | if TYPE_CHECKING: 56 | from _typeshed import StrPath 57 | 58 | logger = logging.getLogger("asimap.set_password") 59 | 60 | 61 | #################################################################### 62 | # 63 | async def update_pw_file( 64 | pwfile: Path, 65 | username: str, 66 | password: Optional[str] = None, 67 | maildir: Optional[Path] = None, 68 | ) -> None: 69 | """ 70 | Read in the password file. If the given user does not exist, add it to the 71 | """ 72 | pwfile = Path(pwfile) 73 | pw_hash = make_password(password) if password else "XXX" 74 | 75 | await read_users_from_file(pwfile) 76 | if username not in USERS: 77 | if maildir is None: 78 | raise RuntimeError( 79 | f"'{username}': maildir must be specified when creating account" 80 | ) 81 | user = PWUser(username, maildir, pw_hash) 82 | USERS[username] = user 83 | else: 84 | user = USERS[username] 85 | user.pw_hash = pw_hash 86 | if maildir: 87 | user.maildir = maildir 88 | write_pwfile(pwfile, USERS) 89 | 90 | 91 | ############################################################################# 92 | # 93 | def main(): 94 | """ """ 95 | args = docopt(__doc__, version=VERSION) 96 | pwfile: StrPath = args["--pwfile"] 97 | username = args[""] 98 | password: Optional[str] = args[""] 99 | maildir_str: str = args[""] 100 | 101 | load_dotenv() 102 | 103 | if not password: 104 | while True: 105 | pw1 = getpass.getpass("Password: ") 106 | pw2 = getpass.getpass("Enter password again to verify: ") 107 | if pw1 == pw2: 108 | password = pw1 109 | break 110 | print("Passwords do NOT match! Re-enter please.") 111 | 112 | if pwfile is None: 113 | pwfile = ( 114 | os.environ["PWFILE"] if "PWFILE" in os.environ else PW_FILE_LOCATION 115 | ) 116 | 117 | pwfile = Path(pwfile) 118 | if not pwfile.exists(): 119 | pwfile.write_text("") 120 | 121 | # NOTE: We do not validate the path to `maildir` because we do not know in 122 | # what context 'set password' is being run vs in what context asimapd 123 | # is being run. 124 | # 125 | maildir = Path(maildir_str) if maildir_str else None 126 | 127 | asyncio.run(update_pw_file(pwfile, username, password, maildir)) 128 | 129 | 130 | ############################################################################ 131 | ############################################################################ 132 | # 133 | # Here is where it all starts 134 | # 135 | if __name__ == "__main__": 136 | main() 137 | # 138 | ############################################################################ 139 | ############################################################################ 140 | -------------------------------------------------------------------------------- /asimap/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scanner/asimap/d09a34d5073a1003028d12fcc0312c08b5e4f63d/asimap/test/__init__.py -------------------------------------------------------------------------------- /asimap/test/asimapd_debug_runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | This is a debug runner for the asimapd_user.py process. 7 | 8 | It takes as input a IMAP trace file and runs the asimapd_user.py 9 | process feeding it the trace file. The trace file is expected to 10 | contain both messages to the IMAP sub-process and responses from the 11 | IMAP sub-process. If the responses we get back from the sub-process do 12 | not match what is in the trace file for expected responses this 13 | program will exit with a non-zero return code (NOTE: the program will 14 | not exit early on the first error.. it will just set a non-zero return 15 | code. Unless the '--exit-on-mismatch' flag is set. In that case we 16 | will exit the first time we do not get the expected response back from 17 | the IMAP server.) 18 | 19 | This lets us play back arbitrary sets of IMAP messages to an 20 | asimap.user_server and do a complete functional test of the imap 21 | server. This lets us test everything except login, ssl, and 22 | multiplexing. 23 | 24 | NOTE: The asimapd_user.py program does its own logging. The log file 25 | will be in the working directory of this script. 26 | 27 | NOTE: need to document the tracefile format and features. 28 | 29 | Usage: 30 | asimapd_debug_runner.py [--asimapd_user=] [--log=] 31 | [--quiet] [--no-rc] [--exit-on-mismatch] 32 | ... 33 | 34 | Options: 35 | --version 36 | -h, --help Show this text and exit 37 | -a , --asimapd_user= The asimapd_user.py server 38 | to run. [default: ../asimapd_user.py] 39 | -l , --log= Write all of our output also to the named 40 | log file 41 | -q, --quiet If specified do not write any of our output to standard out. 42 | It is expected that this would be used in combination 43 | with '--log' in a test rig. 44 | -n, --no-rc If specified always exit with a '0' return code even if there 45 | were failures in the responses from the IMAP server that do 46 | not match the expected ones in the trace file. 47 | -e, --exit-on-mismatch Exit immediately when the output from the IMAP 48 | subprocess does not match the expected 49 | output as set in the tracefile being played back. 50 | """ 51 | # system imports 52 | # 53 | 54 | # 3rd party imports 55 | # 56 | from docopt import docopt 57 | 58 | # Project imports 59 | # 60 | import asimap.user_server 61 | 62 | 63 | ######################################################################## 64 | ######################################################################## 65 | # 66 | class DebugRunner(object): 67 | """ """ 68 | 69 | #################################################################### 70 | # 71 | def __init__(self, args): 72 | pass 73 | 74 | 75 | ############################################################################# 76 | # 77 | def main(): 78 | """ """ 79 | args = docopt(__doc__, version="0.1") 80 | user_server = args[""] 81 | asimap.user_server.set_user_server_program(user_server) 82 | 83 | DebugRunner() 84 | 85 | 86 | ############################################################################ 87 | ############################################################################ 88 | # 89 | # Here is where it all starts 90 | # 91 | if __name__ == "__main__": 92 | main() 93 | # 94 | ############################################################################ 95 | ############################################################################ 96 | -------------------------------------------------------------------------------- /asimap/test/debug_imap_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | A quick little script to test an imap server by connecting to it 7 | on localhost and logging in. 8 | 9 | This is a playground for testing the imap commands we want to actually 10 | use against a test server. 11 | """ 12 | 13 | import imaplib 14 | import mailbox 15 | 16 | # system imports 17 | # 18 | import os 19 | import os.path 20 | import tarfile 21 | 22 | 23 | #################################################################### 24 | # 25 | def cleanup_test_mode_dir(test_mode_dir): 26 | """ 27 | Set up the 'test_mode' mail directory. Clean out any messages it 28 | may have from previous runs 29 | """ 30 | 31 | # Make the test mode directory and inbox & Archive subdirectories 32 | # if they do not already exist. Delete any messages in the 33 | # mailboxes if they exist. 34 | # 35 | mh = mailbox.MH(test_mode_dir, create=True) 36 | 37 | folders = mh.list_folders() 38 | for f in ("inbox", "Archive", "Junk"): 39 | if f not in folders: 40 | mh_f = mh.add_folder(f) 41 | else: 42 | mh_f = mh.get_folder(f) 43 | 44 | # Delete any messages in these folders 45 | # 46 | mh_f.lock() 47 | try: 48 | for msg_key in list(mh_f.keys()): 49 | mh_f.discard(msg_key) 50 | finally: 51 | mh_f.unlock() 52 | 53 | # See if we have a zipfile of messages to seed the now empty 54 | # folder with. 55 | # 56 | init_state_msgs_file = os.path.join(test_mode_dir, f + ".tar.gz") 57 | if os.path.exists(init_state_msgs_file): 58 | # We do not care about the names of the files in this zip 59 | # file. Each file we insert in to this mh folder. 60 | # 61 | print( 62 | "Extracting initial messages from {}".format( 63 | init_state_msgs_file 64 | ) 65 | ) 66 | mh_f.lock() 67 | try: 68 | with tarfile.open(init_state_msgs_file, "r:gz") as tf: 69 | for member in tf.getmembers(): 70 | if member.isfile(): 71 | print( 72 | " Adding message {}, size: {}".format( 73 | member.name, member.size 74 | ) 75 | ) 76 | mh_f.add(tf.extractfile(member).read()) 77 | finally: 78 | mh_f.unlock() 79 | 80 | 81 | #################################################################### 82 | # 83 | def do_baked_appends(test_mode_dir, imap, mbox_name): 84 | """ 85 | Look for a tar file in the test mode directory for the given 86 | mailbox that contains messages we want to send to the IMAP server 87 | via APPEND. 88 | 89 | Keyword Arguments: 90 | test_mode_dir -- the path name for the test mode directory 91 | imap -- imaplib.IMAP4 object 92 | mbox_name -- name of the mailbox to append the messages to 93 | """ 94 | tfile = os.path.join( 95 | test_mode_dir, "append_fodder-{}.tar.gz".format(mbox_name) 96 | ) 97 | if not os.path.exists(tfile): 98 | return 99 | 100 | with tarfile.open(tfile, "r:gz") as tf: 101 | for member in tf.getmembers(): 102 | if member.isfile(): 103 | print( 104 | " Appending tf member {}, size: {}".format( 105 | member.name, member.size 106 | ) 107 | ) 108 | content = tf.extractfile(member).read() 109 | imap.append(mbox_name, None, None, content) 110 | 111 | 112 | #################################################################### 113 | # 114 | def dump_all_messages(imap): 115 | """ 116 | Search and dump all the messages in the currently selected mailbox 117 | Keyword Arguments: 118 | imap -- imaplib.IMAP4 object 119 | """ 120 | typ, data = imap.search(None, "ALL") 121 | if data[0]: 122 | print(" Messages in mailbox: {}".format(data[0])) 123 | for num in data[0].split(): 124 | t, d = imap.fetch(num, "(RFC822.header)") 125 | print(" Message {} header info: {}".format(num, d[0][0])) 126 | # typ, data = imap.fetch(num, '(RFC822)') 127 | # print 'Message {}, length: {}'.format(num, len(d[0][1])) 128 | 129 | 130 | ############################################################################# 131 | # 132 | def main(): 133 | # Look for the credentials in a well known file in several 134 | # locations relative to the location of this file. 135 | # 136 | # XXX Should make a command line option to set the mail dir 137 | # directory we exepct to use. 138 | # 139 | username = None 140 | password = None 141 | for path in ("test_mode", "../test_mode"): 142 | creds_file = os.path.join( 143 | os.path.dirname(__file__), path, "test_mode_creds.txt" 144 | ) 145 | print("Looking for creds file {}".format(creds_file)) 146 | if os.path.exists(creds_file): 147 | print("Using credentials file {}".format(creds_file)) 148 | username, password = open(creds_file).read().strip().split(":") 149 | test_mode_dir = os.path.dirname(creds_file) 150 | break 151 | 152 | if username is None or password is None: 153 | raise RuntimeError("Unable to find test mode credentials") 154 | 155 | # Look for the address file in the same directory as the creds file 156 | # 157 | addr_file = os.path.join(test_mode_dir, "test_mode_addr.txt") 158 | addr, port = open(addr_file).read().strip().split(":") 159 | port = int(port) 160 | 161 | print("Cleaning and setting up test-mode maildir") 162 | cleanup_test_mode_dir(test_mode_dir) 163 | 164 | imap = imaplib.IMAP4(addr, port) 165 | imap.login(username, password) 166 | 167 | for mbox_name in ("INBOX", "Archive", "Junk"): 168 | print("Selected '{}'".format(mbox_name)) 169 | 170 | imap.select(mbox_name) 171 | do_baked_appends(test_mode_dir, imap, mbox_name) 172 | dump_all_messages(imap) 173 | 174 | imap.close() 175 | imap.logout() 176 | 177 | 178 | ############################################################################ 179 | ############################################################################ 180 | # 181 | # Here is where it all starts 182 | # 183 | if __name__ == "__main__": 184 | main() 185 | # 186 | ############################################################################ 187 | ############################################################################ 188 | -------------------------------------------------------------------------------- /asimap/test/factories.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | """ 4 | Factories for various objects in the ASIMAP server. 5 | """ 6 | # system imports 7 | # 8 | from pathlib import Path 9 | from typing import Any, Sequence 10 | 11 | # 3rd party imports 12 | # 13 | import factory 14 | from factory import post_generation 15 | from faker import Faker 16 | 17 | # project imports 18 | # 19 | from ..auth import PWUser 20 | from ..hashers import make_password 21 | 22 | # factory.Faker() is good and all but it delays evaluation and returns a Faker 23 | # instance. Sometimes we just want a fake value now when the object is 24 | # constructed. 25 | # 26 | fake = Faker() 27 | 28 | 29 | ######################################################################## 30 | ######################################################################## 31 | # 32 | class UserFactory(factory.Factory): 33 | class Meta: 34 | model = PWUser 35 | 36 | username = factory.Faker("email") 37 | maildir = factory.LazyAttribute( 38 | lambda o: Path(f"/var/tmp/maildirs/{o.username}") 39 | ) 40 | password_hash = "!invalid_pw" # NOTE: Fixed in post_generation below 41 | 42 | @post_generation 43 | def password(self, create: bool, extracted: Sequence[Any], **kwargs): 44 | password = extracted if extracted else fake.password(length=16) 45 | self.pw_hash = make_password(password) 46 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/1: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: text/plain 7 | 8 | This is the text body. 9 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/11: -------------------------------------------------------------------------------- 1 | Received: from maleman.mcom.com (maleman.mcom.com [198.93.92.3]) by urchin.netscape.com (8.7.5/8.7.3) with SMTP id CAA25028 for ; Mon, 29 Jul 1996 02:26:08 -0700 (PDT) 2 | Received: from ns.netscape.com (ns.netscape.com.mcom.com [198.95.251.10]) by maleman.mcom.com (8.6.9/8.6.9) with ESMTP id CAA12409 for ; Mon, 29 Jul 1996 02:24:55 -0700 3 | Received: from mm1 (mm1.sprynet.com [165.121.1.50]) by ns.netscape.com (8.7.3/8.7.3) with ESMTP id CAA14074 for ; Mon, 29 Jul 1996 02:24:05 -0700 (PDT) 4 | Received: by mm1.sprynet.com id <148226-12799>; Mon, 29 Jul 1996 02:21:58 -0700 5 | From: The Post Office 6 | Subject: email delivery error 7 | Cc: The Postmaster 8 | MIME-Version: 1.0 9 | Content-Type: multipart/report; report-type=delivery-status; boundary="A41C7.838631588=_/mm1" 10 | Precedence: junk 11 | Message-Id: <96Jul29.022158-0700pdt.148226-12799+708@mm1.sprynet.com> 12 | To: noone@example.net 13 | Date: Mon, 29 Jul 1996 02:13:08 -0700 14 | X-UIDL: ee2855c88ed795f63bbcbfd279c80fab 15 | X-Mozilla-Status: 0001 16 | Content-Length: 1263 17 | 18 | Processing your mail message caused the following errors: 19 | 20 | error: err.nosuchuser: newsletter-request@imusic.com 21 | 22 | --A41C7.838631588=_/mm1 23 | Content-Type: message/delivery-status 24 | 25 | Reporting-MTA: dns; mm1 26 | Arrival-Date: Mon, 29 Jul 1996 02:12:50 -0700 27 | 28 | Final-Recipient: RFC822; newsletter-request@imusic.com 29 | Action: failed 30 | Diagnostic-Code: X-LOCAL; 500 (err.nosuchuser) 31 | 32 | 33 | --A41C7.838631588=_/mm1 34 | Content-Type: message/rfc822 35 | 36 | Received: from urchin.netscape.com ([198.95.250.59]) by mm1.sprynet.com with ESMTP id <148217-12799>; Mon, 29 Jul 1996 02:12:50 -0700 37 | Received: from gruntle (gruntle.mcom.com [205.217.230.10]) by urchin.netscape.com (8.7.5/8.7.3) with SMTP id CAA24688 for ; Mon, 29 Jul 1996 02:04:53 -0700 (PDT) 38 | Sender: jwz@netscape.com 39 | Message-ID: <31FC7EB4.41C6@netscape.com> 40 | Date: Mon, 29 Jul 1996 02:04:52 -0700 41 | From: Jamie Zawinski 42 | Organization: Netscape Communications Corporation, Mozilla Division 43 | X-Mailer: Mozilla 3.0b6 (X11; U; IRIX 5.3 IP22) 44 | MIME-Version: 1.0 45 | To: newsletter-request@imusic.com 46 | Subject: unsubscribe 47 | References: <96Jul29.013736-0700pdt.148116-12799+675@mm1.sprynet.com> 48 | Content-Type: text/plain; charset=us-ascii 49 | Content-Transfer-Encoding: 7bit 50 | 51 | unsubscribe 52 | --A41C7.838631588=_/mm1-- 53 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/12: -------------------------------------------------------------------------------- 1 | Received: from maleman.mcom.com (maleman.mcom.com [198.93.92.3]) by urchin.netscape.com (8.7.5/8.7.3) with SMTP id CAA25028 for ; Mon, 29 Jul 1996 02:26:08 -0700 (PDT) 2 | Received: from ns.netscape.com (ns.netscape.com.mcom.com [198.95.251.10]) by maleman.mcom.com (8.6.9/8.6.9) with ESMTP id CAA12409 for ; Mon, 29 Jul 1996 02:24:55 -0700 3 | Received: from mm1 (mm1.sprynet.com [165.121.1.50]) by ns.netscape.com (8.7.3/8.7.3) with ESMTP id CAA14074 for ; Mon, 29 Jul 1996 02:24:05 -0700 (PDT) 4 | Received: by mm1.sprynet.com id <148226-12799>; Mon, 29 Jul 1996 02:21:58 -0700 5 | From: The Post Office 6 | Subject: email delivery error 7 | Cc: The Postmaster 8 | MIME-Version: 1.0 9 | Content-Type: multipart/report; report-type=delivery-status; boundary="A41C7.838631588=_/mm1" 10 | Precedence: junk 11 | Message-Id: <96Jul29.022158-0700pdt.148226-12799+708@mm1.sprynet.com> 12 | To: noone@example.net 13 | Date: Mon, 29 Jul 1996 02:13:08 -0700 14 | X-UIDL: ee2855c88ed795f63bbcbfd279c80fab 15 | X-Mozilla-Status: 0001 16 | Content-Length: 1263 17 | 18 | Processing your mail message caused the following errors: 19 | 20 | error: err.nosuchuser: newsletter-request@imusic.com 21 | 22 | --A41C7.838631588=_/mm1 23 | Content-Type: message/delivery-status 24 | 25 | Reporting-MTA: dns; mm1 26 | Arrival-Date: Mon, 29 Jul 1996 02:12:50 -0700 27 | 28 | Final-Recipient: RFC822; newsletter-request@imusic.com 29 | Action: failed 30 | Diagnostic-Code: X-LOCAL; 500 (err.nosuchuser) 31 | 32 | --A41C7.838631588=_/mm1 33 | Content-Type: message/rfc822 34 | 35 | Received: from urchin.netscape.com ([198.95.250.59]) by mm1.sprynet.com with ESMTP id <148217-12799>; Mon, 29 Jul 1996 02:12:50 -0700 36 | Received: from gruntle (gruntle.mcom.com [205.217.230.10]) by urchin.netscape.com (8.7.5/8.7.3) with SMTP id CAA24688 for ; Mon, 29 Jul 1996 02:04:53 -0700 (PDT) 37 | Sender: jwz@netscape.com 38 | Message-ID: <31FC7EB4.41C6@netscape.com> 39 | Date: Mon, 29 Jul 1996 02:04:52 -0700 40 | From: Jamie Zawinski 41 | Organization: Netscape Communications Corporation, Mozilla Division 42 | X-Mailer: Mozilla 3.0b6 (X11; U; IRIX 5.3 IP22) 43 | MIME-Version: 1.0 44 | To: newsletter-request@imusic.com 45 | Subject: unsubscribe 46 | References: <96Jul29.013736-0700pdt.148116-12799+675@mm1.sprynet.com> 47 | Content-Type: text/plain; charset=us-ascii 48 | Content-Transfer-Encoding: 7bit 49 | 50 | unsubscribe 51 | --A41C7.838631588=_/mm1-- 52 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/13: -------------------------------------------------------------------------------- 1 | Date: Wed, 20 Sep 1995 00:19:00 (EDT) -0400 2 | From: Joe Recipient 3 | Message-Id: <199509200019.12345@example.com> 4 | Subject: Disposition notification 5 | To: Jane Sender 6 | MIME-Version: 1.0 7 | Content-Type: multipart/report; report-type=disposition-notification; 8 | boundary="RAA14128.773615765/example.com" 9 | 10 | --RAA14128.773615765/example.com 11 | 12 | The message sent on 1995 Sep 19 at 13:30:00 (EDT) -0400 to Joe 13 | Recipient with subject "First draft of 14 | report" has been displayed. This is no guarantee that the message 15 | has been read or understood. 16 | 17 | --RAA14128.773615765/example.com 18 | content-type: message/disposition-notification 19 | 20 | Reporting-UA: joes-pc.cs.example.com; Foomail 97.1 21 | Original-Recipient: rfc822;Joe_Recipient@example.com 22 | Final-Recipient: rfc822;Joe_Recipient@example.com 23 | Original-Message-ID: <199509192301.23456@example.org> 24 | Disposition: manual-action/MDN-sent-manually; displayed 25 | 26 | --RAA14128.773615765/example.com 27 | content-type: message/rfc822 28 | 29 | Date: Tue, 19 Sep 1995 13:30:00 (EDT) -0400 30 | From: Jane Sender 31 | Message-Id: <199509192301.23456@example.org> 32 | Subject: First draft of report 33 | To: Joe Recipient 34 | MIME-Version: 1.0 35 | Content-Type: text/plain 36 | 37 | Hey Joe, 38 | 39 | This is a test message for the first draft of the multipart/report spec. 40 | 41 | - Jane 42 | 43 | --RAA14128.773615765/example.com-- 44 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/14: -------------------------------------------------------------------------------- 1 | From: mimekit@example.com 2 | To: mimekit@example.com 3 | Subject: test of empty multipart/alternative 4 | Date: Tue, 12 Nov 2013 09:12:42 -0500 5 | MIME-Version: 1.0 6 | Message-ID: <54AD68C9E3B0184CAC6041320424FD1B5B81E74D@localhost.localdomain> 7 | X-Mailer: Microsoft Office Outlook 12.0 8 | Content-Type: multipart/mixed; 9 | boundary="----=_NextPart_000_003F_01CE98CE.6E826F90" 10 | 11 | 12 | ------=_NextPart_000_003F_01CE98CE.6E826F90 13 | Content-Type: multipart/alternative; 14 | boundary="----=_NextPart_001_0040_01CE98CE.6E826F90" 15 | 16 | 17 | ------=_NextPart_000_003F_01CE98CE.6E826F90 18 | Content-Type: text/plain 19 | 20 | This part should be on the same level as the multipart/alternative and not a child. 21 | 22 | ------=_NextPart_000_003F_01CE98CE.6E826F90-- 23 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/15: -------------------------------------------------------------------------------- 1 | Path: flop.mcom.com!news.Stanford.EDU!agate!newsxfer.itd.umich.edu!news.itd.umich.edu!urkabox 2 | From: Peter Urka 3 | Newsgroups: comp.sys.next.advocacy 4 | Subject: Re: The Once and Future OS 5 | Date: Sun, 7 May 95 16:21:03 GMT 6 | Organization: Squirrel Bin 7 | Lines: 32 8 | Sender: preston@urkabox.chem.lsa.umich.edu 9 | Distribution: world 10 | Message-ID: <07May1621030321@urkabox.chem.lsa.umich.edu> 11 | References: <3ohapq$h3b@gandalf.rutgers.edu> <3notqh$b52@ns2.ny.ubs.com> <3npoh0$2oo@news.blkbox.com> <3nqp09$r7t@ns2.ny.ubs.com> 12 | Reply-To: pcu@umich.edu 13 | NNTP-Posting-Host: urkabox.chem.lsa.umich.edu 14 | Mime-Version: 1.0 15 | Content-Type: multipart/mixed; 16 | boundary="NutNews,-a-nntpmtsonsguinrcfas,-boundary" 17 | X-Newsreader: NutNews 18 | 19 | 20 | --NutNews,-a-nntpmtsonsguinrcfas,-boundary 21 | Content-Type: text/plain 22 | 23 | > NeXTSTEP, NeXTSTEP, NeXTSTEP... 24 | 25 | Don't you mean: "NeXTstep, NeXTSTEP, NEXTSTEP..."? 26 | 27 | --NutNews,-a-nntpmtsonsguinrcfas,-boundary 28 | Content-Type: application/postscript 29 | Content-Transfer-Encoding: base64 30 | 31 | 32 | --NutNews,-a-nntpmtsonsguinrcfas,-boundary-- 33 | Peter Urka 34 | Dept. of Chemistry, Univ. of Michigan 35 | Newt-thought is right-thought. Go Newt! 36 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/16: -------------------------------------------------------------------------------- 1 | Return-Path: 2 | Delivered-To: jang12@linux12.org.new 3 | Received: (qmail 21619 invoked from network); 15 Nov 2017 14:16:18 -0000 4 | Received: from unknown (HELO EUR01-HE1-obe.outbound.protection.outlook.com) (80.68.177.35) 5 | by with SMTP; 15 Nov 2017 14:16:18 -0000 6 | Received-SPF: pass (: local policy designates 80.68.177.35 as permitted sender) 7 | X-Assp-ID: myassp01.mynet.it m1-55381-06197 8 | X-Assp-Session: 7F209ACC1BB8 (mail 1) 9 | X-Assp-Version: 2.5.5(17223) on myassp01.mynet.it 10 | X-Assp-Delay: not delayed (104.47.0.133 in whitebox (PBWhite)); 11 | 15 Nov 2017 15:16:22 +0100 12 | X-Assp-Message-Score: -26 (KnownGoodHelo) 13 | X-Assp-IP-Score: -26 (KnownGoodHelo) 14 | X-Assp-Message-Score: -2 (104.47.0.0 in griplist (0.13)) 15 | X-Original-Authentication-Results: myassp01.mynet.it; spf=pass 16 | X-Assp-Message-Score: -10 (SPF pass) 17 | X-Assp-IP-Score: -10 (SPF pass) 18 | X-Assp-Message-Score: 10 (Foreign IP-Country FI (MICROSOFT CORPORATION)) 19 | X-Assp-Message-Score: -15 (In Penalty White Box) 20 | X-Assp-DKIM: not verified 21 | Received: from mail-he1eur01on0133.outbound.protection.outlook.com 22 | ([104.47.0.133] helo=EUR01-HE1-obe.outbound.protection.outlook.com) by 23 | myassp01.mynet.it with SMTP (2.5.5); 15 Nov 2017 15:16:20 +0100 24 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 25 | d=CMMSRL.onmicrosoft.com; s=selector1-cmmlaser-it; 26 | h=From:Date:Subject:Message-ID:Content-Type:MIME-Version; 27 | bh=JmZzBMD0RLaOTuqX/VlM86EEKHsfeOF0B0kBWE4fKBY=; 28 | b=h65Qop22nh21H30A/T/T47dDaCkb70hySSaJfJCzh+0E2A41BTqlUT7Y3c80Kf6zc5Totg4Kmuub2P8r/Fj30rIiQP5EXW+/caFvHtXEQjZXeuWYRfBweASqK5/1ClHkY3SBgnw3dEuAhlIDzid6M/5YxuJqzn6d/mKvmjV2Ju0= 29 | Received: from AM4PR01MB1444.eurprd01.prod.exchangelabs.com (10.164.76.26) by 30 | AM4PR01MB1442.eurprd01.prod.exchangelabs.com (10.164.76.24) with Microsoft 31 | SMTP Server (version=TLS1_2, 32 | cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P256) id 15.20.218.12; Wed, 15 33 | Nov 2017 14:16:14 +0000 34 | Received: from AM4PR01MB1444.eurprd01.prod.exchangelabs.com 35 | ([fe80::7830:c66f:eaa8:e3dd]) by AM4PR01MB1444.eurprd01.prod.exchangelabs.com 36 | ([fe80::7830:c66f:eaa8:e3dd%14]) with mapi id 15.20.0218.015; Wed, 15 Nov 37 | 2017 14:16:14 +0000 38 | From: jang.abcdef@xyzlinu 39 | To: "jang12@linux12.org.new" 40 | Subject: R: R: R: I: FR-selca LA selcaE 41 | Thread-Topic: R: R: I: FR-selca LA selcaE 42 | Thread-Index: AdNST+6DXK4xfZYaRzuyUbaIacENgAHGVF+AAACaRUAAAhGDmgAASt6QACm+BjkA/3MzkAAAPw7yAAA7j6A= 43 | Date: Wed, 15 Nov 2017 14:16:14 +0000 44 | Message-ID: 45 | References: 46 | <5185e377-81c5-4361-91ba-11d42f4c5cc9@AM5EUR02FT056.eop-EUR02.prod.protection.outlook.com> 47 | In-Reply-To: <5185e377-81c5-4361-91ba-11d42f4c5cc9@AM5EUR02FT056.eop-EUR02.prod.protection.outlook.com> 48 | Accept-Language: it-IT, en-US 49 | Content-Language: it-IT 50 | X-MS-Has-Attach: yes 51 | X-MS-TNEF-Correlator: 52 | authentication-results: spf=none (sender IP is ) 53 | smtp.mailfrom=jang.selca.tubi@linux.selca; 54 | x-originating-ip: [5.157.97.187] 55 | x-ms-publictraffictype: Email 56 | x-microsoft-exchange-diagnostics: 1;AM4PR01MB1442;6:1Y77VdiYZvwhWtdzFszn6F8yBYOr9DiSFIBUWJ8SQrYO+GoiaLLmQOfmDQVXmDvmDnRwyng6J22fXvtcG04spQNMJazjDFklW2fGAIpJ2gvTn7ArKjUtFCyuGykrD/yB/JUK6RZYvsxUsDV3dXwKS3PuVpzifGICmyzMFVALJ7NK8ecOtOj3Qp6KvT2psfZoTpts7Irol8FlEL6C6FlMIN0J//87QnwoTyiOQ5XNePxfNGAcaUY8XNSwAjfSHPQm7bv7OdCzaGWyYhDe77a4ZkGARb4BrEhXIySEYvn5jzBh6eZfhc66eWipkOa9n6XVF8/l0PrnCZctKS01QKPJRauPb0d4hb6ef8J6XuTwp7c=;5:0mdIwN341bN0FTmWCx+Rn0IjBXFHQD0t5xQqdG1YfDjltV8z2aOTs+PA8jwC8gRlQJgCsqV6eA9GRJQSFM+F4v8v4s50czNV0fOKx4Wc12B7WEL/5lgk1aywT30necTuDd7hERC+FWqUuWOFugmGmDk5S4wOGf/Tkw9BVTk24KM=;24:HUnkg54iCuj6MNXhWIzOvI70tz4nrBWXbqDjYqwnhc4VkRaTtvHnwevxRjTi3JaTLxysPao9DaTZNH5KafLalDFhPl20xy2S5P14oPX5MvY=;7:J2PFuP4gaIeIi2SPncoR2UKg6CFsCSZmdOTGG4lFWekuIRS7OtntB9lFVSKNUylGdvOAnLXYYY8X63/sH6MSlFG6/7xmcP8FxVksOO2tmFWl+LxT0Acgv7RaCFBKFmzA31fwJabrvuEaFINDvMVgEV9Cc41bylhc13tqhso5spmuc9CbMZeue0AiPQ9RuNpeVei9YN5e8YUIIdP/S8WATGsVvQE7G5VE4WMrcgXje4icdG+gZn6dPAlKpXuhqGH0 57 | x-ms-exchange-antispam-srfa-diagnostics: SSOS; 58 | x-ms-office365-filtering-correlation-id: b6800147-d5b4-494e-46ff-08d52c336e1f 59 | x-microsoft-antispam: UriScan:;BCL:0;PCL:0;RULEID:(22001)(4534020)(4602075)(4603075)(4627115)(201702281549075)(2017052603258)(49563074);SRVR:AM4PR01MB1442; 60 | x-ms-traffictypediagnostic: AM4PR01MB1442: 61 | x-microsoft-antispam-prvs: 62 | x-exchange-antispam-report-test: UriScan:(227612066756510)(21748063052155); 63 | x-exchange-antispam-report-cfa-test: BCL:0;PCL:0;RULEID:(100000700101)(100105000095)(100000701101)(100105300095)(100000702101)(100105100095)(102415395)(6040450)(2401047)(5005006)(8121501046)(3231022)(93006095)(93001095)(10201501046)(3002001)(100000703101)(100105400095)(6041248)(2016111802025)(20161123564025)(20161123558100)(20161123555025)(20161123560025)(20161123562025)(201703131423075)(201702281528075)(201703061421075)(201703061406153)(6043046)(6072148)(201708071742011)(100000704101)(100105200095)(100000705101)(100105500095);SRVR:AM4PR01MB1442;BCL:0;PCL:0;RULEID:(100000800101)(100110000095)(100000801101)(100110300095)(100000802101)(100110100095)(100000803101)(100110400095)(100000804101)(100110200095)(100000805101)(100110500095);SRVR:AM4PR01MB1442; 64 | x-forefront-prvs: 0492FD61DD 65 | x-forefront-antispam-report: SFV:NSPM;SFS:(10019020)(346002)(376002)(189002)(13464003)(199003)(733005)(606006)(86362001)(6436002)(478600001)(6506006)(8676002)(2501003)(8936002)(55016002)(5640700003)(3660700001)(53546010)(53386004)(5250100002)(3280700002)(99286004)(2950100002)(5660300001)(6916009)(71446004)(7696004)(53936002)(25786009)(101416001)(74316002)(316002)(50986999)(99936001)(1730700003)(66066001)(81166006)(54356999)(76176999)(81156014)(68736007)(14454004)(5630700001)(54896002)(6116002)(7736002)(102836003)(54556002)(9686003)(33656002)(105586002)(106356001)(74482002)(790700001)(6306002)(2900100001)(2906002)(3846002)(236005)(97736004)(2351001)(189998001)(19627235001);DIR:OUT;SFP:1102;SCL:1;SRVR:AM4PR01MB1442;H:AM4PR01MB1444.eurprd01.prod.exchangelabs.com;FPR:;SPF:None;PTR:InfoNoRecords;MX:1;A:1;LANG:it; 66 | received-spf: None (protection.outlook.com: cmmlaser.it does not designate 67 | permitted sender hosts) 68 | spamdiagnosticoutput: 1:99 69 | spamdiagnosticmetadata: NSPM 70 | MIME-Version: 1.0 71 | Content-Type: multipart/alternative; 72 | boundary="----=_NextPart_000_0031_01D36222.8A648550" 73 | X-Priority: 3 74 | X-MSMail-Priority: Normal 75 | X-Mailer: Microsoft Outlook Express 6.00.2900.5931 76 | X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.6157 77 | X-Antivirus: Avast (VPS 171120-2, 20/11/2017), Outbound message 78 | X-Antivirus-Status: Clean 79 | X-UIDL: "Q9"!_8W!!^di!!)A3"! 80 | X-Antivirus: Avast (VPS 171120-2, 20/11/2017), Inbound message 81 | X-Antivirus-Status: Clean 82 | 83 | This is a multi-part message in MIME format. 84 | 85 | ------=_NextPart_000_0031_01D36222.8A648550 86 | Content-Type: text/plain; 87 | charset="iso-8859-1" 88 | Content-Transfer-Encoding: quoted-printable 89 | 90 | Test headers 91 | 92 | 93 | ------=_NextPart_000_0031_01D36222.8A648550 94 | Content-Type: text/html; 95 | charset="iso-8859-1" 96 | Content-Transfer-Encoding: quoted-printable 97 | 98 | 99 | 100 | 102 | 103 | 104 | 105 | 106 |

Test headers

108 | = 109 |
110 | 111 | ------=_NextPart_000_0031_01D36222.8A648550-- 112 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/17: -------------------------------------------------------------------------------- 1 | Message-ID: <55AE6D15.4010805@veritas-vos-liberabit.com> 2 | Date: Wed, 22 Jul 2015 01:02:29 +0900 3 | From: Atsushi Eno 4 | User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.7.0 5 | MIME-Version: 1.0 6 | To: Jeffrey Stedfast 7 | Subject: =?ISO-2022-JP?B?GyRCRnxLXDhsJWEhPCVrJUYlOSVIGyhCICh0ZXN0aW5nIEph?= 8 | =?ISO-2022-JP?B?cGFuZXNlIGVtYWlscyk=?= 9 | Content-Type: text/plain; charset=ISO-2022-JP 10 | Content-Transfer-Encoding: 7bit 11 | 12 | Let's see if both subject and body works fine... 13 | 14 | $BF|K\8l$,(B 15 | $B@5>o$K(B 16 | $BAw$l$F$$$k$+(B 17 | $B%F%9%H(B. 18 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/18: -------------------------------------------------------------------------------- 1 | From: someone 2 | To: someone else 3 | Subject: Test of an invalid mime-type 4 | Date: Tue, 29 Dec 2015 09:06:17 -0400 5 | MIME-Version: 1.0 6 | Content-Type: application-x-gzip; name="document.xml.gz" 7 | 8 | blah blah blah 9 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/19: -------------------------------------------------------------------------------- 1 | From user@domain Fri Feb 22 17:06:23 2008 2 | From: user@domain.org 3 | Date: Sat, 24 Mar 2007 23:00:00 +0200 4 | Mime-Version: 1.0 5 | Content-Type: message/rfc822 6 | 7 | From: sub@domain.org 8 | Date: Sun, 12 Aug 2012 12:34:56 +0300 9 | Subject: submsg 10 | Content-Type: multipart/digest; boundary="foo" 11 | 12 | prologue 13 | 14 | --foo 15 | Content-Type: message/rfc822 16 | 17 | From: m1@example.com 18 | Subject: m1 19 | 20 | m1 body 21 | 22 | --foo 23 | Content-Type: message/rfc822 24 | X-Mime: m2 header 25 | 26 | From: m2@example.com 27 | Subject: m2 28 | 29 | m2 body 30 | 31 | --foo-- 32 | 33 | epilogue 34 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/2: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: text/html 7 | 8 | This is an html body. 9 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/20: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Type: multipart/related; 3 | type="text/html"; 4 | boundary="----=_NextPart_115e1404-dbbc-4611-b4ce-d08a4b021c45" 5 | 6 | This is a multi-part message in MIME format. 7 | ------=_NextPart_115e1404-dbbc-4611-b4ce-d08a4b021c45 8 | Content-Type: image/png 9 | Content-Transfer-Encoding: base64 10 | Content-Location: image1 11 | 12 | iVBORw0KGgoAAAANSUhEUgAAAZAAAABOCAYAAAAO/EAnAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAW 13 | JQAAFiUBSVIk8AAAABl0RVh0U29mdHdhcmUAUGFpbnQuTkVUIHYzLjUuODc7gF0AAAL2SURBVHhe7d2x 14 | ahRRFAZgn0ltBd/BWOZVfAptxNZtUgohIFFBCw1srQm7BmxMIhsQV0TFjHPCDruZXDZ3TzvfD183TPtz 15 | 750751ZTyL+Li+b4/Gczmc0BGKhpa/7776IZrqdYIJ/b8tjeGTdbowMABuph68Wnr5eLilKuFUi0zaOX 16 | H5u7T940tx+/BmDAHjz/cLmoKOVKgUTLRNvce/q2+CIAhiUWE7GoKG1lXSmQH+0DsWQpvQSAYYpFxfsv 17 | 54umWOZKgXz/9ae5/+xd8QUADNOd1qvpt0VTLKNAAFhLgQCQokAASFEgAKQoEABSokD2J2eLplhGgQCw 18 | VhTI3tFp07+PrkAAWCsKZPfwRIEAsBlbWACkOEQHIEWBAJCiQABIUSAApCgQAFKqCsQ8EAD6quaBmEgI 19 | wKrqiYQRM9EB6FTPRO8SD2/vjJut0QEAAxVHGrErFbtTpRQLJB4+bktkMpsDMFDTVmnrqkuxQERERG6K 20 | FQgARakViDMQADY+A/EVFgCd6q+w3AMBYFX1PRA30QHoq7qJ7l9YAPT5mSIAKQoEgBQFAkCKAgEgRYEA 21 | kBIFsj85WzTFMgoEgLWiQPaOTpv+fXQFAsBaUSC7hycKBIDN2MICIMUhOgApCgSAFAUCQIoCASBFgQCQ 22 | UlUg5oEA0Fc1D8REQgBWVU8kjJiJDkCneiZ6l3h4e2fcbI0OABioONKIXanYnSqlWCDx8HFbIpPZHICB 23 | mrZKW1ddigUiIiJyU6xAAChKrUCcgQCw8RmIr7AA6FR/heUeCACrqu+BuIkOQF/VTXT/wgKgz88UAUhR 24 | IACkKBAAUhQIACkKBICUKJD9ydmiKZZRIACsFQWyd3Ta9O+jKxAA1ooC2T08USAAbMYWFgApDtEBSFEg 25 | AKQoEABSFAgAKQoEgJSqAjEPBIC+qnkgJhICsKp6ImHETHQAOtUz0bvEw9s742ZrdADAQMWRRuxKxe5U 26 | KcUCiYeP2xKZzOYADNS0Vdq66lIsEBERkfVpmv/Cb/8ZH82DugAAAABJRU5ErkJggg== 27 | 28 | 29 | ------=_NextPart_115e1404-dbbc-4611-b4ce-d08a4b021c45 30 | Content-Type: text/html; charset="utf-8" 31 | Content-Transfer-Encoding: quoted-printable 32 | 33 | =0D=0A=0D=0A=09=0D=0A=09=09=0D=0A=09= 37 | =09=0D=0A=09=09=0D=0A=09=09=09.cs2654AE3A{te= 38 | xt-align:left;text-indent:0pt;margin:0pt=200pt=200pt=200pt}=0D=0A=09=09=09.csC8= 39 | F6D76{color:#000000;background-color:transparent;font-family:Calibri;font-size:= 40 | 11pt;font-weight:normal;font-style:normal;}=0D=0A=09=09=0D=0A=09= 41 | =0D=0A=09=0D=0A=09=09= 43 |

def

= 44 | =0D=0A=0D=0A 45 | 46 | 47 | ------=_NextPart_115e1404-dbbc-4611-b4ce-d08a4b021c45-- 48 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/21: -------------------------------------------------------------------------------- 1 | From: me@myself.com 2 | To: me@myself.com 3 | Subject: Sample message structure for IMAP part specifiers 4 | MIME-Version: 1.0 5 | Content-Type: MULTIPART/MIXED; boundary="x" 6 | 7 | --x 8 | Content-Type: TEXT/PLAIN 9 | 10 | This part specifier should be: 1 11 | 12 | --x 13 | Content-Type: APPLICATION/OCTET-STREAM 14 | 15 | This part specifier should be: 2 16 | 17 | --x 18 | Content-Type: MESSAGE/RFC822 19 | 20 | From: me@myself.com 21 | To: me@myself.com 22 | Subject: This part specifier should be: 3 23 | MIME-Version: 1.0 24 | Content-Type: MULTIPART/MIXED; boundary="3.x" 25 | 26 | --3.x 27 | Content-Type: TEXT/PLAIN 28 | 29 | This part specifier should be: 3.1 30 | 31 | --3.x 32 | Content-Type: APPLICATION/OCTET-STREAM 33 | 34 | This part specifier should be: 3.2 35 | 36 | --3.x-- 37 | --x 38 | Content-Type: MULTIPART/MIXED; boundary="4.x" 39 | 40 | --4.x 41 | Content-Type: IMAGE/GIF 42 | 43 | This part specifier should be: 4.1 44 | 45 | --4.x 46 | Content-Type: MESSAGE/RFC822 47 | 48 | From: me@myself.com 49 | To: me@myself.com 50 | Subject: This part specifier should be: 4.2 51 | MIME-Version: 1.0 52 | Content-Type: MULTIPART/MIXED; boundary="4.2.x" 53 | 54 | --4.2.x 55 | Content-Type: TEXT/PLAIN 56 | 57 | This part specifier should be: 4.2.1 58 | 59 | --4.2.x 60 | Content-Type: MULTIPART/ALTERNATIVE; boundary="4.2.2.x" 61 | 62 | --4.2.2.x 63 | Content-Type: TEXT/PLAIN 64 | 65 | This part specifier should be: 4.2.2.1 66 | 67 | --4.2.2.x 68 | Content-Type: TEXT/RICHTEXT 69 | 70 | This part specifier should be: 4.2.2.2 71 | 72 | --4.2.2.x-- 73 | --4.2.x-- 74 | --x-- 75 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/3: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/alternative; boundary="Next_Alternative" 7 | 8 | --Next_Alternative 9 | Content-Type: text/html 10 | 11 | This is an html body. 12 | --Next_Alternative 13 | Content-Type: text/plain 14 | 15 | This is the text body. 16 | --Next_Alternative-- 17 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/4: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/related; boundary="Next_Related" 7 | 8 | --Next_Related 9 | Content-Type: text/html 10 | 11 | This is an html body. 12 | --Next_Related 13 | Content-Type: image/gif; name="empty.gif" 14 | Content-Disposition: inline; filename="empty.gif" 15 | 16 | --Next_Related 17 | Content-Type: image/jpeg; name="empty.jpg" 18 | Content-Disposition: inline; filename="empty.jpg" 19 | 20 | --Next_Related-- 21 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/5: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/alternative; boundary="Next_Alternative" 7 | 8 | --Next_Alternative 9 | Content-Type: multipart/related; boundary="Next_Related" 10 | 11 | --Next_Related 12 | Content-Type: text/html 13 | 14 | This is an html body. 15 | --Next_Related 16 | Content-Type: image/gif; name="empty.gif" 17 | Content-Disposition: inline; filename="empty.gif" 18 | 19 | --Next_Related 20 | Content-Type: image/jpeg; name="empty.jpg" 21 | Content-Disposition: inline; filename="empty.jpg" 22 | 23 | --Next_Related-- 24 | --Next_Alternative 25 | Content-Type: text/plain 26 | 27 | This is the text body. 28 | --Next_Alternative-- 29 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/6: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/mixed; boundary="Next_Mixed" 7 | 8 | --Next_Mixed 9 | Content-Type: multipart/alternative; boundary="Next_Alternative" 10 | 11 | --Next_Alternative 12 | Content-Type: multipart/related; boundary="Next_Related" 13 | 14 | --Next_Related 15 | Content-Type: text/html 16 | 17 | This is an html body. 18 | --Next_Related 19 | Content-Type: image/gif; name="empty.gif" 20 | Content-Disposition: inline; filename="empty.gif" 21 | 22 | --Next_Related 23 | Content-Type: image/jpeg; name="empty.jpg" 24 | Content-Disposition: inline; filename="empty.jpg" 25 | 26 | --Next_Related-- 27 | --Next_Alternative 28 | Content-Type: text/plain 29 | 30 | This is the text body. 31 | --Next_Alternative-- 32 | --Next_Mixed 33 | Content-Type: text/plain; name="document.txt" 34 | Content-Disposition: attachment; filename="document.txt" 35 | 36 | This is an attached document. 37 | --Next_Mixed-- 38 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/7: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/mixed; boundary="Next_Mixed" 7 | 8 | --Next_Mixed 9 | Content-Type: multipart/related; boundary="Next_Related" 10 | 11 | --Next_Related 12 | Content-Type: multipart/alternative; boundary="Next_Alternative" 13 | 14 | --Next_Alternative 15 | Content-Type: text/html 16 | 17 | This is an html body. 18 | --Next_Alternative 19 | Content-Type: text/plain 20 | 21 | This is the text body. 22 | --Next_Alternative-- 23 | --Next_Related 24 | Content-Type: image/gif; name="empty.gif" 25 | Content-Disposition: inline; filename="empty.gif" 26 | 27 | --Next_Related 28 | Content-Type: image/jpeg; name="empty.jpg" 29 | Content-Disposition: inline; filename="empty.jpg" 30 | 31 | --Next_Related-- 32 | --Next_Mixed 33 | Content-Type: text/plain; name="document.txt" 34 | Content-Disposition: attachment; filename="document.txt" 35 | 36 | This is an attached document. 37 | --Next_Mixed-- 38 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/8: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/mixed; boundary="Next_Mixed" 7 | 8 | --Next_Mixed 9 | Content-Type: text/plain 10 | 11 | This is the text body. 12 | --Next_Mixed 13 | Content-Type: text/html 14 | 15 | This is some html text but is not the body. 16 | --Next_Mixed 17 | Content-Type: application/octet-stream; name="attachment.dat" 18 | Content-Disposition: attachment; filename="attachment.dat" 19 | 20 | --Next_Mixed-- 21 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/one/9: -------------------------------------------------------------------------------- 1 | From: MimeKit Unit Tests 2 | To: MimeKit Unit Tests 3 | Subject: MimeMessage.TextBody and HtmlBody tests 4 | Date: Sat, 2 Jan 2016 17:42:00 -0400 5 | MIME-Version: 1.0 6 | Content-Type: multipart/mixed; boundary="Next_Mixed" 7 | 8 | --Next_Mixed 9 | Content-Type: text/html 10 | 11 | This is an html body. 12 | --Next_Mixed 13 | Content-Type: text/plain 14 | 15 | This is some plain text but is not the body. 16 | --Next_Mixed 17 | Content-Type: application/octet-stream; name="attachment.dat" 18 | Content-Disposition: attachment; filename="attachment.dat" 19 | 20 | --Next_Mixed-- 21 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/problems/1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scanner/asimap/d09a34d5073a1003028d12fcc0312c08b5e4f63d/asimap/test/fixtures/mhdir/problems/1 -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/problems/2: -------------------------------------------------------------------------------- 1 | Delivery-Date: Tue, 25 Apr 2017 13:30:10 -0700 2 | To: 3 | Subject: =?utf-8?Q?=E2=98=80=EF=B8=8F=20Napa=20Weekend=20Forecast:=2080=20degrees=20=E2=98=80=EF?= 4 | =?utf-8?Q?=B8=8F=20=E2=80=93=20Join=20us=20for=20Carnival=20of=20Flavor!?= 5 | Date: Tue, 25 Apr 2017 13:29:57 -0700 6 | X-Delivery: Level 2 7 | Content-description: d45822e51cscanner%40apricot.com!57be2!47ec72!77ce3458!rynof10.pbz! 8 | Message-Id: <20170425203099.D45822E51C35@elabs10.com> 9 | MIME-Version: 1.0 10 | Content-Type: multipart/alternative; 11 | boundary="=_2af5e5f76f6c3128e72a3bcb13f37167" 12 | From: "=?utf-8?Q?Peju=20Winery?=" 13 | 14 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Et tortor consequat id porta nibh venenatis cras sed. Nulla pellentesque dignissim enim sit. Sed sed risus pretium quam vulputate. Purus in mollis nunc sed id. Nullam vehicula ipsum a arcu cursus. Dictumst quisque sagittis purus sit amet volutpat consequat. Magna ac placerat vestibulum lectus mauris ultrices eros. Vitae auctor eu augue ut lectus arcu bibendum at varius. Scelerisque eleifend donec pretium vulputate sapien nec sagittis aliquam malesuada. Magna eget est lorem ipsum dolor sit amet consectetur adipiscing. Mattis molestie a iaculis at. 15 | 16 | Mi tempus imperdiet nulla malesuada pellentesque. Pretium quam vulputate dignissim suspendisse in est ante. Eu consequat ac felis donec et odio. Lorem dolor sed viverra ipsum nunc aliquet bibendum enim. Auctor neque vitae tempus quam pellentesque nec nam aliquam sem. Sed sed risus pretium quam vulputate. Aliquet nibh praesent tristique magna sit amet purus. In ornare quam viverra orci sagittis eu. Hendrerit gravida rutrum quisque non tellus. Posuere sollicitudin aliquam ultrices sagittis orci a. Convallis aenean et tortor at risus viverra adipiscing. Ipsum consequat nisl vel pretium lectus quam id leo. Ornare arcu dui vivamus arcu felis bibendum ut. Non sodales neque sodales ut etiam sit amet nisl purus. Ullamcorper malesuada proin libero nunc consequat interdum varius sit. In hac habitasse platea dictumst. Etiam sit amet nisl purus in mollis nunc. 17 | 18 | Eget dolor morbi non arcu risus quis. Consequat mauris nunc congue nisi vitae suscipit tellus. Magna fringilla urna porttitor rhoncus dolor. Tortor id aliquet lectus proin nibh nisl. Velit dignissim sodales ut eu sem integer. Cursus mattis molestie a iaculis at erat. Sed viverra tellus in hac habitasse platea dictumst vestibulum. Blandit aliquam etiam erat velit scelerisque in dictum. Leo urna molestie at elementum eu facilisis sed. Urna condimentum mattis pellentesque id nibh tortor id aliquet lectus. Cras semper auctor neque vitae tempus. Magna etiam tempor orci eu. Turpis egestas pretium aenean pharetra magna ac placerat. Ante metus dictum at tempor commodo. Aliquam faucibus purus in massa tempor nec feugiat nisl pretium. 19 | 20 | Diam donec adipiscing tristique risus nec feugiat. Nulla facilisi etiam dignissim diam quis enim lobortis scelerisque fermentum. Eget arcu dictum varius duis. Purus sit amet luctus venenatis lectus magna fringilla urna. Sit amet risus nullam eget felis eget. Id nibh tortor id aliquet. Eget arcu dictum varius duis at consectetur lorem donec. Fames ac turpis egestas sed tempus. Phasellus vestibulum lorem sed risus ultricies tristique nulla aliquet. Quam nulla porttitor massa id. Venenatis tellus in metus vulputate eu scelerisque. Purus semper eget duis at. Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Ut pharetra sit amet aliquam id diam maecenas ultricies mi. Purus sit amet luctus venenatis lectus. Molestie nunc non blandit massa enim. 21 | 22 | Sed sed risus pretium quam vulputate dignissim suspendisse. Imperdiet nulla malesuada pellentesque elit eget gravida cum. Volutpat blandit aliquam etiam erat velit scelerisque in dictum. Integer feugiat scelerisque varius morbi enim nunc faucibus a. Sed elementum tempus egestas sed sed. Tincidunt vitae semper quis lectus nulla at volutpat. Curabitur vitae nunc sed velit dignissim sodales ut. Amet dictum sit amet justo donec enim diam. Id aliquet lectus proin nibh. Molestie at elementum eu facilisis sed odio morbi quis commodo. Iaculis at erat pellentesque adipiscing commodo elit at imperdiet. Tellus integer feugiat scelerisque varius morbi enim nunc faucibus. Vulputate eu scelerisque felis imperdiet proin fermentum leo. 23 | -------------------------------------------------------------------------------- /asimap/test/fixtures/mhdir/problems/4: -------------------------------------------------------------------------------- 1 | Delivery-Date: Fri, 07 Feb 2014 10:17:03 -0800 2 | Received: from mta905.em.ea.com (mta905.em.ea.com [63.211.90.189]) 3 | by kamidake.apricot.com (8.14.7/8.14.2) with ESMTP id s17IGtOO038260 4 | for ; Fri, 7 Feb 2014 10:17:02 -0800 (PST) 5 | (envelope-from bo-b6mqcrwbgwqzxhauyekr5qcep98e9u@b.email.swtor.com) 6 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=email.swtor.com; 7 | s=20111006; t=1391797015; x=1407435415; 8 | bh=aJfoH88jUuurbuWJzQY1bq0yj7MylFMcLKEJDxwefXQ=; h=From:Reply-To; 9 | b=OPsM520fDn3379tSi9U0JORH0TeU1I3zSbr5O/yLVcP+06/VMHmAjgoCMDfZgrDwH 10 | 1Xzyozkx4gR+OLp6+UF6mkKkupCwwer3cT3AzVU8W3MLgYEwspxKoKTIMTSLQ/VJ1g 11 | R34/dy/HxM//RbF6HTMFtCzV73uYgNFDAA1RQHLo= 12 | DomainKey-Signature: a=rsa-sha1; q=dns; c=nofws; 13 | s=200505; d=email.swtor.com; 14 | b=BveliFqbYKGmUwPfqyUj84OWMVH1Z2p9rQuuvMM32NgKkmkMztvcvdGSQi2t7uDFxbZPjsR1Ljn08V9uT8iYO9k5uqBAiUrq6df97afkpF+HYst/NgJ7BZBX87FAnGskJlSZDx/5XJsfE011eb0KVIkahVedMq8+O0dsPtvK86s=; 15 | h=Date:Message-ID:List-Unsubscribe:From:To:Subject:MIME-Version:Reply-To:Content-type:Content-Transfer-Encoding; 16 | Date: Fri, 7 Feb 2014 18:16:55 -0000 17 | Message-ID: 18 | List-Unsubscribe: 19 | From: "STAR WARS: The Old Republic" 20 | To: scanner@apricot.com 21 | Subject: Star Wars™: The Old Republic™ GALACTIC STARFIGHTER FREE-TO-PLAY ACCESS NOW OPEN! 22 | MIME-Version: 1.0 23 | Reply-To: "STAR WARS: The Old Republic" 24 | Content-type: text/html; charset="utf-8" 25 | Content-Transfer-Encoding: quoted-printable 26 | X-asimapd-uid: 0000000182.0000000367 27 | X-asimapd-uid: 0000000182.0000000367 28 | X-asimapd-uid: 0000000182.0000000367 29 | X-asimapd-uid: 0000000182.0000000367 30 | X-asimapd-uid: 0000000182.0000000367 31 | 32 | = 33 | Star Wars™: The Old Republic™ 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 49 | 50 | 165 | 166 |
51 | 54 | 55 | 56 | 68 | 69 |
Star Wars™ The Old Republic™=20 61 | GALACTIC STARFIGHTER FREE-TO-PLAY ACCESS NOW OPEN!
62 | If you are having trouble viewing this email,=20 63 | please click here. 67 |
70 | =20=20=20=20=20=20=20=20 71 | 72 | =20=20=20=20=20=20=20=20 73 | 75 | 76 | =20=20=20=20=20=20=20=20=20=20=20 77 | 78 | 88 | 89 | 90 | 91 | 95 | 96 | 97 | 104 | 105 | 111 | 112 | 115 | 116 | 117 | 120 | 121 | 122 | 123 | 124 | 158 | 159 | 160 | 163 | 164 |
80 | =20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20 81 | 86 | 87 |
84 | =20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20 85 |
<= 102 | /td> 103 |
110 |
126 | 127 | 130 | 135 | 141 | 145 | 148 | 156 | 157 |
153 |
167 | =20 168 | 169 | 171 | 172 | 175 | 201 | 204 | 205 | 206 | 209 | 210 |

To unsubscribe or=20 178 | otherwise manage your email preferences, Click Here.
182 |
183 | LucasArts, the LucasArts logo, STAR WARS and related=20 184 | properties are trademarks in the United States and/or in other countries=20 185 | of Lucasfilm Ltd. and/or its affiliates. © 2014 Lucasfilm=20 186 | Entertainment Company Ltd. or Lucasfilm Ltd. All rights reserved.=20 187 | BioWare and the BioWare logo are trademarks of EA International (Studio=20 188 | and Publishing) Ltd. EA and the EA logo are trademarks of Electronic=20 189 | Arts Inc. All other trademarks are the property of their respective=20 190 | owners.
191 |
192 | Electronic Arts Inc. 209 Redwood Shores Parkway, Redwood City,=20 193 | CA 94065
194 |
195 | Terms of Service | Privacy Policy

211 | 212 | 213 | 214 |
215 | 216 | 218 | = 219 | -------------------------------------------------------------------------------- /asimap/test/test_auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the auth modules.. users, password file, password checking 3 | """ 4 | 5 | # System imports 6 | # 7 | 8 | # 3rd party imports 9 | # 10 | import pytest 11 | 12 | # Project imports 13 | # 14 | from ..auth import authenticate 15 | from ..exceptions import BadAuthentication, NoSuchUser 16 | 17 | 18 | #################################################################### 19 | # 20 | @pytest.mark.asyncio 21 | async def test_authenticate(faker, user_factory, password_file_factory) -> None: 22 | password = faker.password() 23 | user = user_factory(password=password) 24 | users = [user_factory(password=faker.password()) for _ in range(10)] 25 | users.append(user) 26 | password_file_factory(users) 27 | auth_user = await authenticate(user.username, password) 28 | assert auth_user.username == user.username 29 | assert auth_user.pw_hash == user.pw_hash 30 | assert auth_user.maildir == user.maildir 31 | 32 | with pytest.raises(BadAuthentication): 33 | _ = await authenticate(user.username, faker.password()) 34 | 35 | with pytest.raises(NoSuchUser): 36 | _ = await authenticate(faker.email(), password) 37 | -------------------------------------------------------------------------------- /asimap/test/test_db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test our asyncio sqlite db 3 | """ 4 | 5 | # system imports 6 | # 7 | import asyncio 8 | from typing import Dict 9 | 10 | # 3rd party imports 11 | # 12 | import pytest 13 | import pytest_asyncio 14 | 15 | # Project imports 16 | # 17 | from ..db import Database 18 | 19 | 20 | #################################################################### 21 | # 22 | @pytest_asyncio.fixture 23 | async def db(tmp_path): 24 | """ 25 | Fixture that sets up a asimap sqlite db in a temp dir. 26 | """ 27 | db = None 28 | try: 29 | async with asyncio.timeout(1): 30 | db = await Database.new(tmp_path) 31 | assert db 32 | yield db 33 | finally: 34 | if db: 35 | await db.close() 36 | 37 | 38 | #################################################################### 39 | # 40 | @pytest.mark.asyncio 41 | async def test_db_init_migrate(db): 42 | """ 43 | This actually tests all of the db methods so until we need to test 44 | something more complex this is good enough unit test for the Database and 45 | its methods. 46 | 47 | The `new()` method runs the migrations and tests fetchone, execute, 48 | commit. we check the results via the `query` method as an 49 | asynccontextmanager. 50 | """ 51 | schema: Dict[str, Dict[str, str]] = {} 52 | async for table in db.query( 53 | "SELECT name FROM sqlite_schema WHERE type='table'" 54 | ): 55 | table_name = table[0] 56 | schema[table_name] = {} 57 | async for table_info in db.query(f"PRAGMA table_info({table_name})"): 58 | schema[table_name][table_info[1]] = table_info[2] 59 | # NOTE: This requires we update our expected results whenever 60 | # migrations change this pseudo-schema we generate. 61 | expected = { 62 | "versions": {"version": "INTEGER", "date": "TEXT"}, 63 | "user_server": { 64 | "id": "INTEGER", 65 | "uid_vv": "INTEGER", 66 | "date": "TEXT", 67 | }, 68 | "mailboxes": { 69 | "id": "INTEGER", 70 | "name": "TEXT", 71 | "uid_vv": "INTEGER", 72 | "attributes": "TEXT", 73 | "mtime": "INTEGER", 74 | "next_uid": "INTEGER", 75 | "num_msgs": "INTEGER", 76 | "num_recent": "INTEGER", 77 | "date": "TEXT", 78 | "uids": "TEXT", 79 | "last_resync": "INTEGER", 80 | "msg_keys": "TEXT", 81 | "subscribed": "INTEGER", 82 | }, 83 | "sequences": { 84 | "id": "INTEGER", 85 | "name": "TEXT", 86 | "mailbox_id": "INTEGER", 87 | "sequence": "TEXT", 88 | "date": "TEXT", 89 | }, 90 | } 91 | assert schema == expected 92 | -------------------------------------------------------------------------------- /asimap/test/test_generator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fetch.. the part that gets various bits and pieces of messages. 3 | """ 4 | 5 | from email import message_from_bytes, message_from_string 6 | 7 | # System imports 8 | # 9 | from email.generator import BytesGenerator 10 | from email.policy import SMTP, default 11 | from io import BytesIO 12 | 13 | # 3rd party imports 14 | # 15 | import pytest 16 | 17 | # Project imports 18 | # 19 | # from ..generator import msg_as_string, msg_headers_as_string 20 | from ..generator import msg_as_bytes, msg_headers_as_bytes 21 | from .conftest import PROBLEMATIC_EMAIL_MSG_KEYS, STATIC_EMAIL_MSG_KEYS 22 | 23 | 24 | #################################################################### 25 | # 26 | def test_simple_email_text_generator_no_headers(email_factory): 27 | for _ in range(5): 28 | msg = email_factory() 29 | msg_text = msg_as_bytes(msg, render_headers=False) 30 | 31 | # An email message has a bunch of lines as a header and then a two line 32 | # break. After those two lines is the message body. We use this to 33 | # compare an RFC822 message from the default generator with our 34 | # sub-class that can skip headers. NOTE: rfc822 emails have `\r\n` as 35 | # their line ends. 36 | # 37 | fp = BytesIO() 38 | g = BytesGenerator(fp, mangle_from_=False, policy=SMTP) 39 | g.flatten(msg) 40 | rfc822_text = fp.getvalue() 41 | 42 | # Look for the first occurence of "\r\n" in our rfc822_text. Split the 43 | # string on that point. 44 | # 45 | where = rfc822_text.index(b"\r\n\r\n") + 4 46 | body = rfc822_text[where:] 47 | 48 | assert msg_text == body 49 | 50 | 51 | #################################################################### 52 | # 53 | @pytest.mark.parametrize("msg_key", STATIC_EMAIL_MSG_KEYS) 54 | def test_static_email_text_generator_no_headers( 55 | msg_key, static_email_factory_bytes 56 | ): 57 | 58 | msg = message_from_bytes( 59 | static_email_factory_bytes(msg_key), policy=default 60 | ) 61 | msg_text = msg_as_bytes(msg, render_headers=False) 62 | 63 | fp = BytesIO() 64 | g = BytesGenerator(fp, mangle_from_=False, policy=SMTP) 65 | g.flatten(msg) 66 | rfc822_text = fp.getvalue() 67 | 68 | # Look for the first occurence of "\r\n" in our rfc822_text. Split the 69 | # string on that point. 70 | # 71 | where = rfc822_text.index(b"\r\n\r\n") + 4 72 | body = rfc822_text[where:] 73 | assert msg_text == body 74 | 75 | 76 | #################################################################### 77 | # 78 | @pytest.mark.parametrize("msg_key", STATIC_EMAIL_MSG_KEYS) 79 | def test_static_email_text_generator_headers( 80 | msg_key, static_email_factory_bytes 81 | ): 82 | """ 83 | A message with headers is the same as the default generator with 84 | policy=SMTP. 85 | """ 86 | msg = message_from_bytes( 87 | static_email_factory_bytes(msg_key), policy=default 88 | ) 89 | msg_text = msg_as_bytes(msg, render_headers=True) 90 | 91 | fp = BytesIO() 92 | g = BytesGenerator(fp, mangle_from_=False, policy=SMTP) 93 | g.flatten(msg) 94 | rfc822_text = fp.getvalue() 95 | 96 | assert msg_text == rfc822_text 97 | 98 | 99 | #################################################################### 100 | # 101 | @pytest.mark.parametrize("msg_key", STATIC_EMAIL_MSG_KEYS) 102 | def test_static_email_header_generator_all_headers( 103 | msg_key, static_email_factory_bytes 104 | ): 105 | 106 | msg = message_from_bytes( 107 | static_email_factory_bytes(msg_key), policy=default 108 | ) 109 | headers = msg_headers_as_bytes(msg) 110 | 111 | fp = BytesIO() 112 | g = BytesGenerator(fp, mangle_from_=False, policy=SMTP) 113 | g.flatten(msg) 114 | rfc822_text = fp.getvalue() 115 | 116 | # Look for the first occurence of "\r\n" in our rfc822_text. Split the 117 | # string on that point. 118 | # 119 | where = rfc822_text.index(b"\r\n\r\n") + 4 120 | rfc822_headers = rfc822_text[:where] 121 | 122 | assert headers == rfc822_headers 123 | 124 | 125 | #################################################################### 126 | # 127 | def test_header_generator_some_headers(lots_of_headers_email): 128 | """ 129 | Test selective getting of headers. 130 | """ 131 | msg = message_from_string(lots_of_headers_email, policy=default) 132 | 133 | headers = msg_headers_as_bytes( 134 | msg, ("to", "from", "SuBjEct", "Date"), skip=False 135 | ) 136 | 137 | assert ( 138 | headers 139 | == b'From: jang.abcdef@xyzlinu \r\nTo: "jang12@linux12.org.new" \r\nSubject: R: R: R: I: FR-selca LA selcaE\r\nDate: Wed, 15 Nov 2017 14:16:14 +0000\r\n\r\n' 140 | ) 141 | 142 | 143 | #################################################################### 144 | # 145 | def test_header_generator_skip_headers(lots_of_headers_email): 146 | """ 147 | Test selective getting of headers. 148 | """ 149 | msg = message_from_string(lots_of_headers_email, policy=default) 150 | 151 | # Going to skip most of the headers! 152 | to_skip = [ 153 | "X-Assp-ID", 154 | "X-Assp-Session", 155 | "X-Assp-Version", 156 | "X-Assp-Delay", 157 | "X-Assp-Message-Score", 158 | "X-Assp-IP-Score", 159 | "X-Assp-Message-Score", 160 | "X-Original-Authentication-Results", 161 | "X-Assp-Message-Score", 162 | "X-Assp-IP-Score", 163 | "X-Assp-Message-Score", 164 | "X-Assp-Message-Score", 165 | "X-Assp-DKIM", 166 | "X-MS-Has-Attach", 167 | "X-MS-TNEF-Correlator", 168 | "x-originating-ip", 169 | "x-ms-publictraffictype", 170 | "x-microsoft-exchange-diagnostics", 171 | "x-ms-exchange-antispam-srfa-diagnostics" 172 | "x-ms-office365-filtering-correlation-id", 173 | "x-microsoft-antispam", 174 | "x-ms-traffictypediagnostic", 175 | "x-microsoft-antispam-prvs", 176 | "x-exchange-antispam-report-test", 177 | "x-exchange-antispam-report-cfa-test", 178 | "x-forefront-prvs", 179 | "x-forefront-antispam-report", 180 | "received-spf", 181 | "spamdiagnosticoutput", 182 | "spamdiagnosticmetadata", 183 | "X-Priority", 184 | "X-MSMail-Priority", 185 | "X-Mailer" "X-MimeOLE", 186 | "X-Antivirus", 187 | "X-Antivirus-Status", 188 | "X-UIDL", 189 | "X-Antivirus", 190 | "X-Antivirus-Status", 191 | "x-ms-EXCHANGE-ANTISPAM-srfa-diagnostics" 192 | "x-ms-office365-FILTERING-correlation-id", 193 | "X-MIMEOLE", 194 | ] 195 | 196 | expected = b"""Return-Path: \r 197 | Delivered-To: jang12@linux12.org.new\r 198 | Received: (qmail 21619 invoked from network); 15 Nov 2017 14:16:18 -0000\r 199 | Received: from unknown (HELO EUR01-HE1-obe.outbound.protection.outlook.com)\r 200 | (80.68.177.35) by with SMTP; 15 Nov 2017 14:16:18 -0000\r 201 | Received: from mail-he1eur01on0133.outbound.protection.outlook.com\r 202 | \t([104.47.0.133] helo=EUR01-HE1-obe.outbound.protection.outlook.com) by\r 203 | \tmyassp01.mynet.it with SMTP (2.5.5); 15 Nov 2017 15:16:20 +0100\r 204 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r 205 | d=CMMSRL.onmicrosoft.com; s=selector1-cmmlaser-it;\r 206 | h=From:Date:Subject:Message-ID:Content-Type:MIME-Version;\r 207 | bh=JmZzBMD0RLaOTuqX/VlM86EEKHsfeOF0B0kBWE4fKBY=; =?utf-8?q?b=3Dh65Qop22nh21?=\r 208 | =?utf-8?q?H30A/T/T47dDaCkb70hySSaJfJCzh+0E2A41BTqlUT7Y3c80Kf6zc5Totg4Kmuub2?=\r 209 | =?utf-8?q?P8r/Fj30rIiQP5EXW+/caFvHtXEQjZXeuWYRfBweASqK5/1ClHkY3SBgnw3dEuAhl?=\r 210 | =?utf-8?q?IDzid6M/5YxuJqzn6d/mKvmjV2Ju0=3D?=\r 211 | Received: from AM4PR01MB1444.eurprd01.prod.exchangelabs.com (10.164.76.26) by\r 212 | AM4PR01MB1442.eurprd01.prod.exchangelabs.com (10.164.76.24) with Microsoft\r 213 | SMTP Server (version=TLS1_2,\r 214 | cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P256) id 15.20.218.12; Wed, 15\r 215 | Nov 2017 14:16:14 +0000\r 216 | Received: from AM4PR01MB1444.eurprd01.prod.exchangelabs.com\r 217 | ([fe80::7830:c66f:eaa8:e3dd]) by AM4PR01MB1444.eurprd01.prod.exchangelabs.com\r 218 | ([fe80::7830:c66f:eaa8:e3dd%14]) with mapi id 15.20.0218.015; Wed, 15 Nov\r 219 | 2017 14:16:14 +0000\r 220 | From: jang.abcdef@xyzlinu \r 221 | To: "jang12@linux12.org.new" \r 222 | Subject: R: R: R: I: FR-selca LA selcaE\r 223 | Thread-Topic: R: R: I: FR-selca LA selcaE\r 224 | Thread-Index: =?utf-8?q?AdNST+6DXK4xfZYaRzuyUbaIacENgAHGVF+AAACaRUAAAhGDmgAA?=\r 225 | =?utf-8?q?St6QACm+BjkA/3MzkAAAPw7yAAA7j6A=3D?=\r 226 | Date: Wed, 15 Nov 2017 14:16:14 +0000\r 227 | Message-ID: \r 228 | References: =?utf-8?q?=3CAM4PR01MB1444920F2AF5B6F4856FEA13F7290=40AM4PR01MB1?=\r 229 | =?utf-8?q?444=2Eeurprd01=2Eprod=2Eexchangelabs=2Ecom=3E_=3C5185e377-81c5-43?=\r 230 | =?utf-8?q?61-91ba-11d42f4c5cc9=40AM5EUR02FT056=2Eeop-EUR02=2Eprod=2Eprotect?=\r 231 | =?utf-8?q?ion=2Eoutlook=2Ecom=3E?=\r 232 | In-Reply-To: =?utf-8?q?=3C5185e377-81c5-4361-91ba-11d42f4c5cc9=40AM5EUR02FT0?=\r 233 | =?utf-8?q?56=2Eeop-EUR02=2Eprod=2Eprotection=2Eoutlook=2Ecom=3E?=\r 234 | Accept-Language: it-IT, en-US\r 235 | Content-Language: it-IT\r 236 | authentication-results: spf=none (sender IP is )\r 237 | smtp.mailfrom=jang.selca.tubi@linux.selca;\r 238 | x-ms-exchange-antispam-srfa-diagnostics: SSOS;\r 239 | x-ms-office365-filtering-correlation-id: b6800147-d5b4-494e-46ff-08d52c336e1f\r 240 | MIME-Version: 1.0\r 241 | Content-Type: multipart/alternative;\r 242 | \tboundary="----=_NextPart_000_0031_01D36222.8A648550"\r 243 | X-Mailer: Microsoft Outlook Express 6.00.2900.5931\r\n\r\n""" 244 | 245 | headers = msg_headers_as_bytes(msg, tuple(to_skip), skip=True) 246 | assert headers == expected 247 | 248 | 249 | #################################################################### 250 | # 251 | @pytest.mark.parametrize("msg_key", PROBLEMATIC_EMAIL_MSG_KEYS) 252 | def test_generator_problematic_email(msg_key, problematic_email_factory_bytes): 253 | """ 254 | Not all emails can be flattened out of the box without some jiggery 255 | pokery. Such as messages that say they are 7-bit us-ascii but are actually 256 | 8-bit latin-1. 257 | """ 258 | msg = message_from_bytes( 259 | problematic_email_factory_bytes(msg_key), policy=default 260 | ) 261 | msg_text = msg_as_bytes(msg) 262 | assert msg_text 263 | msg_hdrs = msg_headers_as_bytes(msg) 264 | assert msg_hdrs 265 | -------------------------------------------------------------------------------- /asimap/test/test_mh.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for our subclass of `mailbox.MH` that adds some async methods 3 | """ 4 | 5 | # 3rd party imports 6 | # 7 | import pytest 8 | 9 | # Project imports 10 | # 11 | from ..mh import MH 12 | 13 | 14 | #################################################################### 15 | # 16 | @pytest.mark.asyncio 17 | async def test_mh_lock_folder(tmp_path): 18 | """ 19 | XXX To do a proper test we need to fork a separate process and validate 20 | 21 | that it blocks while we hold this lock. Tested this by hand and it 22 | worked so going to leave the full test for later. 23 | 24 | For now we are testing that this does not outright fail. 25 | """ 26 | mh_dir = tmp_path / "Mail" 27 | mh = MH(mh_dir) 28 | inbox = mh.add_folder("inbox") 29 | assert inbox._locked is False 30 | async with inbox.lock_folder(): 31 | assert inbox._locked is True 32 | assert inbox._locked is False 33 | 34 | # Locks are not lost with an exception. 35 | # 36 | with pytest.raises(RuntimeError): 37 | async with inbox.lock_folder(): 38 | assert inbox._locked is True 39 | raise RuntimeError("Woop") 40 | assert inbox._locked is False 41 | 42 | 43 | #################################################################### 44 | # 45 | @pytest.mark.asyncio 46 | async def test_mh_aclear(bunch_of_email_in_folder): 47 | mh_dir = bunch_of_email_in_folder() 48 | mh = MH(mh_dir) 49 | inbox_folder = mh.get_folder("inbox") 50 | inbox_dir = mh_dir / "inbox" 51 | dir_keys = sorted( 52 | [int(x.name) for x in inbox_dir.iterdir() if x.name.isdigit()] 53 | ) 54 | assert dir_keys 55 | 56 | await inbox_folder.aclear() 57 | 58 | dir_keys = sorted( 59 | [int(x.name) for x in inbox_dir.iterdir() if x.name.isdigit()] 60 | ) 61 | assert len(dir_keys) == 0 62 | 63 | 64 | #################################################################### 65 | # 66 | @pytest.mark.asyncio 67 | async def test_mh_aremove(bunch_of_email_in_folder): 68 | mh_dir = bunch_of_email_in_folder() 69 | mh = MH(mh_dir) 70 | inbox_folder = mh.get_folder("inbox") 71 | inbox_dir = mh_dir / "inbox" 72 | 73 | dir_keys = sorted( 74 | [int(x.name) for x in inbox_dir.iterdir() if x.name.isdigit()] 75 | ) 76 | assert dir_keys 77 | 78 | async with inbox_folder.lock_folder(): 79 | folder_keys = inbox_folder.keys() 80 | for key in folder_keys: 81 | await inbox_folder.aremove(key) 82 | 83 | dir_keys = sorted( 84 | [int(x.name) for x in inbox_dir.iterdir() if x.name.isdigit()] 85 | ) 86 | assert len(dir_keys) == 0 87 | -------------------------------------------------------------------------------- /asimap/test/test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | """ 4 | Test the top level asimapd server through a series of integration tests. 5 | """ 6 | # system imports 7 | # 8 | from datetime import datetime, timezone 9 | 10 | # 3rd party imports 11 | # 12 | import pytest 13 | 14 | # Project imports 15 | # 16 | from ..client import CAPABILITIES 17 | 18 | 19 | #################################################################### 20 | # 21 | @pytest.mark.integration 22 | def test_server_capability(imap_server): 23 | """ 24 | We want a high level test of the server, but do not want to get into it 25 | launching the subprocess for an authenticated user. Getting the 26 | 'CAPABILITY' response from the server is good enough for that. 27 | """ 28 | fixtures = imap_server 29 | imap = fixtures["client"] 30 | status, capabilities = imap.capability() 31 | assert status == "OK" 32 | assert str(capabilities[0], "ascii") == " ".join(CAPABILITIES) 33 | imap.logout() 34 | 35 | 36 | #################################################################### 37 | # 38 | @pytest.mark.integration 39 | def test_server_login(imap_server, imap_user_server_program): 40 | """ 41 | Try logging in to the server. This will also launch the subprocess and 42 | communicate with it. 43 | """ 44 | fixtures = imap_server 45 | imap = fixtures["client"] 46 | status, capabilities = imap.capability() 47 | assert status == "OK" 48 | status, resp = imap.login(fixtures["user"].username, fixtures["password"]) 49 | assert status == "OK" 50 | status, resp = imap.logout() 51 | assert status == "BYE" 52 | 53 | 54 | #################################################################### 55 | # 56 | @pytest.mark.integration 57 | def test_server_list_status_select( 58 | bunch_of_email_in_folder, imap_server, imap_user_server_program 59 | ): 60 | """ 61 | LIST, STATUS INBOX, SELECT INBOX 62 | """ 63 | fixtures = imap_server 64 | imap = fixtures["client"] 65 | status, capabilities = imap.capability() 66 | assert status == "OK" 67 | status, resp = imap.login(fixtures["user"].username, fixtures["password"]) 68 | assert status == "OK" 69 | status, resp = imap.list() 70 | status, resp = imap.status( 71 | "INBOX", "(messages recent uidnext uidvalidity unseen)" 72 | ) 73 | status, resp = imap.select(mailbox="INBOX") 74 | status, resp = imap.fetch( 75 | "1:5", "(UID BODY[HEADER.FIELDS (TO FROM SUBJECT DATE)])" 76 | ) 77 | status, resp = imap.uid( 78 | "FETCH", 79 | "1:5", 80 | "(INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from to cc message-id in-reply-to references content-type x-priority x-uniform-type-identifier x-universally-unique-identifier list-id list-unsubscribe bimi-indicator bimi-location x-bimi-indicator-hash authentication-results dkim-signature x-spam-status x-spam-flag received-spf X-Forefront-Antispam-Report)])", 81 | ) 82 | status, resp = imap.logout() 83 | assert status == "BYE" 84 | 85 | 86 | #################################################################### 87 | # 88 | def test_server_append_and_fetch( 89 | bunch_of_email_in_folder, 90 | imap_server, 91 | imap_user_server_program, 92 | email_factory, 93 | ): 94 | """ 95 | Make sure we can append a message to a folder. 96 | """ 97 | fixtures = imap_server 98 | imap = fixtures["client"] 99 | status, resp = imap.login(fixtures["user"].username, fixtures["password"]) 100 | assert status == "OK" 101 | status, resp = imap.list() 102 | status, resp = imap.status( 103 | "INBOX", "(messages recent uidnext uidvalidity unseen)" 104 | ) 105 | status, resp = imap.select(mailbox="INBOX") 106 | msg = email_factory() 107 | now = datetime.now(timezone.utc).astimezone() 108 | status, resp = imap.append("INBOX", r"\Unseen", now, msg.as_bytes()) 109 | status, resp = imap.status( 110 | "INBOX", "(messages recent uidnext uidvalidity unseen)" 111 | ) 112 | status, resp = imap.logout() 113 | assert status == "BYE" 114 | 115 | 116 | # #################################################################### 117 | # # 118 | # @pytest.mark.integration 119 | # def test_server_two_clients( 120 | # bunch_of_email_in_folder, imap_server, imap_user_server_program 121 | # ): 122 | # """ 123 | # Make sure that if we have multiple clients basic operations work fine 124 | # """ 125 | # pass 126 | -------------------------------------------------------------------------------- /asimap/test/test_throttle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test our throttle of clients that attempt to login too much. 3 | """ 4 | 5 | # System imports 6 | # 7 | from time import time 8 | 9 | # Project imports 10 | # 11 | from ..throttle import ( 12 | MAX_ADDR_ATTEMPTS, 13 | MAX_USER_ATTEMPTS, 14 | PURGE_TIME, 15 | check_allow, 16 | login_failed, 17 | ) 18 | 19 | 20 | #################################################################### 21 | # 22 | def test_throttle_by_user(faker, mock_time): 23 | user = faker.email() 24 | ip_addr = faker.ipv4() 25 | 26 | # No failures, it is going to succeed. 27 | # 28 | now = time() 29 | mock_time.return_value = now 30 | assert check_allow(user, ip_addr) 31 | 32 | # Now register several failures such that we exceed the allowed number of 33 | # failures. Each of these comes from a different address to not trigger 34 | # failing by address. 35 | # 36 | for i in range(MAX_USER_ATTEMPTS): 37 | now += 1 38 | mock_time.return_value = now 39 | ip_addr = faker.ipv4() 40 | login_failed(user, ip_addr) 41 | assert check_allow(user, ip_addr) 42 | 43 | # The next login check by user will fail. 44 | # 45 | now += 1 46 | mock_time.return_value = now 47 | ip_addr = faker.ipv4() 48 | login_failed(user, ip_addr) 49 | assert check_allow(user, ip_addr) is False 50 | 51 | # Move time forward by the PURGE_TIME. This user should no longer be 52 | # throttled. 53 | # 54 | now += PURGE_TIME + 1 55 | mock_time.return_value = now 56 | assert check_allow(user, ip_addr) 57 | 58 | 59 | #################################################################### 60 | # 61 | def test_throttle_by_address(faker, mock_time): 62 | user = faker.email() 63 | ip_addr = faker.ipv4() 64 | 65 | # No failures, it is going to succeed. 66 | # 67 | now = time() 68 | mock_time.return_value = now 69 | assert check_allow(user, ip_addr) 70 | 71 | # Now register several failures such that we exceed the allowed number of 72 | # failures. Each of these comes from a different address to not trigger 73 | # failing by address. 74 | # 75 | for i in range(MAX_ADDR_ATTEMPTS): 76 | now += 1 77 | mock_time.return_value = now 78 | user = faker.email() 79 | login_failed(user, ip_addr) 80 | assert check_allow(user, ip_addr) 81 | 82 | # The next login check by user will fail. 83 | # 84 | now += 1 85 | mock_time.return_value = now 86 | user = faker.email() 87 | login_failed(user, ip_addr) 88 | assert check_allow(user, ip_addr) is False 89 | 90 | # Move time forward by the PURGE_TIME. This address should no longer be 91 | # throttled. 92 | # 93 | now += PURGE_TIME + 1 94 | mock_time.return_value = now 95 | user = faker.email() 96 | assert check_allow(user, ip_addr) 97 | -------------------------------------------------------------------------------- /asimap/test/test_user_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the user server. 3 | """ 4 | 5 | # system imports 6 | # 7 | from pathlib import Path 8 | 9 | # 3rd party imports 10 | # 11 | import pytest 12 | 13 | # Project imports 14 | # 15 | from ..client import Authenticated 16 | from ..mbox import Mailbox, NoSuchMailbox 17 | from ..parse import IMAPClientCommand 18 | from ..user_server import IMAPUserServer 19 | 20 | 21 | #################################################################### 22 | # 23 | @pytest.mark.asyncio 24 | async def test_user_server_instantiate(mh_folder): 25 | (mh_dir, _, _) = mh_folder() 26 | try: 27 | user_server = await IMAPUserServer.new(mh_dir) 28 | assert user_server 29 | finally: 30 | await user_server.shutdown() 31 | 32 | 33 | #################################################################### 34 | # 35 | @pytest.mark.asyncio 36 | async def test_find_all_folders( 37 | faker, mailbox_with_bunch_of_email, imap_user_server_and_client 38 | ): 39 | server, imap_client = imap_user_server_and_client 40 | _ = mailbox_with_bunch_of_email 41 | 42 | # Let us make several other folders. 43 | # 44 | folders = ["inbox"] 45 | for _ in range(5): 46 | folder_name = faker.word() 47 | fpath = Path(server.mailbox._path) / folder_name 48 | fpath.mkdir() 49 | folders.append(folder_name) 50 | for _ in range(3): 51 | sub_folder = f"{folder_name}/{faker.word()}" 52 | if sub_folder in folders: 53 | continue 54 | fpath = Path(server.mailbox._path) / sub_folder 55 | fpath.mkdir() 56 | folders.append(sub_folder) 57 | 58 | folders = sorted(folders) 59 | 60 | await server.find_all_folders() 61 | 62 | # After it finds all the folders they will be active for a bit. 63 | # 64 | assert len(server.active_mailboxes) == len(folders) 65 | 66 | # and they should each be in the active mailboxes dict. 67 | # 68 | for folder in folders: 69 | assert folder in server.active_mailboxes 70 | 71 | 72 | #################################################################### 73 | # 74 | @pytest.mark.asyncio 75 | async def test_check_folder( 76 | faker, mailbox_with_bunch_of_email, imap_user_server_and_client 77 | ): 78 | server, imap_client = imap_user_server_and_client 79 | mbox = mailbox_with_bunch_of_email 80 | 81 | # This is testing the code paths in this method alone making sure nothing 82 | # breaks. 83 | # 84 | await server.check_folder(mbox.name, 0, force=False) 85 | await server.check_folder(mbox.name, 0, force=True) 86 | 87 | 88 | #################################################################### 89 | # 90 | @pytest.mark.asyncio 91 | async def test_there_is_a_root_folder(imap_user_server): 92 | server = imap_user_server 93 | # In an attempt to see if the root folder would fix iOS 18's IMAP problems 94 | # we allow the root folder to exist. (But it still did not fix iOS 18) 95 | # 96 | with pytest.raises(NoSuchMailbox): 97 | _ = await server.get_mailbox("") 98 | 99 | 100 | #################################################################### 101 | # 102 | @pytest.mark.asyncio 103 | async def test_check_all_folders( 104 | faker, mailbox_with_bunch_of_email, imap_user_server_and_client 105 | ): 106 | server, imap_client = imap_user_server_and_client 107 | _ = mailbox_with_bunch_of_email 108 | 109 | # Let us make several other folders. 110 | # 111 | folders = ["inbox"] 112 | for _ in range(5): 113 | folder_name = faker.word() 114 | await Mailbox.create(folder_name, server) 115 | # Make sure folder exists and is active 116 | # 117 | await server.get_mailbox(folder_name) 118 | folders.append(folder_name) 119 | 120 | for _ in range(3): 121 | sub_folder = f"{folder_name}/{faker.word()}" 122 | if sub_folder in folders: 123 | continue 124 | await Mailbox.create(sub_folder, server) 125 | # Make sure folder exists and is active 126 | # 127 | await server.get_mailbox(sub_folder) 128 | folders.append(sub_folder) 129 | 130 | # select and idle on the inbox 131 | # 132 | client_handler = Authenticated(imap_client, server) 133 | cmd = IMAPClientCommand("A001 SELECT INBOX\r\n") 134 | cmd.parse() 135 | await client_handler.command(cmd) 136 | cmd = IMAPClientCommand("A002 IDLE\r\n") 137 | cmd.parse() 138 | await client_handler.command(cmd) 139 | 140 | # basically all the sub-components of this action are already tested. We 141 | # are making sure that this code that invokes them runs. Turn debug on for 142 | # the server to test the debugging log statements with statistics. 143 | # 144 | server.debug = True 145 | await server.check_all_folders(force=True) 146 | 147 | # And stop idling on the inbox. 148 | # 149 | await client_handler.do_done() 150 | -------------------------------------------------------------------------------- /asimap/throttle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | This module has some simple logic to deal with failed login attempt throttling. 7 | """ 8 | # system imports 9 | # 10 | import logging 11 | import time 12 | 13 | # We use a pair of dicts that track how often we have had login attempts 14 | # against a username or attempts for user names that do not exist. 15 | # 16 | # If a specific login has a bunch of login failures against it in rapid 17 | # succession then we will actually automatically fail any further login 18 | # attempts against this user for a short amount of time greatly impairing any 19 | # brute force attempts to guess passwords. 20 | # 21 | # Also if a specific IP address attempts to login as users that do not exist 22 | # more than a certain amount in a certain time period then we lock out that IP 23 | # address for attempting to authenticate for a period of time greatly impairing 24 | # any brute force attempts to suss out accounts. 25 | # 26 | # XXX Maybe when a remote connection hits one of those limits we 27 | # should just not respond (no BAD, no NO, just dead air..) 28 | # 29 | 30 | # Key is the user name, value is a tuple of number of attempts within the 31 | # timeout period, and the last time they tried to authenticate this user and 32 | # failed. 33 | # 34 | BAD_USER_AUTHS: dict[str, tuple[int, float]] = {} 35 | 36 | # Key is the ip address of the IMAP client, value is a tuple of number of 37 | # attempts within the timeout period, and the last time they tried to 38 | # authenticate this and failed for any reason. 39 | # 40 | BAD_IP_AUTHS: dict[str, tuple[int, float]] = {} 41 | 42 | # How many seconds before we purge an entry from the dicts. 43 | # 44 | PURGE_TIME = 60 45 | 46 | # How many attempts are they allowed within PURGE_TIME before we decide that 47 | # they are trying to brute force something? 48 | # 49 | # We allow one more attempt for a given address in case an address is basically 50 | # mulitple different users (like behind a home gateway). This way the bad user 51 | # will get locked out after 4 attempts but we will allow other users to login 52 | # successfully from the same ip address. 53 | # 54 | MAX_USER_ATTEMPTS = 4 55 | MAX_ADDR_ATTEMPTS = 5 56 | 57 | logger = logging.getLogger("asimap.throttle") 58 | 59 | 60 | #################################################################### 61 | # 62 | def login_failed(user: str, addr: str): 63 | """ 64 | We had a login attempt that failed, likely due to a bad password. 65 | Record this attempt. 66 | 67 | The failure is recorded both for the username and the address it came from. 68 | 69 | So a number of bad attempts locks both that username from being logged in 70 | from and the address the login attempt came from. 71 | 72 | XXX There is a fundamental flaw with this in that a malicious agent that 73 | knows how our throttling works can esssentially conduct a denial of 74 | service against usernames it knows about. 75 | 76 | To mitigate this somewhat we will block an IP address that has too many 77 | failures before we will block a username that has too many failures. 78 | 79 | NOTE: The purpose of the address based lockout is to shut down IP addresses 80 | that are trying a bunch of different user names. 81 | 82 | Arguments: 83 | - `user`: The username that they tried to login with 84 | - `addr`: The IP address of the client that tried to login 85 | """ 86 | now = time.time() 87 | if user in BAD_USER_AUTHS: 88 | BAD_USER_AUTHS[user] = (BAD_USER_AUTHS[user][0] + 1, now) 89 | else: 90 | BAD_USER_AUTHS[user] = (1, now) 91 | 92 | if addr in BAD_IP_AUTHS: 93 | BAD_IP_AUTHS[addr] = (BAD_IP_AUTHS[addr][0] + 1, now) 94 | else: 95 | BAD_IP_AUTHS[addr] = (1, now) 96 | 97 | 98 | #################################################################### 99 | # 100 | def check_allow(user: str, addr: str) -> bool: 101 | """ 102 | Check the given user and client address to see if they are ones 103 | that are currently being throttled. Retrun True if either the 104 | username or client address is being throttled. 105 | 106 | Arguments: 107 | - `user`: The username that they are trying to login with 108 | - `addr`: The IP address that they are trying to login from 109 | """ 110 | 111 | # If user and/or client addr are in the tracking dicts, but the 112 | # last attempt time is more than seconds ago we clear those 113 | # entries and return True. 114 | # 115 | now = time.time() 116 | if user in BAD_USER_AUTHS and now - BAD_USER_AUTHS[user][1] > PURGE_TIME: 117 | logger.info("clearing '%s' from BAD_USER_AUTHS" % user) 118 | del BAD_USER_AUTHS[user] 119 | if addr in BAD_IP_AUTHS and now - BAD_IP_AUTHS[addr][1] > PURGE_TIME: 120 | logger.info("clearing '%s' from BAD_IP_AUTHS" % addr) 121 | del BAD_IP_AUTHS[addr] 122 | 123 | # if the user or client addr is NOT in either of the tracking dicts 124 | # then we return True. 125 | # 126 | if user not in BAD_USER_AUTHS and addr not in BAD_IP_AUTHS: 127 | return True 128 | 129 | # The entries are still in the dict and not expired (ie: older than the 130 | # PURGE_TIME). See if they have exceeded the number of allowable attempts. 131 | # 132 | if user in BAD_USER_AUTHS and BAD_USER_AUTHS[user][0] > MAX_USER_ATTEMPTS: 133 | logger.warning( 134 | "Deny: too many attempts for user: '%s', from address: %s", 135 | user, 136 | addr, 137 | ) 138 | return False 139 | 140 | if addr in BAD_IP_AUTHS and BAD_IP_AUTHS[addr][0] > MAX_ADDR_ATTEMPTS: 141 | logger.warning("Deny: too many attempts from address: %s" % addr) 142 | return False 143 | 144 | # Otherwise they are not yet blocked from attempting to login. 145 | # 146 | return True 147 | -------------------------------------------------------------------------------- /asimap/trace.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | The support for writing (and eventually reading) trace files. 7 | 8 | Defines a method for setting up the trace writer and writing messages 9 | to trace writer if it has been initialized. 10 | """ 11 | 12 | import logging 13 | import logging.handlers 14 | 15 | # system imports 16 | # 17 | import time 18 | from typing import Any, Dict, Optional 19 | 20 | trace_logger = logging.getLogger("asimap.trace") 21 | logger = logging.getLogger("asimap.trace_logger") 22 | 23 | TRACE_ENABLED = False 24 | 25 | # Trace message timestamps are seconds.microseconds relative to the first 26 | # logged message for the life time of a user subprocess. 27 | # 28 | TRACE_START_TIME = time.monotonic() 29 | TRACE_LAST_TIME = 0.0 30 | 31 | # ######################################################################## 32 | # ######################################################################## 33 | # # 34 | # class TraceFormatter(logging.Formatter): 35 | # """ 36 | # We define a subclass of the logging.Formatter to handle logging 37 | # the timestamps. We want to log the delta since the formatter was 38 | # instantiated and the time delta since the last message was logged. 39 | # """ 40 | 41 | # #################################################################### 42 | # # 43 | # def __init__(self, *args, **kwargs): 44 | # """ """ 45 | # super(TraceFormatter, self).__init__(*args, **kwargs) 46 | # self.start = time.time() 47 | # self.last_time = self.start 48 | 49 | # #################################################################### 50 | # # 51 | # def formatTime(self, record, datefmt=None): 52 | # """ 53 | # We return the string to use for the date entry in the logged message. 54 | # Keyword Arguments: 55 | # record -- 56 | # datefmt -- (default None) 57 | # """ 58 | # now = time.time() 59 | # delta = now - self.start 60 | # delta_trace = now - self.last_time 61 | # self.last_time = now 62 | # return "{:13.4f} {:8.4f}".format(delta, delta_trace) 63 | 64 | 65 | # #################################################################### 66 | # # 67 | # def enable_tracing(logdir, trace_file=None): 68 | # """ 69 | # Keyword Arguments: 70 | # logdir -- The directory in to which write the trace files 71 | # """ 72 | # trace_logger.setLevel(logging.INFO) 73 | 74 | # h: Union[logging.StreamHandler, logging.handlers.RotatingFileHandler] 75 | # if logdir == "stderr" and not trace_file: 76 | # # Do not write traces to a file - write them to stderr. 77 | # # 78 | # log.debug("Logging trace records to stderr") 79 | # h = logging.StreamHandler() 80 | # else: 81 | # # XXX NOTE: We should make a custom logger that writes a trace 82 | # # version string at the start of every file. 83 | # # 84 | # # Rotate on every 10mb, keep 5 files. 85 | # # 86 | # if trace_file: 87 | # trace_file_basename = trace_file 88 | # else: 89 | # p = pwd.getpwuid(os.getuid()) 90 | # trace_file_basename = os.path.join( 91 | # logdir, "%s-asimapd.trace" % p.pw_name 92 | # ) 93 | 94 | # log.debug("Logging trace records to '{}'".format(trace_file_basename)) 95 | 96 | # h = logging.handlers.RotatingFileHandler( 97 | # trace_file_basename, maxBytes=20971520, backupCount=5 98 | # ) 99 | # h.setLevel(logging.INFO) 100 | # formatter = TraceFormatter("%(asctime)s %(message)s") 101 | # h.setFormatter(formatter) 102 | # trace_logger.addHandler(h) 103 | 104 | 105 | #################################################################### 106 | # 107 | def toggle_trace(turn_on: Optional[bool] = None) -> None: 108 | """ 109 | If `turn_on` is True, tracing is turned on. 110 | If `turn_on` is False, tracing is truned off. 111 | If `turn_on` is None, tracing is toggled: Turned on if it is off, 112 | turned off it is on. 113 | """ 114 | global TRACE_ENABLED, TRACE_LAST_TIME, TRACE_START_TIME 115 | if turn_on is None: 116 | match turn_on: 117 | case True: 118 | if TRACE_ENABLED is False: 119 | TRACE_ENABLED = True 120 | logger.info("Tracing is enabled") 121 | TRACE_START_TIME = time.monotonic() 122 | TRACE_LAST_TIME = 0.0 123 | trace({"trace_format": "1.0"}) 124 | case False: 125 | if TRACE_ENABLED is True: 126 | TRACE_ENABLED = False 127 | logger.info("Tracing is disabled") 128 | case None: 129 | if TRACE_ENABLED is True: 130 | TRACE_ENABLED = False 131 | logger.info("Tracing is disabled") 132 | else: 133 | TRACE_ENABLED = True 134 | logger.info("Tracing is enabled") 135 | TRACE_START_TIME = time.monotonic() 136 | TRACE_LAST_TIME = 0.0 137 | trace({"trace_format": "1.0"}) 138 | 139 | 140 | #################################################################### 141 | # 142 | def trace(msg: Dict[str, Any]) -> None: 143 | """ 144 | Keyword Arguments: 145 | msg -- 146 | """ 147 | global TRACE_ENABLED, TRACE_LAST_TIME, TRACE_START_TIME 148 | if TRACE_ENABLED: 149 | now = time.monotonic() - TRACE_START_TIME 150 | trace_delta_time = now - TRACE_LAST_TIME 151 | TRACE_LAST_TIME = now 152 | msg["trace_time"] = now 153 | msg["trace_delta_time"] = trace_delta_time 154 | trace_logger.info(msg) 155 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | asimap: &imap-service 4 | image: asimap:prod 5 | container_name: asimap 6 | build: 7 | context: . 8 | dockerfile: ./Dockerfile 9 | target: prod 10 | platforms: 11 | - "linux/amd64" 12 | - "linux/arm64" 13 | ports: 14 | - "993:993" 15 | restart: unless-stopped 16 | env_file: .env 17 | volumes: 18 | - "${OPT_ASIMAP_DIR}:/opt/asimap" 19 | profiles: 20 | - prod 21 | 22 | asimap-dev: 23 | <<: *imap-service 24 | image: asimap:dev 25 | container_name: asimap-dev 26 | volumes: 27 | - ./:/app:z 28 | - "${OPT_ASIMAP_DIR}:/opt/asimap" 29 | profiles: 30 | - dev 31 | -------------------------------------------------------------------------------- /docs/rfcs/rfc2088.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Network Working Group J. Myers 8 | Request for Comments: 2088 Carnegie Mellon 9 | Cateogry: Standards Track January 1997 10 | 11 | 12 | IMAP4 non-synchronizing literals 13 | 14 | Status of this Memo 15 | 16 | This document specifies an Internet standards track protocol for the 17 | Internet community, and requests discussion and suggestions for 18 | improvements. Please refer to the current edition of the "Internet 19 | Official Protocol Standards" (STD 1) for the standardization state 20 | and status of this protocol. Distribution of this memo is unlimited. 21 | 22 | 1. Abstract 23 | 24 | The Internet Message Access Protocol [IMAP4] contains the "literal" 25 | syntactic construct for communicating strings. When sending a 26 | literal from client to server, IMAP4 requires the client to wait for 27 | the server to send a command continuation request between sending the 28 | octet count and the string data. This document specifies an 29 | alternate form of literal which does not require this network round 30 | trip. 31 | 32 | 2. Conventions Used in this Document 33 | 34 | In examples, "C:" and "S:" indicate lines sent by the client and 35 | server respectively. 36 | 37 | 3. Specification 38 | 39 | The non-synchronizing literal is added an alternate form of literal, 40 | and may appear in communication from client to server instead of the 41 | IMAP4 form of literal. The IMAP4 form of literal, used in 42 | communication from client to server, is referred to as a 43 | synchronizing literal. 44 | 45 | Non-synchronizing literals may be used with any IMAP4 server 46 | implementation which returns "LITERAL+" as one of the supported 47 | capabilities to the CAPABILITY command. If the server does not 48 | advertise the LITERAL+ capability, the client must use synchronizing 49 | literals instead. 50 | 51 | The non-synchronizing literal is distinguished from the original 52 | synchronizing literal by having a plus ('+') between the octet count 53 | and the closing brace ('}'). The server does not generate a command 54 | continuation request in response to a non-synchronizing literal, and 55 | 56 | 57 | 58 | Myers Standards Track [Page 1] 59 | 60 | RFC 2088 LITERAL January 1997 61 | 62 | 63 | clients are not required to wait before sending the octets of a non- 64 | synchronizing literal. 65 | 66 | The protocol receiver of an IMAP4 server must check the end of every 67 | received line for an open brace ('{') followed by an octet count, a 68 | plus ('+'), and a close brace ('}') immediately preceeding the CRLF. 69 | If it finds this sequence, it is the octet count of a non- 70 | synchronizing literal and the server MUST treat the specified number 71 | of following octets and the following line as part of the same 72 | command. A server MAY still process commands and reject errors on a 73 | line-by-line basis, as long as it checks for non-synchronizing 74 | literals at the end of each line. 75 | 76 | Example: C: A001 LOGIN {11+} 77 | C: FRED FOOBAR {7+} 78 | C: fat man 79 | S: A001 OK LOGIN completed 80 | 81 | 4. Formal Syntax 82 | 83 | The following syntax specification uses the augmented Backus-Naur 84 | Form (BNF) notation as specified in [RFC-822] as modified by [IMAP4]. 85 | Non-terminals referenced but not defined below are as defined by 86 | [IMAP4]. 87 | 88 | literal ::= "{" number ["+"] "}" CRLF *CHAR8 89 | ;; Number represents the number of CHAR8 octets 90 | 91 | 6. References 92 | 93 | [IMAP4] Crispin, M., "Internet Message Access Protocol - Version 4", 94 | draft-crispin-imap-base-XX.txt, University of Washington, April 1996. 95 | 96 | [RFC-822] Crocker, D., "Standard for the Format of ARPA Internet Text 97 | Messages", STD 11, RFC 822. 98 | 99 | 7. Security Considerations 100 | 101 | There are no known security issues with this extension. 102 | 103 | 8. Author's Address 104 | 105 | John G. Myers 106 | Carnegie-Mellon University 107 | 5000 Forbes Ave. 108 | Pittsburgh PA, 15213-3890 109 | 110 | Email: jgm+@cmu.edu 111 | 112 | 113 | 114 | Myers Standards Track [Page 2] 115 | -------------------------------------------------------------------------------- /docs/rfcs/rfc2177.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Network Working Group B. Leiba 8 | Request for Comments: 2177 IBM T.J. Watson Research Center 9 | Category: Standards Track June 1997 10 | 11 | 12 | IMAP4 IDLE command 13 | 14 | Status of this Memo 15 | 16 | This document specifies an Internet standards track protocol for the 17 | Internet community, and requests discussion and suggestions for 18 | improvements. Please refer to the current edition of the "Internet 19 | Official Protocol Standards" (STD 1) for the standardization state 20 | and status of this protocol. Distribution of this memo is unlimited. 21 | 22 | 1. Abstract 23 | 24 | The Internet Message Access Protocol [IMAP4] requires a client to 25 | poll the server for changes to the selected mailbox (new mail, 26 | deletions). It's often more desirable to have the server transmit 27 | updates to the client in real time. This allows a user to see new 28 | mail immediately. It also helps some real-time applications based on 29 | IMAP, which might otherwise need to poll extremely often (such as 30 | every few seconds). (While the spec actually does allow a server to 31 | push EXISTS responses aysynchronously, a client can't expect this 32 | behaviour and must poll.) 33 | 34 | This document specifies the syntax of an IDLE command, which will 35 | allow a client to tell the server that it's ready to accept such 36 | real-time updates. 37 | 38 | 2. Conventions Used in this Document 39 | 40 | In examples, "C:" and "S:" indicate lines sent by the client and 41 | server respectively. 42 | 43 | The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" 44 | in this document are to be interpreted as described in RFC 2060 45 | [IMAP4]. 46 | 47 | 3. Specification 48 | 49 | IDLE Command 50 | 51 | Arguments: none 52 | 53 | Responses: continuation data will be requested; the client sends 54 | the continuation data "DONE" to end the command 55 | 56 | 57 | 58 | Leiba Standards Track [Page 1] 59 | 60 | RFC 2177 IMAP4 IDLE command June 1997 61 | 62 | 63 | 64 | Result: OK - IDLE completed after client sent "DONE" 65 | NO - failure: the server will not allow the IDLE 66 | command at this time 67 | BAD - command unknown or arguments invalid 68 | 69 | The IDLE command may be used with any IMAP4 server implementation 70 | that returns "IDLE" as one of the supported capabilities to the 71 | CAPABILITY command. If the server does not advertise the IDLE 72 | capability, the client MUST NOT use the IDLE command and must poll 73 | for mailbox updates. In particular, the client MUST continue to be 74 | able to accept unsolicited untagged responses to ANY command, as 75 | specified in the base IMAP specification. 76 | 77 | The IDLE command is sent from the client to the server when the 78 | client is ready to accept unsolicited mailbox update messages. The 79 | server requests a response to the IDLE command using the continuation 80 | ("+") response. The IDLE command remains active until the client 81 | responds to the continuation, and as long as an IDLE command is 82 | active, the server is now free to send untagged EXISTS, EXPUNGE, and 83 | other messages at any time. 84 | 85 | The IDLE command is terminated by the receipt of a "DONE" 86 | continuation from the client; such response satisfies the server's 87 | continuation request. At that point, the server MAY send any 88 | remaining queued untagged responses and then MUST immediately send 89 | the tagged response to the IDLE command and prepare to process other 90 | commands. As in the base specification, the processing of any new 91 | command may cause the sending of unsolicited untagged responses, 92 | subject to the ambiguity limitations. The client MUST NOT send a 93 | command while the server is waiting for the DONE, since the server 94 | will not be able to distinguish a command from a continuation. 95 | 96 | The server MAY consider a client inactive if it has an IDLE command 97 | running, and if such a server has an inactivity timeout it MAY log 98 | the client off implicitly at the end of its timeout period. Because 99 | of that, clients using IDLE are advised to terminate the IDLE and 100 | re-issue it at least every 29 minutes to avoid being logged off. 101 | This still allows a client to receive immediate mailbox updates even 102 | though it need only "poll" at half hour intervals. 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | Leiba Standards Track [Page 2] 115 | 116 | RFC 2177 IMAP4 IDLE command June 1997 117 | 118 | 119 | Example: C: A001 SELECT INBOX 120 | S: * FLAGS (Deleted Seen) 121 | S: * 3 EXISTS 122 | S: * 0 RECENT 123 | S: * OK [UIDVALIDITY 1] 124 | S: A001 OK SELECT completed 125 | C: A002 IDLE 126 | S: + idling 127 | ...time passes; new mail arrives... 128 | S: * 4 EXISTS 129 | C: DONE 130 | S: A002 OK IDLE terminated 131 | ...another client expunges message 2 now... 132 | C: A003 FETCH 4 ALL 133 | S: * 4 FETCH (...) 134 | S: A003 OK FETCH completed 135 | C: A004 IDLE 136 | S: * 2 EXPUNGE 137 | S: * 3 EXISTS 138 | S: + idling 139 | ...time passes; another client expunges message 3... 140 | S: * 3 EXPUNGE 141 | S: * 2 EXISTS 142 | ...time passes; new mail arrives... 143 | S: * 3 EXISTS 144 | C: DONE 145 | S: A004 OK IDLE terminated 146 | C: A005 FETCH 3 ALL 147 | S: * 3 FETCH (...) 148 | S: A005 OK FETCH completed 149 | C: A006 IDLE 150 | 151 | 4. Formal Syntax 152 | 153 | The following syntax specification uses the augmented Backus-Naur 154 | Form (BNF) notation as specified in [RFC-822] as modified by [IMAP4]. 155 | Non-terminals referenced but not defined below are as defined by 156 | [IMAP4]. 157 | 158 | command_auth ::= append / create / delete / examine / list / lsub / 159 | rename / select / status / subscribe / unsubscribe 160 | / idle 161 | ;; Valid only in Authenticated or Selected state 162 | 163 | idle ::= "IDLE" CRLF "DONE" 164 | 165 | 166 | 167 | 168 | 169 | 170 | Leiba Standards Track [Page 3] 171 | 172 | RFC 2177 IMAP4 IDLE command June 1997 173 | 174 | 175 | 5. References 176 | 177 | [IMAP4] Crispin, M., "Internet Message Access Protocol - Version 178 | 4rev1", RFC 2060, December 1996. 179 | 180 | 6. Security Considerations 181 | 182 | There are no known security issues with this extension. 183 | 184 | 7. Author's Address 185 | 186 | Barry Leiba 187 | IBM T.J. Watson Research Center 188 | 30 Saw Mill River Road 189 | Hawthorne, NY 10532 190 | 191 | Email: leiba@watson.ibm.com 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | Leiba Standards Track [Page 4] 227 | -------------------------------------------------------------------------------- /docs/rfcs/rfc3691.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Network Working Group A. Melnikov 8 | Request for Comments: 3691 Isode Ltd. 9 | Category: Standards Track February 2004 10 | 11 | 12 | Internet Message Access Protocol (IMAP) UNSELECT command 13 | 14 | Status of this Memo 15 | 16 | This document specifies an Internet standards track protocol for the 17 | Internet community, and requests discussion and suggestions for 18 | improvements. Please refer to the current edition of the "Internet 19 | Official Protocol Standards" (STD 1) for the standardization state 20 | and status of this protocol. Distribution of this memo is unlimited. 21 | 22 | Copyright Notice 23 | 24 | Copyright (C) The Internet Society (2004). All Rights Reserved. 25 | 26 | Abstract 27 | 28 | This document defines an UNSELECT command that can be used to close 29 | the current mailbox in an Internet Message Access Protocol - version 30 | 4 (IMAP4) session without expunging it. Certain types of IMAP 31 | clients need to release resources associated with the selected 32 | mailbox without selecting a different mailbox. While IMAP4 provides 33 | this functionality (via a SELECT command with a nonexistent mailbox 34 | name or reselecting the same mailbox with EXAMINE command), a more 35 | clean solution is desirable. 36 | 37 | Table of Contents 38 | 39 | 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2 40 | 2. UNSELECT command . . . . . . . . . . . . . . . . . . . . . . . 2 41 | 3. Security Considerations. . . . . . . . . . . . . . . . . . . . 3 42 | 4. Formal Syntax. . . . . . . . . . . . . . . . . . . . . . . . . 3 43 | 5. IANA Considerations. . . . . . . . . . . . . . . . . . . . . . 3 44 | 6. Acknowledgments. . . . . . . . . . . . . . . . . . . . . . . . 3 45 | 7. Normative References . . . . . . . . . . . . . . . . . . . . . 4 46 | 8. Author's Address . . . . . . . . . . . . . . . . . . . . . . . 4 47 | 9. Full Copyright Statement . . . . . . . . . . . . . . . . . . . 5 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | Melnikov Standards Track [Page 1] 59 | 60 | RFC 3691 IMAP UNSELECT command February 2004 61 | 62 | 63 | 1. Introduction 64 | 65 | Certain types of IMAP clients need to release resources associated 66 | with the selected mailbox without selecting a different mailbox. 67 | While [IMAP4] provides this functionality (via a SELECT command with 68 | a nonexistent mailbox name or reselecting the same mailbox with 69 | EXAMINE command), a more clean solution is desirable. 70 | 71 | [IMAP4] defines the CLOSE command that closes the selected mailbox as 72 | well as permanently removes all messages with the \Deleted flag set. 73 | 74 | However [IMAP4] lacks a command that simply closes the mailbox 75 | without expunging it. This document defines the UNSELECT command for 76 | this purpose. 77 | 78 | A server which supports this extension indicates this with a 79 | capability name of "UNSELECT". 80 | 81 | "C:" and "S:" in examples show lines sent by the client and server 82 | respectively. 83 | 84 | The keywords "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in 85 | this document when typed in uppercase are to be interpreted as 86 | defined in "Key words for use in RFCs to Indicate Requirement Levels" 87 | [KEYWORDS]. 88 | 89 | 2. UNSELECT Command 90 | 91 | Arguments: none 92 | 93 | Responses: no specific responses for this command 94 | 95 | Result: OK - unselect completed, now in authenticated state 96 | BAD - no mailbox selected, or argument supplied but 97 | none permitted 98 | 99 | The UNSELECT command frees server's resources associated with the 100 | selected mailbox and returns the server to the authenticated 101 | state. This command performs the same actions as CLOSE, except 102 | that no messages are permanently removed from the currently 103 | selected mailbox. 104 | 105 | Example: C: A341 UNSELECT 106 | S: A341 OK Unselect completed 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | Melnikov Standards Track [Page 2] 115 | 116 | RFC 3691 IMAP UNSELECT command February 2004 117 | 118 | 119 | 3. Security Considerations 120 | 121 | It is believed that this extension doesn't raise any additional 122 | security concerns not already discussed in [IMAP4]. 123 | 124 | 4. Formal Syntax 125 | 126 | The following syntax specification uses the Augmented Backus-Naur 127 | Form (ABNF) notation as specified in [ABNF]. Non-terminals 128 | referenced but not defined below are as defined by [IMAP4]. 129 | 130 | Except as noted otherwise, all alphabetic characters are case- 131 | insensitive. The use of upper or lower case characters to define 132 | token strings is for editorial clarity only. Implementations MUST 133 | accept these strings in a case-insensitive fashion. 134 | 135 | command-select /= "UNSELECT" 136 | 137 | 5. IANA Considerations 138 | 139 | IMAP4 capabilities are registered by publishing a standards track or 140 | IESG approved experimental RFC. The registry is currently located 141 | at: 142 | 143 | http://www.iana.org/assignments/imap4-capabilities 144 | 145 | This document defines the UNSELECT IMAP capabilities. IANA has added 146 | this capability to the registry. 147 | 148 | 6. Acknowledgments 149 | 150 | UNSELECT command was originally implemented by Tim Showalter in Cyrus 151 | IMAP server. 152 | 153 | Also, the author of the document would like to thank Vladimir Butenko 154 | and Mark Crispin for reminding that UNSELECT has to be documented. 155 | Also thanks to Simon Josefsson for pointing out that there are 156 | multiple ways to implement UNSELECT. 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | Melnikov Standards Track [Page 3] 171 | 172 | RFC 3691 IMAP UNSELECT command February 2004 173 | 174 | 175 | 7. Normative References 176 | 177 | [KEYWORDS] Bradner, S., "Key words for use in RFCs to Indicate 178 | Requirement Levels", BCP 14, RFC 2119, March 1997. 179 | 180 | [IMAP4] Crispin, M., "Internet Message Access Protocol - Version 181 | 4rev1", RFC 3501, March 2003. 182 | 183 | [ABNF] Crocker, D., Ed. and P. Overell, "Augmented BNF for Syntax 184 | Specifications: ABNF", RFC 2234, November 1997. 185 | 186 | 8. Author's Address 187 | 188 | Alexey Melnikov 189 | Isode Limited 190 | 5 Castle Business Village 191 | Hampton, Middlesex TW12 2BX 192 | 193 | EMail: Alexey.Melnikov@isode.com 194 | URI: http://www.melnikov.ca/ 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | Melnikov Standards Track [Page 4] 227 | 228 | RFC 3691 IMAP UNSELECT command February 2004 229 | 230 | 231 | 9. Full Copyright Statement 232 | 233 | Copyright (C) The Internet Society (2004). This document is subject 234 | to the rights, licenses and restrictions contained in BCP 78 and 235 | except as set forth therein, the authors retain all their rights. 236 | 237 | This document and the information contained herein are provided on an 238 | "AS IS" basis and THE CONTRIBUTOR, THE ORGANIZATION HE/SHE 239 | REPRESENTS OR IS SPONSORED BY (IF ANY), THE INTERNET SOCIETY AND THE 240 | INTERNET ENGINEERING TASK FORCE DISCLAIM ALL WARRANTIES, EXPRESS OR 241 | IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF 242 | THE INFORMATION HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED 243 | WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. 244 | 245 | Intellectual Property 246 | 247 | The IETF takes no position regarding the validity or scope of any 248 | Intellectual Property Rights or other rights that might be claimed 249 | to pertain to the implementation or use of the technology 250 | described in this document or the extent to which any license 251 | under such rights might or might not be available; nor does it 252 | represent that it has made any independent effort to identify any 253 | such rights. Information on the procedures with respect to 254 | rights in RFC documents can be found in BCP 78 and BCP 79. 255 | 256 | Copies of IPR disclosures made to the IETF Secretariat and any 257 | assurances of licenses to be made available, or the result of an 258 | attempt made to obtain a general license or permission for the use 259 | of such proprietary rights by implementers or users of this 260 | specification can be obtained from the IETF on-line IPR repository 261 | at http://www.ietf.org/ipr. 262 | 263 | The IETF invites any interested party to bring to its attention 264 | any copyrights, patents or patent applications, or other 265 | proprietary rights that may cover technology that may be required 266 | to implement this standard. Please address the information to the 267 | IETF at ietf-ipr@ietf.org. 268 | 269 | Acknowledgement 270 | 271 | Funding for the RFC Editor function is currently provided by the 272 | Internet Society. 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | Melnikov Standards Track [Page 5] 283 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "asimap" 7 | dynamic = ["version", "dependencies"] 8 | authors = [ 9 | { name="Scanner Luce", email="scanner@apricot.com" }, 10 | ] 11 | maintainers = [ 12 | { name="Scanner Luce", email="scanner@apricot.com" }, 13 | ] 14 | description = "An IMAP server that uses `mailbox.MH` as its storage" 15 | keywords = ["email", "imap"] 16 | readme = "README.md" 17 | requires-python = ">=3.12" 18 | classifiers = [ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: BSD License", 21 | "Operating System :: OS Independent", 22 | ] 23 | 24 | [project.scripts] 25 | asimapd = "asimap.asimapd:main" 26 | asimapd_user = "asimap.asimapd_user:main" 27 | asimapd_set_password = "asimap.set_password:main" 28 | 29 | [project.urls] 30 | Homepage = "https://github.com/scanner/asimap" 31 | Issues = "https://github.com/scanner/asimap/issues" 32 | Changelog = "https://github.com/scanner/asimap/blob/main/CHANGELOG.md" 33 | 34 | [tool.setuptools.dynamic] 35 | dependencies = {file = ["requirements/production.txt"]} 36 | 37 | [tool.hatch.version] 38 | path = "asimap/__init__.py" 39 | 40 | [tool.hatch.build.targets.sdist] 41 | include = [ 42 | "asimap", 43 | "LICENSE", 44 | "README.md", 45 | ] 46 | exclude = [ 47 | "asimap/test", 48 | "docs", 49 | "venv", 50 | ".gitignore", 51 | ] 52 | 53 | [tool.black] 54 | line-length = 80 55 | exclude = ''' 56 | ( 57 | /( 58 | \.tox 59 | | .+/migrations 60 | | venv* 61 | | \.venv 62 | | \.pre-commit-cache 63 | )/ 64 | ) 65 | ''' 66 | 67 | [tool.isort] 68 | profile = "black" 69 | line_length = 80 70 | skip_gitignore = true 71 | filter_files = true 72 | skip_glob = ["*venv*","*/migrations/*",".*cache"] 73 | 74 | [tool.mypy] 75 | exclude = [ 76 | '__pycache__', 77 | '^\.mnt', 78 | '.*[-_]cache', 79 | '.git', 80 | '\.venv', 81 | 'venv*', 82 | 'tmp', 83 | 'fixtures', 84 | 'deployment', 85 | 'docs', 86 | 'requirements', 87 | 'migrations' 88 | ] 89 | ignore_missing_imports = true 90 | check_untyped_defs = true 91 | warn_unused_ignores = true 92 | warn_redundant_casts = true 93 | warn_unused_configs = true 94 | plugins = [ 95 | ] 96 | 97 | [[tool.mypy.overrides]] 98 | module = [ 99 | 'boto3', 100 | 'click', 101 | 'funcy', 102 | ] 103 | 104 | [tool.pytest.ini_options] 105 | asyncio_default_fixture_loop_scope = "function" 106 | markers = [ 107 | "smoke: marks tests as smoke (deselect with '-m \"not smoke\"')", 108 | "integration", 109 | ] 110 | 111 | [tool.pylint.MASTER] 112 | ignore-paths=[ ".*/migrations/.*" ] 113 | 114 | [tool.pylint.FORMAT] 115 | max-module-lines=2000 116 | 117 | [tool.pylint.DESIGN] 118 | max-attributes=15 119 | max-branches=15 120 | 121 | [tool.pylint.BASIC] 122 | no-docstring-rgx='^_|^Meta$|.+Serializer$|.+ViewSet$|^[Tt]est' 123 | 124 | [tool.pylint.'MESSAGES CONTROL'] 125 | disable=[ 126 | "unnecessary-pass", 127 | "import-error", 128 | "too-few-public-methods" 129 | ] 130 | -------------------------------------------------------------------------------- /requirements/Makefile: -------------------------------------------------------------------------------- 1 | # -*- Mode: Makefile -*- 2 | # 3 | ACTIVATE := . venv_req/bin/activate && 4 | 5 | objects := $(wildcard *.in) 6 | outputs := $(objects:.in=.txt) 7 | 8 | all: $(outputs) 9 | 10 | venv_req: 11 | @python -m venv "$@" 12 | @$(ACTIVATE) pip install -U pip pip-tools 13 | @touch "$@" 14 | 15 | %.txt: %.in venv_req 16 | $(ACTIVATE) pip-compile --resolver=backtracking --strip-extras --quiet --output-file "$@" "$<" 17 | 18 | development.txt: production.txt lint.txt build.txt 19 | 20 | clean: 21 | @rm -rf venv_req 22 | @rm -rf *.txt 23 | 24 | .PHONY: all clean 25 | -------------------------------------------------------------------------------- /requirements/build.in: -------------------------------------------------------------------------------- 1 | build 2 | hatch 3 | -------------------------------------------------------------------------------- /requirements/build.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=build.txt --strip-extras build.in 6 | # 7 | anyio==4.8.0 8 | # via httpx 9 | build==1.2.2.post1 10 | # via -r build.in 11 | certifi==2025.1.31 12 | # via 13 | # httpcore 14 | # httpx 15 | click==8.1.8 16 | # via 17 | # hatch 18 | # userpath 19 | distlib==0.3.9 20 | # via virtualenv 21 | filelock==3.17.0 22 | # via virtualenv 23 | h11==0.14.0 24 | # via httpcore 25 | hatch==1.14.0 26 | # via -r build.in 27 | hatchling==1.27.0 28 | # via hatch 29 | httpcore==1.0.7 30 | # via httpx 31 | httpx==0.28.1 32 | # via hatch 33 | hyperlink==21.0.0 34 | # via hatch 35 | idna==3.10 36 | # via 37 | # anyio 38 | # httpx 39 | # hyperlink 40 | jaraco-classes==3.4.0 41 | # via keyring 42 | jaraco-context==6.0.1 43 | # via keyring 44 | jaraco-functools==4.1.0 45 | # via keyring 46 | keyring==25.6.0 47 | # via hatch 48 | markdown-it-py==3.0.0 49 | # via rich 50 | mdurl==0.1.2 51 | # via markdown-it-py 52 | more-itertools==10.6.0 53 | # via 54 | # jaraco-classes 55 | # jaraco-functools 56 | packaging==24.2 57 | # via 58 | # build 59 | # hatch 60 | # hatchling 61 | pathspec==0.12.1 62 | # via hatchling 63 | pexpect==4.9.0 64 | # via hatch 65 | platformdirs==4.3.6 66 | # via 67 | # hatch 68 | # virtualenv 69 | pluggy==1.5.0 70 | # via hatchling 71 | ptyprocess==0.7.0 72 | # via pexpect 73 | pygments==2.19.1 74 | # via rich 75 | pyproject-hooks==1.2.0 76 | # via build 77 | rich==13.9.4 78 | # via hatch 79 | shellingham==1.5.4 80 | # via hatch 81 | sniffio==1.3.1 82 | # via anyio 83 | tomli-w==1.2.0 84 | # via hatch 85 | tomlkit==0.13.2 86 | # via hatch 87 | trove-classifiers==2025.3.3.18 88 | # via hatchling 89 | typing-extensions==4.12.2 90 | # via anyio 91 | userpath==1.9.2 92 | # via hatch 93 | uv==0.6.4 94 | # via hatch 95 | virtualenv==20.29.2 96 | # via hatch 97 | zstandard==0.23.0 98 | # via hatch 99 | -------------------------------------------------------------------------------- /requirements/development.in: -------------------------------------------------------------------------------- 1 | -r production.txt 2 | -r build.txt 3 | -r lint.txt 4 | 5 | codetiming 6 | dirty-equals 7 | imapclient 8 | ipython 9 | pytest 10 | pytest-asyncio 11 | pytest-coverage 12 | pytest-factoryboy 13 | pytest-mock 14 | pytest-sugar 15 | requests-mock 16 | rich 17 | trustme 18 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=development.txt --strip-extras development.in 6 | # 7 | aiofiles==24.1.0 8 | # via -r /Users/scanner/src/asimap/requirements/production.txt 9 | aioretry==6.3.1 10 | # via -r /Users/scanner/src/asimap/requirements/production.txt 11 | aiosqlite==0.21.0 12 | # via -r /Users/scanner/src/asimap/requirements/production.txt 13 | anyio==4.8.0 14 | # via 15 | # -r /Users/scanner/src/asimap/requirements/build.txt 16 | # httpx 17 | asttokens==3.0.0 18 | # via stack-data 19 | black==25.1.0 20 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 21 | build==1.2.2.post1 22 | # via 23 | # -r /Users/scanner/src/asimap/requirements/build.txt 24 | # -r /Users/scanner/src/asimap/requirements/lint.txt 25 | # pip-tools 26 | certifi==2025.1.31 27 | # via 28 | # -r /Users/scanner/src/asimap/requirements/build.txt 29 | # -r /Users/scanner/src/asimap/requirements/production.txt 30 | # httpcore 31 | # httpx 32 | # requests 33 | # sentry-sdk 34 | cffi==1.17.1 35 | # via 36 | # -r /Users/scanner/src/asimap/requirements/lint.txt 37 | # cryptography 38 | cfgv==3.4.0 39 | # via 40 | # -r /Users/scanner/src/asimap/requirements/lint.txt 41 | # pre-commit 42 | charset-normalizer==3.4.1 43 | # via 44 | # -r /Users/scanner/src/asimap/requirements/production.txt 45 | # requests 46 | click==8.1.8 47 | # via 48 | # -r /Users/scanner/src/asimap/requirements/build.txt 49 | # -r /Users/scanner/src/asimap/requirements/lint.txt 50 | # black 51 | # hatch 52 | # pip-tools 53 | # userpath 54 | codetiming==1.4.0 55 | # via -r development.in 56 | coverage==7.6.12 57 | # via 58 | # -r /Users/scanner/src/asimap/requirements/lint.txt 59 | # pytest-cov 60 | cryptography==44.0.2 61 | # via 62 | # -r /Users/scanner/src/asimap/requirements/lint.txt 63 | # trustme 64 | # types-pyopenssl 65 | # types-redis 66 | decorator==5.2.1 67 | # via ipython 68 | dirty-equals==0.9.0 69 | # via -r development.in 70 | distlib==0.3.9 71 | # via 72 | # -r /Users/scanner/src/asimap/requirements/build.txt 73 | # -r /Users/scanner/src/asimap/requirements/lint.txt 74 | # virtualenv 75 | docopt==0.6.2 76 | # via -r /Users/scanner/src/asimap/requirements/production.txt 77 | executing==2.2.0 78 | # via stack-data 79 | factory-boy==3.3.3 80 | # via pytest-factoryboy 81 | faker==36.1.1 82 | # via factory-boy 83 | filelock==3.17.0 84 | # via 85 | # -r /Users/scanner/src/asimap/requirements/build.txt 86 | # -r /Users/scanner/src/asimap/requirements/lint.txt 87 | # virtualenv 88 | h11==0.14.0 89 | # via 90 | # -r /Users/scanner/src/asimap/requirements/build.txt 91 | # httpcore 92 | hatch==1.14.0 93 | # via -r /Users/scanner/src/asimap/requirements/build.txt 94 | hatchling==1.27.0 95 | # via 96 | # -r /Users/scanner/src/asimap/requirements/build.txt 97 | # hatch 98 | httpcore==1.0.7 99 | # via 100 | # -r /Users/scanner/src/asimap/requirements/build.txt 101 | # httpx 102 | httpx==0.28.1 103 | # via 104 | # -r /Users/scanner/src/asimap/requirements/build.txt 105 | # hatch 106 | hyperlink==21.0.0 107 | # via 108 | # -r /Users/scanner/src/asimap/requirements/build.txt 109 | # hatch 110 | identify==2.6.8 111 | # via 112 | # -r /Users/scanner/src/asimap/requirements/lint.txt 113 | # pre-commit 114 | idna==3.10 115 | # via 116 | # -r /Users/scanner/src/asimap/requirements/build.txt 117 | # anyio 118 | # httpx 119 | # hyperlink 120 | # requests 121 | # trustme 122 | imapclient==3.0.1 123 | # via -r development.in 124 | inflection==0.5.1 125 | # via pytest-factoryboy 126 | iniconfig==2.0.0 127 | # via pytest 128 | ipython==9.0.1 129 | # via -r development.in 130 | ipython-pygments-lexers==1.1.1 131 | # via ipython 132 | isort==6.0.1 133 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 134 | jaraco-classes==3.4.0 135 | # via 136 | # -r /Users/scanner/src/asimap/requirements/build.txt 137 | # keyring 138 | jaraco-context==6.0.1 139 | # via 140 | # -r /Users/scanner/src/asimap/requirements/build.txt 141 | # keyring 142 | jaraco-functools==4.1.0 143 | # via 144 | # -r /Users/scanner/src/asimap/requirements/build.txt 145 | # keyring 146 | jedi==0.19.2 147 | # via ipython 148 | keyring==25.6.0 149 | # via 150 | # -r /Users/scanner/src/asimap/requirements/build.txt 151 | # hatch 152 | markdown-it-py==3.0.0 153 | # via 154 | # -r /Users/scanner/src/asimap/requirements/build.txt 155 | # rich 156 | matplotlib-inline==0.1.7 157 | # via ipython 158 | mdurl==0.1.2 159 | # via 160 | # -r /Users/scanner/src/asimap/requirements/build.txt 161 | # markdown-it-py 162 | more-itertools==10.6.0 163 | # via 164 | # -r /Users/scanner/src/asimap/requirements/build.txt 165 | # jaraco-classes 166 | # jaraco-functools 167 | mypy==1.15.0 168 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 169 | mypy-extensions==1.0.0 170 | # via 171 | # -r /Users/scanner/src/asimap/requirements/lint.txt 172 | # black 173 | # mypy 174 | nodeenv==1.9.1 175 | # via 176 | # -r /Users/scanner/src/asimap/requirements/lint.txt 177 | # pre-commit 178 | packaging==24.2 179 | # via 180 | # -r /Users/scanner/src/asimap/requirements/build.txt 181 | # -r /Users/scanner/src/asimap/requirements/lint.txt 182 | # black 183 | # build 184 | # hatch 185 | # hatchling 186 | # pytest 187 | # pytest-factoryboy 188 | # pytest-sugar 189 | parso==0.8.4 190 | # via jedi 191 | pathspec==0.12.1 192 | # via 193 | # -r /Users/scanner/src/asimap/requirements/build.txt 194 | # -r /Users/scanner/src/asimap/requirements/lint.txt 195 | # black 196 | # hatchling 197 | pexpect==4.9.0 198 | # via 199 | # -r /Users/scanner/src/asimap/requirements/build.txt 200 | # hatch 201 | # ipython 202 | pip-tools==7.4.1 203 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 204 | platformdirs==4.3.6 205 | # via 206 | # -r /Users/scanner/src/asimap/requirements/build.txt 207 | # -r /Users/scanner/src/asimap/requirements/lint.txt 208 | # black 209 | # hatch 210 | # virtualenv 211 | # yapf 212 | pluggy==1.5.0 213 | # via 214 | # -r /Users/scanner/src/asimap/requirements/build.txt 215 | # hatchling 216 | # pytest 217 | pre-commit==4.1.0 218 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 219 | prompt-toolkit==3.0.50 220 | # via ipython 221 | ptyprocess==0.7.0 222 | # via 223 | # -r /Users/scanner/src/asimap/requirements/build.txt 224 | # pexpect 225 | pure-eval==0.2.3 226 | # via stack-data 227 | pycparser==2.22 228 | # via 229 | # -r /Users/scanner/src/asimap/requirements/lint.txt 230 | # cffi 231 | pygments==2.19.1 232 | # via 233 | # -r /Users/scanner/src/asimap/requirements/build.txt 234 | # ipython 235 | # ipython-pygments-lexers 236 | # rich 237 | pyproject-hooks==1.2.0 238 | # via 239 | # -r /Users/scanner/src/asimap/requirements/build.txt 240 | # -r /Users/scanner/src/asimap/requirements/lint.txt 241 | # build 242 | # pip-tools 243 | pytest==8.3.5 244 | # via 245 | # -r development.in 246 | # pytest-asyncio 247 | # pytest-cov 248 | # pytest-factoryboy 249 | # pytest-mock 250 | # pytest-sugar 251 | pytest-asyncio==0.25.3 252 | # via -r development.in 253 | pytest-cov==6.0.0 254 | # via pytest-cover 255 | pytest-cover==3.0.0 256 | # via pytest-coverage 257 | pytest-coverage==0.0 258 | # via -r development.in 259 | pytest-factoryboy==2.7.0 260 | # via -r development.in 261 | pytest-mock==3.14.0 262 | # via -r development.in 263 | pytest-sugar==1.0.0 264 | # via -r development.in 265 | python-dotenv==1.0.1 266 | # via -r /Users/scanner/src/asimap/requirements/production.txt 267 | python-json-logger==3.2.1 268 | # via -r /Users/scanner/src/asimap/requirements/production.txt 269 | pyyaml==6.0.2 270 | # via 271 | # -r /Users/scanner/src/asimap/requirements/lint.txt 272 | # pre-commit 273 | requests==2.32.3 274 | # via requests-mock 275 | requests-mock==1.12.1 276 | # via -r development.in 277 | rich==13.9.4 278 | # via 279 | # -r /Users/scanner/src/asimap/requirements/build.txt 280 | # -r development.in 281 | # hatch 282 | ruff==0.9.9 283 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 284 | sentry-sdk==2.22.0 285 | # via -r /Users/scanner/src/asimap/requirements/production.txt 286 | shellingham==1.5.4 287 | # via 288 | # -r /Users/scanner/src/asimap/requirements/build.txt 289 | # hatch 290 | sniffio==1.3.1 291 | # via 292 | # -r /Users/scanner/src/asimap/requirements/build.txt 293 | # anyio 294 | stack-data==0.6.3 295 | # via ipython 296 | termcolor==2.5.0 297 | # via pytest-sugar 298 | tomli-w==1.2.0 299 | # via 300 | # -r /Users/scanner/src/asimap/requirements/build.txt 301 | # hatch 302 | tomlkit==0.13.2 303 | # via 304 | # -r /Users/scanner/src/asimap/requirements/build.txt 305 | # hatch 306 | traitlets==5.14.3 307 | # via 308 | # ipython 309 | # matplotlib-inline 310 | trove-classifiers==2025.3.3.18 311 | # via 312 | # -r /Users/scanner/src/asimap/requirements/build.txt 313 | # hatchling 314 | trustme==1.2.1 315 | # via -r development.in 316 | types-aiofiles==24.1.0.20241221 317 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 318 | types-cachetools==5.5.0.20240820 319 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 320 | types-cffi==1.16.0.20241221 321 | # via 322 | # -r /Users/scanner/src/asimap/requirements/lint.txt 323 | # types-pyopenssl 324 | types-pyopenssl==24.1.0.20240722 325 | # via 326 | # -r /Users/scanner/src/asimap/requirements/lint.txt 327 | # types-redis 328 | types-pytz==2025.1.0.20250204 329 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 330 | types-redis==4.6.0.20241004 331 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 332 | types-requests==2.32.0.20250301 333 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 334 | types-setuptools==75.8.2.20250301 335 | # via 336 | # -r /Users/scanner/src/asimap/requirements/lint.txt 337 | # types-cffi 338 | typing-extensions==4.12.2 339 | # via 340 | # -r /Users/scanner/src/asimap/requirements/build.txt 341 | # -r /Users/scanner/src/asimap/requirements/lint.txt 342 | # -r /Users/scanner/src/asimap/requirements/production.txt 343 | # aiosqlite 344 | # anyio 345 | # mypy 346 | # pytest-factoryboy 347 | tzdata==2025.1 348 | # via faker 349 | urllib3==2.3.0 350 | # via 351 | # -r /Users/scanner/src/asimap/requirements/lint.txt 352 | # -r /Users/scanner/src/asimap/requirements/production.txt 353 | # requests 354 | # sentry-sdk 355 | # types-requests 356 | userpath==1.9.2 357 | # via 358 | # -r /Users/scanner/src/asimap/requirements/build.txt 359 | # hatch 360 | uv==0.6.4 361 | # via 362 | # -r /Users/scanner/src/asimap/requirements/build.txt 363 | # hatch 364 | virtualenv==20.29.2 365 | # via 366 | # -r /Users/scanner/src/asimap/requirements/build.txt 367 | # -r /Users/scanner/src/asimap/requirements/lint.txt 368 | # hatch 369 | # pre-commit 370 | wcwidth==0.2.13 371 | # via prompt-toolkit 372 | wheel==0.45.1 373 | # via 374 | # -r /Users/scanner/src/asimap/requirements/lint.txt 375 | # pip-tools 376 | yapf==0.43.0 377 | # via -r /Users/scanner/src/asimap/requirements/lint.txt 378 | zstandard==0.23.0 379 | # via 380 | # -r /Users/scanner/src/asimap/requirements/build.txt 381 | # hatch 382 | 383 | # The following packages are considered to be unsafe in a requirements file: 384 | # pip 385 | # setuptools 386 | -------------------------------------------------------------------------------- /requirements/lint.in: -------------------------------------------------------------------------------- 1 | black 2 | coverage 3 | isort 4 | mypy 5 | pip-tools 6 | pre-commit 7 | ruff 8 | types-cachetools 9 | types-pytz 10 | types-requests 11 | types-setuptools 12 | types-aiofiles 13 | types-redis 14 | yapf 15 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=lint.txt --strip-extras lint.in 6 | # 7 | black==25.1.0 8 | # via -r lint.in 9 | build==1.2.2.post1 10 | # via pip-tools 11 | cffi==1.17.1 12 | # via cryptography 13 | cfgv==3.4.0 14 | # via pre-commit 15 | click==8.1.8 16 | # via 17 | # black 18 | # pip-tools 19 | coverage==7.6.12 20 | # via -r lint.in 21 | cryptography==44.0.2 22 | # via 23 | # types-pyopenssl 24 | # types-redis 25 | distlib==0.3.9 26 | # via virtualenv 27 | filelock==3.17.0 28 | # via virtualenv 29 | identify==2.6.8 30 | # via pre-commit 31 | isort==6.0.1 32 | # via -r lint.in 33 | mypy==1.15.0 34 | # via -r lint.in 35 | mypy-extensions==1.0.0 36 | # via 37 | # black 38 | # mypy 39 | nodeenv==1.9.1 40 | # via pre-commit 41 | packaging==24.2 42 | # via 43 | # black 44 | # build 45 | pathspec==0.12.1 46 | # via black 47 | pip-tools==7.4.1 48 | # via -r lint.in 49 | platformdirs==4.3.6 50 | # via 51 | # black 52 | # virtualenv 53 | # yapf 54 | pre-commit==4.1.0 55 | # via -r lint.in 56 | pycparser==2.22 57 | # via cffi 58 | pyproject-hooks==1.2.0 59 | # via 60 | # build 61 | # pip-tools 62 | pyyaml==6.0.2 63 | # via pre-commit 64 | ruff==0.9.9 65 | # via -r lint.in 66 | types-aiofiles==24.1.0.20241221 67 | # via -r lint.in 68 | types-cachetools==5.5.0.20240820 69 | # via -r lint.in 70 | types-cffi==1.16.0.20241221 71 | # via types-pyopenssl 72 | types-pyopenssl==24.1.0.20240722 73 | # via types-redis 74 | types-pytz==2025.1.0.20250204 75 | # via -r lint.in 76 | types-redis==4.6.0.20241004 77 | # via -r lint.in 78 | types-requests==2.32.0.20250301 79 | # via -r lint.in 80 | types-setuptools==75.8.2.20250301 81 | # via 82 | # -r lint.in 83 | # types-cffi 84 | typing-extensions==4.12.2 85 | # via mypy 86 | urllib3==2.3.0 87 | # via types-requests 88 | virtualenv==20.29.2 89 | # via pre-commit 90 | wheel==0.45.1 91 | # via pip-tools 92 | yapf==0.43.0 93 | # via -r lint.in 94 | 95 | # The following packages are considered to be unsafe in a requirements file: 96 | # pip 97 | # setuptools 98 | -------------------------------------------------------------------------------- /requirements/production.in: -------------------------------------------------------------------------------- 1 | aiofiles 2 | aioretry 3 | aiosqlite 4 | charset_normalizer 5 | docopt 6 | python-dotenv 7 | python-json-logger 8 | sentry-sdk 9 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=production.txt --strip-extras production.in 6 | # 7 | aiofiles==24.1.0 8 | # via -r production.in 9 | aioretry==6.3.1 10 | # via -r production.in 11 | aiosqlite==0.21.0 12 | # via -r production.in 13 | certifi==2025.1.31 14 | # via sentry-sdk 15 | charset-normalizer==3.4.1 16 | # via -r production.in 17 | docopt==0.6.2 18 | # via -r production.in 19 | python-dotenv==1.0.1 20 | # via -r production.in 21 | python-json-logger==3.2.1 22 | # via -r production.in 23 | sentry-sdk==2.22.0 24 | # via -r production.in 25 | typing-extensions==4.12.2 26 | # via aiosqlite 27 | urllib3==2.3.0 28 | # via sentry-sdk 29 | -------------------------------------------------------------------------------- /scripts/asimapd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # PROVIDE: asimapd_daemon 4 | # KEYWORD: FreeBSD 5 | # 6 | pid_file="/var/run/asimapd.pid" 7 | 8 | asimapd_flags=${asimapd_flags-"--debug --ssl_certificate=/etc/ssl/imapscert.pem"} 9 | 10 | . /etc/rc.subr 11 | 12 | name=asimapd 13 | 14 | rcvar=`set_rcvar` 15 | 16 | start_cmd=asimapd_start 17 | stop_cmd=asimapd_stop 18 | 19 | export PATH=$PATH:/usr/local/bin 20 | asimapd_bin=/usr/local/libexec/asimapd.py 21 | 22 | asimapd_start() { 23 | checkyesno asimapd_enable && echo "Starting asimapd" && \ 24 | ${asimapd_bin} ${asimapd_flags} 25 | } 26 | 27 | asimapd_stop() { 28 | if [ -f ${pid_file} ] 29 | then echo "Stopping asimapd" && kill `cat ${pid_file}` && rm ${pid_file} 30 | else 31 | echo "pid file ${pid_file} does not exist. asimapd not running?" 32 | fi 33 | } 34 | 35 | load_rc_config ${name} 36 | run_rc_command "$1" 37 | -------------------------------------------------------------------------------- /scripts/imap_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | """ 4 | A simple IMAP client to test our server with 5 | """ 6 | # system imports 7 | # 8 | import imaplib 9 | from typing import Union 10 | 11 | 12 | ############################################################################# 13 | # 14 | def main(): 15 | """ 16 | Connect to imap server on localhost, using ssl, authenticate 17 | """ 18 | imap = imaplib.IMAP4_SSL(host="127.0.0.1", port=993) 19 | print(f"IMAP Connection: {imap}") 20 | resp = imap.capability() 21 | print(f"Server capabilities: {resp}") 22 | resp = imap.login("tyler38@example.net", "x6tnBEr4&i") 23 | print(f"login response: {resp}") 24 | resp = imap.capability() 25 | print(f"Server capabilities (again): {resp}") 26 | ok, resp = imap.list() 27 | print(f"List response: {ok}") 28 | if ok.lower() == "ok": 29 | mbox: Union[str, bytes] 30 | for mbox in resp: 31 | mbox = str(mbox, "latin-1").split(" ")[-1] 32 | ok, r = imap.subscribe(mbox) 33 | if ok.lower() != "ok": 34 | print(f"Subscribe for {mbox} failed: {r}") 35 | resp = imap.lsub() 36 | print(f"lsub response: {resp}") 37 | mbox = "inbox" 38 | resp = imap.select(mbox) 39 | print(f"Select {mbox} response: {resp}") 40 | resp = imap.fetch( 41 | "1:*", 42 | "(INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from to cc message-id in-reply-to references content-type x-priority x-uniform-type-identifier x-universally-unique-identifier list-id list-unsubscribe bimi-indicator bimi-location x-bimi-indicator-hash authentication-results dkim-signature)])", 43 | ) 44 | print(f"FETCH response: {resp}") 45 | resp = imap.close() 46 | print(f"Close response: {resp}") 47 | print("Logging out") 48 | resp = imap.logout() 49 | print(f"Server LOGOUT: {resp}") 50 | 51 | 52 | ############################################################################ 53 | ############################################################################ 54 | # 55 | # Here is where it all starts 56 | # 57 | if __name__ == "__main__": 58 | main() 59 | # 60 | ############################################################################ 61 | ############################################################################ 62 | -------------------------------------------------------------------------------- /scripts/message_load_times.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # File: $Id$ 4 | # 5 | """ 6 | Run through a bunch of messages in an asimap trace file passed as the first 7 | argument and see how fast it takes us to parse the messages in that file 8 | """ 9 | 10 | import json 11 | 12 | # system imports 13 | # 14 | import sys 15 | from pathlib import Path 16 | 17 | # 3rd party imports 18 | # 19 | from codetiming import Timer 20 | 21 | # Project imports 22 | # 23 | from asimap.parse import BadCommand, IMAPClientCommand 24 | 25 | 26 | ############################################################################# 27 | # 28 | def main(): 29 | """ 30 | parse options.. read trace file.. for each "msg_type" of 31 | "RECEIVED" parse the message. Compare length of message being 32 | parsed with time to parse. 33 | """ 34 | msg_results = {} 35 | trace_file = Path(sys.argv[1]) 36 | timer = Timer("message_parser", text="Elapsed time: {:.7f}") 37 | with open(trace_file, "r") as traces: 38 | for idx, line in enumerate(traces): 39 | msg = line[22:].strip() 40 | trace = json.loads(msg) 41 | if ( 42 | "data" in trace 43 | and "msg_type" in trace 44 | and trace["msg_type"] == "RECEIVED" 45 | ): 46 | data = trace["data"] 47 | if data.upper() == "DONE": 48 | # 'DONE' are not processed by the imap message parser. 49 | continue 50 | try: 51 | with timer: 52 | p = IMAPClientCommand(data) 53 | p.parse() 54 | except BadCommand: 55 | print(f"Parse failed on: '{data}'") 56 | raise 57 | msg_results[f"{timer.last:.7f}"] = data 58 | max_time = f"{Timer.timers.max('message_parser'):.7f}" 59 | print(f"Timer max: {max_time}") 60 | print(f"Timer mean: {Timer.timers.mean('message_parser'):.7f}") 61 | print(f"Timer std dev: {Timer.timers.stdev('message_parser'):.7f}") 62 | print(f"Timer total: {Timer.timers.total('message_parser'):.7f}") 63 | # print(f"Message for max time: {msg_results[max_time]}") 64 | 65 | 66 | ############################################################################ 67 | ############################################################################ 68 | # 69 | # Here is where it all starts 70 | # 71 | if __name__ == "__main__": 72 | main() 73 | # 74 | ############################################################################ 75 | ############################################################################ 76 | -------------------------------------------------------------------------------- /scripts/move_to_subdir_by_year.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | """ 4 | A little tool that connect to an IMAP Server and given a folder will move messages in the folder 5 | in to subfolders that are named '_' by the 6 | timestamp of when the mail was received in its headers. 7 | 8 | We will exclude the current year from this process (ie: we move all messages 9 | from previous years in to subfolders.) 10 | """ 11 | # system imports 12 | # 13 | import email.utils 14 | import mailbox 15 | import optparse 16 | import os 17 | import time 18 | 19 | 20 | ############################################################################ 21 | # 22 | def setup_option_parser(): 23 | """ 24 | This function uses the python OptionParser module to define an option 25 | parser for parsing the command line options for this script. This does not 26 | actually parse the command line options. It returns the parser object that 27 | can be used for parsing them. 28 | """ 29 | parser = optparse.OptionParser(usage="%prog [options]", version="1.0") 30 | 31 | parser.set_defaults(dry_run=False, keep=1000) 32 | parser.add_option( 33 | "--dry_run", 34 | action="store_true", 35 | dest="dry_run", 36 | help="Do a dry run - ie: do not create any folders, " 37 | "do not move any messages", 38 | ) 39 | parser.add_option( 40 | "--keep", 41 | action="store", 42 | dest="keep", 43 | type="int", 44 | help="How many messages to keep in the folder and " 45 | "not move to subfolders", 46 | ) 47 | return parser 48 | 49 | 50 | ############################################################################# 51 | # 52 | def main(): 53 | """ """ 54 | parser = setup_option_parser() 55 | (options, args) = parser.parse_args() 56 | if len(args) != 1: 57 | print("need to supply a MH folder to operation on") 58 | return 59 | 60 | source_folder = args[0] 61 | 62 | # Trim off any trailing "/" because basename will trim it not how 63 | # we want if the path ends in a "/" 64 | # 65 | if source_folder[-1] == "/": 66 | source_folder = source_folder[:-1] 67 | 68 | mbox = mailbox.MH(source_folder) 69 | mbox.lock() 70 | 71 | print("Collecting timestamps for all messages..") 72 | try: 73 | msg_array = [] 74 | msgs = list(mbox.keys()) 75 | 76 | if len(msgs) < options.keep: 77 | print( 78 | "Less than %s messages in folder. Nothing to do." % options.keep 79 | ) 80 | return 81 | 82 | # Find the dates of all the messages and sort them so we know 83 | # which ones to move in to which sub-folders. 84 | # 85 | for i, msg_key in enumerate(msgs): 86 | if i % 200 == 0: 87 | print("%d out of %d" % (i, len(msgs) - i)) 88 | 89 | msg = mbox[msg_key] 90 | 91 | tt = None 92 | try: 93 | if "delivery-date" in msg: 94 | tt = email.utils.parsedate_tz(msg["delivery-date"]) 95 | date = email.utils.mktime_tz(tt) 96 | except (ValueError, TypeError) as e: 97 | print( 98 | f"Yow. Message {msg_key}'s 'delivery-date'({msg['delivery-date']}) resulted in {e}" 99 | ) 100 | tt = None 101 | 102 | try: 103 | if tt is None and "date" in msg: 104 | tt = email.utils.parsedate_tz(msg["date"]) 105 | date = email.utils.mktime_tz(tt) 106 | except (ValueError, TypeError) as e: 107 | print( 108 | "Yow. Message %d's 'date'(%s) resulted in a: %s" 109 | % (msg_key, msg["date"], str(e)) 110 | ) 111 | tt = None 112 | except OverflowError as e: 113 | print( 114 | "Yow. Message %d's 'date'(%s) resulted in a: %s" 115 | % (msg_key, msg["date"], str(e)) 116 | ) 117 | tt = None 118 | 119 | if tt is None: 120 | date = os.path.getmtime( 121 | os.path.join(source_folder, str(msg_key)) 122 | ) 123 | 124 | msg_array.append((date, msg_key)) 125 | 126 | msg_array.sort(key=lambda x: x[0]) 127 | 128 | print("Total number of messages: %d" % len(msg_array)) 129 | print( 130 | "Spanning from %s, to %s" 131 | % (time.ctime(msg_array[0][0]), time.ctime(msg_array[-1][0])) 132 | ) 133 | 134 | msg_array = msg_array[: -options.keep] 135 | print(f"Going to move {len(msg_array)} messages") 136 | 137 | subfolder = None 138 | 139 | if options.dry_run: 140 | print("Doing a dry run! So nothing is actually being done..") 141 | 142 | cur_year = 0 143 | for date, msg_key in msg_array: 144 | msg = mbox[msg_key] 145 | tt = time.gmtime(date) 146 | year = tt.tm_year 147 | 148 | if cur_year != year: 149 | cur_year = year 150 | folder_name = "%s_%04d" % ( 151 | os.path.basename(source_folder), 152 | year, 153 | ) 154 | folder_path = os.path.join(source_folder, folder_name) 155 | print("making folder: %s" % folder_path) 156 | if not options.dry_run: 157 | subfolder = mailbox.MH( 158 | os.path.join(folder_path), create=True 159 | ) 160 | 161 | if not options.dry_run: 162 | mtime = os.path.getmtime( 163 | os.path.join(source_folder, str(msg_key)) 164 | ) 165 | new_msg_key = subfolder.add(msg) 166 | os.utime( 167 | os.path.join(folder_path, str(new_msg_key)), (mtime, mtime) 168 | ) 169 | mbox.unlock() 170 | mbox.remove(msg_key) 171 | mbox.lock() 172 | 173 | finally: 174 | mbox.unlock() 175 | return 176 | 177 | 178 | ############################################################################ 179 | ############################################################################ 180 | # 181 | # Here is where it all starts 182 | # 183 | if __name__ == "__main__": 184 | main() 185 | # 186 | # 187 | ############################################################################ 188 | ############################################################################ 189 | --------------------------------------------------------------------------------