mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'mail_rework' into dev
This commit is contained in:
commit
42a02991e7
1
Pipfile
1
Pipfile
@ -29,6 +29,7 @@ watchdog = "*"
|
|||||||
pathvalidate = "*"
|
pathvalidate = "*"
|
||||||
django-q = "*"
|
django-q = "*"
|
||||||
redis = "*"
|
redis = "*"
|
||||||
|
imap-tools = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
coveralls = "*"
|
coveralls = "*"
|
||||||
|
10
Pipfile.lock
generated
10
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "c0dfeedbac2e9b705267336349e6f72ba650ff9184affae06046db32299e2c87"
|
"sha256": "d6416e6844126b09200b9839a3abdcf3c24ef5cf70052b8f134d8bc804552c17"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {},
|
"requires": {},
|
||||||
@ -123,6 +123,14 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==20.0.4"
|
"version": "==20.0.4"
|
||||||
},
|
},
|
||||||
|
"imap-tools": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:070929b8ec429c0aad94588a37a2962eed656a119ab61dcf91489f20fe983f5d",
|
||||||
|
"sha256:6232cd43748741496446871e889eb137351fc7a7e7f4c7888cd8c0fa28e20cda"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.31.0"
|
||||||
|
},
|
||||||
"joblib": {
|
"joblib": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:698c311779f347cf6b7e6b8a39bb682277b8ee4aba8cf9507bc0cf4cd4737b72",
|
"sha256:698c311779f347cf6b7e6b8a39bb682277b8ee4aba8cf9507bc0cf4cd4737b72",
|
||||||
|
BIN
docs/_static/paperless-11-mail-filters.png
vendored
Normal file
BIN
docs/_static/paperless-11-mail-filters.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
@ -36,6 +36,12 @@ paperless-ng 0.9.0
|
|||||||
multi user solution, however, it allows more than one user to access the website
|
multi user solution, however, it allows more than one user to access the website
|
||||||
and set some basic permissions / renew passwords.
|
and set some basic permissions / renew passwords.
|
||||||
|
|
||||||
|
* **Modified [breaking]:** All new mail consumer with customizable filters, actions and
|
||||||
|
multiple account support. Replaces the old mail consumer. The new mail consumer
|
||||||
|
needs different configuration but can be configured to act exactly like the old
|
||||||
|
consumer.
|
||||||
|
|
||||||
|
|
||||||
* **Modified:** Changes to the consumer:
|
* **Modified:** Changes to the consumer:
|
||||||
|
|
||||||
* Now uses the excellent watchdog library that should make sure files are
|
* Now uses the excellent watchdog library that should make sure files are
|
||||||
|
@ -36,6 +36,10 @@ The old admin is still there and accessible!
|
|||||||
|
|
||||||
.. image:: _static/paperless-9-admin.png
|
.. image:: _static/paperless-9-admin.png
|
||||||
|
|
||||||
|
Fancy mail filters!
|
||||||
|
|
||||||
|
.. image:: _static/paperless-11-mail-filters.png
|
||||||
|
|
||||||
Mobile support in the future? This doesn't really work yet.
|
Mobile support in the future? This doesn't really work yet.
|
||||||
|
|
||||||
.. image:: _static/paperless-10-mobile.png
|
.. image:: _static/paperless-10-mobile.png
|
||||||
|
@ -27,7 +27,7 @@ Each document has a couple of fields that you can assign to them:
|
|||||||
a document either originates form, or is sent to.
|
a document either originates form, or is sent to.
|
||||||
* A *tag* is a label that you can assign to documents. Think of labels as more
|
* A *tag* is a label that you can assign to documents. Think of labels as more
|
||||||
powerful folders: Multiple documents can be grouped together with a single
|
powerful folders: Multiple documents can be grouped together with a single
|
||||||
tag, however, a single document can also have multiple tags. This is not
|
tag, however, a single document can also have multiple tags. This is not
|
||||||
possible with folders. The reason folders are not implemented in paperless
|
possible with folders. The reason folders are not implemented in paperless
|
||||||
is simply that tags are much more versatile than folders.
|
is simply that tags are much more versatile than folders.
|
||||||
* A *document type* is used to demarkate the type of a document such as letter,
|
* A *document type* is used to demarkate the type of a document such as letter,
|
||||||
@ -86,49 +86,48 @@ files from the scanner. Typically, you're looking at an FTP server like
|
|||||||
IMAP (Email)
|
IMAP (Email)
|
||||||
============
|
============
|
||||||
|
|
||||||
Another handy way to get documents into your database is to email them to
|
You can tell paperless-ng to consume documents from your email accounts.
|
||||||
yourself. The typical use-case would be to be out for lunch and want to send a
|
This is a very flexible and powerful feature, if you regularly received documents
|
||||||
copy of the receipt back to your system at home. Paperless can be taught to
|
via mail that you need to archive. The mail consumer can be configured by using the
|
||||||
pull emails down from an arbitrary account and dump them into the consumption
|
admin interface in the following manner:
|
||||||
directory where the consumer will follow the
|
|
||||||
usual pattern on consuming the document.
|
|
||||||
|
|
||||||
.. hint::
|
1. Define e-mail accounts.
|
||||||
|
2. Define mail rules for your account.
|
||||||
|
|
||||||
It's disabled by default. By setting the values below it will be enabled.
|
These rules perform the following:
|
||||||
|
|
||||||
It's been tested in a limited environment, so it may not work for you (please
|
|
||||||
submit a pull request if you can!)
|
|
||||||
|
|
||||||
.. danger::
|
1. Connect to the mail server.
|
||||||
|
2. Fetch all matching mails (as defined by folder, maximum age and the filters)
|
||||||
|
3. Check if there are any consumable attachments.
|
||||||
|
4. If so, instruct paperless to consume the attachments and optionally
|
||||||
|
use the metadata provided in the rule for the new document.
|
||||||
|
5. If documents were consumed from a mail, the rule action is performed
|
||||||
|
on that mail.
|
||||||
|
|
||||||
It's designed to **delete mail from the server once consumed**. So don't go
|
Paperless will completely ignore mails that do not match your filters. It will also
|
||||||
pointing this to your personal email account and wonder where all your stuff
|
only perform the action on mails that it has consumed documents from.
|
||||||
went.
|
|
||||||
|
|
||||||
.. hint::
|
The actions all ensure that the same mail is not consumed twice by different means.
|
||||||
|
These are as follows:
|
||||||
|
|
||||||
Currently, only one photo (attachment) per email will work.
|
* **Delete:** Immediately deletes mail that paperless has consumed documents from.
|
||||||
|
Use with caution.
|
||||||
|
* **Mark as read:** Mark consumed mail as read. Paperless will not consume documents
|
||||||
|
from already read mails. If you read a mail before paperless sees it, it will be
|
||||||
|
ignored.
|
||||||
|
* **Flag:** Sets the 'important' flag on mails with consumed documents. Paperless
|
||||||
|
will not consume flagged mails.
|
||||||
|
* **Move to folder:** Moves consumed mails out of the way so that paperless wont
|
||||||
|
consume them again.
|
||||||
|
|
||||||
So, with all that in mind, here's what you do to get it running:
|
.. caution::
|
||||||
|
|
||||||
1. Setup a new email account somewhere, or if you're feeling daring, create a
|
The mail consumer will perform these actions on all mails it has consumed
|
||||||
folder in an existing email box and note the path to that folder.
|
documents from. Keep in mind that the actual consumption process may fail
|
||||||
2. In ``/etc/paperless.conf`` set all of the appropriate values in
|
for some reason, leaving you with missing documents in paperless.
|
||||||
``PATHS AND FOLDERS`` and ``SECURITY``.
|
|
||||||
If you decided to use a subfolder of an existing account, then make sure you
|
Paperless is set up to check your mails every 10 minutes. this can be configured on the
|
||||||
set ``PAPERLESS_CONSUME_MAIL_INBOX`` accordingly here. You also have to set
|
'Scheduled tasks' page in the admin.
|
||||||
the ``PAPERLESS_EMAIL_SECRET`` to something you can remember 'cause you'll
|
|
||||||
have to include that in every email you send.
|
|
||||||
3. Restart paperless. Paperless will check
|
|
||||||
the configured email account at startup and from then on every 10 minutes
|
|
||||||
for something new and pulls down whatever it finds.
|
|
||||||
4. Send yourself an email! Note that the subject is treated as the file name,
|
|
||||||
so if you set the subject to ``Correspondent - Title - tag,tag,tag``, you'll
|
|
||||||
get what you expect. Also, you must include the aforementioned secret
|
|
||||||
string in every email so the fetcher knows that it's safe to import.
|
|
||||||
Note that Paperless only allows the email title to consist of safe characters
|
|
||||||
to be imported. These consist of alpha-numeric characters and ``-_ ,.'``.
|
|
||||||
|
|
||||||
|
|
||||||
REST API
|
REST API
|
||||||
@ -161,7 +160,7 @@ Preparations in paperless
|
|||||||
Processing of the physical documents
|
Processing of the physical documents
|
||||||
====================================
|
====================================
|
||||||
|
|
||||||
Keep a physical inbox. Whenever you receive a document that you need to
|
Keep a physical inbox. Whenever you receive a document that you need to
|
||||||
archive, put it into your inbox. Regulary, do the following for all documents
|
archive, put it into your inbox. Regulary, do the following for all documents
|
||||||
in your inbox:
|
in your inbox:
|
||||||
|
|
||||||
|
@ -59,22 +59,6 @@ PAPERLESS_CONSUMPTION_DIR="../consume"
|
|||||||
#PAPERLESS_STATIC_URL="/static/"
|
#PAPERLESS_STATIC_URL="/static/"
|
||||||
|
|
||||||
|
|
||||||
# These values are required if you want paperless to check a particular email
|
|
||||||
# box every 10 minutes and attempt to consume documents from there. If you
|
|
||||||
# don't define a HOST, mail checking will just be disabled.
|
|
||||||
#PAPERLESS_CONSUME_MAIL_HOST=""
|
|
||||||
#PAPERLESS_CONSUME_MAIL_PORT=""
|
|
||||||
#PAPERLESS_CONSUME_MAIL_USER=""
|
|
||||||
#PAPERLESS_CONSUME_MAIL_PASS=""
|
|
||||||
|
|
||||||
# Override the default IMAP inbox here. If not set Paperless defaults to
|
|
||||||
# "INBOX".
|
|
||||||
#PAPERLESS_CONSUME_MAIL_INBOX="INBOX"
|
|
||||||
|
|
||||||
# Any email sent to the target account that does not contain this text will be
|
|
||||||
# ignored.
|
|
||||||
PAPERLESS_EMAIL_SECRET=""
|
|
||||||
|
|
||||||
# Specify a filename format for the document (directories are supported)
|
# Specify a filename format for the document (directories are supported)
|
||||||
# Use the following placeholders:
|
# Use the following placeholders:
|
||||||
# * {correspondent}
|
# * {correspondent}
|
||||||
|
@ -1,249 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import imaplib
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from base64 import b64decode
|
|
||||||
from email import policy
|
|
||||||
from email.parser import BytesParser
|
|
||||||
|
|
||||||
from dateutil import parser
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from .models import Correspondent
|
|
||||||
|
|
||||||
|
|
||||||
class MailFetcherError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidMessageError(MailFetcherError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Loggable(object):
|
|
||||||
|
|
||||||
def __init__(self, group=None):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self.logging_group = group or uuid.uuid4()
|
|
||||||
|
|
||||||
def log(self, level, message):
|
|
||||||
getattr(self.logger, level)(message, extra={
|
|
||||||
"group": self.logging_group
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Loggable):
|
|
||||||
"""
|
|
||||||
A crude, but simple email message class. We assume that there's a subject
|
|
||||||
and n attachments, and that we don't care about the message body.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SECRET = os.getenv("PAPERLESS_EMAIL_SECRET")
|
|
||||||
|
|
||||||
def __init__(self, data, group=None):
|
|
||||||
"""
|
|
||||||
Cribbed heavily from
|
|
||||||
https://www.ianlewis.org/en/parsing-email-attachments-python
|
|
||||||
"""
|
|
||||||
|
|
||||||
Loggable.__init__(self, group=group)
|
|
||||||
|
|
||||||
self.subject = None
|
|
||||||
self.time = None
|
|
||||||
self.attachment = None
|
|
||||||
|
|
||||||
message = BytesParser(policy=policy.default).parsebytes(data)
|
|
||||||
self.subject = str(message["Subject"]).replace("\r\n", "")
|
|
||||||
self.body = str(message.get_body())
|
|
||||||
|
|
||||||
self.check_subject()
|
|
||||||
self.check_body()
|
|
||||||
|
|
||||||
self._set_time(message)
|
|
||||||
|
|
||||||
self.log("info", 'Importing email: "{}"'.format(self.subject))
|
|
||||||
|
|
||||||
attachments = []
|
|
||||||
for part in message.walk():
|
|
||||||
|
|
||||||
content_disposition = part.get("Content-Disposition")
|
|
||||||
if not content_disposition:
|
|
||||||
continue
|
|
||||||
|
|
||||||
dispositions = content_disposition.strip().split(";")
|
|
||||||
if len(dispositions) < 2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not dispositions[0].lower() == "attachment" and \
|
|
||||||
"filename" not in dispositions[1].lower():
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_data = part.get_payload()
|
|
||||||
|
|
||||||
attachments.append(Attachment(
|
|
||||||
b64decode(file_data), content_type=part.get_content_type()))
|
|
||||||
|
|
||||||
if len(attachments) == 0:
|
|
||||||
raise InvalidMessageError(
|
|
||||||
"There don't appear to be any attachments to this message")
|
|
||||||
|
|
||||||
if len(attachments) > 1:
|
|
||||||
raise InvalidMessageError(
|
|
||||||
"There's more than one attachment to this message. It cannot "
|
|
||||||
"be indexed automatically."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.attachment = attachments[0]
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return bool(self.attachment)
|
|
||||||
|
|
||||||
def check_subject(self):
|
|
||||||
if self.subject is None:
|
|
||||||
raise InvalidMessageError("Message does not have a subject")
|
|
||||||
if not Correspondent.SAFE_REGEX.match(self.subject):
|
|
||||||
raise InvalidMessageError("Message subject is unsafe: {}".format(
|
|
||||||
self.subject))
|
|
||||||
|
|
||||||
def check_body(self):
|
|
||||||
if self.SECRET not in self.body:
|
|
||||||
raise InvalidMessageError("The secret wasn't in the body")
|
|
||||||
|
|
||||||
def _set_time(self, message):
|
|
||||||
self.time = datetime.datetime.now()
|
|
||||||
message_time = message.get("Date")
|
|
||||||
if message_time:
|
|
||||||
try:
|
|
||||||
self.time = parser.parse(message_time)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass # We assume that "now" is ok
|
|
||||||
|
|
||||||
@property
|
|
||||||
def file_name(self):
|
|
||||||
return "{}.{}".format(self.subject, self.attachment.suffix)
|
|
||||||
|
|
||||||
|
|
||||||
class Attachment(object):
|
|
||||||
|
|
||||||
SAFE_SUFFIX_REGEX = re.compile(
|
|
||||||
r"^(application/(pdf))|(image/(png|jpeg|gif|tiff))$")
|
|
||||||
|
|
||||||
def __init__(self, data, content_type):
|
|
||||||
|
|
||||||
self.content_type = content_type
|
|
||||||
self.data = data
|
|
||||||
self.suffix = None
|
|
||||||
|
|
||||||
m = self.SAFE_SUFFIX_REGEX.match(self.content_type)
|
|
||||||
if not m:
|
|
||||||
raise MailFetcherError(
|
|
||||||
"Not-awesome file type: {}".format(self.content_type))
|
|
||||||
self.suffix = m.group(2) or m.group(4)
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
|
|
||||||
class MailFetcher(Loggable):
|
|
||||||
|
|
||||||
def __init__(self, consume=settings.CONSUMPTION_DIR):
|
|
||||||
|
|
||||||
Loggable.__init__(self)
|
|
||||||
|
|
||||||
self._connection = None
|
|
||||||
self._host = os.getenv("PAPERLESS_CONSUME_MAIL_HOST")
|
|
||||||
self._port = os.getenv("PAPERLESS_CONSUME_MAIL_PORT")
|
|
||||||
self._username = os.getenv("PAPERLESS_CONSUME_MAIL_USER")
|
|
||||||
self._password = os.getenv("PAPERLESS_CONSUME_MAIL_PASS")
|
|
||||||
self._inbox = os.getenv("PAPERLESS_CONSUME_MAIL_INBOX", "INBOX")
|
|
||||||
|
|
||||||
self._enabled = bool(self._host)
|
|
||||||
if self._enabled and Message.SECRET is None:
|
|
||||||
raise MailFetcherError("No PAPERLESS_EMAIL_SECRET defined")
|
|
||||||
|
|
||||||
self.last_checked = time.time()
|
|
||||||
self.consume = consume
|
|
||||||
|
|
||||||
def pull(self):
|
|
||||||
"""
|
|
||||||
Fetch all available mail at the target address and store it locally in
|
|
||||||
the consumption directory so that the file consumer can pick it up and
|
|
||||||
do its thing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._enabled:
|
|
||||||
|
|
||||||
# Reset the grouping id for each fetch
|
|
||||||
self.logging_group = uuid.uuid4()
|
|
||||||
|
|
||||||
self.log("debug", "Checking mail")
|
|
||||||
|
|
||||||
for message in self._get_messages():
|
|
||||||
|
|
||||||
self.log("info", 'Storing email: "{}"'.format(message.subject))
|
|
||||||
|
|
||||||
t = int(time.mktime(message.time.timetuple()))
|
|
||||||
file_name = os.path.join(self.consume, message.file_name)
|
|
||||||
with open(file_name, "wb") as f:
|
|
||||||
f.write(message.attachment.data)
|
|
||||||
os.utime(file_name, times=(t, t))
|
|
||||||
|
|
||||||
self.last_checked = time.time()
|
|
||||||
|
|
||||||
def _get_messages(self):
|
|
||||||
|
|
||||||
r = []
|
|
||||||
try:
|
|
||||||
|
|
||||||
self._connect()
|
|
||||||
self._login()
|
|
||||||
|
|
||||||
for message in self._fetch():
|
|
||||||
if message:
|
|
||||||
r.append(message)
|
|
||||||
|
|
||||||
self._connection.expunge()
|
|
||||||
self._connection.close()
|
|
||||||
self._connection.logout()
|
|
||||||
|
|
||||||
except MailFetcherError as e:
|
|
||||||
self.log("error", str(e))
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
def _connect(self):
|
|
||||||
try:
|
|
||||||
self._connection = imaplib.IMAP4_SSL(self._host, self._port)
|
|
||||||
except OSError as e:
|
|
||||||
msg = "Problem connecting to {}: {}".format(self._host, e.strerror)
|
|
||||||
raise MailFetcherError(msg)
|
|
||||||
|
|
||||||
def _login(self):
|
|
||||||
|
|
||||||
login = self._connection.login(self._username, self._password)
|
|
||||||
if not login[0] == "OK":
|
|
||||||
raise MailFetcherError("Can't log into mail: {}".format(login[1]))
|
|
||||||
|
|
||||||
inbox = self._connection.select(self._inbox)
|
|
||||||
if not inbox[0] == "OK":
|
|
||||||
raise MailFetcherError("Can't find the inbox: {}".format(inbox[1]))
|
|
||||||
|
|
||||||
def _fetch(self):
|
|
||||||
|
|
||||||
for num in self._connection.search(None, "ALL")[1][0].split():
|
|
||||||
|
|
||||||
__, data = self._connection.fetch(num, "(RFC822)")
|
|
||||||
|
|
||||||
message = None
|
|
||||||
try:
|
|
||||||
message = Message(data[0][1], self.logging_group)
|
|
||||||
except InvalidMessageError as e:
|
|
||||||
self.log("error", str(e))
|
|
||||||
else:
|
|
||||||
self._connection.store(num, "+FLAGS", "\\Deleted")
|
|
||||||
|
|
||||||
if message:
|
|
||||||
yield message
|
|
@ -34,7 +34,7 @@ class Handler(FileSystemEventHandler):
|
|||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
On every iteration of an infinite loop, consume what we can from the
|
On every iteration of an infinite loop, consume what we can from the
|
||||||
consumption directory, and fetch any mail available.
|
consumption directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -9,13 +9,11 @@ from django_q.tasks import schedule
|
|||||||
def add_schedules(apps, schema_editor):
|
def add_schedules(apps, schema_editor):
|
||||||
schedule('documents.tasks.train_classifier', name="Train the classifier", schedule_type=Schedule.HOURLY)
|
schedule('documents.tasks.train_classifier', name="Train the classifier", schedule_type=Schedule.HOURLY)
|
||||||
schedule('documents.tasks.index_optimize', name="Optimize the index", schedule_type=Schedule.DAILY)
|
schedule('documents.tasks.index_optimize', name="Optimize the index", schedule_type=Schedule.DAILY)
|
||||||
schedule('documents.tasks.consume_mail', name="Check E-Mail", schedule_type=Schedule.MINUTES, minutes=10)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_schedules(apps, schema_editor):
|
def remove_schedules(apps, schema_editor):
|
||||||
Schedule.objects.filter(func='documents.tasks.train_classifier').delete()
|
Schedule.objects.filter(func='documents.tasks.train_classifier').delete()
|
||||||
Schedule.objects.filter(func='documents.tasks.index_optimize').delete()
|
Schedule.objects.filter(func='documents.tasks.index_optimize').delete()
|
||||||
Schedule.objects.filter(func='documents.tasks.consume_mail').delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -7,14 +7,9 @@ from documents import index
|
|||||||
from documents.classifier import DocumentClassifier, \
|
from documents.classifier import DocumentClassifier, \
|
||||||
IncompatibleClassifierVersionError
|
IncompatibleClassifierVersionError
|
||||||
from documents.consumer import Consumer, ConsumerError
|
from documents.consumer import Consumer, ConsumerError
|
||||||
from documents.mail import MailFetcher
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
|
|
||||||
def consume_mail():
|
|
||||||
MailFetcher().pull()
|
|
||||||
|
|
||||||
|
|
||||||
def index_optimize():
|
def index_optimize():
|
||||||
index.open_index().optimize()
|
index.open_index().optimize()
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,208 +0,0 @@
|
|||||||
Return-Path: <sender@example.com>
|
|
||||||
X-Original-To: sender@mailbox4.mailhost.com
|
|
||||||
Delivered-To: sender@mailbox4.mailhost.com
|
|
||||||
Received: from mx8.mailhost.com (mail8.mailhost.com [75.126.24.68])
|
|
||||||
by mailbox4.mailhost.com (Postfix) with ESMTP id B62BD5498001
|
|
||||||
for <sender@mailbox4.mailhost.com>; Thu, 4 Feb 2016 22:01:17 +0000 (UTC)
|
|
||||||
Received: from localhost (localhost.localdomain [127.0.0.1])
|
|
||||||
by mx8.mailhost.com (Postfix) with ESMTP id B41796F190D
|
|
||||||
for <sender@mailbox4.mailhost.com>; Thu, 4 Feb 2016 22:01:17 +0000 (UTC)
|
|
||||||
X-Spam-Flag: NO
|
|
||||||
X-Spam-Score: 0
|
|
||||||
X-Spam-Level:
|
|
||||||
X-Spam-Status: No, score=0 tagged_above=-999 required=3
|
|
||||||
tests=[RCVD_IN_DNSWL_NONE=-0.0001]
|
|
||||||
Received: from mx8.mailhost.com ([127.0.0.1])
|
|
||||||
by localhost (mail8.mailhost.com [127.0.0.1]) (amavisd-new, port 10024)
|
|
||||||
with ESMTP id 3cj6d28FXsS3 for <sender@mailbox4.mailhost.com>;
|
|
||||||
Thu, 4 Feb 2016 22:01:17 +0000 (UTC)
|
|
||||||
Received: from smtp.mailhost.com (smtp.mailhost.com [74.55.86.74])
|
|
||||||
by mx8.mailhost.com (Postfix) with ESMTP id 527D76F1529
|
|
||||||
for <paperless@example.com>; Thu, 4 Feb 2016 22:01:17 +0000 (UTC)
|
|
||||||
Received: from [10.114.0.19] (nl3x.mullvad.net [46.166.136.162])
|
|
||||||
by smtp.mailhost.com (Postfix) with ESMTP id 9C52420C6FDA
|
|
||||||
for <paperless@example.com>; Thu, 4 Feb 2016 22:01:16 +0000 (UTC)
|
|
||||||
To: paperless@example.com
|
|
||||||
From: Daniel Quinn <sender@example.com>
|
|
||||||
Subject: Test 0
|
|
||||||
Message-ID: <56B3CA2A.6030806@example.com>
|
|
||||||
Date: Thu, 4 Feb 2016 22:01:14 +0000
|
|
||||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101
|
|
||||||
Thunderbird/38.5.0
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Content-Type: multipart/mixed;
|
|
||||||
boundary="------------090701020702030809070008"
|
|
||||||
|
|
||||||
This is a multi-part message in MIME format.
|
|
||||||
--------------090701020702030809070008
|
|
||||||
Content-Type: text/plain; charset=utf-8
|
|
||||||
Content-Transfer-Encoding: 7bit
|
|
||||||
|
|
||||||
The secret word is "paperless" :-)
|
|
||||||
|
|
||||||
--------------090701020702030809070008
|
|
||||||
Content-Type: application/pdf;
|
|
||||||
name="test0.pdf"
|
|
||||||
Content-Transfer-Encoding: base64
|
|
||||||
Content-Disposition: attachment;
|
|
||||||
filename="test0.pdf"
|
|
||||||
|
|
||||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0
|
|
||||||
ZURlY29kZT4+CnN0cmVhbQp4nFWLQQvCMAyF7/kVOQutSdeuHZSA0+3gbVDwIN6c3gR38e/b
|
|
||||||
bF4kkPfyvReyjB94IyFVF7pgG0ze4TLDZYevLamzPKEvEFqbMEZfq+WO+5GRHZbHNROLy+So
|
|
||||||
UfFi6g7/RyusEpUl9VsQxQTlHR2oV3wUEzOdhOnXG1aw/o1yK2cYCkww4RdbUCevCmVuZHN0
|
|
||||||
cmVhbQplbmRvYmoKCjMgMCBvYmoKMTM5CmVuZG9iagoKNSAwIG9iago8PC9MZW5ndGggNiAw
|
|
||||||
IFIvRmlsdGVyL0ZsYXRlRGVjb2RlL0xlbmd0aDEgMTA4MjQ+PgpzdHJlYW0KeJzlOWt0G9WZ
|
|
||||||
95uRbNmWLckPWY4SaRTFedmybI8T4rw8sS3ZiZ1YfqWSCbFkS7YEtiQkJSE8GlNeOQ5pUmh5
|
|
||||||
Zkt2l+XQNl3GhLaBpcWw0D19UGALLRRS0gM9nD0lxVBK9wCx97tXI0UJAc727L8d+c587/u9
|
|
||||||
7p0rOZXYEyJaMkV4Io1OBuLOqmqBEPJLQqB0dG9K2NRTsQHhM4Rw/zkWH5+870e7PiRE9Rgh
|
|
||||||
+Y+NT+wf+/b3e4YI0YYJKX41HAoEfxj6vUjIIgltrA0jYef8/nzEr0F8WXgydY2bP7QO8WOI
|
|
||||||
SxOx0cDxxbUmxN9AfOlk4Jr4apWLI8SMKBGigcmQpYXrRBx9KtobjyVTQbJsgZDl91B+PBGK
|
|
||||||
d9838hzipwjhjyIN8EMvLYJ5FOd4lTovX1NQWKQtLtGR/3eX+jCpIJ3qTURH4ux+wcWfIFXk
|
|
||||||
XkIW3qXY+ft898LH/5deaNKPe8hD5DFymLxGrlAYbuIhEbIHKbnX0+QlpNLLQ4bId8n055g9
|
|
||||||
QU4hPy3nJ0doJJe8PORucpL8xwWzeMgkuQ59+QF5DRrIz7BVYuQD0JAbyXNo9QOkbb+UKa4E
|
|
||||||
b2MMHMuhvk7u5w6RbdzbiNxLOZyT05NnyTHYjZZTGOfhbMQbP2P0NnID3vtJmOxFmF3qTZ/+
|
|
||||||
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
|
|
||||||
l6zxXC7DQbm6n96l3iE576BMBocu984AfN13y+HDpHVJl9zY75X9S3xdchABiQJTCOiXzBhJ
|
|
||||||
qy+ZTNWwC2pqEN6Dd1KzpwaJu5NpKsnySU0SkrhHJZkS1FCBNA54r6E8JFA9QO3dSUJvlFmT
|
|
||||||
VqLaScUcU07fGGDa/T/LhW2oCmVuZHN0cmVhbQplbmRvYmoKCjYgMCBvYmoKNjI5MQplbmRv
|
|
||||||
YmoKCjcgMCBvYmoKPDwvVHlwZS9Gb250RGVzY3JpcHRvci9Gb250TmFtZS9CQUFBQUErTGli
|
|
||||||
ZXJhdGlvblNlcmlmCi9GbGFncyA0Ci9Gb250QkJveFstNTQzIC0zMDMgMTI3NyA5ODFdL0l0
|
|
||||||
YWxpY0FuZ2xlIDAKL0FzY2VudCA4OTEKL0Rlc2NlbnQgLTIxNgovQ2FwSGVpZ2h0IDk4MQov
|
|
||||||
U3RlbVYgODAKL0ZvbnRGaWxlMiA1IDAgUgo+PgplbmRvYmoKCjggMCBvYmoKPDwvTGVuZ3Ro
|
|
||||||
IDI5Mi9GaWx0ZXIvRmxhdGVEZWNvZGU+PgpzdHJlYW0KeJxdkctuwyAQRfd8Bct0EfmROA/J
|
|
||||||
spQmseRFH6rbD3BgnCLVGGGy8N+XmUlbqQvQmZl7BxiSY3NqrAnJqx9VC0H2xmoP03jzCuQF
|
|
||||||
rsaKLJfaqHCPaFdD50QSve08BRga249lKZK3WJuCn+XioMcLPIjkxWvwxl7l4uPYxri9OfcF
|
|
||||||
A9ggU1FVUkMf+zx17rkbICHXstGxbMK8jJY/wfvsQOYUZ3wVNWqYXKfAd/YKokzTSpZ1XQmw
|
|
||||||
+l8tK9hy6dVn56M0i9I0LdZV5Jx4s0NeMe+R18TbFXJBnKfIG9ZkyFvWUJ8d5wvkPTPlD8w1
|
|
||||||
8iMz9Tyyl/Qnzp+Qz8xn5JrPPdOj7rfH5+H8f8Ym1c37ODL6JJoVTslY+P1HNzp00foG7l+O
|
|
||||||
gwplbmRzdHJlYW0KZW5kb2JqCgo5IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1RydWVU
|
|
||||||
eXBlL0Jhc2VGb250L0JBQUFBQStMaWJlcmF0aW9uU2VyaWYKL0ZpcnN0Q2hhciAwCi9MYXN0
|
|
||||||
Q2hhciAxNQovV2lkdGhzWzc3NyA2MTAgNTAwIDI3NyAzODkgMjUwIDQ0MyAyNzcgNDQzIDUw
|
|
||||||
MCA1MDAgNDQzIDUwMCA3NzcgNTAwIDI1MApdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9V
|
|
||||||
bmljb2RlIDggMCBSCj4+CmVuZG9iagoKMTAgMCBvYmoKPDwvRjEgOSAwIFIKPj4KZW5kb2Jq
|
|
||||||
CgoxMSAwIG9iago8PC9Gb250IDEwIDAgUgovUHJvY1NldFsvUERGL1RleHRdCj4+CmVuZG9i
|
|
||||||
agoKMSAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIv
|
|
||||||
TWVkaWFCb3hbMCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNl
|
|
||||||
UkdCL0kgdHJ1ZT4+L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUv
|
|
||||||
UGFnZXMKL1Jlc291cmNlcyAxMSAwIFIKL01lZGlhQm94WyAwIDAgNTk1IDg0MiBdCi9LaWRz
|
|
||||||
WyAxIDAgUiBdCi9Db3VudCAxPj4KZW5kb2JqCgoxMiAwIG9iago8PC9UeXBlL0NhdGFsb2cv
|
|
||||||
UGFnZXMgNCAwIFIKL09wZW5BY3Rpb25bMSAwIFIgL1hZWiBudWxsIG51bGwgMF0KL0xhbmco
|
|
||||||
ZW4tR0IpCj4+CmVuZG9iagoKMTMgMCBvYmoKPDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5
|
|
||||||
MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAwNEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAw
|
|
||||||
NjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzUwMDJFMDAzMD4KL0NyZWF0aW9uRGF0ZShEOjIw
|
|
||||||
MTYwMjA0MjIwMDAyWicpPj4KZW5kb2JqCgp4cmVmCjAgMTQKMDAwMDAwMDAwMCA2NTUzNSBm
|
|
||||||
IAowMDAwMDA3NTA5IDAwMDAwIG4gCjAwMDAwMDAwMTkgMDAwMDAgbiAKMDAwMDAwMDIyOSAw
|
|
||||||
MDAwMCBuIAowMDAwMDA3NjUyIDAwMDAwIG4gCjAwMDAwMDAyNDkgMDAwMDAgbiAKMDAwMDAw
|
|
||||||
NjYyNSAwMDAwMCBuIAowMDAwMDA2NjQ2IDAwMDAwIG4gCjAwMDAwMDY4NDEgMDAwMDAgbiAK
|
|
||||||
MDAwMDAwNzIwMiAwMDAwMCBuIAowMDAwMDA3NDIyIDAwMDAwIG4gCjAwMDAwMDc0NTQgMDAw
|
|
||||||
MDAgbiAKMDAwMDAwNzc1MSAwMDAwMCBuIAowMDAwMDA3ODQ4IDAwMDAwIG4gCnRyYWlsZXIK
|
|
||||||
PDwvU2l6ZSAxNC9Sb290IDEyIDAgUgovSW5mbyAxMyAwIFIKL0lEIFsgPDRFN0ZCMEZCMjA4
|
|
||||||
ODBCNURBQkIzQTNEOTQxNDlBRTQ3Pgo8NEU3RkIwRkIyMDg4MEI1REFCQjNBM0Q5NDE0OUFF
|
|
||||||
NDc+IF0KL0RvY0NoZWNrc3VtIC8yQTY0RDMzNzRFQTVEODMwNTRDNEI2RDFEMUY4QzU1RQo+
|
|
||||||
PgpzdGFydHhyZWYKODAxOAolJUVPRgo=
|
|
||||||
--------------090701020702030809070008--
|
|
@ -1,90 +0,0 @@
|
|||||||
import base64
|
|
||||||
import os
|
|
||||||
from hashlib import md5
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import magic
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from ..mail import Message, Attachment
|
|
||||||
|
|
||||||
|
|
||||||
class TestMessage(TestCase):
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
|
|
||||||
TestCase.__init__(self, *args, **kwargs)
|
|
||||||
self.sample = os.path.join(
|
|
||||||
settings.BASE_DIR,
|
|
||||||
"documents",
|
|
||||||
"tests",
|
|
||||||
"samples",
|
|
||||||
"mail.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
|
|
||||||
with open(self.sample, "rb") as f:
|
|
||||||
|
|
||||||
with mock.patch("logging.StreamHandler.emit") as __:
|
|
||||||
message = Message(f.read())
|
|
||||||
|
|
||||||
self.assertTrue(message)
|
|
||||||
self.assertEqual(message.subject, "Test 0")
|
|
||||||
|
|
||||||
data = message.attachment.read()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
md5(data).hexdigest(), "7c89655f9e9eb7dd8cde8568e8115d59")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
message.attachment.content_type, "application/pdf")
|
|
||||||
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
|
|
||||||
self.assertEqual(m.id_buffer(data), "application/pdf")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInlineMessage(TestCase):
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
|
|
||||||
TestCase.__init__(self, *args, **kwargs)
|
|
||||||
self.sample = os.path.join(
|
|
||||||
settings.BASE_DIR,
|
|
||||||
"documents",
|
|
||||||
"tests",
|
|
||||||
"samples",
|
|
||||||
"inline_mail.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
|
|
||||||
with open(self.sample, "rb") as f:
|
|
||||||
|
|
||||||
with mock.patch("logging.StreamHandler.emit") as __:
|
|
||||||
message = Message(f.read())
|
|
||||||
|
|
||||||
self.assertTrue(message)
|
|
||||||
self.assertEqual(message.subject, "Paperless Inline Image")
|
|
||||||
|
|
||||||
data = message.attachment.read()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
md5(data).hexdigest(), "30c00a7b42913e65f7fdb0be40b9eef3")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
message.attachment.content_type, "image/png")
|
|
||||||
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
|
|
||||||
self.assertEqual(m.id_buffer(data), "image/png")
|
|
||||||
|
|
||||||
|
|
||||||
class TestAttachment(TestCase):
|
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
data = base64.encodebytes(b"0")
|
|
||||||
self.assertEqual(Attachment(data, "application/pdf").suffix, "pdf")
|
|
||||||
self.assertEqual(Attachment(data, "image/png").suffix, "png")
|
|
||||||
self.assertEqual(Attachment(data, "image/jpeg").suffix, "jpeg")
|
|
||||||
self.assertEqual(Attachment(data, "image/gif").suffix, "gif")
|
|
||||||
self.assertEqual(Attachment(data, "image/tiff").suffix, "tiff")
|
|
||||||
self.assertEqual(Attachment(data, "image/png").read(), data)
|
|
@ -80,6 +80,7 @@ INSTALLED_APPS = [
|
|||||||
"documents.apps.DocumentsConfig",
|
"documents.apps.DocumentsConfig",
|
||||||
"paperless_tesseract.apps.PaperlessTesseractConfig",
|
"paperless_tesseract.apps.PaperlessTesseractConfig",
|
||||||
"paperless_text.apps.PaperlessTextConfig",
|
"paperless_text.apps.PaperlessTextConfig",
|
||||||
|
"paperless_mail.apps.PaperlessMailConfig",
|
||||||
|
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
|
|
||||||
|
0
src/paperless_mail/__init__.py
Normal file
0
src/paperless_mail/__init__.py
Normal file
27
src/paperless_mail/admin.py
Normal file
27
src/paperless_mail/admin.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from paperless_mail.models import MailAccount, MailRule
|
||||||
|
|
||||||
|
|
||||||
|
class MailAccountForm(forms.ModelForm):
|
||||||
|
|
||||||
|
password = forms.CharField(widget=forms.PasswordInput)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = '__all__'
|
||||||
|
model = MailAccount
|
||||||
|
|
||||||
|
|
||||||
|
class MailAccountAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
list_display = ("name", "imap_server", "username")
|
||||||
|
|
||||||
|
|
||||||
|
class MailRuleAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
list_display = ("name", "account", "folder", "action")
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(MailAccount, MailAccountAdmin)
|
||||||
|
admin.site.register(MailRule, MailRuleAdmin)
|
7
src/paperless_mail/apps.py
Normal file
7
src/paperless_mail/apps.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PaperlessMailConfig(AppConfig):
|
||||||
|
name = 'paperless_mail'
|
||||||
|
|
||||||
|
verbose_name = 'Paperless Mail'
|
227
src/paperless_mail/mail.py
Normal file
227
src/paperless_mail/mail.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta, date
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django_q.tasks import async_task
|
||||||
|
from imap_tools import MailBox, MailBoxUnencrypted, AND, MailMessageFlags, \
|
||||||
|
MailboxFolderSelectError
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from paperless_mail.models import MailAccount, MailRule
|
||||||
|
|
||||||
|
|
||||||
|
class MailError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMailAction:
|
||||||
|
|
||||||
|
def get_criteria(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def post_consume(self, M, message_uids, parameter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteMailAction(BaseMailAction):
|
||||||
|
|
||||||
|
def post_consume(self, M, message_uids, parameter):
|
||||||
|
M.delete(message_uids)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadMailAction(BaseMailAction):
|
||||||
|
|
||||||
|
def get_criteria(self):
|
||||||
|
return {'seen': False}
|
||||||
|
|
||||||
|
def post_consume(self, M, message_uids, parameter):
|
||||||
|
M.seen(message_uids, True)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveMailAction(BaseMailAction):
|
||||||
|
|
||||||
|
def post_consume(self, M, message_uids, parameter):
|
||||||
|
M.move(message_uids, parameter)
|
||||||
|
|
||||||
|
|
||||||
|
class FlagMailAction(BaseMailAction):
|
||||||
|
|
||||||
|
def get_criteria(self):
|
||||||
|
return {'flagged': False}
|
||||||
|
|
||||||
|
def post_consume(self, M, message_uids, parameter):
|
||||||
|
M.flag(message_uids, [MailMessageFlags.FLAGGED], True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rule_action(rule):
|
||||||
|
if rule.action == MailRule.ACTION_FLAG:
|
||||||
|
return FlagMailAction()
|
||||||
|
elif rule.action == MailRule.ACTION_DELETE:
|
||||||
|
return DeleteMailAction()
|
||||||
|
elif rule.action == MailRule.ACTION_MOVE:
|
||||||
|
return MoveMailAction()
|
||||||
|
elif rule.action == MailRule.ACTION_MARK_READ:
|
||||||
|
return MarkReadMailAction()
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown action.")
|
||||||
|
|
||||||
|
|
||||||
|
def make_criterias(rule):
|
||||||
|
maximum_age = date.today() - timedelta(days=rule.maximum_age)
|
||||||
|
criterias = {
|
||||||
|
"date_gte": maximum_age
|
||||||
|
}
|
||||||
|
if rule.filter_from:
|
||||||
|
criterias["from_"] = rule.filter_from
|
||||||
|
if rule.filter_subject:
|
||||||
|
criterias["subject"] = rule.filter_subject
|
||||||
|
if rule.filter_body:
|
||||||
|
criterias["body"] = rule.filter_body
|
||||||
|
|
||||||
|
return {**criterias, **get_rule_action(rule).get_criteria()}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mail_account(account):
|
||||||
|
|
||||||
|
if account.imap_security == MailAccount.IMAP_SECURITY_NONE:
|
||||||
|
mailbox = MailBoxUnencrypted(account.imap_server, account.imap_port)
|
||||||
|
elif account.imap_security == MailAccount.IMAP_SECURITY_STARTTLS:
|
||||||
|
mailbox = MailBox(account.imap_server, account.imap_port, starttls=True)
|
||||||
|
elif account.imap_security == MailAccount.IMAP_SECURITY_SSL:
|
||||||
|
mailbox = MailBox(account.imap_server, account.imap_port)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown IMAP security")
|
||||||
|
|
||||||
|
total_processed_files = 0
|
||||||
|
|
||||||
|
with mailbox as M:
|
||||||
|
|
||||||
|
try:
|
||||||
|
M.login(account.username, account.password)
|
||||||
|
except Exception:
|
||||||
|
raise MailError(
|
||||||
|
f"Error while authenticating account {account.name}")
|
||||||
|
|
||||||
|
for rule in account.rules.all():
|
||||||
|
|
||||||
|
try:
|
||||||
|
M.folder.set(rule.folder)
|
||||||
|
except MailboxFolderSelectError:
|
||||||
|
raise MailError(
|
||||||
|
f"Rule {rule.name}: Folder {rule.folder} does not exist "
|
||||||
|
f"in account {account.name}")
|
||||||
|
|
||||||
|
criterias = make_criterias(rule)
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages = M.fetch(criteria=AND(**criterias), mark_seen=False)
|
||||||
|
except Exception:
|
||||||
|
raise MailError(
|
||||||
|
f"Rule {rule.name}: Error while fetching folder "
|
||||||
|
f"{rule.folder} of account {account.name}")
|
||||||
|
|
||||||
|
post_consume_messages = []
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
processed_files = handle_message(message, rule)
|
||||||
|
except Exception:
|
||||||
|
raise MailError(
|
||||||
|
f"Rule {rule.name}: Error while processing mail "
|
||||||
|
f"{message.uid} of account {account.name}")
|
||||||
|
if processed_files > 0:
|
||||||
|
post_consume_messages.append(message.uid)
|
||||||
|
|
||||||
|
total_processed_files += processed_files
|
||||||
|
try:
|
||||||
|
get_rule_action(rule).post_consume(
|
||||||
|
M,
|
||||||
|
post_consume_messages,
|
||||||
|
rule.action_parameter)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
raise MailError(
|
||||||
|
f"Rule {rule.name}: Error while processing post-consume "
|
||||||
|
f"actions for account {account.name}")
|
||||||
|
|
||||||
|
return total_processed_files
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(message, att, rule):
|
||||||
|
if rule.assign_title_from == MailRule.TITLE_FROM_SUBJECT:
|
||||||
|
title = message.subject
|
||||||
|
elif rule.assign_title_from == MailRule.TITLE_FROM_FILENAME:
|
||||||
|
title = os.path.splitext(os.path.basename(att.filename))[0]
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown title selector.")
|
||||||
|
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def get_correspondent(message, rule):
|
||||||
|
if rule.assign_correspondent_from == MailRule.CORRESPONDENT_FROM_NOTHING:
|
||||||
|
correspondent = None
|
||||||
|
elif rule.assign_correspondent_from == MailRule.CORRESPONDENT_FROM_EMAIL:
|
||||||
|
correspondent_name = message.from_
|
||||||
|
correspondent = Correspondent.objects.get_or_create(
|
||||||
|
name=correspondent_name, defaults={
|
||||||
|
"slug": slugify(correspondent_name)
|
||||||
|
})[0]
|
||||||
|
elif rule.assign_correspondent_from == MailRule.CORRESPONDENT_FROM_NAME:
|
||||||
|
if message.from_values and \
|
||||||
|
'name' in message.from_values \
|
||||||
|
and message.from_values['name']:
|
||||||
|
correspondent_name = message.from_values['name']
|
||||||
|
else:
|
||||||
|
correspondent_name = message.from_
|
||||||
|
|
||||||
|
correspondent = Correspondent.objects.get_or_create(
|
||||||
|
name=correspondent_name, defaults={
|
||||||
|
"slug": slugify(correspondent_name)
|
||||||
|
})[0]
|
||||||
|
elif rule.assign_correspondent_from == MailRule.CORRESPONDENT_FROM_CUSTOM:
|
||||||
|
correspondent = rule.assign_correspondent
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknwown correspondent selector")
|
||||||
|
|
||||||
|
return correspondent
|
||||||
|
|
||||||
|
|
||||||
|
def handle_message(message, rule):
|
||||||
|
if not message.attachments:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
correspondent = get_correspondent(message, rule)
|
||||||
|
tag = rule.assign_tag
|
||||||
|
doc_type = rule.assign_document_type
|
||||||
|
|
||||||
|
processed_attachments = 0
|
||||||
|
|
||||||
|
for att in message.attachments:
|
||||||
|
|
||||||
|
title = get_title(message, att, rule)
|
||||||
|
|
||||||
|
# TODO: check with parsers what files types are supported
|
||||||
|
if att.content_type == 'application/pdf':
|
||||||
|
|
||||||
|
os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
|
||||||
|
_, temp_filename = tempfile.mkstemp(prefix="paperless-mail-", dir=settings.SCRATCH_DIR)
|
||||||
|
with open(temp_filename, 'wb') as f:
|
||||||
|
f.write(att.payload)
|
||||||
|
|
||||||
|
async_task(
|
||||||
|
"documents.tasks.consume_file",
|
||||||
|
path=temp_filename,
|
||||||
|
override_filename=att.filename,
|
||||||
|
override_title=title,
|
||||||
|
override_correspondent_id=correspondent.id if correspondent else None,
|
||||||
|
override_document_type_id=doc_type.id if doc_type else None,
|
||||||
|
override_tag_ids=[tag.id] if tag else None,
|
||||||
|
task_name=f"Mail: {att.filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_attachments += 1
|
||||||
|
|
||||||
|
return processed_attachments
|
0
src/paperless_mail/management/__init__.py
Normal file
0
src/paperless_mail/management/__init__.py
Normal file
0
src/paperless_mail/management/commands/__init__.py
Normal file
0
src/paperless_mail/management/commands/__init__.py
Normal file
13
src/paperless_mail/management/commands/mail_fetcher.py
Normal file
13
src/paperless_mail/management/commands/mail_fetcher.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from paperless_mail import mail, tasks
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
|
||||||
|
help = """
|
||||||
|
""".replace(" ", "")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
|
||||||
|
tasks.process_mail_accounts()
|
48
src/paperless_mail/migrations/0001_initial.py
Normal file
48
src/paperless_mail/migrations/0001_initial.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Generated by Django 3.1.3 on 2020-11-15 22:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('documents', '1002_auto_20201111_1105'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MailAccount',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=256, unique=True)),
|
||||||
|
('imap_server', models.CharField(max_length=256)),
|
||||||
|
('imap_port', models.IntegerField(blank=True, null=True)),
|
||||||
|
('imap_security', models.PositiveIntegerField(choices=[(1, 'No encryption'), (2, 'Use SSL'), (3, 'Use STARTTLS')], default=2)),
|
||||||
|
('username', models.CharField(max_length=256)),
|
||||||
|
('password', models.CharField(max_length=256)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MailRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=256)),
|
||||||
|
('folder', models.CharField(default='INBOX', max_length=256)),
|
||||||
|
('filter_from', models.CharField(blank=True, max_length=256, null=True)),
|
||||||
|
('filter_subject', models.CharField(blank=True, max_length=256, null=True)),
|
||||||
|
('filter_body', models.CharField(blank=True, max_length=256, null=True)),
|
||||||
|
('maximum_age', models.PositiveIntegerField(default=30)),
|
||||||
|
('action', models.PositiveIntegerField(choices=[(1, 'Delete'), (2, 'Move to specified folder'), (3, "Mark as read, don't process read mails"), (4, "Flag the mail, don't process flagged mails")], default=3, help_text='The action applied to the mail. This action is only performed when documents were consumed from the mail. Mails without attachments will remain entirely untouched.')),
|
||||||
|
('action_parameter', models.CharField(blank=True, help_text='Additional parameter for the action selected above, i.e., the target folder of the move to folder action.', max_length=256, null=True)),
|
||||||
|
('assign_title_from', models.PositiveIntegerField(choices=[(1, 'Use subject as title'), (2, 'Use attachment filename as title')], default=1)),
|
||||||
|
('assign_correspondent_from', models.PositiveIntegerField(choices=[(1, 'Do not assign a correspondent'), (2, 'Use mail address'), (3, 'Use name (or mail address if not available)'), (4, 'Use correspondent selected below')], default=1)),
|
||||||
|
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='paperless_mail.mailaccount')),
|
||||||
|
('assign_correspondent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='documents.correspondent')),
|
||||||
|
('assign_document_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='documents.documenttype')),
|
||||||
|
('assign_tag', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='documents.tag')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
32
src/paperless_mail/migrations/0002_auto_20201117_1334.py
Normal file
32
src/paperless_mail/migrations/0002_auto_20201117_1334.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 3.1.3 on 2020-11-17 13:34
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.migrations import RunPython
|
||||||
|
from django_q.models import Schedule
|
||||||
|
from django_q.tasks import schedule
|
||||||
|
|
||||||
|
|
||||||
|
def add_schedules(apps, schema_editor):
|
||||||
|
schedule('paperless_mail.tasks.process_mail_accounts',
|
||||||
|
name="Check all e-mail accounts",
|
||||||
|
schedule_type=Schedule.MINUTES,
|
||||||
|
minutes=10)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_schedules(apps, schema_editor):
|
||||||
|
Schedule.objects.filter(
|
||||||
|
func='paperless_mail.tasks.process_mail_accounts').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('paperless_mail', '0001_initial'),
|
||||||
|
('django_q', '0013_task_attempt_count'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
RunPython(add_schedules, remove_schedules)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
0
src/paperless_mail/migrations/__init__.py
Normal file
0
src/paperless_mail/migrations/__init__.py
Normal file
137
src/paperless_mail/models.py
Normal file
137
src/paperless_mail/models.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
import documents.models as document_models
|
||||||
|
|
||||||
|
|
||||||
|
class MailAccount(models.Model):
|
||||||
|
|
||||||
|
IMAP_SECURITY_NONE = 1
|
||||||
|
IMAP_SECURITY_SSL = 2
|
||||||
|
IMAP_SECURITY_STARTTLS = 3
|
||||||
|
|
||||||
|
IMAP_SECURITY_OPTIONS = (
|
||||||
|
(IMAP_SECURITY_NONE, "No encryption"),
|
||||||
|
(IMAP_SECURITY_SSL, "Use SSL"),
|
||||||
|
(IMAP_SECURITY_STARTTLS, "Use STARTTLS"),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=256, unique=True)
|
||||||
|
|
||||||
|
imap_server = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
imap_port = models.IntegerField(blank=True, null=True)
|
||||||
|
|
||||||
|
imap_security = models.PositiveIntegerField(
|
||||||
|
choices=IMAP_SECURITY_OPTIONS,
|
||||||
|
default=IMAP_SECURITY_SSL
|
||||||
|
)
|
||||||
|
|
||||||
|
username = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
password = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class MailRule(models.Model):
|
||||||
|
|
||||||
|
ACTION_DELETE = 1
|
||||||
|
ACTION_MOVE = 2
|
||||||
|
ACTION_MARK_READ = 3
|
||||||
|
ACTION_FLAG = 4
|
||||||
|
|
||||||
|
ACTIONS = (
|
||||||
|
(ACTION_DELETE, "Delete"),
|
||||||
|
(ACTION_MOVE, "Move to specified folder"),
|
||||||
|
(ACTION_MARK_READ, "Mark as read, don't process read mails"),
|
||||||
|
(ACTION_FLAG, "Flag the mail, don't process flagged mails")
|
||||||
|
)
|
||||||
|
|
||||||
|
TITLE_FROM_SUBJECT = 1
|
||||||
|
TITLE_FROM_FILENAME = 2
|
||||||
|
|
||||||
|
TITLE_SELECTOR = (
|
||||||
|
(TITLE_FROM_SUBJECT, "Use subject as title"),
|
||||||
|
(TITLE_FROM_FILENAME, "Use attachment filename as title")
|
||||||
|
)
|
||||||
|
|
||||||
|
CORRESPONDENT_FROM_NOTHING = 1
|
||||||
|
CORRESPONDENT_FROM_EMAIL = 2
|
||||||
|
CORRESPONDENT_FROM_NAME = 3
|
||||||
|
CORRESPONDENT_FROM_CUSTOM = 4
|
||||||
|
|
||||||
|
CORRESPONDENT_SELECTOR = (
|
||||||
|
(CORRESPONDENT_FROM_NOTHING, "Do not assign a correspondent"),
|
||||||
|
(CORRESPONDENT_FROM_EMAIL, "Use mail address"),
|
||||||
|
(CORRESPONDENT_FROM_NAME, "Use name (or mail address if not available)"),
|
||||||
|
(CORRESPONDENT_FROM_CUSTOM, "Use correspondent selected below")
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
account = models.ForeignKey(
|
||||||
|
MailAccount,
|
||||||
|
related_name="rules",
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = models.CharField(default='INBOX', max_length=256)
|
||||||
|
|
||||||
|
filter_from = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
filter_subject = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
filter_body = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
|
||||||
|
maximum_age = models.PositiveIntegerField(default=30)
|
||||||
|
|
||||||
|
action = models.PositiveIntegerField(
|
||||||
|
choices=ACTIONS,
|
||||||
|
default=ACTION_MARK_READ,
|
||||||
|
help_text="The action applied to the mail. This action is only "
|
||||||
|
"performed when documents were consumed from the mail. "
|
||||||
|
"Mails without attachments will remain entirely "
|
||||||
|
"untouched."
|
||||||
|
)
|
||||||
|
|
||||||
|
action_parameter = models.CharField(
|
||||||
|
max_length=256, blank=True, null=True,
|
||||||
|
help_text="Additional parameter for the action selected above, i.e., "
|
||||||
|
"the target folder of the move to folder action."
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_title_from = models.PositiveIntegerField(
|
||||||
|
choices=TITLE_SELECTOR,
|
||||||
|
default=TITLE_FROM_SUBJECT
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_tag = models.ForeignKey(
|
||||||
|
document_models.Tag,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_document_type = models.ForeignKey(
|
||||||
|
document_models.DocumentType,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_correspondent_from = models.PositiveIntegerField(
|
||||||
|
choices=CORRESPONDENT_SELECTOR,
|
||||||
|
default=CORRESPONDENT_FROM_NOTHING
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_correspondent = models.ForeignKey(
|
||||||
|
document_models.Correspondent,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
23
src/paperless_mail/tasks.py
Normal file
23
src/paperless_mail/tasks.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from paperless_mail import mail
|
||||||
|
from paperless_mail.models import MailAccount
|
||||||
|
|
||||||
|
|
||||||
|
def process_mail_accounts():
|
||||||
|
total_new_documents = 0
|
||||||
|
for account in MailAccount.objects.all():
|
||||||
|
total_new_documents += mail.handle_mail_account(account)
|
||||||
|
|
||||||
|
if total_new_documents > 0:
|
||||||
|
return f"Added {total_new_documents} document(s)."
|
||||||
|
else:
|
||||||
|
return "No new documents were added."
|
||||||
|
|
||||||
|
|
||||||
|
def process_mail_account(name):
|
||||||
|
account = MailAccount.objects.find(name=name)
|
||||||
|
if account:
|
||||||
|
mail.handle_mail_account(account)
|
||||||
|
else:
|
||||||
|
logging.error("Unknown mail acccount: {}".format(name))
|
0
src/paperless_mail/tests/__init__.py
Normal file
0
src/paperless_mail/tests/__init__.py
Normal file
352
src/paperless_mail/tests/test_mail.py
Normal file
352
src/paperless_mail/tests/test_mail.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import uuid
|
||||||
|
from collections import namedtuple
|
||||||
|
from typing import ContextManager
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from imap_tools import MailMessageFlags, MailboxFolderSelectError
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from paperless_mail.mail import get_correspondent, get_title, handle_message, handle_mail_account, MailError
|
||||||
|
from paperless_mail.models import MailRule, MailAccount
|
||||||
|
|
||||||
|
|
||||||
|
class BogusFolderManager:
|
||||||
|
|
||||||
|
current_folder = "INBOX"
|
||||||
|
|
||||||
|
def set(self, new_folder):
|
||||||
|
if new_folder not in ["INBOX", "spam"]:
|
||||||
|
raise MailboxFolderSelectError(None, "uhm")
|
||||||
|
self.current_folder = new_folder
|
||||||
|
|
||||||
|
|
||||||
|
class BogusMailBox(ContextManager):
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
self.messages_spam = []
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
if not (username == 'admin' and password == 'secret'):
|
||||||
|
raise Exception()
|
||||||
|
|
||||||
|
folder = BogusFolderManager()
|
||||||
|
|
||||||
|
def fetch(self, criteria, mark_seen):
|
||||||
|
msg = self.messages
|
||||||
|
|
||||||
|
criteria = str(criteria).strip('()').split(" ")
|
||||||
|
|
||||||
|
if 'UNSEEN' in criteria:
|
||||||
|
msg = filter(lambda m: not m.seen, msg)
|
||||||
|
|
||||||
|
if 'SUBJECT' in criteria:
|
||||||
|
subject = criteria[criteria.index('SUBJECT') + 1].strip('"')
|
||||||
|
msg = filter(lambda m: subject in m.subject, msg)
|
||||||
|
|
||||||
|
if 'BODY' in criteria:
|
||||||
|
body = criteria[criteria.index('BODY') + 1].strip('"')
|
||||||
|
msg = filter(lambda m: body in m.body, msg)
|
||||||
|
|
||||||
|
if 'FROM' in criteria:
|
||||||
|
from_ = criteria[criteria.index('FROM') + 1].strip('"')
|
||||||
|
msg = filter(lambda m: from_ in m.from_, msg)
|
||||||
|
|
||||||
|
if 'UNFLAGGED' in criteria:
|
||||||
|
msg = filter(lambda m: not m.flagged, msg)
|
||||||
|
|
||||||
|
return list(msg)
|
||||||
|
|
||||||
|
def seen(self, uid_list, seen_val):
|
||||||
|
for message in self.messages:
|
||||||
|
if message.uid in uid_list:
|
||||||
|
message.seen = seen_val
|
||||||
|
|
||||||
|
def delete(self, uid_list):
|
||||||
|
self.messages = list(filter(lambda m: m.uid not in uid_list, self.messages))
|
||||||
|
|
||||||
|
def flag(self, uid_list, flag_set, value):
|
||||||
|
for message in self.messages:
|
||||||
|
if message.uid in uid_list:
|
||||||
|
for flag in flag_set:
|
||||||
|
if flag == MailMessageFlags.FLAGGED:
|
||||||
|
message.flagged = value
|
||||||
|
|
||||||
|
def move(self, uid_list, folder):
|
||||||
|
if folder == "spam":
|
||||||
|
self.messages_spam.append(
|
||||||
|
filter(lambda m: m.uid in uid_list, self.messages)
|
||||||
|
)
|
||||||
|
self.messages = list(
|
||||||
|
filter(lambda m: m.uid not in uid_list, self.messages)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception()
|
||||||
|
|
||||||
|
|
||||||
|
def create_message(num_attachments=1, body="", subject="the suject", from_="noone@mail.com", seen=False, flagged=False):
|
||||||
|
message = namedtuple('MailMessage', [])
|
||||||
|
|
||||||
|
message.uid = uuid.uuid4()
|
||||||
|
message.subject = subject
|
||||||
|
message.attachments = []
|
||||||
|
message.from_ = from_
|
||||||
|
message.body = body
|
||||||
|
for i in range(num_attachments):
|
||||||
|
attachment = namedtuple('Attachment', [])
|
||||||
|
attachment.filename = 'some_file.pdf'
|
||||||
|
attachment.content_type = 'application/pdf'
|
||||||
|
attachment.payload = b'content of the attachment'
|
||||||
|
message.attachments.append(attachment)
|
||||||
|
|
||||||
|
message.seen = seen
|
||||||
|
message.flagged = flagged
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class TestMail(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
patcher = mock.patch('paperless_mail.mail.MailBox')
|
||||||
|
m = patcher.start()
|
||||||
|
self.bogus_mailbox = BogusMailBox()
|
||||||
|
m.return_value = self.bogus_mailbox
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
patcher = mock.patch('paperless_mail.mail.async_task')
|
||||||
|
self.async_task = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
self.reset_bogus_mailbox()
|
||||||
|
|
||||||
|
def reset_bogus_mailbox(self):
|
||||||
|
self.bogus_mailbox.messages = []
|
||||||
|
self.bogus_mailbox.messages_spam = []
|
||||||
|
self.bogus_mailbox.messages.append(create_message(subject="Invoice 1", from_="amazon@amazon.de", body="cables", seen=True, flagged=False))
|
||||||
|
self.bogus_mailbox.messages.append(create_message(subject="Invoice 2", body="from my favorite electronic store", seen=False, flagged=True))
|
||||||
|
self.bogus_mailbox.messages.append(create_message(subject="Claim your $10M price now!", from_="amazon@amazon-some-indian-site.org", seen=False))
|
||||||
|
|
||||||
|
def test_get_correspondent(self):
|
||||||
|
message = namedtuple('MailMessage', [])
|
||||||
|
message.from_ = "someone@somewhere.com"
|
||||||
|
message.from_values = {'name': "Someone!", 'email': "someone@somewhere.com"}
|
||||||
|
|
||||||
|
message2 = namedtuple('MailMessage', [])
|
||||||
|
message2.from_ = "me@localhost.com"
|
||||||
|
message2.from_values = {'name': "", 'email': "fake@localhost.com"}
|
||||||
|
|
||||||
|
me_localhost = Correspondent.objects.create(name=message2.from_)
|
||||||
|
someone_else = Correspondent.objects.create(name="someone else")
|
||||||
|
|
||||||
|
rule = MailRule(assign_correspondent_from=MailRule.CORRESPONDENT_FROM_NOTHING)
|
||||||
|
self.assertIsNone(get_correspondent(message, rule))
|
||||||
|
|
||||||
|
rule = MailRule(assign_correspondent_from=MailRule.CORRESPONDENT_FROM_EMAIL)
|
||||||
|
c = get_correspondent(message, rule)
|
||||||
|
self.assertIsNotNone(c)
|
||||||
|
self.assertEqual(c.name, "someone@somewhere.com")
|
||||||
|
c = get_correspondent(message2, rule)
|
||||||
|
self.assertIsNotNone(c)
|
||||||
|
self.assertEqual(c.name, "me@localhost.com")
|
||||||
|
self.assertEqual(c.id, me_localhost.id)
|
||||||
|
|
||||||
|
rule = MailRule(assign_correspondent_from=MailRule.CORRESPONDENT_FROM_NAME)
|
||||||
|
c = get_correspondent(message, rule)
|
||||||
|
self.assertIsNotNone(c)
|
||||||
|
self.assertEqual(c.name, "Someone!")
|
||||||
|
c = get_correspondent(message2, rule)
|
||||||
|
self.assertIsNotNone(c)
|
||||||
|
self.assertEqual(c.id, me_localhost.id)
|
||||||
|
|
||||||
|
rule = MailRule(assign_correspondent_from=MailRule.CORRESPONDENT_FROM_CUSTOM, assign_correspondent=someone_else)
|
||||||
|
c = get_correspondent(message, rule)
|
||||||
|
self.assertEqual(c, someone_else)
|
||||||
|
|
||||||
|
def test_get_title(self):
|
||||||
|
message = namedtuple('MailMessage', [])
|
||||||
|
message.subject = "the message title"
|
||||||
|
att = namedtuple('Attachment', [])
|
||||||
|
att.filename = "this_is_the_file.pdf"
|
||||||
|
rule = MailRule(assign_title_from=MailRule.TITLE_FROM_FILENAME)
|
||||||
|
self.assertEqual(get_title(message, att, rule), "this_is_the_file")
|
||||||
|
rule = MailRule(assign_title_from=MailRule.TITLE_FROM_SUBJECT)
|
||||||
|
self.assertEqual(get_title(message, att, rule), "the message title")
|
||||||
|
|
||||||
|
def test_handle_message(self):
|
||||||
|
message = namedtuple('MailMessage', [])
|
||||||
|
message.subject = "the message title"
|
||||||
|
|
||||||
|
att = namedtuple('Attachment', [])
|
||||||
|
att.filename = "test1.pdf"
|
||||||
|
att.content_type = 'application/pdf'
|
||||||
|
att.payload = b"attachment contents"
|
||||||
|
|
||||||
|
att2 = namedtuple('Attachment', [])
|
||||||
|
att2.filename = "test2.pdf"
|
||||||
|
att2.content_type = 'application/pdf'
|
||||||
|
att2.payload = b"attachment contents"
|
||||||
|
|
||||||
|
att3 = namedtuple('Attachment', [])
|
||||||
|
att3.filename = "test3.pdf"
|
||||||
|
att3.content_type = 'application/invalid'
|
||||||
|
att3.payload = b"attachment contents"
|
||||||
|
|
||||||
|
message.attachments = [att, att2, att3]
|
||||||
|
|
||||||
|
rule = MailRule(assign_title_from=MailRule.TITLE_FROM_FILENAME)
|
||||||
|
|
||||||
|
result = handle_message(message, rule)
|
||||||
|
|
||||||
|
self.assertEqual(result, 2)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.async_task.call_args_list), 2)
|
||||||
|
|
||||||
|
args1, kwargs1 = self.async_task.call_args_list[0]
|
||||||
|
args2, kwargs2 = self.async_task.call_args_list[1]
|
||||||
|
|
||||||
|
self.assertEqual(kwargs1['override_title'], "test1")
|
||||||
|
self.assertEqual(kwargs1['override_filename'], "test1.pdf")
|
||||||
|
|
||||||
|
self.assertEqual(kwargs2['override_title'], "test2")
|
||||||
|
self.assertEqual(kwargs2['override_filename'], "test2.pdf")
|
||||||
|
|
||||||
|
@mock.patch("paperless_mail.mail.async_task")
|
||||||
|
def test_handle_empty_message(self, m):
|
||||||
|
message = namedtuple('MailMessage', [])
|
||||||
|
|
||||||
|
message.attachments = []
|
||||||
|
rule = MailRule()
|
||||||
|
|
||||||
|
result = handle_message(message, rule)
|
||||||
|
|
||||||
|
self.assertFalse(m.called)
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
|
||||||
|
def test_handle_mail_account_mark_read(self):
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(name="test", imap_server="", username="admin", password="secret")
|
||||||
|
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_MARK_READ)
|
||||||
|
|
||||||
|
self.assertEqual(self.async_task.call_count, 0)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.fetch("UNSEEN", False)), 2)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(self.async_task.call_count, 2)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.fetch("UNSEEN", False)), 0)
|
||||||
|
|
||||||
|
def test_handle_mail_account_delete(self):
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(name="test", imap_server="", username="admin", password="secret")
|
||||||
|
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_DELETE, filter_subject="Invoice")
|
||||||
|
|
||||||
|
self.assertEqual(self.async_task.call_count, 0)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(self.async_task.call_count, 2)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 1)
|
||||||
|
|
||||||
|
def test_handle_mail_account_flag(self):
|
||||||
|
account = MailAccount.objects.create(name="test", imap_server="", username="admin", password="secret")
|
||||||
|
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_FLAG, filter_subject="Invoice")
|
||||||
|
|
||||||
|
self.assertEqual(self.async_task.call_count, 0)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.fetch("UNFLAGGED", False)), 2)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(self.async_task.call_count, 1)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.fetch("UNFLAGGED", False)), 1)
|
||||||
|
|
||||||
|
def test_handle_mail_account_move(self):
|
||||||
|
account = MailAccount.objects.create(name="test", imap_server="", username="admin", password="secret")
|
||||||
|
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_MOVE, action_parameter="spam", filter_subject="Claim")
|
||||||
|
|
||||||
|
self.assertEqual(self.async_task.call_count, 0)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages_spam), 0)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(self.async_task.call_count, 1)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 2)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages_spam), 1)
|
||||||
|
|
||||||
|
def test_errors(self):
|
||||||
|
account = MailAccount.objects.create(name="test", imap_server="", username="admin", password="wrong")
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_mail_account(account)
|
||||||
|
except MailError as e:
|
||||||
|
self.assertTrue(str(e).startswith("Error while authenticating account"))
|
||||||
|
else:
|
||||||
|
self.fail("Should raise exception")
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(name="test2", imap_server="", username="admin", password="secret")
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, folder="uuuh")
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_mail_account(account)
|
||||||
|
except MailError as e:
|
||||||
|
self.assertTrue("uuuh does not exist" in str(e))
|
||||||
|
else:
|
||||||
|
self.fail("Should raise exception")
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(name="test3", imap_server="", username="admin", password="secret")
|
||||||
|
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_MOVE, action_parameter="doesnotexist", filter_subject="Claim")
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_mail_account(account)
|
||||||
|
except MailError as e:
|
||||||
|
self.assertTrue("Error while processing post-consume actions" in str(e))
|
||||||
|
else:
|
||||||
|
self.fail("Should raise exception")
|
||||||
|
|
||||||
|
def test_filters(self):
|
||||||
|
|
||||||
|
account = MailAccount.objects.create(name="test3", imap_server="", username="admin", password="secret")
|
||||||
|
rule = MailRule.objects.create(name="testrule", account=account, action=MailRule.ACTION_DELETE, filter_subject="Claim")
|
||||||
|
|
||||||
|
self.assertEqual(self.async_task.call_count, 0)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 2)
|
||||||
|
self.assertEqual(self.async_task.call_count, 1)
|
||||||
|
|
||||||
|
self.reset_bogus_mailbox()
|
||||||
|
|
||||||
|
rule.filter_subject = None
|
||||||
|
rule.filter_body = "electronic"
|
||||||
|
rule.save()
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 2)
|
||||||
|
self.assertEqual(self.async_task.call_count, 2)
|
||||||
|
|
||||||
|
self.reset_bogus_mailbox()
|
||||||
|
|
||||||
|
rule.filter_from = "amazon"
|
||||||
|
rule.filter_body = None
|
||||||
|
rule.save()
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 1)
|
||||||
|
self.assertEqual(self.async_task.call_count, 4)
|
||||||
|
|
||||||
|
self.reset_bogus_mailbox()
|
||||||
|
|
||||||
|
rule.filter_from = "amazon"
|
||||||
|
rule.filter_body = "cables"
|
||||||
|
rule.filter_subject = "Invoice"
|
||||||
|
rule.save()
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 3)
|
||||||
|
handle_mail_account(account)
|
||||||
|
self.assertEqual(len(self.bogus_mailbox.messages), 2)
|
||||||
|
self.assertEqual(self.async_task.call_count, 5)
|
3
src/paperless_mail/views.py
Normal file
3
src/paperless_mail/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
Loading…
x
Reference in New Issue
Block a user