docbow/docbow_project/pfwb/management/commands/sendmail.py

300 lines
12 KiB
Python

from datetime import datetime as dt
import sys
import mailbox
import email
import email.errors
import email.utils
import email.header
import logging
import re
import time
from urllib.request import urlopen
from django.conf import settings
from django.core.management.base import BaseCommand
import django.contrib.auth.models as auth_models
from django.core.files.base import ContentFile
from django.core.exceptions import MultipleObjectsReturned
from django.utils.timezone import utc, make_aware
from django.utils.encoding import force_text
from django.template.defaultfilters import slugify
from docbow_project.docbow import models, utils
from docbow_project.docbow.email_utils import u2u_decode
from django_journal.journal import record
from django.db.transaction import atomic
from docbow_project.docbow import app_settings as docbow_app_settings
from docbow_project.pfwb import app_settings
logger = logging.getLogger('docbow.mail_interface')
# modes
EXPEDITION = 'expedition'
ATTACHED_FILE = 'attached_file'
class Command(BaseCommand):
args = ''
help = '''Convert a mail to a document send.
In case of failure the following return value is returned:
- 1 failure to parse the message on stdin
- 2 the mail is missing a subject
- 3 the subject could not be decoded
- 4 obligatory attributes or contents were missing
- 5 notifications mails could not be sent
- 6 missing Message-ID
- 7 sender not authorized
- 8 missing sender
'''
all_recipients = []
filenames = []
message_id = None
def add_arguments(self, parser):
parser.add_argument('recipient', type=str)
parser.add_argument("--sender")
parser.add_argument("--file")
def handle(self, *args, **options):
if not options.get('sender'):
self.error('7.7.1 No sender', exit_code=8)
self.setup_mailing_list_dict()
try:
if options.get('file'):
mail = email.message_from_binary_file(open(options['file'], 'rb'))
else:
mail = email.message_from_binary_file(sys.stdin.buffer)
except email.errors.MessageParseError as e:
self.error('7.7.1 Error parsing message', exit_code=1)
if settings.SENDMAIL_DEBUG_MBOX:
try:
mbox = mailbox.mbox(settings.SENDMAIL_DEBUG_MBOX)
mbox.add(mail)
except:
logger.exception('mbox exception')
try:
self.handle_mail(mail, (options['recipient'],), **options)
except Exception as e:
logger.exception('Unknown exception')
self.error('7.7.1 Internal error when handling the mail', exit_code=5)
def error(self, msg, exit_code=None, **kwargs):
sys.stderr.write(msg.format(**kwargs) + '\n')
if hasattr(self, 'message_id'):
record(
'warning-smtp-interface',
'message {message_id} to {all_recipients} containing {filenames} refused: ' + msg,
message_id=self.message_id,
all_recipients=self.all_recipients,
filenames=self.filenames,
**kwargs,
)
else:
record('warning-smtp-interface', 'message refused: ' + msg, **kwargs)
if exit_code:
sys.exit(exit_code)
def decode_filename(self, filename):
'''See if the filename contains encoded-word work around bugs in FileMakerPro'''
m = re.match(r'=\?(.*)\?(.*)\?(.*)\?=', filename)
if m:
result = []
for content, encoding in email.header.decode_header(filename):
result.append(force_text(content, encoding or 'ascii'))
return ''.join(result)
else:
return filename
def setup_mailing_list_dict(self):
self.mailing_lists = {}
for mailing_list in models.MailingList.objects.all():
self.mailing_lists[slugify(mailing_list.name)] = mailing_list
def resolve_username_for_list(self, username):
if not username.startswith('liste-'):
return None
return self.mailing_lists.get(username[len('liste-') :])
@atomic
def handle_mail(self, mail, mail_recipients, **options):
content_errors = []
attachments = []
recipients = []
mailing_list_recipients = []
description = u''
from_email = email.utils.parseaddr(options['sender'])[1]
if not from_email:
self.error('7.7.1 No sender', exit_code=8)
if from_email == app_settings.PFWB_SENDMAIL_TABELLIO_EXPEDITION_EMAIL:
mode = EXPEDITION
elif from_email == app_settings.PFWB_SENDMAIL_ATTACHED_FILE_EMAIL:
mode = ATTACHED_FILE
else:
self.error('Email unknown, not authorized to post', exit_code=7)
return
tos = mail.get_all('to', [])
ccs = mail.get_all('cc', [])
resent_tos = mail.get_all('resent-to', [])
resent_ccs = mail.get_all('resent-cc', [])
self.all_recipients = all_recipients = mail_recipients or [
b for a, b in email.utils.getaddresses(tos + ccs + resent_tos + resent_ccs)
]
self.message_id = mail.get('Message-ID', None)
if not self.message_id:
self.error('7.7.1 Mail is missing a Message-ID', exit_code=6)
# determine the filetype
filetype = None
if mode == ATTACHED_FILE:
subject = mail.get('Subject', None)
if not subject:
self.error('7.7.1 Mail is missing a subject', exit_code=2)
try:
subject = u2u_decode(subject)
except Exception as e:
self.error('7.7.1 The subject cannot be decoded', exit_code=3)
try:
filetype = models.FileType.objects.get(name=subject)
except models.FileType.DoesNotExist:
record('warning', 'unknown filetype ' '{filetype}, using default filetype', filetype=subject)
else:
tabellio_doc_type = mail.get('x-tabellio-doc-type')
if tabellio_doc_type:
try:
filetype = models.FileType.objects.get(
tabelliodoctype__tabellio_doc_type=tabellio_doc_type
)
except models.FileType.DoesNotExist:
record(
'warning',
'unknown x-tabellio-doc-type ' '{tabellio_doc_type}, using default filetype',
tabellio_doc_type=tabellio_doc_type,
)
except models.FileType.MultipleObjectsReturned:
record(
'warning',
'unknown x-tabellio-doc-type ' '{tabellio_doc_type}, using default filetype',
tabellio_doc_type=tabellio_doc_type,
)
if filetype is None:
try:
filetype = models.FileType.objects.get(id=app_settings.PFWB_SENDMAIL_DEFAULT_TYPE_ID)
except models.FileType.DoesNotExist:
filetype, created = models.FileType.objects.get_or_create(
name=app_settings.PFWB_SENDMAIL_DEFAULT_TYPE_NAME
)
if mode == ATTACHED_FILE:
for part in mail.walk():
filename = part.get_filename(None)
if part.get_content_type() == 'text/plain' and (
'Content-Disposition' not in part or 'inline' in part['Content-Disposition']
):
charset = part.get_content_charset('us-ascii')
description = force_text(part.get_payload(decode=True), charset)
if filename:
filename = self.decode_filename(filename)
# be defensive, truncate at 230 characters !
filename = utils.truncate_filename(filename)
attachments.append((filename, part.get_payload(decode=True)))
else:
url = mail.get('x-tabellio-doc-url')
try:
stream = urlopen(url)
except Exception as e:
self.error('7.7.1 Unable to retrieve %r: %r' % (url, e), exit_code=3)
content = stream.read()
subject = mail.get('Subject', None)
if not subject:
self.error('7.7.1 Mail is missing a subject', exit_code=2)
try:
subject = u2u_decode(subject)
except Exception as e:
self.error('7.7.1 The subject cannot be decoded', exit_code=3)
try:
name = subject.split(':', 1)[1].strip()
# be defensive, truncate
name = name[: docbow_app_settings.TRUNCATE_FILENAME]
except IndexError:
self.error('7.7.1 Filename cannot be extracted from the subject', exit_code=3)
if not name.endswith('.pdf'):
name += '.pdf'
attachments.append((name, content))
for email_address in all_recipients:
username, domain = email_address.split('@', 1)
# mailing list case
mailing_list = self.resolve_username_for_list(username)
if mailing_list is not None:
mailing_list_recipients.append(mailing_list)
continue
# classic user case
try:
user = auth_models.User.objects.get(username=username)
recipients.append(user)
except auth_models.User.DoesNotExist:
try:
users_qs = auth_models.User.objects.filter(email=email_address)
if not users_qs.count():
raise auth_models.User.DoesNotExist()
recipients.extend(users_qs.all())
except auth_models.User.DoesNotExist:
msg = 'Recipient %r is not an user of the platform' % email_address
content_errors.append(msg)
self.filenames = [a for a, b in attachments]
if not len(attachments):
content_errors.append('You must send at least one attached file')
if not len(all_recipients):
content_errors.append('You must have at least one recipient in your message.')
try:
if mode == ATTACHED_FILE:
user_id = app_settings.PFWB_SENDMAIL_ATTACHED_FILE_USER_ID
else:
user_id = app_settings.PFWB_SENDMAIL_TABELLIO_EXPEDITION_USER_ID
sender = auth_models.User.objects.get(id=user_id)
except auth_models.User.DoesNotExist:
content_errors.append('No user match the sender user_id %s in mode ' '%s' % (user_id, mode))
if content_errors:
msg = ['7.7.1 The email sent contains many errors:']
for error in content_errors:
msg.append(' - %s' % error)
self.error('\n'.join(msg), exit_code=4)
else:
if mode == ATTACHED_FILE:
record(
'smtp-received-document',
'mode: {mode} message-id: {message_id} subject: {subject}',
mode=mode,
message_id=self.message_id,
subject=subject,
)
else:
record(
'smtp-received-document',
'mode: {mode} message-id: '
'{message_id} x-tabellio-doc-url: {x_tabellio_doc_url} '
'x-tabellio-doc-type: {x_tabellio_doc_type}',
mode=mode,
message_id=self.message_id,
subject=repr(subject),
x_tabellio_doc_url=url,
x_tabellio_doc_type=tabellio_doc_type,
)
document = models.Document(sender=sender, comment=description, filetype=filetype)
document.save()
document.to_user.set(recipients)
document.to_list.set(mailing_list_recipients)
for filename, payload in attachments:
content = ContentFile(payload)
attached_file = models.AttachedFile(document=document, name=filename)
attached_file.content.save(filename, content, save=False)
attached_file.save()
document._timestamp = time.time()
document.date = dt.fromtimestamp(document._timestamp, utc)
document.save()
document.post()