├── django_mailbox ├── tests │ ├── __init__.py │ ├── messages │ │ ├── message_with_single_byte_encoding.eml │ │ ├── message_with_invalid_content_for_declared_encoding.eml │ │ ├── message_with_single_byte_extended_subject_encoding.eml │ │ ├── generic_message.eml │ │ ├── message_with_invalid_encoding.eml │ │ ├── message_with_long_text_lines.eml │ │ ├── multipart_text.eml │ │ ├── message_with_text_attachment.eml │ │ ├── message_with_attachment.eml │ │ ├── message_with_defective_attachment_association.eml │ │ ├── message_with_not_decoded_attachment_header.eml │ │ ├── message_with_defective_attachment_association_result.eml │ │ ├── message_with_many_multiparts.eml │ │ ├── message_with_many_multiparts_stripped_html.eml │ │ ├── email_issue_82.eml │ │ ├── message_with_rfc822_attachment.eml │ │ ├── message_with_utf8_attachment.eml │ │ ├── message_with_utf8_char.eml │ │ ├── message_with_image_jpg_mimetype.eml │ │ └── message_with_long_content.eml │ ├── settings.py │ ├── test_mailbox.py │ ├── test_processincomingmessage.py │ ├── test_integration_imap.py │ ├── test_message_flattening.py │ ├── test_transports.py │ ├── base.py │ └── test_process_email.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── getmail.py │ │ ├── processincomingmessage.py │ │ └── rebuildmessageattachments.py ├── migrations │ ├── __init__.py │ ├── 0003_auto_20150409_0316.py │ ├── 0005_auto_20160523_2240.py │ ├── 0002_add_eml_to_message.py │ ├── 0009_alter_message_eml.py │ ├── 0006_mailbox_last_polling.py │ ├── 0004_bytestring_to_unicode.py │ ├── 0007_auto_20180421_0026.py │ ├── 0008_auto_20190219_1553.py │ └── 0001_initial.py ├── __init__.py ├── signals.py ├── locale │ ├── bg │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── ru_RU │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── transports │ ├── mh.py │ ├── mbox.py │ ├── mmdf.py │ ├── babyl.py │ ├── maildir.py │ ├── base.py │ ├── __init__.py │ ├── generic.py │ ├── pop3.py │ ├── gmail.py │ ├── office365.py │ └── imap.py ├── apps.py ├── admin.py ├── google_utils.py └── utils.py ├── rtd_requirements.txt ├── test_requirements.txt ├── .flake8 ├── docs ├── topics │ ├── appendix.rst │ ├── appendix │ │ ├── instance-documentation.rst │ │ ├── settings.rst │ │ └── message-storage.rst │ ├── signal.rst │ ├── installation.rst │ ├── development.rst │ ├── polling.rst │ └── mailbox_types.rst ├── index.rst ├── Makefile └── conf.py ├── setup.cfg ├── .bumpversion.cfg ├── .gitignore ├── MANIFEST.in ├── manage.py ├── .github └── workflows │ └── main.yml ├── LICENSE ├── tox.ini ├── readme.rst ├── setup.py └── CHANGELOG.rst /django_mailbox/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_mailbox/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_mailbox/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_mailbox/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rtd_requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.2,<4.0 2 | six 3 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | mock 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = django_mailbox/migrations, django_mailbox/south_migrations 3 | -------------------------------------------------------------------------------- /django_mailbox/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.10.1' 2 | 3 | default_app_config = 'django_mailbox.apps.MailBoxConfig' 4 | -------------------------------------------------------------------------------- /docs/topics/appendix.rst: -------------------------------------------------------------------------------- 1 | Appendix 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | :glob: 7 | 8 | appendix/* 9 | -------------------------------------------------------------------------------- /django_mailbox/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch.dispatcher import Signal 2 | 3 | message_received = Signal() # providing_args=['message'] 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=env docs lib .eggs 3 | DJANGO_SETTINGS_MODULE=django_mailbox.tests.settings 4 | addopts = --tb=short -rxs 5 | -------------------------------------------------------------------------------- /django_mailbox/locale/bg/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/django-mailbox/HEAD/django_mailbox/locale/bg/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_mailbox/locale/ru_RU/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/django-mailbox/HEAD/django_mailbox/locale/ru_RU/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 4.10.1 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:django_mailbox/__init__.py] 8 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_single_byte_encoding.eml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/django-mailbox/HEAD/django_mailbox/tests/messages/message_with_single_byte_encoding.eml -------------------------------------------------------------------------------- /django_mailbox/transports/mh.py: -------------------------------------------------------------------------------- 1 | from mailbox import MH 2 | from django_mailbox.transports.generic import GenericFileMailbox 3 | 4 | 5 | class MHTransport(GenericFileMailbox): 6 | _variant = MH 7 | -------------------------------------------------------------------------------- /django_mailbox/transports/mbox.py: -------------------------------------------------------------------------------- 1 | from mailbox import mbox 2 | from django_mailbox.transports.generic import GenericFileMailbox 3 | 4 | 5 | class MboxTransport(GenericFileMailbox): 6 | _variant = mbox 7 | -------------------------------------------------------------------------------- /django_mailbox/transports/mmdf.py: -------------------------------------------------------------------------------- 1 | from mailbox import MMDF 2 | from django_mailbox.transports.generic import GenericFileMailbox 3 | 4 | 5 | class MMDFTransport(GenericFileMailbox): 6 | _variant = MMDF 7 | -------------------------------------------------------------------------------- /django_mailbox/transports/babyl.py: -------------------------------------------------------------------------------- 1 | from mailbox import Babyl 2 | from django_mailbox.transports.generic import GenericFileMailbox 3 | 4 | 5 | class BabylTransport(GenericFileMailbox): 6 | _variant = Babyl 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | bin/* 3 | lib/* 4 | src/* 5 | dist/* 6 | share/* 7 | docs/_build/* 8 | include/* 9 | .Python 10 | *egg* 11 | dummy_project/* 12 | .cache/ 13 | .tox/ 14 | /messages 15 | *.sqlite3 16 | .idea/ 17 | venv/ -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_invalid_content_for_declared_encoding.eml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/django-mailbox/HEAD/django_mailbox/tests/messages/message_with_invalid_content_for_declared_encoding.eml -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_single_byte_extended_subject_encoding.eml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coddingtonbear/django-mailbox/HEAD/django_mailbox/tests/messages/message_with_single_byte_extended_subject_encoding.eml -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include MANIFEST 3 | recursive-include django_mailbox *.eml 4 | recursive-include docs *.py 5 | recursive-include docs *.rst 6 | recursive-include docs Makefile 7 | recursive-include django_mailbox/locale *.po 8 | recursive-include django_mailbox/locale *.mo 9 | -------------------------------------------------------------------------------- /django_mailbox/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class MailBoxConfig(AppConfig): 6 | name = 'django_mailbox' 7 | verbose_name = _("Mail Box") 8 | 9 | default_auto_field = "django.db.models.AutoField" 10 | -------------------------------------------------------------------------------- /django_mailbox/transports/maildir.py: -------------------------------------------------------------------------------- 1 | from mailbox import Maildir 2 | from django_mailbox.transports.generic import GenericFileMailbox 3 | 4 | 5 | class MaildirTransport(GenericFileMailbox): 6 | _variant = Maildir 7 | 8 | def get_instance(self): 9 | return self._variant(self._path, None) 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_mailbox.tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /django_mailbox/tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'NAME': 'db.sqlite3', 4 | 'ENGINE': 'django.db.backends.sqlite3', 5 | }, 6 | } 7 | INSTALLED_APPS = [ 8 | 'django.contrib.auth', 9 | 'django.contrib.contenttypes', 10 | 'django_mailbox', 11 | ] 12 | SECRET_KEY = 'beepboop' 13 | -------------------------------------------------------------------------------- /django_mailbox/management/commands/getmail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from django_mailbox.models import Mailbox 6 | 7 | 8 | class Command(BaseCommand): 9 | def handle(self, *args, **options): 10 | logging.basicConfig(level=logging.INFO) 11 | Mailbox.get_new_mail_all_mailboxes(args) 12 | -------------------------------------------------------------------------------- /django_mailbox/transports/base.py: -------------------------------------------------------------------------------- 1 | import email 2 | 3 | # Do *not* remove this, we need to use this in subclasses of EmailTransport 4 | from email.errors import MessageParseError # noqa: F401 5 | 6 | 7 | class EmailTransport: 8 | def get_email_from_bytes(self, contents): 9 | message = email.message_from_bytes(contents) 10 | 11 | return message 12 | 13 | def close(self): 14 | pass 15 | -------------------------------------------------------------------------------- /docs/topics/appendix/instance-documentation.rst: -------------------------------------------------------------------------------- 1 | Class Documentation 2 | =================== 3 | 4 | Mailbox 5 | ------- 6 | 7 | .. autoclass:: django_mailbox.models.Mailbox 8 | :members: 9 | :undoc-members: 10 | 11 | Message 12 | ------- 13 | 14 | .. autoclass:: django_mailbox.models.Message 15 | :members: 16 | :undoc-members: 17 | 18 | Message Attachment 19 | ------------------ 20 | 21 | .. autoclass:: django_mailbox.models.MessageAttachment 22 | :members: 23 | :undoc-members: 24 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/generic_message.eml: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | X-Originating-IP: [24.22.122.177] 3 | Date: Sun, 20 Jan 2013 11:53:53 -0800 4 | Delivered-To: test@adamcoddington.net 5 | Message-ID: 6 | Subject: Message Without Attachment 7 | From: Adam Coddington 8 | To: Adam Coddington 9 | Content-Type: text/plain; charset="iso-8859-1" 10 | Content-Transfer-Encoding: 7bit 11 | 12 | Hello there. 13 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0003_auto_20150409_0316.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('django_mailbox', '0002_add_eml_to_message'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='message', 13 | name='eml', 14 | field=models.FileField(help_text='Original full content of message', upload_to=b'messages', null=True, verbose_name='Raw message contents'), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_invalid_encoding.eml: -------------------------------------------------------------------------------- 1 | Reply-To: 2 | From: "Refinance" 3 | Subject: Apply For Loans @ 2% Per Annum 4 | Date: Sun, 19 Oct 2014 11:48:49 +0100 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; 7 | charset="_iso-2022-jp$ESC" 8 | Content-Transfer-Encoding: 7bit 9 | 10 | We offer loans to private individuals and corporate organizations at 2% interest rate. Interested serious applicants should apply via email with details of their requirements. 11 | 12 | Warm Regards, 13 | Loan Team 14 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0005_auto_20160523_2240.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django_mailbox.utils 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('django_mailbox', '0004_bytestring_to_unicode'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='messageattachment', 14 | name='document', 15 | field=models.FileField(upload_to=django_mailbox.utils.get_attachment_save_path, verbose_name='Document'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0002_add_eml_to_message.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('django_mailbox', '0001_initial'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name='message', 13 | name='eml', 14 | field=models.FileField(help_text='Original full content of message', upload_to=b'messages', null=True, verbose_name='Message as a file'), 15 | preserve_default=True, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0009_alter_message_eml.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.23 on 2023-12-16 23:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_mailbox', '0008_auto_20190219_1553'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='message', 15 | name='eml', 16 | field=models.FileField(blank=True, help_text='Original full content of message', null=True, upload_to='messages', verbose_name='Raw message contents'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_long_text_lines.eml: -------------------------------------------------------------------------------- 1 | Return-path: 2 | Delivery-date: Sat, 01 Jun 2013 00:30:16 +0000 3 | Content-Type: text/plain; charset=us-ascii 4 | Content-Transfer-Encoding: quoted-printable 5 | Subject: Flat tire 6 | From: Somebody 7 | Message-Id: <8E4EA9F3-4F4D-45D5-94F8-D3B91B68ABC8@somewhere.net> 8 | Date: Fri, 31 May 2013 16:39:45 -0700 9 | To: something@somewhere.net 10 | Mime-Version: 1.0 (1.0) 11 | X-Mailer: iPhone Mail (10B329) 12 | 13 | The one of us with a bike pump is far ahead, but a man stopped to help us an= 14 | d gave us his pump. 15 | 16 | -------------------------------------------------------------------------------- /django_mailbox/transports/__init__.py: -------------------------------------------------------------------------------- 1 | # all imports below are only used by external modules 2 | # flake8: noqa 3 | from django_mailbox.transports.imap import ImapTransport 4 | from django_mailbox.transports.pop3 import Pop3Transport 5 | from django_mailbox.transports.maildir import MaildirTransport 6 | from django_mailbox.transports.mbox import MboxTransport 7 | from django_mailbox.transports.babyl import BabylTransport 8 | from django_mailbox.transports.mh import MHTransport 9 | from django_mailbox.transports.mmdf import MMDFTransport 10 | from django_mailbox.transports.gmail import GmailImapTransport 11 | from django_mailbox.transports.office365 import Office365Transport 12 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0006_mailbox_last_polling.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.8 on 2016-08-15 22:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_mailbox', '0005_auto_20160523_2240'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='mailbox', 15 | name='last_polling', 16 | field=models.DateTimeField(blank=True, help_text='The time of last successful polling for messages.It is blank for new mailboxes and is not set for mailboxes that only receive messages via a pipe.', null=True, verbose_name='Last polling'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_mailbox/transports/generic.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from .base import EmailTransport 4 | 5 | 6 | class GenericFileMailbox(EmailTransport): 7 | _variant = None 8 | _path = None 9 | 10 | def __init__(self, path): 11 | super().__init__() 12 | self._path = path 13 | 14 | def get_instance(self): 15 | return self._variant(self._path) 16 | 17 | def get_message(self, condition=None): 18 | repository = self.get_instance() 19 | repository.lock() 20 | for key, message in repository.items(): 21 | if condition and not condition(message): 22 | continue 23 | repository.remove(key) 24 | yield message 25 | repository.flush() 26 | repository.unlock() 27 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0004_bytestring_to_unicode.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('django_mailbox', '0003_auto_20150409_0316'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='message', 13 | name='eml', 14 | field=models.FileField(verbose_name='Raw message contents', upload_to='messages', null=True, help_text='Original full content of message'), 15 | ), 16 | migrations.AlterField( 17 | model_name='messageattachment', 18 | name='document', 19 | field=models.FileField(verbose_name='Document', upload_to='mailbox_attachments/%Y/%m/%d/'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /docs/topics/signal.rst: -------------------------------------------------------------------------------- 1 | 2 | Subscribing to the incoming mail signal 3 | ======================================= 4 | 5 | To subscribe to the incoming mail signal, following this lead:: 6 | 7 | from django_mailbox.signals import message_received 8 | from django.dispatch import receiver 9 | 10 | @receiver(message_received) 11 | def dance_jig(sender, message, **args): 12 | print "I just recieved a message titled %s from a mailbox named %s" % (message.subject, message.mailbox.name, ) 13 | 14 | .. warning:: 15 | 16 | `As with all django signals `_, 17 | this should be loaded either in an app's ``models.py`` 18 | or somewhere else loaded early on. 19 | If you do not load it early enough, the signal may be fired before your 20 | signal handler's registration is processed! 21 | 22 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0007_auto_20180421_0026.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.7 on 2018-04-21 00:26 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_mailbox', '0006_mailbox_last_polling'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='mailbox', 15 | options={'verbose_name': 'Mailbox', 'verbose_name_plural': 'Mailboxes'}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='message', 19 | options={'verbose_name': 'E-mail message', 'verbose_name_plural': 'E-mail messages'}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name='messageattachment', 23 | options={'verbose_name': 'Message attachment', 'verbose_name_plural': 'Message attachments'}, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | tests: 15 | name: Python ${{ matrix.python-version }} 16 | runs-on: ubuntu-22.04 17 | 18 | strategy: 19 | matrix: 20 | python-version: 21 | - '3.8' 22 | - '3.9' 23 | - '3.10' 24 | - '3.11' 25 | - '3.12' 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip setuptools wheel 37 | python -m pip install --upgrade 'tox>=4.0.0rc3' 38 | 39 | - name: Run tox targets for ${{ matrix.python-version }} 40 | run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 41 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0008_auto_20190219_1553.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-02-19 14:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_mailbox', '0007_auto_20180421_0026'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='mailbox', 15 | name='active', 16 | field=models.BooleanField(blank=True, default=True, help_text='Check this e-mail inbox for new e-mail messages during polling cycles. This checkbox does not have an effect upon whether mail is collected here when this mailbox receives mail from a pipe, and does not affect whether e-mail messages can be dispatched from this mailbox. ', verbose_name='Active'), 17 | ), 18 | migrations.AlterField( 19 | model_name='message', 20 | name='outgoing', 21 | field=models.BooleanField(blank=True, default=False, verbose_name='Outgoing'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Adam Coddington 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | # sort by django version, next by python version 3 | envlist= 4 | flake8 5 | py{310,311,312}-django50 6 | py{38,39,310,311}-django42 7 | py{38,39,310,311}-django41 8 | py{38,39,310}-django40 9 | py{38,39,310}-django32 10 | 11 | [gh-actions] 12 | python = 13 | 3.8: py38 14 | 3.9: py39 15 | 3.10: py310 16 | 3.11: py311 17 | 3.12: py312 18 | 19 | [testenv] 20 | passenv= 21 | EMAIL_IMAP_SERVER 22 | EMAIL_ACCOUNT 23 | EMAIL_PASSWORD 24 | EMAIL_SMTP_SERVER 25 | deps= 26 | django50: Django==5.0,<5.1 27 | django42: Django>=4.2,<5.0 28 | django41: Django>=4.1,<4.2 29 | django40: Django>=4.0,<4.1 30 | django32: Django>=3.2,<4.0 31 | -r{toxinidir}/test_requirements.txt 32 | sitepackages=False 33 | commands= 34 | python {toxinidir}/manage.py makemigrations --check --dry-run 35 | python -Wd manage.py test -v2 {posargs} 36 | 37 | [testenv:docs] 38 | deps= 39 | sphinx 40 | -r{toxinidir}/rtd_requirements.txt 41 | . 42 | commands=make html clean SPHINXOPTS="-W --keep-going" 43 | changedir={toxinidir}/docs 44 | allowlist_externals=make 45 | 46 | [testenv:flake8] 47 | deps=flake8 48 | commands=flake8 django_mailbox 49 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/multipart_text.eml: -------------------------------------------------------------------------------- 1 | Return-path: 2 | Delivery-date: Thu, 17 Jan 2013 19:26:25 +0000 3 | Received: by mail-vc0-f174.google.com with SMTP id n11so944457vch.33 4 | for ; Thu, 17 Jan 2013 11:26:20 -0800 (PST) 5 | X-Received: by 10.220.226.68 with SMTP id iv4mr332182vcb.31.1358450780039; 6 | Thu, 17 Jan 2013 11:26:20 -0800 (PST) 7 | MIME-Version: 1.0 8 | Received: by 10.221.0.211 with HTTP; Thu, 17 Jan 2013 11:25:59 -0800 (PST) 9 | X-Originating-IP: [67.131.102.78] 10 | From: Adam Coddington 11 | Date: Thu, 17 Jan 2013 11:25:59 -0800 12 | Message-ID: 13 | Subject: Test Multipart Text E-mail 14 | To: test@latestrevision.net 15 | Content-Type: multipart/alternative; boundary=14dae9cdc8cb30695204d380f8a2 16 | X-Gm-Message-State: ALoCoQm0/CGO1NxYZBBXjGN7748Ohv5P068bWQiELfbo+g19i80yvx1xi0th+N28ElGN1oS06SuR 17 | 18 | --14dae9cdc8cb30695204d380f8a2 19 | Content-Type: text/plain; charset=UTF-8 20 | 21 | Hello there! 22 | 23 | --14dae9cdc8cb30695204d380f8a2 24 | Content-Type: text/html; charset=UTF-8 25 | 26 |
Hello there!
27 | 28 | --14dae9cdc8cb30695204d380f8a2-- 29 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_text_attachment.eml: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.76.173.10 with HTTP; Tue, 7 Jul 2015 19:50:29 -0700 (PDT) 3 | X-Originating-IP: [71.63.222.120] 4 | Date: Tue, 7 Jul 2015 19:50:29 -0700 5 | Delivered-To: me@adamcoddington.net 6 | Message-ID: 7 | Subject: Test e-mail message 8 | From: Adam Coddington 9 | To: Adam Coddington 10 | Content-Type: multipart/mixed; boundary=089e0111d1089c45be051a5433d8 11 | 12 | --089e0111d1089c45be051a5433d8 13 | Content-Type: multipart/alternative; boundary=089e0111d1089c45b4051a5433d6 14 | 15 | --089e0111d1089c45b4051a5433d6 16 | Content-Type: text/plain; charset=UTF-8 17 | 18 | Has an attached text document, too! 19 | 20 | --089e0111d1089c45b4051a5433d6 21 | Content-Type: text/html; charset=UTF-8 22 | 23 |
Has an attached text document, too!
24 | 25 | --089e0111d1089c45b4051a5433d6-- 26 | --089e0111d1089c45be051a5433d8 27 | Content-Type: text/plain; charset=US-ASCII; name="attachment.txt" 28 | Content-Disposition: attachment; filename="attachment.txt" 29 | Content-Transfer-Encoding: base64 30 | X-Attachment-Id: f_ibu64edo0 31 | 32 | VGhpcyBpcyBhbiBhdHRhY2htZW50Lgo= 33 | --089e0111d1089c45be051a5433d8-- 34 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_attachment.eml: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.221.0.211 with HTTP; Sun, 20 Jan 2013 12:07:07 -0800 (PST) 3 | X-Originating-IP: [24.22.122.177] 4 | Date: Sun, 20 Jan 2013 12:07:07 -0800 5 | Delivered-To: test@adamcoddington.net 6 | Message-ID: 7 | Subject: Message With Attachment 8 | From: Adam Coddington 9 | To: Adam Coddington 10 | Content-Type: multipart/mixed; boundary=047d7b33dd729737fe04d3bde348 11 | 12 | --047d7b33dd729737fe04d3bde348 13 | Content-Type: text/plain; charset=UTF-8 14 | 15 | This message has an attachment. 16 | 17 | --047d7b33dd729737fe04d3bde348 18 | Content-Type: image/png; name="heart.png" 19 | Content-Disposition: attachment; filename="heart.png" 20 | X-Attachment-Id: f_hc6mair60 21 | Content-Transfer-Encoding: base64 22 | 23 | iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAFoTx1HAAAAzUlEQVQoz32RWxXDIBBEr4NIQEIl 24 | ICESkFAJkRAJSIgEpEQCEqYfu6QUkn7sCcyDGQiSACKSKCAkGwBJwhDwZQNMEiYAIBdQvk7rfaHf 25 | AO8NBJwCxTGhtFgTHVNaNaJeWFu44AXEHzKCktc7zZ0vss+bMoHSiM2b9mQoX1eZCgGqnWskY3gi 26 | XXAAxb8BqFiUgBNY7k49Tu/kV7UKPsefrjEOT9GmghYzrk9V03pjDGYKj3d0c06dKZkpTboRaD9o 27 | B+1m2m81d2Az948xzgdjLaFe95e83AAAAABJRU5ErkJggg== 28 | 29 | --047d7b33dd729737fe04d3bde348-- 30 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_defective_attachment_association.eml: -------------------------------------------------------------------------------- 1 | X-sender: 2 | X-receiver: 3 | From: "Senders Name" 4 | To: "Recipient Name" 5 | Message-ID: <5bec11c119194c14999e592feb46e3cf@sendersdomain.com> 6 | Date: Sat, 24 Sep 2005 15:06:49 -0400 7 | Subject: Sample Multi-Part 8 | MIME-Version: 1.0 9 | Content-Type: multipart/alternative; boundary="----=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D" 10 | 11 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 12 | Content-type: text/plain; charset=iso-8859-1 13 | Content-Transfer-Encoding: quoted-printable 14 | 15 | Sample Text Content 16 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 17 | Content-Type: multipart/alternative; boundary="----=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C" 18 | 19 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 20 | Content-Type: text/plain; charset=iso-8859-1 21 | Content-Transfer-Encoding: quoted-printable 22 | 23 | Multipart inside of multipart! 24 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 25 | X-Django-Mailbox-Interpolate-Attachment: 9013 26 | 27 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C-- 28 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D-- 29 | -------------------------------------------------------------------------------- /docs/topics/installation.rst: -------------------------------------------------------------------------------- 1 | 2 | Installation 3 | ============ 4 | 5 | 1. You can either install from pip:: 6 | 7 | pip install django-mailbox 8 | 9 | *or* checkout and install the source from the `github repository `_:: 10 | 11 | git clone https://github.com/coddingtonbear/django-mailbox.git 12 | cd django-mailbox 13 | python setup.py install 14 | 15 | 2. After you have installed the package, 16 | add ``django_mailbox`` to the ``INSTALLED_APPS`` setting in 17 | your project's ``settings.py`` file. 18 | 19 | 3. From your project folder, run ``python manage.py migrate django_mailbox`` to 20 | create the required database tables. 21 | 22 | 4. Head to your project's Django Admin and create a mailbox to consume. 23 | 24 | 25 | .. note:: 26 | 27 | Once you have entered a mailbox to consume, you can easily verify that you 28 | have properly configured your mailbox by either: 29 | 30 | * From the Django Admin, using the 'Get New Mail' action from the action 31 | dropdown on the Mailbox changelist 32 | (http://yourproject.com/admin/django_mailbox/mailbox/). 33 | * *Or* from a shell opened to your project's directory, using the 34 | ``getmail`` management command by running:: 35 | 36 | python manage.py getmail 37 | 38 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_not_decoded_attachment_header.eml: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Received: by 10.221.0.211 with HTTP; Sun, 20 Jan 2013 12:07:07 -0800 (PST) 3 | X-Originating-IP: [24.22.122.177] 4 | Date: Sun, 20 Jan 2013 12:07:07 -0800 5 | Delivered-To: test@adamcoddington.net 6 | Message-ID: 7 | Subject: Message With Attachment 8 | From: Adam Coddington 9 | To: Adam Coddington 10 | Content-Type: multipart/mixed; boundary=047d7b33dd729737fe04d3bde348 11 | 12 | --047d7b33dd729737fe04d3bde348 13 | Content-Type: text/plain; charset=UTF-8 14 | 15 | This message has an attachment. 16 | 17 | --047d7b33dd729737fe04d3bde348 18 | Content-Type: image/png; name="ð̞oβ̞le.png" 19 | Content-Disposition: attachment; filename="ð̞oβ̞le.png" 20 | X-Attachment-Id: f_hc6mair60 21 | Content-Transfer-Encoding: base64 22 | 23 | iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAFoTx1HAAAAzUlEQVQoz32RWxXDIBBEr4NIQEIl 24 | ICESkFAJkRAJSIgEpEQCEqYfu6QUkn7sCcyDGQiSACKSKCAkGwBJwhDwZQNMEiYAIBdQvk7rfaHf 25 | AO8NBJwCxTGhtFgTHVNaNaJeWFu44AXEHzKCktc7zZ0vss+bMoHSiM2b9mQoX1eZCgGqnWskY3gi 26 | XXAAxb8BqFiUgBNY7k49Tu/kV7UKPsefrjEOT9GmghYzrk9V03pjDGYKj3d0c06dKZkpTboRaD9o 27 | B+1m2m81d2Az948xzgdjLaFe95e83AAAAABJRU5ErkJggg== 28 | 29 | --047d7b33dd729737fe04d3bde348-- 30 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_defective_attachment_association_result.eml: -------------------------------------------------------------------------------- 1 | X-sender: 2 | X-receiver: 3 | From: "Senders Name" 4 | To: "Recipient Name" 5 | Message-ID: <5bec11c119194c14999e592feb46e3cf@sendersdomain.com> 6 | Date: Sat, 24 Sep 2005 15:06:49 -0400 7 | Subject: Sample Multi-Part 8 | MIME-Version: 1.0 9 | Content-Type: multipart/alternative; boundary="----=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D" 10 | 11 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 12 | Content-type: text/plain; charset=iso-8859-1 13 | Content-Transfer-Encoding: quoted-printable 14 | 15 | Sample Text Content 16 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 17 | Content-Type: multipart/alternative; boundary="----=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C" 18 | 19 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 20 | Content-Type: text/plain; charset=iso-8859-1 21 | Content-Transfer-Encoding: quoted-printable 22 | 23 | Multipart inside of multipart! 24 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 25 | X-Django-Mailbox-Altered-Message: Missing; Attachment 9013 not found 26 | 27 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C-- 28 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D-- 29 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_many_multiparts.eml: -------------------------------------------------------------------------------- 1 | X-sender: 2 | X-receiver: 3 | From: "Senders Name" 4 | To: "Recipient Name" 5 | Message-ID: <5bec11c119194c14999e592feb46e3cf@sendersdomain.com> 6 | Date: Sat, 24 Sep 2005 15:06:49 -0400 7 | Subject: Sample Multi-Part 8 | MIME-Version: 1.0 9 | Content-Type: multipart/alternative; boundary="----=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D" 10 | 11 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 12 | Content-type: text/plain; charset=iso-8859-1 13 | Content-Transfer-Encoding: quoted-printable 14 | 15 | Sample Text Content 16 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 17 | Content-Type: multipart/alternative; boundary="----=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C" 18 | 19 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 20 | Content-Type: text/plain; charset=iso-8859-1 21 | Content-Transfer-Encoding: quoted-printable 22 | 23 | Multipart inside of multipart! 24 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 25 | Content-type: text/html; charset=iso-8859-1 26 | Content-Transfer-Encoding: quoted-printable 27 | 28 | 29 |

Hello!

30 | 31 | 32 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C-- 33 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D-- 34 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_many_multiparts_stripped_html.eml: -------------------------------------------------------------------------------- 1 | X-sender: 2 | X-receiver: 3 | From: "Senders Name" 4 | To: "Recipient Name" 5 | Message-ID: <5bec11c119194c14999e592feb46e3cf@sendersdomain.com> 6 | Date: Sat, 24 Sep 2005 15:06:49 -0400 7 | Subject: Sample Multi-Part 8 | MIME-Version: 1.0 9 | Content-Type: multipart/alternative; boundary="----=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D" 10 | 11 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 12 | Content-type: text/plain; charset=iso-8859-1 13 | Content-Transfer-Encoding: quoted-printable 14 | 15 | Sample Text Content 16 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D 17 | Content-Type: multipart/alternative; boundary="----=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C" 18 | 19 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 20 | Content-Type: text/plain; charset=iso-8859-1 21 | Content-Transfer-Encoding: quoted-printable 22 | 23 | Multipart inside of multipart! 24 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C 25 | Content-type: text/html; charset=iso-8859-1 26 | X-Django-Mailbox-Altered-Message: Stripped; Content type text/html not allowed 27 | 28 | ------=_OtherPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB011C-- 29 | ------=_NextPart_DC7E1BB5_1105_4DB3_BAE3_2A6208EB099D-- 30 | -------------------------------------------------------------------------------- /readme.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/coddingtonbear/django-mailbox/actions/workflows/main.yml/badge.svg 2 | :target: https://github.com/coddingtonbear/django-mailbox/actions/workflows/main.yml 3 | 4 | .. image:: https://badge.fury.io/py/django-mailbox.png 5 | :target: https://pypi.org/project/django-mailbox/ 6 | 7 | 8 | Easily ingest messages from POP3, IMAP, Office365 API or local mailboxes into your Django application. 9 | 10 | This app allows you to either ingest e-mail content from common e-mail services (as long as the service provides POP3 or IMAP support), 11 | or directly receive e-mail messages from ``stdin`` (for locally processing messages from Postfix or Exim4). 12 | 13 | These ingested messages will be stored in the database in Django models and you can process their content at will, 14 | or -- if you're in a hurry -- by using a signal receiver. 15 | 16 | - Documentation for django-mailbox is available on 17 | `ReadTheDocs `_. 18 | - Please post issues on 19 | `Github `_. 20 | - Test status available on 21 | `Github-Actions `_. 22 | 23 | 24 | .. image:: https://badges.gitter.im/Join%20Chat.svg 25 | :alt: Join the chat at https://gitter.im/coddingtonbear/django-mailbox 26 | :target: https://gitter.im/coddingtonbear/django-mailbox?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge -------------------------------------------------------------------------------- /django_mailbox/transports/pop3.py: -------------------------------------------------------------------------------- 1 | from poplib import POP3, POP3_SSL 2 | 3 | from .base import EmailTransport, MessageParseError 4 | 5 | 6 | class Pop3Transport(EmailTransport): 7 | def __init__(self, hostname, port=None, ssl=False): 8 | self.hostname = hostname 9 | self.port = port 10 | if ssl: 11 | self.transport = POP3_SSL 12 | if not self.port: 13 | self.port = 995 14 | else: 15 | self.transport = POP3 16 | if not self.port: 17 | self.port = 110 18 | 19 | def connect(self, username, password): 20 | self.server = self.transport(self.hostname, self.port) 21 | self.server.user(username) 22 | self.server.pass_(password) 23 | 24 | def get_message_body(self, message_lines): 25 | return bytes('\r\n', 'ascii').join(message_lines) 26 | 27 | def get_message(self, condition=None): 28 | message_count = len(self.server.list()[1]) 29 | for i in range(message_count): 30 | try: 31 | msg_contents = self.get_message_body( 32 | self.server.retr(i + 1)[1] 33 | ) 34 | message = self.get_email_from_bytes(msg_contents) 35 | 36 | if condition and not condition(message): 37 | continue 38 | 39 | yield message 40 | except MessageParseError: 41 | continue 42 | self.server.dele(i + 1) 43 | self.server.quit() 44 | return 45 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_mailbox.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.test import TestCase 4 | 5 | from django_mailbox.models import Mailbox 6 | 7 | 8 | __all__ = ['TestMailbox'] 9 | 10 | 11 | class TestMailbox(TestCase): 12 | def test_protocol_info(self): 13 | mailbox = Mailbox() 14 | mailbox.uri = 'alpha://test.com' 15 | 16 | expected_protocol = 'alpha' 17 | actual_protocol = mailbox._protocol_info.scheme 18 | 19 | self.assertEqual( 20 | expected_protocol, 21 | actual_protocol, 22 | ) 23 | 24 | def test_last_polling_field_exists(self): 25 | mailbox = Mailbox() 26 | self.assertTrue(hasattr(mailbox, 'last_polling')) 27 | 28 | def test_get_new_mail_update_last_polling(self): 29 | mailbox = Mailbox.objects.create(uri="mbox://" + os.path.join( 30 | os.path.dirname(__file__), 31 | 'messages', 32 | 'generic_message.eml', 33 | )) 34 | self.assertEqual(mailbox.last_polling, None) 35 | list(mailbox.get_new_mail()) 36 | self.assertNotEqual(mailbox.last_polling, None) 37 | 38 | def test_queryset_get_new_mail(self): 39 | mailbox = Mailbox.objects.create(uri="mbox://" + os.path.join( 40 | os.path.dirname(__file__), 41 | 'messages', 42 | 'generic_message.eml', 43 | )) 44 | Mailbox.objects.filter(pk=mailbox.pk).get_new_mail() 45 | mailbox.refresh_from_db() 46 | self.assertNotEqual(mailbox.last_polling, None) 47 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-mailbox documentation master file, created by 2 | sphinx-quickstart on Tue Jan 22 20:29:12 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django-mailbox 7 | ============== 8 | 9 | .. image:: https://github.com/coddingtonbear/django-mailbox/actions/workflows/main.yml/badge.svg 10 | :target: https://github.com/coddingtonbear/django-mailbox/actions/workflows/main.yml 11 | 12 | How many times have you had to consume some sort of POP3, IMAP, or local mailbox for incoming content, 13 | or had to otherwise construct an application driven by e-mail? 14 | One too many times, I'm sure. 15 | 16 | This small Django application will allow you to specify mailboxes that you would like consumed for incoming content; 17 | the e-mail will be stored, and you can process it at will (or, if you're in a hurry, by subscribing to a signal). 18 | 19 | The Django-mailbox retrieves the e-mail messages by eg. IMAP, POP and then erases them to not download again the next time. Django-mailbox is not a typical mail program, and is a development library that makes it easy to process email messages in Django. A mailbox in that case plays the role of a message queue that needs to be processed. Messages processed from the queue are removed from the queue. 20 | 21 | Contents: 22 | 23 | .. toctree:: 24 | :maxdepth: 3 25 | 26 | topics/installation 27 | topics/mailbox_types 28 | topics/polling 29 | topics/signal 30 | topics/development 31 | topics/appendix 32 | 33 | 34 | Indices and tables 35 | ================== 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | 41 | -------------------------------------------------------------------------------- /django_mailbox/management/commands/processincomingmessage.py: -------------------------------------------------------------------------------- 1 | import email 2 | import logging 3 | import sys 4 | try: 5 | from email import utils 6 | except ImportError: 7 | import rfc822 as utils 8 | 9 | from django.core.management.base import BaseCommand 10 | 11 | from django_mailbox.models import Mailbox 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | logging.basicConfig(level=logging.INFO) 16 | 17 | 18 | class Command(BaseCommand): 19 | args = "<[Mailbox Name (optional)]>" 20 | help = "Receive incoming mail via stdin" 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument( 24 | 'mailbox_name', 25 | nargs='?', 26 | help="The name of the mailbox that will receive the message" 27 | ) 28 | 29 | def handle(self, mailbox_name=None, *args, **options): 30 | message = email.message_from_string(sys.stdin.read()) 31 | if message: 32 | if mailbox_name: 33 | mailbox = self.get_mailbox_by_name(mailbox_name) 34 | else: 35 | mailbox = self.get_mailbox_for_message(message) 36 | mailbox.process_incoming_message(message) 37 | logger.info( 38 | "Message received from %s", 39 | message['from'] 40 | ) 41 | else: 42 | logger.warning("Message not processable.") 43 | 44 | def get_mailbox_by_name(self, name): 45 | mailbox, created = Mailbox.objects.get_or_create( 46 | name=name, 47 | ) 48 | return mailbox 49 | 50 | def get_mailbox_for_message(self, message): 51 | email_address = utils.parseaddr(message['to'])[1][0:255] 52 | return self.get_mailbox_by_name(email_address) 53 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_processincomingmessage.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.management import call_command, CommandError 4 | from django.test import TestCase 5 | 6 | 7 | class CommandsTestCase(TestCase): 8 | def test_processincomingmessage_no_args(self): 9 | """Check that processincomingmessage works with no args""" 10 | 11 | mailbox_name = None 12 | # Mock handle so that the test doesn't hang waiting for input. Note that we are only testing 13 | # the argument parsing here -- functionality should be tested elsewhere 14 | with mock.patch('django_mailbox.management.commands.processincomingmessage.Command.handle') as handle: 15 | # Don't care about the return value 16 | handle.return_value = None 17 | 18 | call_command('processincomingmessage') 19 | args, kwargs = handle.call_args 20 | 21 | # Make sure that we called with the right arguments 22 | self.assertEqual(kwargs['mailbox_name'], mailbox_name) 23 | 24 | def test_processincomingmessage_with_arg(self): 25 | """Check that processincomingmessage works with mailbox_name given""" 26 | 27 | mailbox_name = 'foo_mailbox' 28 | 29 | with mock.patch('django_mailbox.management.commands.processincomingmessage.Command.handle') as handle: 30 | handle.return_value = None 31 | 32 | call_command('processincomingmessage', mailbox_name) 33 | args, kwargs = handle.call_args 34 | 35 | self.assertEqual(kwargs['mailbox_name'], mailbox_name) 36 | 37 | def test_processincomingmessage_too_many_args(self): 38 | """Check that processincomingmessage raises an error if too many args""" 39 | 40 | with self.assertRaises(CommandError): 41 | call_command('processincomingmessage', 'foo_mailbox', 'invalid_arg') 42 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/email_issue_82.eml: -------------------------------------------------------------------------------- 1 | From nobody Wed Mar 30 12:15:10 2016 2 | Delivered-To: info@test.org 3 | X-Orig-To: info@test.org 4 | MIME-Version: 1.0 5 | Content-Type: multipart/mixed; boundary="----_=_NextPart_001_01D18A74.C17A9D2D" 6 | Subject: character issue. 7 | Date: Wed, 30 Mar 2016 08:10:23 -0300 8 | Message-ID: <2A4988FCD265FE438FD0848B3ED91ABA2C0AAF3E@issue> 9 | From: "Me" 10 | To: 11 | 12 | This is a multi-part message in MIME format. 13 | 14 | ------_=_NextPart_001_01D18A74.C17A9D2D 15 | Content-Type: multipart/alternative; 16 | boundary="----_=_NextPart_002_01D18A74.C17A9D2D" 17 | 18 | 19 | ------_=_NextPart_002_01D18A74.C17A9D2D 20 | Content-Type: text/plain; 21 | charset="utf-8" 22 | Content-Transfer-Encoding: base64 23 | 24 | 25 | QXRlbmNpw7NuIGFsdXN0b21lciBTZXJ2aWNlIEYW9ubGluZQ0KCVIA0KCURJ 26 | aWxlZ2lhZGFzIChwcm90ZWdpZGFzIHBvciBzaWdpbG8gcHJvZmlzc2lvbmFs 27 | IGNvbnRyYXR1YWwsIHBvciBsZWkgZGUKcHJvcHJpZWRhZGUgaW50ZWxlY3R1 28 | YWwvaW5kdXN0cmlhbCBvdSBjdWphIGRpdnVsZ2Hn428gc2VqYSBwcm9pYmlk 29 | YSBwb3Igc2V1IHByb3ByaWV04XJpbykgcXVlIHPjbyBkZSBpbnRlcmVzc2Ug 30 | ZXhjbHVzaXZvIGRlIHNldShzKSByZW1ldGVudGUocykgZQpkZXN0aW5hdOFy 31 | aW8ocykuIENhc28gbuNvIGVzdGVqYSBlbnZvbHZpZG8gbm8gcHJvY2Vzc28g 32 | ZG8gcXVhbCB0cmF0YSBlc3RhIG1lbnNhZ2VtIGUvb3UgcmVjZWJldS1hIHBv 33 | ciBlbmdhbm8sIHBvciBmYXZvciwgZXhjbHVhLWEgZGEgc3VhIGNhaXhhIHBv 34 | c3RhbCBlCmNvbXVuaXF1ZSBvIG9jb3JyaWRvIGFvIHNldSBwcm9wcmlldOFy 35 | aW8uIE8gdXNvLCByZXZlbGHn428sIGRpc3RyaWJ1aefjbyxpbXByZXNz428g 36 | b3UgY/NwaWEgZGUgdG9kYSBvdSBhbGd1bWEgcGFydGUgZGFzIGluZm9ybWHn 37 | 9WVzLCBzZW0gcHLpdmlhCmF1dG9yaXph5+NvLCBlc3ThIHN1amVpdG8g4HMg 38 | cGVuYWxpZGFkZXMgY2Fi7XZlaXMuIEFzIGluZm9ybWHn9WVzIGFxdWkgY29u 39 | dGlkYXMgbuNvIG5lY2Vzc2FyaWFtZW50ZSByZWZsZXRlbSBhIG9waW5p428g 40 | ZGEgTUVUQUdBTCBJTkQuIENPTS4gTFREQS4sCmZpY2FuZG8gbyByZW1ldGVu 41 | dGUgaW50ZWlyYW1lbnRlIHJlc3BvbnPhdmVsIHBlbG8gc2V1IGNvbnRl+mRv 42 | LiAKCg== 43 | 44 | ------_=_NextPart_002_01D18A74.C17A9D2D-- 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from django_mailbox import __version__ as version_string 4 | 5 | 6 | tests_require = [ 7 | 'django', 8 | 'mock', 9 | 'unittest2', 10 | ] 11 | 12 | gmail_oauth2_require = [ 13 | 'python-social-auth', 14 | ] 15 | 16 | office365_oauth2_require = [ 17 | 'O365', 18 | ] 19 | 20 | setup( 21 | name='django-mailbox', 22 | version=version_string, 23 | url='http://github.com/coddingtonbear/django-mailbox/', 24 | description=( 25 | 'Import mail from POP3, IMAP, local mailboxes or directly from ' 26 | 'Postfix or Exim4 into your Django application automatically.' 27 | ), 28 | license='MIT', 29 | author='Adam Coddington', 30 | author_email='me@adamcoddington.net', 31 | extras_require={ 32 | 'gmail-oauth2': gmail_oauth2_require, 33 | 'office365-oauth2': office365_oauth2_require 34 | }, 35 | python_requires=">=3.8", 36 | classifiers=[ 37 | 'Framework :: Django', 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: OS Independent', 41 | 'Framework :: Django', 42 | 'Framework :: Django :: 3.2', 43 | 'Framework :: Django :: 4.0', 44 | 'Framework :: Django :: 4.1', 45 | 'Framework :: Django :: 4.2', 46 | 'Framework :: Django :: 5.0', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.8', 50 | 'Programming Language :: Python :: 3.9', 51 | 'Programming Language :: Python :: 3.10', 52 | 'Programming Language :: Python :: 3.11', 53 | 'Programming Language :: Python :: 3.12', 54 | 'Topic :: Communications :: Email', 55 | 'Topic :: Communications :: Email :: Post-Office', 56 | 'Topic :: Communications :: Email :: Post-Office :: IMAP', 57 | 'Topic :: Communications :: Email :: Post-Office :: POP3', 58 | 'Topic :: Communications :: Email :: Email Clients (MUA)', 59 | ], 60 | packages=find_packages(), 61 | include_package_data=True, 62 | ) 63 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_rfc822_attachment.eml: -------------------------------------------------------------------------------- 1 | From: Adam Coddington 2 | To: Adam Coddington 3 | Subject: Email with RFC 822 attachment 4 | Date: Fri, 15 May 2020 11:15:00 +0000 5 | Message-ID: 6 | Content-Type: multipart/mixed; 7 | boundary="_004_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_" 8 | Return-Path: test@adamcoddington.net 9 | MIME-Version: 1.0 10 | 11 | --_004_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_ 12 | Content-Type: multipart/alternative; 13 | boundary="_000_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_" 14 | 15 | --_000_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_ 16 | Content-Type: text/plain; charset="iso-8859-1" 17 | Content-Transfer-Encoding: quoted-printable 18 | 19 | Dear Sir or Madam, 20 | 21 | I am sending you an email message as an attachment. 22 | 23 | 24 | --_000_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_ 25 | Content-Type: text/html; charset="iso-8859-1" 26 | Content-Transfer-Encoding: quoted-printable 27 | 28 | 29 | 30 | 32 | 33 | 34 |
35 |

Dear Sir or Madam,

36 |

I am sending you an email message as an attachment.

37 |
38 | 39 | 40 | 41 | --_000_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_-- 42 | 43 | --_004_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_ 44 | Content-Type: message/rfc822 45 | Content-Disposition: attachment; 46 | creation-date="Fri, 15 May 2020 11:10:00 GMT"; 47 | modification-date="Fri, 15 May 2020 11:10:00 GMT" 48 | 49 | From: Adam Coddington 50 | To: Adam Coddington 51 | Subject: Attached email 52 | Date: Fri, 15 May 2020 09:00:00 +0000 53 | Message-ID: 54 | Content-Type: text/plain; charset="iso-8859-1" 55 | Content-Transfer-Encoding: quoted-printable 56 | MIME-Version: 1.0 57 | 58 | Hello! 59 | 60 | This is the attached email. 61 | 62 | --_004_ce1f843c1a2d4de7a0c7c78c84a50f8dadamcoddington.net_-- 63 | -------------------------------------------------------------------------------- /django_mailbox/transports/gmail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django_mailbox.transports.imap import ImapTransport 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class GmailImapTransport(ImapTransport): 10 | 11 | def connect(self, username, password): 12 | # Try to use oauth2 first. It's much safer 13 | try: 14 | self._connect_oauth(username) 15 | except (TypeError, ValueError) as e: 16 | logger.warning("Couldn't do oauth2 because %s" % e) 17 | self.server = self.transport(self.hostname, self.port) 18 | typ, msg = self.server.login(username, password) 19 | self.server.select() 20 | 21 | def _connect_oauth(self, username): 22 | # username should be an email address that has already been authorized 23 | # for gmail access 24 | try: 25 | from django_mailbox.google_utils import ( 26 | get_google_access_token, 27 | fetch_user_info, 28 | AccessTokenNotFound, 29 | ) 30 | except ImportError: 31 | raise ValueError( 32 | "Install python-social-auth to use oauth2 auth for gmail" 33 | ) 34 | 35 | access_token = None 36 | while access_token is None: 37 | try: 38 | # token refreshed here when expired 39 | google_email_address = fetch_user_info(username)['email'] 40 | # retrieve token from db 41 | access_token = get_google_access_token(username) 42 | except TypeError: 43 | # This means that the google process took too long 44 | # Trying again is the right thing to do 45 | pass 46 | except AccessTokenNotFound: 47 | raise ValueError( 48 | "No Token available in python-social-auth for %s" % ( 49 | username 50 | ) 51 | ) 52 | 53 | auth_string = 'user={}\1auth=Bearer {}\1\1'.format( 54 | google_email_address, 55 | access_token 56 | ) 57 | self.server = self.transport(self.hostname, self.port) 58 | self.server.authenticate('XOAUTH2', lambda x: auth_string) 59 | self.server.select() 60 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_utf8_attachment.eml: -------------------------------------------------------------------------------- 1 | Return-path: 2 | Envelope-to: porady@REDACTED 3 | Delivery-date: Thu, 30 Jun 2016 12:52:33 +0200 4 | Received: from s33.linuxpl.com ([88.198.46.46]) 5 | by s50.hekko.net.pl with esmtps (TLSv1.2:DHE-RSA-AES256-GCM-SHA384:256) 6 | (Exim 4.87) 7 | (envelope-from ) 8 | id 1bIZaC-0000Cx-W0 9 | for porady@REDACTED; Thu, 30 Jun 2016 12:52:33 +0200 10 | Received: from [127.0.0.1] (helo=s33.linuxpl.com) 11 | by s33.linuxpl.com with esmtpa (Exim 4.87) 12 | (envelope-from ) 13 | id 1bIZaB-0002KZ-Lq 14 | for porady@REDACTED; Thu, 30 Jun 2016 12:52:31 +0200 15 | MIME-Version: 1.0 16 | Content-Type: multipart/mixed; 17 | boundary="=_23825343e9a28de91c2eec59d6c7d4bf" 18 | Date: Thu, 30 Jun 2016 12:52:31 +0200 19 | From: Kontakt 20 | To: porady@REDACTED 21 | Subject: =?UTF-8?Q?Odpowied=C5=BA_od_burmistrza_na_wniosek_stowarzyszenia?= 22 | Organization: VE-TO 23 | Message-ID: <8cd4a49b11cf9a1867432bd5909a06bb@REDACTED> 24 | X-Sender: kontakt@REDACTED 25 | User-Agent: Roundcube Webmail/1.1.4 26 | X-HEKKO: 88.198.46.46: 27 | 28 | --=_23825343e9a28de91c2eec59d6c7d4bf 29 | Content-Transfer-Encoding: 8bit 30 | Content-Type: text/plain; charset=UTF-8; 31 | format=flowed 32 | 33 | REDACTED 34 | ------------------------------------------------------------------------------------ 35 | Niniejsza wiadomość elektroniczna [lub jej załączniki] może zawierać 36 | poufne lub chronione prawem informacje, które przeznaczone są wyłącznie 37 | dla adresata tej wiadomości. 38 | Należy dołożyć należytej staranności w celu nie ujawniania ich osobom 39 | trzecim. 40 | --=_23825343e9a28de91c2eec59d6c7d4bf 41 | Content-Transfer-Encoding: base64 42 | Content-Type: image/jpeg; 43 | name="=?UTF-8?Q?pi=C5=82kochwyty=2Ejpg?=" 44 | Content-Disposition: attachment; 45 | filename*=UTF-8''pi%C5%82kochwyty.jpg; 46 | size=465813 47 | 48 | UkVEQUNURUQK 49 | --=_23825343e9a28de91c2eec59d6c7d4bf 50 | Content-Transfer-Encoding: base64 51 | Content-Type: image/jpeg; 52 | name="=?UTF-8?Q?odpowied=C5=BA_Burmistrza=2Ejpg?=" 53 | Content-Disposition: attachment; 54 | filename*=UTF-8''odpowied%C5%BA%20Burmistrza.jpg; 55 | size=544348 56 | 57 | UkVEQUNURUQK 58 | --=_23825343e9a28de91c2eec59d6c7d4bf-- 59 | -------------------------------------------------------------------------------- /django_mailbox/transports/office365.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | 5 | from .base import EmailTransport, MessageParseError 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Office365Transport(EmailTransport): 11 | def __init__( 12 | self, hostname, username, archive='', folder=None 13 | ): 14 | self.integration_testing_subject = getattr( 15 | settings, 16 | 'DJANGO_MAILBOX_INTEGRATION_TESTING_SUBJECT', 17 | None 18 | ) 19 | self.hostname = hostname 20 | self.username = username 21 | self.archive = archive 22 | self.folder = folder 23 | 24 | def connect(self, client_id, client_secret, tenant_id): 25 | try: 26 | import O365 27 | except ImportError: 28 | raise ValueError( 29 | "Install o365 to use oauth2 auth for office365" 30 | ) 31 | 32 | credentials = (client_id, client_secret) 33 | 34 | self.account = O365.Account(credentials, auth_flow_type='credentials', tenant_id=tenant_id) 35 | self.account.authenticate() 36 | 37 | self.mailbox = self.account.mailbox(resource=self.username) 38 | self.mailbox_folder = self.mailbox.inbox_folder() 39 | if self.folder: 40 | self.mailbox_folder = self.mailbox.get_folder(folder_name=self.folder) 41 | 42 | def get_message(self, condition=None): 43 | archive_folder = None 44 | if self.archive: 45 | archive_folder = self.mailbox.get_folder(folder_name=self.archive) 46 | if not archive_folder: 47 | archive_folder = self.mailbox.create_child_folder(self.archive) 48 | 49 | for o365message in self.mailbox_folder.get_messages(order_by='receivedDateTime'): 50 | try: 51 | mime_content = o365message.get_mime_content() 52 | message = self.get_email_from_bytes(mime_content) 53 | 54 | if condition and not condition(message): 55 | continue 56 | 57 | yield message 58 | except MessageParseError: 59 | continue 60 | 61 | if self.archive and archive_folder: 62 | o365message.copy(archive_folder) 63 | 64 | o365message.delete() 65 | return 66 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_integration_imap.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from urllib import parse 4 | 5 | from django.core.mail import EmailMultiAlternatives 6 | 7 | from django_mailbox.models import Mailbox 8 | from django_mailbox.tests.base import EmailMessageTestCase 9 | 10 | 11 | __all__ = ['TestImap'] 12 | 13 | 14 | class TestImap(EmailMessageTestCase): 15 | def setUp(self): 16 | super().setUp() 17 | 18 | self.test_imap_server = ( 19 | os.environ.get('EMAIL_IMAP_SERVER') 20 | ) 21 | 22 | required_settings = [ 23 | self.test_imap_server, 24 | self.test_account, 25 | self.test_password, 26 | self.test_smtp_server, 27 | self.test_from_email, 28 | ] 29 | if not all(required_settings): 30 | self.skipTest( 31 | "Integration tests are not available without having " 32 | "the the following environment variables set: " 33 | "EMAIL_ACCOUNT, EMAIL_PASSWORD, EMAIL_SMTP_SERVER, " 34 | "EMAIL_IMAP_SERVER." 35 | ) 36 | 37 | self.mailbox = Mailbox.objects.create( 38 | name='Integration Test Imap', 39 | uri=self.get_connection_string() 40 | ) 41 | self.arbitrary_identifier = str(uuid.uuid4()) 42 | 43 | def get_connection_string(self): 44 | return "imap+ssl://{account}:{password}@{server}".format( 45 | account=parse.quote(self.test_account), 46 | password=parse.quote(self.test_password), 47 | server=self.test_imap_server, 48 | ) 49 | 50 | def test_get_imap_message(self): 51 | text_content = 'This is some content' 52 | msg = EmailMultiAlternatives( 53 | self.arbitrary_identifier, 54 | text_content, 55 | self.test_from_email, 56 | [ 57 | self.test_account, 58 | ] 59 | ) 60 | msg.send() 61 | 62 | messages = self._get_new_messages( 63 | self.mailbox, 64 | condition=lambda m: m['subject'] == self.arbitrary_identifier 65 | ) 66 | message = next(messages) 67 | 68 | self.assertEqual(message.subject, self.arbitrary_identifier) 69 | self.assertEqual(message.text, text_content) 70 | self.assertEqual(0, len(list(messages))) 71 | -------------------------------------------------------------------------------- /docs/topics/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Here we describe the development process overview. It's in F.A.Q. format to 5 | make it simple. 6 | 7 | 8 | How to file a ticket? 9 | --------------------- 10 | 11 | Just go to https://github.com/coddingtonbear/django-mailbox/issues and create new 12 | one. 13 | 14 | 15 | How do I get involved? 16 | ---------------------- 17 | 18 | It's simple! If you want to fix a bug, extend documentation or whatever you 19 | think is appropriate for the project and involves changes, just fork the 20 | project on github (https://github.com/coddingtonbear/django-mailbox), create a 21 | separate branch, hack on it, publish changes at your fork and create a pull 22 | request. 23 | 24 | 25 | Why my issue/pull request was closed? 26 | ------------------------------------- 27 | 28 | We usually put an explonation while we close issue or PR. It might be for 29 | various reasons, i.e. there were no reply for over a month after our last 30 | comment, there were no tests for the changes etc. 31 | 32 | 33 | How to do a new release? 34 | ---------------------------- 35 | 36 | To enroll a new release you should perform the following task: 37 | 38 | * Ensure the file ``CHANGELOG.rst`` reflects all important changes. 39 | * Ensure the file ``CHANGELOG.rst`` includes a new version identifier and current release date. 40 | * Execute ``bumpversion patch`` (or accordingly - see `Semantic Versioning 2.0 `_ ) to reflect changes in codebase. 41 | * Commit changes to the codebase, e.g. ``git commit -m "Release 1.4.8" -a``. 42 | * Tag a new release, e.g. ``git tag "1.4.8"``. 43 | * Push new tag to repo - ``git push origin --tags``. 44 | * Push a new release to PyPI - ``python setup.py sdist bdist_wheel upload``. 45 | 46 | How to add support for a new Django version? 47 | -------------------------------------------- 48 | 49 | Changes are only necessary for new minor or major Django versions. 50 | 51 | To add support for a new version perform the following task: 52 | 53 | * Ensure that ``.github/workflows/main.yml`` file reflects support for new Python release. 54 | * Ensure that ``tox.ini`` file reflects support for new Django release. 55 | * Verify in tox that the code is executed correctly on all versions of the Python interpreter. 56 | * Verify by pushing changes on a separate branch to see if the changes in Github Actions are correct. 57 | * Proceed to the standard procedure for a new package release (see `How to do a new release?`_ ). 58 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 4.9.0 5 | ----- 6 | 7 | * Add Django 3.2, 4.0, 4.1, 4.2, 5.0 support 8 | * Remove support for deprecated Django versions 9 | 10 | * Add Python 3.8, 3.9, 3.10, 3.11, 3.12 support 11 | * Remove support for deprecated Python versions 12 | 13 | 4.8.1 14 | ----- 15 | 16 | * Add missing migration 17 | 18 | 4.8.0 19 | ----- 20 | 21 | * ```django_mailbox.models.Mailbox.get_new_mail``` become generator 22 | * Added to ```django_mailbox.models.Message.mailbox``` in-memory caches of result 23 | * Added to command ```processincomingmessage``` argument to pass mailbox name 24 | * Improved tests, especially different Django & Python version 25 | 26 | 4.6.1 27 | ----- 28 | 29 | * Add Django 2.0 support 30 | 31 | - Add on_delete=models.CASCADE in models & migrations 32 | - Add Django 2.0 to tests matrices 33 | 34 | 4.4 35 | --- 36 | 37 | * Adds Django 1.8 support. 38 | 39 | 4.3 40 | --- 41 | 42 | * Adds functionality for allowing one to store the message body on-disk 43 | instead of in the database. 44 | 45 | 4.2 46 | --- 47 | 48 | * Adds 'envelope headers' to the Admin interface. 49 | 50 | 4.1 51 | --- 52 | 53 | * Adds Django 1.7 migrations support. 54 | 55 | 4.0 56 | --- 57 | 58 | * Adds ``html`` property returning the HTML contents of 59 | ``django_mailbox.models.Message`` instances. 60 | Thanks `@ariel17 `_! 61 | * Adds translation support. 62 | Thanks `@ariel17 `_! 63 | * **Drops support for Python 3.2**. The fact that only versions of 64 | Python newer than 3.2 allow unicode literals has convinced me 65 | that supporting Python 3.2 is probably more trouble than it's worth. 66 | Please let me know if you were using Python 3.2, and I've left you 67 | out in the cold; I'm willing to fix Python 3.2 support if it is 68 | actively used. 69 | 70 | 3.4 71 | --- 72 | 73 | * Adds ``gmail`` transport allowing one to use Google 74 | OAuth credentials for gathering messages from gmail. 75 | Thanks `@alexlovelltroy `_! 76 | 77 | 3.3 78 | --- 79 | 80 | * Adds functionality to ``imap`` transport allowing one to 81 | archive processed e-mails. 82 | Thanks `@yellowcap `_! 83 | 84 | 3.2 85 | --- 86 | 87 | * Fixes `#13 `_; 88 | Python 3 support had been broken for some time. Thanks for catching that, 89 | `@greendee `_! 90 | 91 | 3.1 92 | --- 93 | 94 | * Fixes a wide variety of unicode-related errors. 95 | 96 | 3.0 97 | --- 98 | 99 | * Restructures message storage such that non-text message attachments 100 | are stored as files, rather than in the database in their original 101 | (probably base64-encoded) blobs. 102 | * So many new tests. 103 | -------------------------------------------------------------------------------- /django_mailbox/management/commands/rebuildmessageattachments.py: -------------------------------------------------------------------------------- 1 | import email 2 | import hashlib 3 | import logging 4 | 5 | from django.core.management.base import BaseCommand 6 | 7 | from django_mailbox.models import MessageAttachment, Message 8 | 9 | logger = logging.getLogger(__name__) 10 | logging.basicConfig(level=logging.INFO) 11 | 12 | 13 | class Command(BaseCommand): 14 | """ Briefly, a bug existed in a migration that may have caused message 15 | attachments to become disassociated with their messages. This management 16 | command will read through existing message attachments and attempt to 17 | re-associate them with their original message. 18 | 19 | This isn't foolproof, I'm afraid. If an attachment exists twice, it will 20 | be associated only with the most recent e-mail message. That said, 21 | I'm quite sure that the bug in the migration is gone (and you'd have to 22 | have been quite unlucky to have ran the bad migration). 23 | 24 | """ 25 | def handle(self, *args, **options): 26 | attachment_hash_map = {} 27 | 28 | attachments_without_messages = MessageAttachment.objects.filter( 29 | message=None 30 | ).order_by( 31 | 'id' 32 | ) 33 | 34 | if attachments_without_messages.count() < 1: 35 | return 36 | 37 | for attachment in attachments_without_messages: 38 | md5 = hashlib.md5() 39 | for chunk in attachment.document.file.chunks(): 40 | md5.update(chunk) 41 | attachment_hash_map[md5.hexdigest()] = attachment.pk 42 | 43 | for message_record in Message.objects.all().order_by('id'): 44 | message = email.message_from_string(message_record.body) 45 | if message.is_multipart(): 46 | for part in message.walk(): 47 | if part.get_content_maintype() == 'multipart': 48 | continue 49 | if part.get('Content-Disposition') is None: 50 | continue 51 | md5 = hashlib.md5() 52 | md5.update(part.get_payload(decode=True)) 53 | digest = md5.hexdigest() 54 | if digest in attachment_hash_map: 55 | attachment = MessageAttachment.objects.get( 56 | pk=attachment_hash_map[digest] 57 | ) 58 | attachment.message = message_record 59 | attachment.save() 60 | logger.info( 61 | "Associated message %s with attachment %s (%s)", 62 | message_record.pk, 63 | attachment.pk, 64 | digest 65 | ) 66 | else: 67 | logger.info( 68 | "%s(%s) not found in currently-stored attachments", 69 | part.get_filename(), 70 | digest 71 | ) 72 | -------------------------------------------------------------------------------- /django_mailbox/admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Model configuration in application ``django_mailbox`` for administration 5 | console. 6 | """ 7 | 8 | import logging 9 | 10 | from django.conf import settings 11 | from django.contrib import admin 12 | from django.db.models import Count 13 | from django.utils.translation import gettext_lazy as _ 14 | 15 | from django_mailbox.models import MessageAttachment, Message, Mailbox 16 | from django_mailbox.signals import message_received 17 | from django_mailbox.utils import convert_header_to_unicode 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def get_new_mail(mailbox_admin, request, queryset): 24 | queryset.get_new_mail() 25 | 26 | 27 | get_new_mail.short_description = _('Get new mail') 28 | 29 | 30 | def resend_message_received_signal(message_admin, request, queryset): 31 | for message in queryset.all(): 32 | logger.debug('Resending \'message_received\' signal for %s' % message) 33 | message_received.send(sender=message_admin, message=message) 34 | 35 | 36 | resend_message_received_signal.short_description = ( 37 | _('Re-send message received signal') 38 | ) 39 | 40 | 41 | class MailboxAdmin(admin.ModelAdmin): 42 | list_display = ( 43 | 'name', 44 | 'uri', 45 | 'from_email', 46 | 'active', 47 | 'last_polling', 48 | ) 49 | readonly_fields = ['last_polling', ] 50 | actions = [get_new_mail] 51 | 52 | 53 | class MessageAttachmentAdmin(admin.ModelAdmin): 54 | raw_id_fields = ('message', ) 55 | list_display = ('message', 'document',) 56 | 57 | 58 | class MessageAttachmentInline(admin.TabularInline): 59 | model = MessageAttachment 60 | extra = 0 61 | 62 | 63 | class MessageAdmin(admin.ModelAdmin): 64 | def get_queryset(self, *args, **kwargs): 65 | return super().get_queryset(*args, **kwargs).annotate(num_attachments=Count('attachments')) 66 | 67 | def attachment_count(self, msg): 68 | return msg.num_attachments 69 | 70 | attachment_count.short_description = _('Attachment count') 71 | 72 | def subject(self, msg): 73 | return convert_header_to_unicode(msg.subject) 74 | 75 | def envelope_headers(self, msg): 76 | email = msg.get_email_object() 77 | return '\n'.join( 78 | [('{}: {}'.format(h, v)) for h, v in email.items()] 79 | ) 80 | 81 | inlines = [ 82 | MessageAttachmentInline, 83 | ] 84 | list_display = ( 85 | 'subject', 86 | 'processed', 87 | 'read', 88 | 'mailbox', 89 | 'outgoing', 90 | 'attachment_count', 91 | ) 92 | ordering = ['-processed'] 93 | list_filter = ( 94 | 'mailbox', 95 | 'outgoing', 96 | 'processed', 97 | 'read', 98 | ) 99 | exclude = ( 100 | 'body', 101 | ) 102 | raw_id_fields = ( 103 | 'in_reply_to', 104 | ) 105 | readonly_fields = ( 106 | 'envelope_headers', 107 | 'text', 108 | 'html', 109 | ) 110 | actions = [resend_message_received_signal] 111 | 112 | 113 | if getattr(settings, 'DJANGO_MAILBOX_ADMIN_ENABLED', True): 114 | admin.site.register(Message, MessageAdmin) 115 | admin.site.register(MessageAttachment, MessageAttachmentAdmin) 116 | admin.site.register(Mailbox, MailboxAdmin) 117 | -------------------------------------------------------------------------------- /docs/topics/appendix/settings.rst: -------------------------------------------------------------------------------- 1 | 2 | Settings 3 | ======== 4 | 5 | * ``DJANGO_MAILBOX_ADMIN_ENABLED`` 6 | 7 | * Default: ``True`` 8 | * Type: ``boolean`` 9 | * Controls whether mailboxes appear in the Django Admin. 10 | 11 | * ``DJANGO_MAILBOX_STRIP_UNALLOWED_MIMETYPES`` 12 | 13 | * Default: ``False`` 14 | * Type: ``boolean`` 15 | * Controls whether or not we remove mimetypes not specified in 16 | ``DJANGO_MAILBOX_PRESERVED_MIMETYPES`` from the message prior to storage. 17 | 18 | * ``DJANGO_MAILBOX_ALLOWED_MIMETYPES`` 19 | 20 | * Default ``['text/html', 'text/plain']`` 21 | * Type: ``list`` 22 | * Should ``DJANGO_MAILBOX_STRIP_UNALLOWED_MIMETYPES`` be ``True``, this is 23 | a list of mimetypes that will not be stripped from the message prior 24 | to processing attachments. 25 | Has no effect unless ``DJANGO_MAILBOX_STRIP_UNALLOWED_MIMETYPES`` 26 | is set to ``True``. 27 | 28 | * ``DJANGO_MAILBOX_TEXT_STORED_MIMETYPES`` 29 | 30 | * Default: ``['text/html', 'text/plain']`` 31 | * Type: ``list`` 32 | * A list of mimetypes that will remain stored in the text body of the 33 | message in the database. See :doc:`message-storage`. 34 | 35 | * ``DJANGO_MAILBOX_ALTERED_MESSAGE_HEADER`` 36 | 37 | * Default: ``X-Django-Mailbox-Altered-Message`` 38 | * Type: ``string`` 39 | * Header to add to a message payload part in the event that the message 40 | cannot be reproduced accurately. Possible values include: 41 | 42 | * ``Missing``: The message could not be reconstructed because the message 43 | payload component (stored outside this database record) could not be 44 | found. This will be followed by a semicolon (``;``) and a short, more 45 | detailed description of which record was not found. 46 | * ``Stripped`` The message could not be reconstructed because the message 47 | payload component was intentionally stripped from the message body prior 48 | to storage. This will be followed by a semicolon (``;``) and a short, 49 | more detailed description of why this payload component was stripped. 50 | 51 | * ``DJANGO_MAILBOX_ATTACHMENT_INTERPOLATION_HEADER`` 52 | 53 | * Default: ``X-Django-Mailbox-Interpolate-Attachment`` 54 | * Type: ``string`` 55 | * Header to add to the temporary 'dehydrated' message body in lieu of 56 | a non-text message payload component. The value of this header will be used 57 | to 'rehydrate' the message into a proper e-mail object in the event of 58 | a message instance's ``get_email_object`` method being called. Value of 59 | this field is the primary key of the ``django_mailbox.MessageAttachment`` 60 | instance currently storing this payload component's contents. 61 | 62 | * ``DJANGO_MAILBOX_ATTACHMENT_UPLOAD_TO`` 63 | 64 | * Default: ``mailbox_attachments/%Y/%m/%d/`` 65 | * Type: ``string`` 66 | * Attachments will be saved to this location. Specifies the ``upload_to`` setting 67 | for the attachment FileField. For more on FileFields and upload_to, see the 68 | `Django docs `__ 69 | 70 | * ``DJANGO_MAILBOX_MAX_MESSAGE_SIZE`` 71 | 72 | * Default: ``False`` 73 | * Type: ``integer`` 74 | * If this is set, it will be read as a number of 75 | bytes. Any messages above that size will not be 76 | downloaded. ``2000000`` is 2 Megabytes. 77 | 78 | * ``DJANGO_MAILBOX_STORE_ORIGINAL_MESSAGE`` 79 | 80 | * Default: ``False`` 81 | * Type: ``boolean`` 82 | * Controls whether or not we store original messages in ``eml`` field 83 | 84 | * ``DJANGO_MAILBOX_DEFAULT_CHARSET`` 85 | 86 | * Default: ``iso8859-1`` 87 | * Type: ``string`` 88 | * Controls which charset is used if the charset is not specified in the header. 89 | -------------------------------------------------------------------------------- /django_mailbox/google_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | import requests 5 | from social_django.models import UserSocialAuth 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class AccessTokenNotFound(Exception): 12 | pass 13 | 14 | 15 | class RefreshTokenNotFound(Exception): 16 | pass 17 | 18 | 19 | def get_google_consumer_key(): 20 | return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY 21 | 22 | 23 | def get_google_consumer_secret(): 24 | return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET 25 | 26 | 27 | def get_google_access_token(email): 28 | # TODO: This should be cacheable 29 | try: 30 | me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") 31 | return me.extra_data['access_token'] 32 | except (UserSocialAuth.DoesNotExist, KeyError): 33 | raise AccessTokenNotFound 34 | 35 | 36 | def update_google_extra_data(email, extra_data): 37 | try: 38 | me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") 39 | me.extra_data = extra_data 40 | me.save() 41 | except (UserSocialAuth.DoesNotExist, KeyError): 42 | raise AccessTokenNotFound 43 | 44 | 45 | def get_google_refresh_token(email): 46 | try: 47 | me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") 48 | return me.extra_data['refresh_token'] 49 | except (UserSocialAuth.DoesNotExist, KeyError): 50 | raise RefreshTokenNotFound 51 | 52 | 53 | def google_api_get(email, url): 54 | headers = dict( 55 | Authorization="Bearer %s" % get_google_access_token(email), 56 | ) 57 | r = requests.get(url, headers=headers) 58 | logger.info("I got a %s", r.status_code) 59 | if r.status_code == 401: 60 | # Go use the refresh token 61 | refresh_authorization(email) 62 | # Force use of the new token 63 | headers = dict( 64 | Authorization="Bearer %s" % get_google_access_token(email), 65 | ) 66 | r = requests.get(url, headers=headers) 67 | logger.info("I got a %s", r.status_code) 68 | if r.status_code == 200: 69 | try: 70 | return r.json() 71 | except ValueError: 72 | return r.text 73 | 74 | 75 | def google_api_post(email, url, post_data, authorized=True): 76 | # TODO: Make this a lot less ugly. especially the 401 handling 77 | headers = dict() 78 | if authorized is True: 79 | headers.update(dict( 80 | Authorization="Bearer %s" % get_google_access_token(email), 81 | )) 82 | r = requests.post(url, headers=headers, data=post_data) 83 | if r.status_code == 401: 84 | refresh_authorization(email) 85 | r = requests.post(url, headers=headers, data=post_data) 86 | if r.status_code == 200: 87 | try: 88 | return r.json() 89 | except ValueError: 90 | return r.text 91 | 92 | 93 | def refresh_authorization(email): 94 | refresh_token = get_google_refresh_token(email) 95 | post_data = dict( 96 | refresh_token=refresh_token, 97 | client_id=get_google_consumer_key(), 98 | client_secret=get_google_consumer_secret(), 99 | grant_type='refresh_token', 100 | ) 101 | results = google_api_post( 102 | email, 103 | "https://accounts.google.com/o/oauth2/token?access_type=offline", 104 | post_data, 105 | authorized=False) 106 | results.update({'refresh_token': refresh_token}) 107 | update_google_extra_data(email, results) 108 | 109 | 110 | def fetch_user_info(email): 111 | result = google_api_get( 112 | email, 113 | "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" 114 | ) 115 | return result 116 | -------------------------------------------------------------------------------- /django_mailbox/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ] 8 | 9 | operations = [ 10 | migrations.CreateModel( 11 | name='Mailbox', 12 | fields=[ 13 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 14 | ('name', models.CharField(max_length=255, verbose_name='Name')), 15 | ('uri', models.CharField(default=None, max_length=255, blank=True, help_text="Example: imap+ssl://myusername:mypassword@someserver

Internet transports include 'imap' and 'pop3'; common local file transports include 'maildir', 'mbox', and less commonly 'babyl', 'mh', and 'mmdf'.

Be sure to urlencode your username and password should they contain illegal characters (like @, :, etc).", null=True, verbose_name='URI')), 16 | ('from_email', models.CharField(default=None, max_length=255, blank=True, help_text="Example: MailBot <mailbot@yourdomain.com>
'From' header to set for outgoing email.

If you do not use this e-mail inbox for outgoing mail, this setting is unnecessary.
If you send e-mail without setting this, your 'From' header will'be set to match the setting `DEFAULT_FROM_EMAIL`.", null=True, verbose_name='From email')), 17 | ('active', models.BooleanField(default=True, help_text='Check this e-mail inbox for new e-mail messages during polling cycles. This checkbox does not have an effect upon whether mail is collected here when this mailbox receives mail from a pipe, and does not affect whether e-mail messages can be dispatched from this mailbox. ', verbose_name='Active')), 18 | ], 19 | options={ 20 | 'verbose_name_plural': 'Mailboxes', 21 | }, 22 | bases=(models.Model,), 23 | ), 24 | migrations.CreateModel( 25 | name='Message', 26 | fields=[ 27 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 28 | ('subject', models.CharField(max_length=255, verbose_name='Subject')), 29 | ('message_id', models.CharField(max_length=255, verbose_name='Message ID')), 30 | ('from_header', models.CharField(max_length=255, verbose_name='From header')), 31 | ('to_header', models.TextField(verbose_name='To header')), 32 | ('outgoing', models.BooleanField(default=False, verbose_name='Outgoing')), 33 | ('body', models.TextField(verbose_name='Body')), 34 | ('encoded', models.BooleanField(default=False, help_text='True if the e-mail body is Base64 encoded', verbose_name='Encoded')), 35 | ('processed', models.DateTimeField(auto_now_add=True, verbose_name='Processed')), 36 | ('read', models.DateTimeField(default=None, null=True, verbose_name='Read', blank=True)), 37 | ('in_reply_to', models.ForeignKey(related_name='replies', verbose_name='In reply to', blank=True, to='django_mailbox.Message', null=True, on_delete=models.CASCADE)), 38 | ('mailbox', models.ForeignKey(related_name='messages', verbose_name='Mailbox', to='django_mailbox.Mailbox', on_delete=models.CASCADE)), 39 | ], 40 | options={ 41 | }, 42 | bases=(models.Model,), 43 | ), 44 | migrations.CreateModel( 45 | name='MessageAttachment', 46 | fields=[ 47 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 48 | ('headers', models.TextField(null=True, verbose_name='Headers', blank=True)), 49 | ('document', models.FileField(upload_to=b'mailbox_attachments/%Y/%m/%d/', verbose_name='Document')), 50 | ('message', models.ForeignKey(related_name='attachments', verbose_name='Message', blank=True, to='django_mailbox.Message', null=True, on_delete=models.CASCADE)), 51 | ], 52 | options={ 53 | }, 54 | bases=(models.Model,), 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_utf8_char.eml: -------------------------------------------------------------------------------- 1 | Delivered-To: user1@example.com 2 | Received: by 10.76.18.165 with SMTP id x5csp6205oad; 3 | Sat, 5 Jan 2013 17:59:14 -0800 (PST) 4 | X-Received: by 10.66.80.202 with SMTP id t10mr166379994pax.81.1357437554302; 5 | Sat, 05 Jan 2013 17:59:14 -0800 (PST) 6 | Return-Path: 7 | Received: from p3plsmtp10-04.prod.phx3.secureserver.net (p3plsmtp10-04.prod.phx3.secureserver.net. [123.123.123.123]) 8 | by mx.google.com with ESMTP id u7si55229097paw.156.2013.01.05.17.59.12; 9 | Sat, 05 Jan 2013 17:59:14 -0800 (PST) 10 | Received-SPF: pass (google.com: domain of SRS0=JyPW=K7=yahoo.com=user2@bounce.secureserver.net designates 123.123.123.123 as permitted sender) client-ip=123.123.123.123; 11 | Authentication-Results: mx.google.com; spf=pass (google.com: domain of SRS0=JyPW=K7=yahoo.com=user2@bounce.secureserver.net designates 123.123.123.123 as permitted sender) smtp.mail=SRS0=JyPW=K7=yahoo.com=user2@bounce.secureserver.net; dkim=pass header.i=@yahoo.com 12 | Received: (qmail 339 invoked from network); 6 Jan 2013 01:59:12 -0000 13 | Delivered-To: user3@example.com 14 | Precedence: bulk 15 | Received: (qmail 336 invoked by uid 30297); 6 Jan 2013 01:59:12 -0000 16 | Received: from unknown (HELO p3pismtp01-063.prod.phx3.secureserver.net) ([10.6.12.182]) 17 | (envelope-sender ) 18 | by p3plsmtp10-04.prod.phx3.secureserver.net (qmail-1.03) with SMTP 19 | for ; 6 Jan 2013 01:59:12 -0000 20 | X-IronPort-Anti-Spam-Result: AmkQAGLY6FBiilvXbmdsb2JhbAAvEwOHXYYInRqSVhYODQkMBhYngh4BASMBIQEIBSUHCwoFEQMBAhQ6EhQKHAUdBIdjAQMPDJcyjnwkgnuDbwEjJwOGcwYKAotcagMIEHoFgykDiCyDJoFohn+BWYVkLkGEXYglgUkCAwQX 21 | Received: from nm27-vm2.bullet.mail.ne1.yahoo.com ([98.138.91.215]) 22 | by p3pismtp01-063.prod.phx3.secureserver.net with ESMTP; 05 Jan 2013 18:59:11 -0700 23 | Received: from [123.123.123.123] by nm27.bullet.mail.ne1.yahoo.com with NNFMP; 06 Jan 2013 01:59:08 -0000 24 | Received: from [123.123.123.123] by tm5.bullet.mail.ne1.yahoo.com with NNFMP; 06 Jan 2013 01:59:08 -0000 25 | Received: from [127.0.0.1] by smtp120-mob.biz.mail.ne1.yahoo.com with NNFMP; 06 Jan 2013 01:59:08 -0000 26 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=yahoo.com; s=s1024; t=1357437548; bh=j9eMknAL8YCreS+PbgbAzzAQMit5AJ87f7DXzFjDFRo=; h=X-Yahoo-Newman-Id:Message-ID:X-Yahoo-Newman-Property:X-YMail-OSG:X-Yahoo-SMTP:Received:Date:Subject:From:To:CC:MIME-Version:Content-Type:Content-Transfer-Encoding:X-Mailer; b=zYM501CTyiUtnTD1JFZlKIma5xV8Eu7m5mpgB/8gyZtBVEbCboDfJjqN4VNLUTC0w1AQO//dvW/Ir3ZbM6NVgvsT39hqx/RjY+CKHh/5VwVRaQgDiZznqrzVMSt/XsokA1gbPhtTFlpE5uiyzx39Zj6OwqFLHYTzAbWAMQ6IJCo= 27 | X-Yahoo-Newman-Id: 388586.81550.bm@smtp120-mob.biz.mail.ne1.yahoo.com 28 | Message-ID: <388586.81550.bm@smtp120-mob.biz.mail.ne1.yahoo.com> 29 | X-Yahoo-Newman-Property: ymail-3 30 | X-YMail-OSG: URwIIAQVM1kAga269ds2iV1suOTImektR4w6BYjFmP8vp0X 31 | IUkN.Y8FSBOvb.6jCOF.LoIp2HxYepgZj1wbmbSpntFhshBFQZa6Wp2eGduY 32 | exmK.gdLKnQoZdjyoCoxAZ.tA9.CL5fRijaLa7zv3XFWHs62FjtDCbrPoPrC 33 | FqvjQf41TR3aKNOYyALf7hHkHdA5RiU9vHelpU8jyTo8DSV1aucIGJqpSLda 34 | mFHkMzcl57HESZkRfGbp_iC0DhNdzRVGTfQSNcs.wSfrpSgzqM1VhkU5_JAJ 35 | qdHTyl4RT27ofNZu3ch6gdZeqrv54vDLLAdWZRwL0rzBGKKKV.tzWOB.0JZ3 36 | KRz_A75EcTDQlGKmb79MWbWZZydO_r2QgCfsFcyLGmoqcLE_yLpxdTC4KkRC 37 | AFDsB0fS_wT5z2z2s_jk8aLIbtJ60D7BfrUW2MdxuqBeRuG2uzkX6V5Ls0BQ 38 | 5OKDv7BEHipYhl6NnhrzW1SPcxWB3emTVgTMV6xLC5SsKvHwbAEWoMjbdHKh 39 | CgH9Y3_7tEccw4cOJhMuLAGTwbY4Ou0s.JmUbIrgTH6zTLkzcuFzYUBji7dO 40 | 3a3aujVZnMz9wAI0dgFfG4zZD6rb4MyF5qRBbuSDR8Y39FGyY1iAScA_RDsU 41 | QaA-- 42 | X-Yahoo-SMTP: HDH0Fu.swBDT3Lu6hwn51OaZQ.iA 43 | Received: from ap1.p9.sca.7sys.net (user2@123.123.123.123 with xymcookie) 44 | by smtp120-mob.biz.mail.ne1.yahoo.com with SMTP; 05 Jan 2013 17:59:08 -0800 PST 45 | Date: Sat, 05 Jan 2013 19:59:07 CST 46 | Subject: RE: Fwd: Stuff 47 | From: "Person" 48 | To: user3@example.com 49 | CC: 50 | MIME-Version: 1.0 51 | Content-Type: text/plain; charset="iso-8859-1" 52 | Content-Transfer-Encoding: 7bit 53 | X-Mailer: Seven Enterprise Gateway (v 2.0) 54 | 55 | 56 | 57 | This message contains funny UTF16 characters like this one: " " and this one "✿". 58 | -------------------------------------------------------------------------------- /docs/topics/polling.rst: -------------------------------------------------------------------------------- 1 | 2 | Getting incoming mail 3 | ===================== 4 | 5 | In your code 6 | ------------ 7 | 8 | Mailbox instances have a method named ``get_new_mail``; 9 | this method will gather new messages from the server. 10 | 11 | Using the Django Admin 12 | ---------------------- 13 | 14 | From the 'Mailboxes' page in the Django Admin, 15 | check the box next to each of the mailboxes you'd like to fetch e-mail from, 16 | select 'Get new mail' from the action selector at the top of the list 17 | of mailboxes, then click 'Go'. 18 | 19 | Using a cron job 20 | ---------------- 21 | 22 | You can easily consume incoming mail by running the management command named 23 | ``getmail`` (optionally with an argument of the name of the mailbox you'd like 24 | to get the mail for).:: 25 | 26 | python manage.py getmail 27 | 28 | 29 | .. _receiving-mail-from-exim4-or-postfix: 30 | 31 | Receiving mail directly from Exim4 or Postfix via a pipe 32 | -------------------------------------------------------- 33 | 34 | Django Mailbox's ``processincomingmessage`` management command accepts, via 35 | ``stdin``, incoming messages. 36 | You can configure Postfix or Exim4 to pipe incoming mail to this management 37 | command to import messages directly without polling. 38 | 39 | You need not configure mailbox settings when piping-in messages, 40 | mailbox entries will be automatically created matching the e-mail address to 41 | which incoming messages are sent, 42 | but if you would like to specify the mailbox name, 43 | you may provide a single argument to the ``processincmingmessage`` command 44 | specifying the name of the mailbox you would like it to use 45 | (and, if necessary, create). 46 | 47 | Receiving Mail from Exim4 48 | ......................... 49 | 50 | To configure Exim4 to receive incoming mail, 51 | start by adding a new router configuration to your Exim4 configuration like:: 52 | 53 | django_mailbox: 54 | debug_print = 'R: django_mailbox for $localpart@$domain' 55 | driver = accept 56 | transport = send_to_django_mailbox 57 | domains = mydomain.com 58 | local_parts = emailusernameone : emailusernametwo 59 | 60 | Make sure that the e-mail addresses you would like handled by Django Mailbox 61 | are not handled by another router; 62 | you may need to disable some existing routers. 63 | 64 | Change the contents of ``local_parts`` to match a colon-delimited list of 65 | usernames for which you would like to receive mail. 66 | For example, if one of the e-mail addresses targeted at this machine is 67 | ``jane@example.com``, 68 | the contents of ``local_parts`` would be, simply ``jane``. 69 | 70 | .. note:: 71 | 72 | If you would like messages addressed to *any* account *@mydomain.com* 73 | to be delivered to django_mailbox, simply omit the above ``local_parts`` 74 | setting. In the same vein, if you would like messages addressed to 75 | any domain or any local domains, you can omit the ``domains`` setting 76 | or set it to ``+local_domains`` respectively. 77 | 78 | Next, a new transport configuration to your Exim4 configuration:: 79 | 80 | send_to_django_mailbox: 81 | driver = pipe 82 | command = /path/to/your/environments/python /path/to/your/projects/manage.py processincomingmessage 83 | user = www-data 84 | group = www-data 85 | return_path_add 86 | delivery_date_add 87 | 88 | Like your router configuration, transport configuration should be altered to 89 | match your environment. 90 | First, modify the ``command`` setting such that it points at the proper 91 | python executable 92 | (if you're using a virtual environment, you'll want to direct that at the 93 | python executable in your virtual environment) 94 | and project ``manage.py`` script. 95 | Additionally, you'll need to set ``user`` and ``group`` such that 96 | they match a reasonable user and group 97 | (on Ubuntu, ``www-data`` suffices for both). 98 | 99 | Receiving mail from Postfix 100 | ........................... 101 | 102 | Although I have not personally tried using Postfix for this, 103 | Postfix is capable of delivering new mail to a script using ``pipe``. 104 | Please consult the 105 | `Postfix documentation for pipe here `_. 106 | You may want to consult the above Exim4 configuration for tips. 107 | 108 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_message_flattening.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from unittest import mock 4 | 5 | from django_mailbox import models, utils 6 | from django_mailbox.models import Message 7 | from django_mailbox.tests.base import EmailMessageTestCase 8 | 9 | 10 | __all__ = ['TestMessageFlattening'] 11 | 12 | 13 | class TestMessageFlattening(EmailMessageTestCase): 14 | def test_quopri_message_is_properly_rehydrated(self): 15 | incoming_email_object = self._get_email_object( 16 | 'message_with_many_multiparts.eml', 17 | ) 18 | # Note: this is identical to the above, but it appears that 19 | # while reading-in an e-mail message, we do alter it slightly 20 | expected_email_object = self._get_email_object( 21 | 'message_with_many_multiparts.eml', 22 | ) 23 | models.TEXT_STORED_MIMETYPES = ['text/plain'] 24 | 25 | msg = self.mailbox.process_incoming_message(incoming_email_object) 26 | 27 | actual_email_object = msg.get_email_object() 28 | 29 | self.assertEqual( 30 | actual_email_object, 31 | expected_email_object, 32 | ) 33 | 34 | def test_base64_message_is_properly_rehydrated(self): 35 | incoming_email_object = self._get_email_object( 36 | 'message_with_attachment.eml', 37 | ) 38 | # Note: this is identical to the above, but it appears that 39 | # while reading-in an e-mail message, we do alter it slightly 40 | expected_email_object = self._get_email_object( 41 | 'message_with_attachment.eml', 42 | ) 43 | 44 | msg = self.mailbox.process_incoming_message(incoming_email_object) 45 | 46 | actual_email_object = msg.get_email_object() 47 | 48 | self.assertEqual( 49 | actual_email_object, 50 | expected_email_object, 51 | ) 52 | 53 | def test_message_handles_rehydration_problems(self): 54 | incoming_email_object = self._get_email_object( 55 | 'message_with_defective_attachment_association.eml', 56 | ) 57 | expected_email_object = self._get_email_object( 58 | 'message_with_defective_attachment_association_result.eml', 59 | ) 60 | # Note: this is identical to the above, but it appears that 61 | # while reading-in an e-mail message, we do alter it slightly 62 | message = Message() 63 | message.body = incoming_email_object.as_string() 64 | 65 | msg = self.mailbox.process_incoming_message(incoming_email_object) 66 | 67 | del msg._email_object # Cache flush 68 | actual_email_object = msg.get_email_object() 69 | 70 | self.assertEqual( 71 | actual_email_object, 72 | expected_email_object, 73 | ) 74 | 75 | def test_message_content_type_stripping(self): 76 | incoming_email_object = self._get_email_object( 77 | 'message_with_many_multiparts.eml', 78 | ) 79 | expected_email_object = self._get_email_object( 80 | 'message_with_many_multiparts_stripped_html.eml', 81 | ) 82 | default_settings = utils.get_settings() 83 | 84 | with mock.patch('django_mailbox.utils.get_settings') as get_settings: 85 | altered = copy.deepcopy(default_settings) 86 | altered['strip_unallowed_mimetypes'] = True 87 | altered['allowed_mimetypes'] = ['text/plain'] 88 | 89 | get_settings.return_value = altered 90 | 91 | msg = self.mailbox.process_incoming_message(incoming_email_object) 92 | 93 | del msg._email_object # Cache flush 94 | actual_email_object = msg.get_email_object() 95 | 96 | self.assertEqual( 97 | actual_email_object, 98 | expected_email_object, 99 | ) 100 | 101 | def test_message_processing_unknown_encoding(self): 102 | incoming_email_object = self._get_email_object( 103 | 'message_with_invalid_encoding.eml', 104 | ) 105 | 106 | msg = self.mailbox.process_incoming_message(incoming_email_object) 107 | 108 | expected_text = ( 109 | "We offer loans to private individuals and corporate " 110 | "organizations at 2% interest rate. Interested serious " 111 | "applicants should apply via email with details of their " 112 | "requirements.\n\nWarm Regards,\nLoan Team" 113 | ) 114 | actual_text = msg.text 115 | 116 | self.assertEqual(actual_text, expected_text) 117 | -------------------------------------------------------------------------------- /django_mailbox/transports/imap.py: -------------------------------------------------------------------------------- 1 | import imaplib 2 | import logging 3 | 4 | from django.conf import settings 5 | 6 | from .base import EmailTransport, MessageParseError 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class ImapTransport(EmailTransport): 13 | def __init__( 14 | self, hostname, port=None, ssl=False, tls=False, 15 | archive='', folder=None, 16 | ): 17 | self.max_message_size = getattr( 18 | settings, 19 | 'DJANGO_MAILBOX_MAX_MESSAGE_SIZE', 20 | False 21 | ) 22 | self.integration_testing_subject = getattr( 23 | settings, 24 | 'DJANGO_MAILBOX_INTEGRATION_TESTING_SUBJECT', 25 | None 26 | ) 27 | self.hostname = hostname 28 | self.port = port 29 | self.archive = archive 30 | self.folder = folder 31 | self.tls = tls 32 | if ssl: 33 | self.transport = imaplib.IMAP4_SSL 34 | if not self.port: 35 | self.port = 993 36 | else: 37 | self.transport = imaplib.IMAP4 38 | if not self.port: 39 | self.port = 143 40 | 41 | def connect(self, username, password): 42 | self.server = self.transport(self.hostname, self.port) 43 | if self.tls: 44 | self.server.starttls() 45 | typ, msg = self.server.login(username, password) 46 | 47 | if self.folder: 48 | self.server.select(self.folder) 49 | else: 50 | self.server.select() 51 | 52 | def close(self): 53 | try: 54 | self.server.close() 55 | self.server.logout() 56 | except (imaplib.IMAP4.error, OSError) as e: 57 | logger.warning(f'Failed to close IMAP connection, ignoring: {e}') 58 | pass 59 | 60 | def _get_all_message_ids(self): 61 | # Fetch all the message uids 62 | response, message_ids = self.server.uid('search', None, 'ALL') 63 | message_id_string = message_ids[0].strip() 64 | # Usually `message_id_string` will be a list of space-separated 65 | # ids; we must make sure that it isn't an empty string before 66 | # splitting into individual UIDs. 67 | if message_id_string: 68 | return message_id_string.decode().split(' ') 69 | return [] 70 | 71 | def _get_small_message_ids(self, message_ids): 72 | # Using existing message uids, get the sizes and 73 | # return only those that are under the size 74 | # limit 75 | safe_message_ids = [] 76 | 77 | status, data = self.server.uid( 78 | 'fetch', 79 | ','.join(message_ids), 80 | '(RFC822.SIZE)' 81 | ) 82 | 83 | for each_msg in data: 84 | each_msg = each_msg.decode() 85 | try: 86 | uid = each_msg.split(' ')[2] 87 | size = each_msg.split(' ')[4].rstrip(')') 88 | if int(size) <= int(self.max_message_size): 89 | safe_message_ids.append(uid) 90 | except ValueError as e: 91 | logger.warning( 92 | "ValueError: {} working on {}".format(e, each_msg[0]) 93 | ) 94 | pass 95 | return safe_message_ids 96 | 97 | def get_message(self, condition=None): 98 | message_ids = self._get_all_message_ids() 99 | 100 | if not message_ids: 101 | return 102 | 103 | # Limit the uids to the small ones if we care about that 104 | if self.max_message_size: 105 | message_ids = self._get_small_message_ids(message_ids) 106 | 107 | if self.archive: 108 | typ, folders = self.server.list(pattern=self.archive) 109 | if folders[0] is None: 110 | # If the archive folder does not exist, create it 111 | self.server.create(self.archive) 112 | 113 | for uid in message_ids: 114 | try: 115 | typ, msg_contents = self.server.uid('fetch', uid, '(RFC822)') 116 | if not msg_contents: 117 | continue 118 | try: 119 | message = self.get_email_from_bytes(msg_contents[0][1]) 120 | except TypeError: 121 | # This happens if another thread/process deletes the 122 | # message between our generating the ID list and our 123 | # processing it here. 124 | continue 125 | 126 | if condition and not condition(message): 127 | continue 128 | 129 | yield message 130 | except MessageParseError: 131 | continue 132 | 133 | if self.archive: 134 | self.server.uid('copy', uid, self.archive) 135 | 136 | self.server.uid('store', uid, "+FLAGS", "(\\Deleted)") 137 | self.server.expunge() 138 | return 139 | -------------------------------------------------------------------------------- /django_mailbox/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import email.header 3 | import logging 4 | import os 5 | 6 | from django.conf import settings 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get_settings(): 13 | return { 14 | 'strip_unallowed_mimetypes': getattr( 15 | settings, 16 | 'DJANGO_MAILBOX_STRIP_UNALLOWED_MIMETYPES', 17 | False 18 | ), 19 | 'allowed_mimetypes': getattr( 20 | settings, 21 | 'DJANGO_MAILBOX_ALLOWED_MIMETYPES', 22 | [ 23 | 'text/plain', 24 | 'text/html' 25 | ] 26 | ), 27 | 'text_stored_mimetypes': getattr( 28 | settings, 29 | 'DJANGO_MAILBOX_TEXT_STORED_MIMETYPES', 30 | [ 31 | 'text/plain', 32 | 'text/html' 33 | ] 34 | ), 35 | 'altered_message_header': getattr( 36 | settings, 37 | 'DJANGO_MAILBOX_ALTERED_MESSAGE_HEADER', 38 | 'X-Django-Mailbox-Altered-Message' 39 | ), 40 | 'attachment_interpolation_header': getattr( 41 | settings, 42 | 'DJANGO_MAILBOX_ATTACHMENT_INTERPOLATION_HEADER', 43 | 'X-Django-Mailbox-Interpolate-Attachment' 44 | ), 45 | 'attachment_upload_to': getattr( 46 | settings, 47 | 'DJANGO_MAILBOX_ATTACHMENT_UPLOAD_TO', 48 | 'mailbox_attachments/%Y/%m/%d/' 49 | ), 50 | 'store_original_message': getattr( 51 | settings, 52 | 'DJANGO_MAILBOX_STORE_ORIGINAL_MESSAGE', 53 | False 54 | ), 55 | 'compress_original_message': getattr( 56 | settings, 57 | 'DJANGO_MAILBOX_COMPRESS_ORIGINAL_MESSAGE', 58 | False 59 | ), 60 | 'original_message_compression': getattr( 61 | settings, 62 | 'DJANGO_MAILBOX_ORIGINAL_MESSAGE_COMPRESSION', 63 | 6 64 | ), 65 | 'default_charset': getattr( 66 | settings, 67 | 'DJANGO_MAILBOX_DEFAULT_CHARSET', 68 | 'iso8859-1', 69 | ) 70 | } 71 | 72 | 73 | def convert_header_to_unicode(header): 74 | default_charset = get_settings()['default_charset'] 75 | 76 | def _decode(value, encoding): 77 | if isinstance(value, str): 78 | return value 79 | if not encoding or encoding == 'unknown-8bit': 80 | encoding = default_charset 81 | return value.decode(encoding, 'replace') 82 | 83 | try: 84 | return ''.join( 85 | [ 86 | ( 87 | _decode(bytestr, encoding) 88 | ) for bytestr, encoding in email.header.decode_header(header) 89 | ] 90 | ) 91 | except UnicodeDecodeError: 92 | logger.exception( 93 | 'Errors encountered decoding header %s into encoding %s.', 94 | header, 95 | default_charset, 96 | ) 97 | return header.decode(default_charset, 'replace') 98 | 99 | 100 | def get_body_from_message(message, maintype, subtype): 101 | """ 102 | Fetchs the body message matching main/sub content type. 103 | """ 104 | body = '' 105 | for part in message.walk(): 106 | if convert_header_to_unicode(part.get('content-disposition', '')).startswith('attachment;'): 107 | continue 108 | if part.get_content_maintype() == maintype and \ 109 | part.get_content_subtype() == subtype: 110 | charset = part.get_content_charset() 111 | this_part = part.get_payload(decode=True) 112 | if charset: 113 | try: 114 | this_part = this_part.decode(charset, 'replace') 115 | except LookupError: 116 | this_part = this_part.decode('ascii', 'replace') 117 | logger.warning( 118 | 'Unknown encoding %s encountered while decoding ' 119 | 'text payload. Interpreting as ASCII with ' 120 | 'replacement, but some data may not be ' 121 | 'represented as the sender intended.', 122 | charset 123 | ) 124 | except ValueError: 125 | this_part = this_part.decode('ascii', 'replace') 126 | logger.warning( 127 | 'Error encountered while decoding text ' 128 | 'payload from an incorrectly-constructed ' 129 | 'e-mail; payload was converted to ASCII with ' 130 | 'replacement, but some data may not be ' 131 | 'represented as the sender intended.' 132 | ) 133 | else: 134 | this_part = this_part.decode('ascii', 'replace') 135 | 136 | body += this_part 137 | 138 | return body 139 | 140 | 141 | def get_attachment_save_path(instance, filename): 142 | settings = get_settings() 143 | 144 | path = settings['attachment_upload_to'] 145 | if '%' in path: 146 | path = datetime.datetime.utcnow().strftime(path) 147 | 148 | return os.path.join( 149 | path, 150 | filename, 151 | ) 152 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_transports.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test.utils import override_settings 4 | 5 | from django_mailbox.tests.base import EmailMessageTestCase, get_email_as_text 6 | from django_mailbox.transports import ImapTransport, Pop3Transport 7 | 8 | FAKE_UID_SEARCH_ANSWER = ( 9 | 'OK', 10 | [ 11 | b'18 19 20 21 22 23 24 25 26 27 28 29 ' + 12 | b'30 31 32 33 34 35 36 37 38 39 40 41 42 43 44' 13 | ] 14 | ) 15 | FAKE_UID_FETCH_SIZES = ( 16 | 'OK', 17 | [ 18 | b'1 (UID 18 RFC822.SIZE 58070000000)', 19 | b'2 (UID 19 RFC822.SIZE 2593)' 20 | ] 21 | ) 22 | FAKE_UID_FETCH_MSG = ( 23 | 'OK', 24 | [ 25 | ( 26 | b'1 (UID 18 RFC822 {5807}', 27 | get_email_as_text('generic_message.eml') 28 | ), 29 | ] 30 | ) 31 | FAKE_UID_COPY_MSG = ( 32 | 'OK', 33 | [ 34 | b'[COPYUID 1 2 2] (Success)' 35 | ] 36 | ) 37 | FAKE_LIST_ARCHIVE_FOLDERS_ANSWERS = ( 38 | 'OK', 39 | [ 40 | b'(\\HasNoChildren \\All) "/" "[Gmail]/All Mail"' 41 | ] 42 | ) 43 | 44 | 45 | class IMAPTestCase(EmailMessageTestCase): 46 | def setUp(self): 47 | def imap_server_uid_method(*args): 48 | cmd = args[0] 49 | arg2 = args[2] 50 | if cmd == 'search': 51 | return FAKE_UID_SEARCH_ANSWER 52 | if cmd == 'copy': 53 | return FAKE_UID_COPY_MSG 54 | if cmd == 'fetch': 55 | if arg2 == '(RFC822.SIZE)': 56 | return FAKE_UID_FETCH_SIZES 57 | if arg2 == '(RFC822)': 58 | return FAKE_UID_FETCH_MSG 59 | 60 | def imap_server_list_method(pattern=None): 61 | return FAKE_LIST_ARCHIVE_FOLDERS_ANSWERS 62 | 63 | self.imap_server = mock.Mock() 64 | self.imap_server.uid = imap_server_uid_method 65 | self.imap_server.list = imap_server_list_method 66 | super().setUp() 67 | 68 | 69 | class TestImapTransport(IMAPTestCase): 70 | def setUp(self): 71 | super().setUp() 72 | self.arbitrary_hostname = 'one.two.three' 73 | self.arbitrary_port = 100 74 | self.ssl = False 75 | self.transport = ImapTransport( 76 | self.arbitrary_hostname, 77 | self.arbitrary_port, 78 | self.ssl 79 | ) 80 | self.transport.server = self.imap_server 81 | 82 | def test_get_email_message(self): 83 | actual_messages = list(self.transport.get_message()) 84 | self.assertEqual(len(actual_messages), 27) 85 | actual_message = actual_messages[0] 86 | expected_message = self._get_email_object('generic_message.eml') 87 | self.assertEqual(expected_message, actual_message) 88 | 89 | 90 | class TestImapArchivedTransport(TestImapTransport): 91 | def setUp(self): 92 | super().setUp() 93 | self.archive = 'Archive' 94 | self.transport = ImapTransport( 95 | self.arbitrary_hostname, 96 | self.arbitrary_port, 97 | self.ssl, 98 | self.archive 99 | ) 100 | self.transport.server = self.imap_server 101 | 102 | 103 | class TestMaxSizeImapTransport(TestImapTransport): 104 | 105 | @override_settings(DJANGO_MAILBOX_MAX_MESSAGE_SIZE=5807) 106 | def setUp(self): 107 | super().setUp() 108 | 109 | self.transport = ImapTransport( 110 | self.arbitrary_hostname, 111 | self.arbitrary_port, 112 | self.ssl, 113 | ) 114 | self.transport.server = self.imap_server 115 | 116 | def test_size_limit(self): 117 | all_message_ids = self.transport._get_all_message_ids() 118 | small_message_ids = self.transport._get_small_message_ids( 119 | all_message_ids, 120 | ) 121 | self.assertEqual(len(small_message_ids), 1) 122 | 123 | def test_get_email_message(self): 124 | actual_messages = list(self.transport.get_message()) 125 | self.assertEqual(len(actual_messages), 1) 126 | actual_message = actual_messages[0] 127 | expected_message = self._get_email_object('generic_message.eml') 128 | self.assertEqual(expected_message, actual_message) 129 | 130 | 131 | class TestPop3Transport(EmailMessageTestCase): 132 | def setUp(self): 133 | self.arbitrary_hostname = 'one.two.three' 134 | self.arbitrary_port = 100 135 | self.ssl = False 136 | self.transport = Pop3Transport( 137 | self.arbitrary_hostname, 138 | self.arbitrary_port, 139 | self.ssl 140 | ) 141 | self.transport.server = None 142 | super().setUp() 143 | 144 | def test_get_email_message(self): 145 | with mock.patch.object(self.transport, 'server') as server: 146 | # Consider this value arbitrary, the second parameter 147 | # should have one entry per message in the inbox 148 | server.list.return_value = [None, ['some_msg']] 149 | server.retr.return_value = [ 150 | '+OK message follows', 151 | [ 152 | line.encode('ascii') 153 | for line in self._get_email_as_text( 154 | 'generic_message.eml' 155 | ).decode('ascii').split('\n') 156 | ], 157 | 10018, # Some arbitrary size, ideally matching the above 158 | ] 159 | 160 | actual_messages = list(self.transport.get_message()) 161 | 162 | self.assertEqual(len(actual_messages), 1) 163 | 164 | actual_message = actual_messages[0] 165 | expected_message = self._get_email_object('generic_message.eml') 166 | 167 | self.assertEqual(expected_message, actual_message) 168 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_image_jpg_mimetype.eml: -------------------------------------------------------------------------------- 1 | Delivered-To: person3@example.com 2 | Received: by 10.76.18.165 with SMTP id x5csp92235oad; 3 | Thu, 31 Jan 2013 10:00:48 -0800 (PST) 4 | X-Received: by 10.66.81.231 with SMTP id d7mr22593565pay.27.1359655248387; 5 | Thu, 31 Jan 2013 10:00:48 -0800 (PST) 6 | Return-Path: 7 | Received: from p3plsmtp21-02.prod.phx3.secureserver.net (p3plsmtp21-02.prod.phx3.secureserver.net. [123.123.123.123]) 8 | by mx.google.com with ESMTP id wk7si5300126pbc.79.2013.01.31.10.00.47; 9 | Thu, 31 Jan 2013 10:00:48 -0800 (PST) 10 | Received-SPF: pass (google.com: domain of SRS0=RahL=LY=example.net=person2@bounce.secureserver.net designates 123.123.123.123 as permitted sender) client-ip=123.123.123.123; 11 | Authentication-Results: mx.google.com; 12 | spf=pass (google.com: domain of SRS0=RahL=LY=example.net=person2@bounce.secureserver.net designates 123.123.123.123 as permitted sender) smtp.mail=SRS0=RahL=LY=example.net=person2@bounce.secureserver.net 13 | Received: (qmail 23678 invoked from network); 31 Jan 2013 18:00:47 -0000 14 | Delivered-To: person4@example.com 15 | Precedence: bulk 16 | Received: (qmail 23676 invoked by uid 30297); 31 Jan 2013 18:00:47 -0000 17 | Received: from unknown (HELO p3pismtp01-017.prod.phx3.secureserver.net) ([123.123.123.123]) 18 | (envelope-sender ) 19 | by p3plsmtp21-02.prod.phx3.secureserver.net (qmail-1.03) with SMTP 20 | for ; 31 Jan 2013 18:00:47 -0000 21 | X-IronPort-Anti-Spam-Result: AjsDACKwClFIFIAKnGdsb2JhbABCA4JJg3+BJKR7iSQBiCaBCA4BAQEBAQgLCQkUJ4IeAQEEAR4FQSUFBhEDAQIGAQEBIgICAhUIBykIFQMBAYgKBgyvRyuSOI0RED2CG4ETA4hkgnKBaYJxhWKBHIVAjQqBRwIDBBc 22 | Received: from mail.example.net (HELO example.com) ([72.20.128.10]) 23 | by p3pismtp01-017.prod.phx3.secureserver.net with ESMTP; 31 Jan 2013 11:00:28 -0700 24 | Received: from DOM-ADM-MTA by aa03.example.net 25 | with Novell_GroupWise; Thu, 31 Jan 2013 12:00:24 -0600 26 | Mime-Version: 1.0 27 | Date: Thu, 31 Jan 2013 12:00:08 -0600 28 | References: <1359492484.4590710694371947@mf102> 29 | In-Reply-To: <1359492484.4590710694371947@mf102> 30 | Message-ID: <510A5CC8.6E86.00A9.1@example.net> 31 | X-Priority: 1 32 | X-Mailer: Groupwise 7.0.3 33 | From: "Person" 34 | Subject: Re: Fwd: Stuff 35 | To: person4@example.com 36 | Content-Type: multipart/alternative; boundary="____SRNGMVRNJTDKENWSQEFF____" 37 | 38 | 39 | --____SRNGMVRNJTDKENWSQEFF____ 40 | Content-Type: text/plain; charset=utf-8 41 | Content-Transfer-Encoding: base64 42 | Content-Disposition: inline; modification-date="Fri, 31 Jan 2013 06:00:09 43 | -0600" 44 | 45 | SGVsbG8sIFdvcmxk 46 | --____SRNGMVRNJTDKENWSQEFF____ 47 | Content-Type: multipart/related; boundary="____AVETMQDQOMFFURFTDWUU____" 48 | 49 | 50 | --____AVETMQDQOMFFURFTDWUU____ 51 | Content-Type: text/html; charset=utf-8 52 | Content-Transfer-Encoding: base64 53 | Content-Disposition: inline; modification-date="Fri, 31 Jan 2013 06:00:09 54 | -0600" 55 | 56 | PCFET0NUWVBFIGh0bWwgUFVCTElDICItLy9XM0MvL0RURCBYSFRNTCAxLjAgU3RyaWN0Ly9FTiIN 57 | CiAgICAgICAgImh0dHA6Ly93d3cudzMub3JnL1RSL3hodG1sMS9EVEQveGh0bWwxLXN0cmljdC5k 58 | dGQiPg0KPGh0bWwgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiPg0KDQo8aGVh 59 | ZD4NCgk8dGl0bGU+QW4gWEhUTUwgMS4wIFN0cmljdCBzdGFuZGFyZCB0ZW1wbGF0ZTwvdGl0bGU+ 60 | DQoJPG1ldGEgaHR0cC1lcXVpdj0iY29udGVudC10eXBlIiANCgkJY29udGVudD0idGV4dC9odG1s 61 | O2NoYXJzZXQ9dXRmLTgiIC8+DQo8L2hlYWQ+DQoNCjxib2R5Pg0KDQogICAgIDxpbWcgc3JjPSJj 62 | aWQ6Uk1YUExSSUhXUlFLLjMuanBnIj4NCiAgICAgPHA+SGVsbG8sIHdvcmxkPC9wPg0KICAgICA8 63 | aW1nIHNyYz0iY2lkOk9FT1lLRUpFTkxJSy4yLmpwZyI+DQoNCjwvYm9keT4NCjwvaHRtbD4= 64 | --____AVETMQDQOMFFURFTDWUU____ 65 | Content-ID: 66 | Content-Type: image/jpg 67 | Content-Transfer-Encoding: base64 68 | 69 | /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAd 70 | Hx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5Ojf/2wBDAQoKCg0MDRoPDxo3JR8lNzc3Nzc3 71 | Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzf/wAARCAApABYDASIA 72 | AhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAYHCAT/xAAvEAABAwMDAwEFCQAAAAAAAAABAgME 73 | AAURBhIhBzFBswgTFSRSGCIyNlF1o9Hx/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAA 74 | AAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AH7qnb9V3GwIa0dKLMgOgvIad9064nxscJATg8nkZHnw 75 | ZMjSvWXen5q8Dnub0kj1a0ZRQKuhtVwr2y5bVXWPPu8BIRLcYRsQ6cDK0fUnJxkcZHYAiio37OP5 76 | 4nftbnqtUUFO6namvcSKuBolTUm7slK5bTKQ6+y0rsUt4O7J79yAQccgiZo1X1l3p+VvB57GypA9 77 | KjRqlfaGk/ePNzuAPPja9/VaMoFHQWj4NgaXcza2IN2noBlNsub0MnglDf0pyM4GeeMkAUU3UUEZ 78 | 6gWVnp9entdWWC7MmSHlHa9ksRHF53uKwQo7txAHYEnn8IpZR151UVpHw6zqyewZdyf5K0ZRQcVl 79 | mPXG0xJkqE7BeeaC1xniCpsnwcf7+oB4ortooP/Z 80 | --____AVETMQDQOMFFURFTDWUU____ 81 | Content-ID: 82 | Content-Type: image/jpg 83 | Content-Transfer-Encoding: base64 84 | 85 | /9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBwgHBgkTBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAd 86 | Hx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD8uQzQ5OjcBCgoKBQUFDgUFDisZExkrKysrKysrKysr 87 | KysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrK//AABEIACkAFgMBIgACEQED 88 | EQH/xAAYAAEBAQEBAAAAAAAAAAAAAAAHAAgGAf/EACoQAAECBAYABQUAAAAAAAAAAAECAwAEBREG 89 | BxIhMUEIEyRSsyU2UXWj/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAA 90 | AAD/2gAMAwEAAhEDEQA/AO+zTp+K6jQEJwdNFmYDoLyG3fKdcT1pcJGmx3O4uO+iTIwrnLrT6qsD 91 | fk1pJHyxoyKA5XA2K5KtsuS6qrLz9WkEhE2thGhDp2utHuTfa47HABEUDfhx+9579W58rUewC1mn 92 | UMV06gIVg6VL0wXQHltNea62nrS2QdVzsdjYddgmRirOTWn0tYO/BoqQPijRkUBwmWlFoMi5PuSU 93 | vJsV1RSipMSz3mJlVkAltHtTfewuLgi507UF3hyUo43n7qJvTHCbnk+a1HkAtZp4tqWD6Ah6l00T 94 | a1uhCnXAS0wPyoAg78DgX74BJkZ84qK0/TqOq54DLtz/AEjRkUAf5Y4Fp2H1OzrEtNys1UGQEycy 95 | oEyjZsot7c7gG53sACAb3oQIoD//2Q== 96 | --____AVETMQDQOMFFURFTDWUU____ 97 | Content-ID: 98 | Content-Type: image/gif 99 | Content-Transfer-Encoding: base64 100 | 101 | R0lGODlhAQABAPAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAICTAEAOw== 102 | --____AVETMQDQOMFFURFTDWUU____-- 103 | 104 | --____SRNGMVRNJTDKENWSQEFF____-- 105 | 106 | 107 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-mailbox.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-mailbox.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-mailbox" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-mailbox" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /django_mailbox/locale/bg/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-06-12 18:01+0300\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: django_mailbox/admin.py:26 21 | msgid "Get new mail" 22 | msgstr "Изтегли новата поща" 23 | 24 | #: django_mailbox/admin.py:36 25 | msgid "Re-send message received signal" 26 | msgstr "Изпрати отново сигнал за прието съобщение" 27 | 28 | #: django_mailbox/admin.py:66 29 | msgid "Attachment count" 30 | msgstr "Брой приложения" 31 | 32 | #: django_mailbox/apps.py:7 33 | msgid "Mail Box" 34 | msgstr "Пощенска кутия" 35 | 36 | #: django_mailbox/models.py:62 37 | msgid "Name" 38 | msgstr "Име" 39 | 40 | #: django_mailbox/models.py:67 41 | msgid "URI" 42 | msgstr "URI" 43 | 44 | #: django_mailbox/models.py:70 45 | msgid "" 46 | "Example: imap+ssl://myusername:mypassword@someserver

Internet " 47 | "transports include 'imap' and 'pop3'; common local file transports include " 48 | "'maildir', 'mbox', and less commonly 'babyl', 'mh', and 'mmdf'.

Be sure to urlencode your username and password should they contain illegal " 50 | "characters (like @, :, etc)." 51 | msgstr "" 52 | "Пример: imap+ssl://myusername:mypassword@someserver

Интернет " 53 | "протоколите за обмен на поща включват 'imap' and 'pop3'; стандартните протоколи за обмен чрез локални файлове включват " 54 | "'maildir', 'mbox' и по-рядко 'babyl', 'mh', and 'mmdf'.

Моля, не забравяйте да използвате urlencode за вашето потребителско име и парола, ако съдържат забранени " 56 | "символи (като @, : и т.н.)." 57 | 58 | #: django_mailbox/models.py:85 59 | msgid "From email" 60 | msgstr "От имейл адрес" 61 | 62 | #: django_mailbox/models.py:88 63 | msgid "" 64 | "Example: MailBot <mailbot@yourdomain.com>
'From' header to set " 65 | "for outgoing email.

If you do not use this e-mail inbox for " 66 | "outgoing mail, this setting is unnecessary.
If you send e-mail without " 67 | "setting this, your 'From' header will'be set to match the setting " 68 | "`DEFAULT_FROM_EMAIL`." 69 | msgstr "" 70 | "Example: MailBot <mailbot@yourdomain.com>
'От' хедър за изходяща " 71 | "поща.

Ако не използвате тази пощенска кутия за " 72 | "изходяща поща, тази настройка не е необходима.
Ако изпращате поща без " 73 | "да сте настроили горното, вашият 'От' хедър ще бъде настроен да отговаря на настройката " 74 | "`DEFAULT_FROM_EMAIL`." 75 | 76 | #: django_mailbox/models.py:102 77 | msgid "Active" 78 | msgstr "Активна" 79 | 80 | #: django_mailbox/models.py:104 81 | msgid "" 82 | "Check this e-mail inbox for new e-mail messages during polling cycles. This " 83 | "checkbox does not have an effect upon whether mail is collected here when " 84 | "this mailbox receives mail from a pipe, and does not affect whether e-mail " 85 | "messages can be dispatched from this mailbox. " 86 | msgstr "" 87 | "Проверявай тази пощенска кутия за нови съобщения, когато се извършва събиране на поща. Тази " 88 | "настройка не се прилага, когато пощата се събира чрез " 89 | "pipe и няма ефект върху това дали могат да се изпращат " 90 | "съобщения чрез тази пощенска кутия. " 91 | 92 | #: django_mailbox/models.py:115 93 | msgid "Last polling" 94 | msgstr "Последна проверка" 95 | 96 | #: django_mailbox/models.py:116 97 | msgid "" 98 | "The time of last successful polling for messages.It is blank for new " 99 | "mailboxes and is not set for mailboxes that only receive messages via a pipe." 100 | msgstr "" 101 | "Времето на последната успешна проверка за нови съобщения. Празно за нови " 102 | "пощенски кутии и не се обновява за пощенски кутии, които събират поща само чрез pipe." 103 | 104 | #: django_mailbox/models.py:490 django_mailbox/models.py:519 105 | msgid "Mailbox" 106 | msgstr "Пощенска кутия" 107 | 108 | #: django_mailbox/models.py:491 109 | msgid "Mailboxes" 110 | msgstr "Пощенски кутии" 111 | 112 | #: django_mailbox/models.py:524 113 | msgid "Subject" 114 | msgstr "Тема" 115 | 116 | #: django_mailbox/models.py:529 117 | msgid "Message ID" 118 | msgstr "ID на съобщението" 119 | 120 | #: django_mailbox/models.py:538 121 | msgid "In reply to" 122 | msgstr "В отговор на" 123 | 124 | #: django_mailbox/models.py:543 125 | msgid "From header" 126 | msgstr "От хедър" 127 | 128 | #: django_mailbox/models.py:548 129 | msgid "To header" 130 | msgstr "До хедър" 131 | 132 | #: django_mailbox/models.py:552 133 | msgid "Outgoing" 134 | msgstr "Изходяща" 135 | 136 | #: django_mailbox/models.py:558 137 | msgid "Body" 138 | msgstr "Тяло" 139 | 140 | #: django_mailbox/models.py:562 141 | msgid "Encoded" 142 | msgstr "Енкоднато" 143 | 144 | #: django_mailbox/models.py:564 145 | msgid "True if the e-mail body is Base64 encoded" 146 | msgstr "Да ако тялото на съобщението е енкоднато в Base64" 147 | 148 | #: django_mailbox/models.py:568 149 | msgid "Processed" 150 | msgstr "Обработено" 151 | 152 | #: django_mailbox/models.py:573 153 | msgid "Read" 154 | msgstr "Прочетено" 155 | 156 | #: django_mailbox/models.py:580 157 | msgid "Raw message contents" 158 | msgstr "Сурово съдържание на съобщението" 159 | 160 | #: django_mailbox/models.py:583 161 | msgid "Original full content of message" 162 | msgstr "Оригинално пълно съдържание на съобщението" 163 | 164 | #: django_mailbox/models.py:800 165 | msgid "E-mail message" 166 | msgstr "Имейл съобщение" 167 | 168 | #: django_mailbox/models.py:801 169 | msgid "E-mail messages" 170 | msgstr "Имейл съобщения" 171 | 172 | #: django_mailbox/models.py:810 173 | msgid "Message" 174 | msgstr "Съобщение" 175 | 176 | #: django_mailbox/models.py:815 177 | msgid "Headers" 178 | msgstr "Хедъри" 179 | 180 | #: django_mailbox/models.py:821 181 | msgid "Document" 182 | msgstr "Документ" 183 | 184 | #: django_mailbox/models.py:873 185 | msgid "Message attachment" 186 | msgstr "Приложение към съобщението" 187 | 188 | #: django_mailbox/models.py:874 189 | msgid "Message attachments" 190 | msgstr "Приложения към съобщението" 191 | -------------------------------------------------------------------------------- /django_mailbox/locale/ru_RU/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-07-09 05:36-0500\n" 11 | "PO-Revision-Date: 2017-07-09 13:37+0300\n" 12 | "Last-Translator: Ivlev Denis \n" 13 | "Language-Team: \n" 14 | "Language: ru_RU\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 19 | "%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" 20 | "%100>=11 && n%100<=14)? 2 : 3);\n" 21 | "X-Generator: Poedit 1.8.7.1\n" 22 | 23 | #: django_mailbox/admin.py:29 24 | msgid "Get new mail" 25 | msgstr "Получить новые сообщения" 26 | 27 | #: django_mailbox/admin.py:39 28 | msgid "Re-send message received signal" 29 | msgstr "Повторно отправить сигнал о получении" 30 | 31 | #: django_mailbox/admin.py:69 32 | msgid "Attachment count" 33 | msgstr "Кол-во вложений" 34 | 35 | #: django_mailbox/apps.py:7 36 | msgid "Mail Box" 37 | msgstr "Почтовый ящик" 38 | 39 | #: django_mailbox/models.py:49 40 | msgid "Name" 41 | msgstr "Название" 42 | 43 | #: django_mailbox/models.py:54 44 | msgid "URI" 45 | msgstr "URI" 46 | 47 | #: django_mailbox/models.py:57 48 | msgid "" 49 | "Example: imap+ssl://myusername:mypassword@someserver

Internet " 50 | "transports include 'imap' and 'pop3'; common local file transports include " 51 | "'maildir', 'mbox', and less commonly 'babyl', 'mh', and 'mmdf'.

Be sure to urlencode your username and password should they contain illegal " 53 | "characters (like @, :, etc)." 54 | msgstr "" 55 | "Пример: imap+ssl://myusername:mypassword@someserver

Интернет-" 56 | "транспорт может быть 'imap' или 'pop3'; поддерживаются локальные файловые " 57 | "транспорты 'maildir', 'mbox', а также 'babyl', 'mh', and 'mmdf'.

Используйте urlencode, если имя пользователя или пароль содержат " 59 | "недопустипые символы (@, :, и т.д.)." 60 | 61 | #: django_mailbox/models.py:72 62 | msgid "From email" 63 | msgstr "От" 64 | 65 | #: django_mailbox/models.py:75 66 | msgid "" 67 | "Example: MailBot <mailbot@yourdomain.com>
'From' header to set " 68 | "for outgoing email.

If you do not use this e-mail inbox for " 69 | "outgoing mail, this setting is unnecessary.
If you send e-mail without " 70 | "setting this, your 'From' header will'be set to match the setting " 71 | "`DEFAULT_FROM_EMAIL`." 72 | msgstr "" 73 | "Пример: MailBot <mailbot@yourdomain.com>
Исходящая электронная " 74 | "почта.

Если вы не используете этот почтовый ящик для исходящей " 75 | "почты, этот параметр не нужен.
Если вы не указали данный параметр, " 76 | "будет использоваться указанный в настройках `DEFAULT_FROM_EMAIL`." 77 | 78 | #: django_mailbox/models.py:89 79 | msgid "Active" 80 | msgstr "Активный" 81 | 82 | #: django_mailbox/models.py:91 83 | msgid "" 84 | "Check this e-mail inbox for new e-mail messages during polling cycles. This " 85 | "checkbox does not have an effect upon whether mail is collected here when " 86 | "this mailbox receives mail from a pipe, and does not affect whether e-mail " 87 | "messages can be dispatched from this mailbox. " 88 | msgstr "" 89 | "Параметр указывает на необходимость проверки почтового ящика на наличие " 90 | "новых сообщений в цикле опроса. Этот флажок не влияет на сбор почты, когда " 91 | "этот почтовый ящик получает почту из канала и не влияет на отправку " 92 | "сообщений электронной почты из этого почтового ящика." 93 | 94 | #: django_mailbox/models.py:102 95 | msgid "Last polling" 96 | msgstr "Последний опрос" 97 | 98 | #: django_mailbox/models.py:103 99 | msgid "" 100 | "The time of last successful polling for messages.It is blank for new " 101 | "mailboxes and is not set for mailboxes that only receive messages via a pipe." 102 | msgstr "" 103 | "Время последнего успешного опроса сообщений. Для нового почтового ящика не " 104 | "установлено. Также не устанавливается для почтовых ящиков " 105 | "обновляющих сообщения через pipe." 106 | 107 | #: django_mailbox/models.py:409 django_mailbox/models.py:438 108 | msgid "Mailbox" 109 | msgstr "Почтовый ящик" 110 | 111 | #: django_mailbox/models.py:410 112 | msgid "Mailboxes" 113 | msgstr "Почтовые ящики" 114 | 115 | #: django_mailbox/models.py:442 116 | msgid "Subject" 117 | msgstr "Тема" 118 | 119 | #: django_mailbox/models.py:447 120 | msgid "Message ID" 121 | msgstr "Идентификатор сообщения" 122 | 123 | #: django_mailbox/models.py:456 124 | msgid "In reply to" 125 | msgstr "В ответ на" 126 | 127 | #: django_mailbox/models.py:460 128 | msgid "From header" 129 | msgstr "От(From)" 130 | 131 | #: django_mailbox/models.py:465 132 | msgid "To header" 133 | msgstr "Кому(To)" 134 | 135 | #: django_mailbox/models.py:469 136 | msgid "Outgoing" 137 | msgstr "Исходящее" 138 | 139 | #: django_mailbox/models.py:475 140 | msgid "Body" 141 | msgstr "Тело" 142 | 143 | #: django_mailbox/models.py:479 144 | msgid "Encoded" 145 | msgstr "Закодировано" 146 | 147 | #: django_mailbox/models.py:481 148 | msgid "True if the e-mail body is Base64 encoded" 149 | msgstr "True если тело сообщения закодировано в Base64" 150 | 151 | #: django_mailbox/models.py:485 152 | msgid "Processed" 153 | msgstr "Обработано" 154 | 155 | #: django_mailbox/models.py:490 156 | msgid "Read" 157 | msgstr "Прочитано" 158 | 159 | #: django_mailbox/models.py:497 160 | msgid "Raw message contents" 161 | msgstr "Исходное содержимое сообщения" 162 | 163 | #: django_mailbox/models.py:500 164 | msgid "Original full content of message" 165 | msgstr "Полное содержимое сообщения" 166 | 167 | #: django_mailbox/models.py:716 168 | msgid "E-mail message" 169 | msgstr "Сообщение" 170 | 171 | #: django_mailbox/models.py:717 172 | msgid "E-mail messages" 173 | msgstr "Сообщения" 174 | 175 | #: django_mailbox/models.py:726 176 | msgid "Message" 177 | msgstr "Сообщение" 178 | 179 | #: django_mailbox/models.py:730 180 | msgid "Headers" 181 | msgstr "Заголовки" 182 | 183 | #: django_mailbox/models.py:736 184 | msgid "Document" 185 | msgstr "Документ" 186 | 187 | #: django_mailbox/models.py:793 188 | msgid "Message attachment" 189 | msgstr "Вложение" 190 | 191 | #: django_mailbox/models.py:794 192 | msgid "Message attachments" 193 | msgstr "Вложения" 194 | -------------------------------------------------------------------------------- /docs/topics/appendix/message-storage.rst: -------------------------------------------------------------------------------- 1 | 2 | Message Storage Details 3 | ======================= 4 | 5 | First, it may be helpful to know a little bit about how e-mail messages 6 | are actually sent across the wire: 7 | 8 | .. code-block:: 9 | 10 | MIME-Version: 1.0 11 | Received: by 10.221.0.211 with HTTP; Sun, 20 Jan 2013 12:07:07 -0800 (PST) 12 | X-Originating-IP: [24.22.122.177] 13 | Date: Sun, 20 Jan 2013 12:07:07 -0800 14 | Delivered-To: test@adamcoddington.net 15 | Message-ID: 16 | Subject: Message With Attachment 17 | From: Adam Coddington 18 | To: Adam Coddington 19 | Content-Type: multipart/mixed; boundary=047d7b33dd729737fe04d3bde348 20 | 21 | --047d7b33dd729737fe04d3bde348 22 | Content-Type: text/plain; charset=UTF-8 23 | 24 | This message has an attachment. 25 | 26 | --047d7b33dd729737fe04d3bde348 27 | Content-Type: image/png; name="heart.png" 28 | Content-Disposition: attachment; filename="heart.png" 29 | Content-Transfer-Encoding: base64 30 | X-Attachment-Id: f_hc6mair60 31 | 32 | iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAFoTx1HAAAAzUlEQVQoz32RWxXDIBBEr4NIQEIl 33 | ICESkFAJkRAJSIgEpEQCEqYfu6QUkn7sCcyDGQiSACKSKCAkGwBJwhDwZQNMEiYAIBdQvk7rfaHf 34 | AO8NBJwCxTGhtFgTHVNaNaJeWFu44AXEHzKCktc7zZ0vss+bMoHSiM2b9mQoX1eZCgGqnWskY3gi 35 | XXAAxb8BqFiUgBNY7k49Tu/kV7UKPsefrjEOT9GmghYzrk9V03pjDGYKj3d0c06dKZkpTboRaD9o 36 | B+1m2m81d2Az948xzgdjLaFe95e83AAAAABJRU5ErkJggg== 37 | 38 | --047d7b33dd729737fe04d3bde348-- 39 | 40 | Messages are grouped into multiple message payload parts, and should binary 41 | attachments exist, they are encoded into text using, generally, ``base64`` or 42 | ``quoted-printable`` encodings. 43 | 44 | Earlier versions of this library would preserve the above text verbatim in the 45 | database, but neither of the above encodings are very efficient methods of 46 | storing binary data, and databases aren't really ideal for storing large 47 | chunks of binary data anyway. 48 | 49 | Modern versions of this library (>=2.1) will walk through the original message, 50 | write ``models.MessageAttachment`` records for each non-text attachment, 51 | and alter the message body removing the original payload component, but writing 52 | a custom header providing the library enough information to re-build the 53 | message in the event that one needs a python ``email.message.Message`` object. 54 | 55 | .. code-block:: 56 | 57 | MIME-Version: 1.0 58 | Received: by 10.221.0.211 with HTTP; Sun, 20 Jan 2013 12:07:07 -0800 (PST) 59 | X-Originating-IP: [24.22.122.177] 60 | Date: Sun, 20 Jan 2013 12:07:07 -0800 61 | Delivered-To: test@adamcoddington.net 62 | Message-ID: 63 | Subject: Message With Attachment 64 | From: Adam Coddington 65 | To: Adam Coddington 66 | Content-Type: multipart/mixed; boundary=047d7b33dd729737fe04d3bde348 67 | 68 | --047d7b33dd729737fe04d3bde348 69 | Content-Type: text/plain; charset=UTF-8 70 | 71 | This message has an attachment. 72 | 73 | --047d7b33dd729737fe04d3bde348 74 | X-Django-Mailbox-Interpolate-Attachment: 1308 75 | 76 | 77 | --047d7b33dd729737fe04d3bde348-- 78 | 79 | The above payload is what would continue to be stored in the database. 80 | Although in this constructed example, this reduces the message's size only 81 | marginally, in most instances, attached files are much larger than the 82 | attachment shown here. 83 | 84 | .. note:: 85 | 86 | Email message bodies are ``base-64`` encoded when stored in the database. 87 | 88 | Although the attachment is no longer preserved in the message body above, 89 | and only the ``X-Django-Mailbox-Interpolate-Attachment: 1308`` header remains 90 | in the place of the original attachment, the attachment was stored in a 91 | ``django_mailbox.MesageAttachment`` record: 92 | 93 | .. list-table:: 94 | :header-rows: 1 95 | 96 | * - Field 97 | - Value 98 | - Description 99 | * - Primary Key 100 | - ``1308`` 101 | - Uniquely generated for each attachment. 102 | * - Headers 103 | - ``Content-Type: image/png; name="heart.png" 104 | Content-Disposition: attachment; filename="heart.png" 105 | Content-Transfer-Encoding: base64 106 | X-Attachment-Id: f_hc6mair60`` 107 | - Raw headers from the actual message's payload part. 108 | * - File 109 | - ``(binary file object)`` 110 | - References a stored-on-disk binary file corresponding with this 111 | attachment. 112 | 113 | And were one to run the ``django_mailbox.Message`` instance's 114 | ``get_email_object`` method, the following message will be returned: 115 | 116 | .. code-block:: 117 | 118 | MIME-Version: 1.0 119 | Received: by 10.221.0.211 with HTTP; Sun, 20 Jan 2013 12:07:07 -0800 (PST) 120 | X-Originating-IP: [24.22.122.177] 121 | Date: Sun, 20 Jan 2013 12:07:07 -0800 122 | Delivered-To: test@adamcoddington.net 123 | Message-ID: 124 | Subject: Message With Attachment 125 | From: Adam Coddington 126 | To: Adam Coddington 127 | Content-Type: multipart/mixed; boundary=047d7b33dd729737fe04d3bde348 128 | 129 | --047d7b33dd729737fe04d3bde348 130 | Content-Type: text/plain; charset=UTF-8 131 | 132 | This message has an attachment. 133 | 134 | --047d7b33dd729737fe04d3bde348 135 | Content-Type: image/png; name="heart.png" 136 | Content-Disposition: attachment; filename="heart.png" 137 | X-Attachment-Id: f_hc6mair60 138 | Content-Transfer-Encoding: base64 139 | 140 | iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAFoTx1HAAAAzUlEQVQoz32RWxXDIBBEr4NIQEIl 141 | ICESkFAJkRAJSIgEpEQCEqYfu6QUkn7sCcyDGQiSACKSKCAkGwBJwhDwZQNMEiYAIBdQvk7rfaHf 142 | AO8NBJwCxTGhtFgTHVNaNaJeWFu44AXEHzKCktc7zZ0vss+bMoHSiM2b9mQoX1eZCgGqnWskY3gi 143 | XXAAxb8BqFiUgBNY7k49Tu/kV7UKPsefrjEOT9GmghYzrk9V03pjDGYKj3d0c06dKZkpTboRaD9o 144 | B+1m2m81d2Az948xzgdjLaFe95e83AAAAABJRU5ErkJggg== 145 | 146 | --047d7b33dd729737fe04d3bde348-- 147 | 148 | .. note:: 149 | 150 | Note that although the above is functionally identical to the originally 151 | received message, there were changes in the order of headers in rehydrated 152 | message components, and whitespace changes are also possible (but not 153 | shown above). 154 | -------------------------------------------------------------------------------- /docs/topics/mailbox_types.rst: -------------------------------------------------------------------------------- 1 | 2 | Supported Mailbox Types 3 | ======================= 4 | 5 | Django Mailbox supports polling both common internet mailboxes like 6 | POP3 and IMAP as well as local file-based mailboxes. 7 | 8 | .. table:: 'Protocol' Options 9 | 10 | ================ ================ ==================================================================================================================================================================== 11 | Mailbox Type 'Protocol':// Notes 12 | ================ ================ ==================================================================================================================================================================== 13 | POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` 14 | IMAP ``imap://`` Can also specify SSL with ``imap+ssl://`` or STARTTLS with ``imap+tls``; additional configuration is also possible: see :ref:`pop3-and-imap-mailboxes` for details. 15 | Gmail IMAP ``gmail+ssl://`` Uses OAuth authentication for Gmail's IMAP transport. See :ref:`gmail-oauth` for details. 16 | Office365 API ``office365://`` Uses OAuth authentication for Office365 API transport. See :ref:`office365-oauth` for details. 17 | Maildir ``maildir://`` *empty* 18 | Mbox ``mbox://`` *empty* 19 | Babyl ``babyl://`` *empty* 20 | MH ``mh://`` *empty* 21 | MMDF ``mmdf://`` *empty* 22 | Piped Mail *empty* See :ref:`receiving-mail-from-exim4-or-postfix` 23 | ================ ================ ==================================================================================================================================================================== 24 | 25 | 26 | .. warning:: 27 | 28 | Unless you are using IMAP's 'Archive' feature, 29 | this will delete any messages it can find in the inbox you specify; 30 | do not use an e-mail inbox that you would like to share between 31 | applications. 32 | 33 | .. _pop3-and-imap-mailboxes: 34 | 35 | POP3 and IMAP Mailboxes 36 | ----------------------- 37 | 38 | Mailbox URIs are in the normal URI format:: 39 | 40 | protocol://username:password@domain 41 | 42 | Basic IMAP Example: ``imap://username:password@server`` 43 | 44 | Basic POP3 Example: ``pop3://username:password@server`` 45 | 46 | Most mailboxes these days are SSL-enabled; 47 | if yours use plain SSL add ``+ssl`` to the protocol section of your URI, 48 | but for STARTTLS add ``+tls``. 49 | Also, if your username or password include any non-ascii characters, 50 | they should be URL-encoded (for example, if your username includes an 51 | ``@``, it should be changed to ``%40`` in your URI). 52 | 53 | For a verbose example, if you have an account named 54 | ``youremailaddress@gmail.com`` with a password 55 | of ``1234`` on GMail, which uses a IMAP server of ``imap.gmail.com`` (requiring 56 | SSL) and you would like to fetch new emails from folder named `Myfolder` and archive them after processing 57 | into a folder named ``Archived``, you 58 | would enter the following as your URI:: 59 | 60 | imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?archive=Archived&folder=Myfolder 61 | 62 | Additional IMAP Mailbox Features 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | If you are using an IMAP Mailbox, you have two additional configuration 66 | options that you can set by appending parameters to the end of your 67 | mailbox URI. 68 | 69 | Specifying the source folder 70 | ++++++++++++++++++++++++++++ 71 | 72 | Although by default, Django Mailbox will consume messages from your 'INBOX' 73 | folder, you can specify the folder from which you'd like messages consumed 74 | by specifying the ``folder`` URI query parameter; for example, to instead 75 | consume from the folder named 'MyFolder', you could add ``?folder=MyFolder`` 76 | to the end of your URI:: 77 | 78 | imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?folder=MyFolder 79 | 80 | If you have a space in your folder like ``Junk Mail`` you have to wrap the 81 | foldername in (encoded)quotes like: 82 | 83 | ``…?folder=%22Junk%20Mail%22`` 84 | 85 | .. _gmail-oauth: 86 | 87 | Specifying an archive folder 88 | ++++++++++++++++++++++++++++ 89 | 90 | Django Mailbox will delete messages immediately after processing them, 91 | but you can specify an IMAP folder to which the messages should be copied 92 | before the original message is deleted. 93 | 94 | To archive email messages, add the archive folder 95 | name as a query parameter to the URI. For example, if your mailbox has a 96 | folder named ``myarchivefolder`` that you would like to copy messages to 97 | after processing, add ``?archive=myarchivefolder`` to the end of the URI:: 98 | 99 | 100 | imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?archive=myarchivefolder 101 | 102 | If you want to specifying both folder use ``&``:: 103 | 104 | imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?archive=myarchivefolder&folder=MyFolder 105 | 106 | Gmail IMAP with Oauth2 authentication 107 | ------------------------------------- 108 | 109 | For added security, Gmail supports using OAuth2 for authentication_. 110 | To handle the handshake and storing the credentials, use python-social-auth_. 111 | 112 | .. _authentication: https://developers.google.com/gmail/xoauth2_protocol 113 | .. _python-social-auth: https://github.com/python-social-auth 114 | 115 | The Gmail Mailbox is also a regular IMAP mailbox, 116 | but the password you specify will be ignored if OAuth2 authentication succeeds. 117 | It will fall back to use your specified password as needed. 118 | 119 | Build your URI accordingly:: 120 | 121 | gmail+ssl://youremailaddress%40gmail.com:oauth2@imap.gmail.com?archive=Archived 122 | 123 | 124 | .. _office365-oauth: 125 | 126 | Office 365 API 127 | ------------------------------------- 128 | 129 | Office 365 allows through the API to read a mailbox with Oauth. 130 | The O365_ library is used and must be installed. 131 | 132 | .. _O365: https://github.com/O365/python-o365 133 | .. _configuration: https://github.com/O365/python-o365#authentication 134 | 135 | For the Oauth configuration you need to follow the instructions on the O365 configuration_ page. 136 | You need to register an application and get a client_id, client_secret and tenant_id. 137 | 138 | client_secret is equivalent to the app secret value and not the ID. 139 | 140 | This implementation uses the client credentials grant flow and the password you specify will be ignored. 141 | 142 | Build your URI accordingly:: 143 | 144 | office365://youremailaddress%40yourdomain.com:oauth2@outlook.office365.com?client_id=client_id&client_secret=client_secret&tenant_id=tenant_id&archive=Archived 145 | 146 | 147 | Local File-based Mailboxes 148 | -------------------------- 149 | 150 | If you happen to want to consume a file-based mailbox like an Maildir, Mbox, 151 | Babyl, MH, or MMDF mailbox, you can use this too by entering the appropriate 152 | 'protocol' in the URI. If you had a maildir, for example, at ``/var/mail/``, 153 | you would enter a URI like:: 154 | 155 | maildir:///var/mail 156 | 157 | Note that there is an additional ``/`` in the above URI after the protocol; 158 | this is important. 159 | 160 | -------------------------------------------------------------------------------- /django_mailbox/tests/base.py: -------------------------------------------------------------------------------- 1 | import email 2 | import os.path 3 | import time 4 | 5 | from django.conf import settings 6 | from django.test import TestCase 7 | 8 | from django_mailbox import models, utils 9 | from django_mailbox.models import Mailbox, Message 10 | 11 | 12 | class EmailIntegrationTimeout(Exception): 13 | pass 14 | 15 | 16 | def get_email_as_text(name): 17 | with open( 18 | os.path.join( 19 | os.path.dirname(__file__), 20 | 'messages', 21 | name, 22 | ), 23 | 'rb' 24 | ) as f: 25 | return f.read() 26 | 27 | 28 | class EmailMessageTestCase(TestCase): 29 | ALLOWED_EXTRA_HEADERS = [ 30 | 'MIME-Version', 31 | 'Content-Transfer-Encoding', 32 | ] 33 | 34 | def setUp(self): 35 | dm_settings = utils.get_settings() 36 | 37 | self._ALLOWED_MIMETYPES = dm_settings['allowed_mimetypes'] 38 | self._STRIP_UNALLOWED_MIMETYPES = ( 39 | dm_settings['strip_unallowed_mimetypes'] 40 | ) 41 | self._TEXT_STORED_MIMETYPES = dm_settings['text_stored_mimetypes'] 42 | 43 | self.mailbox = Mailbox.objects.create(from_email='from@example.com') 44 | 45 | self.test_account = os.environ.get('EMAIL_ACCOUNT') 46 | self.test_password = os.environ.get('EMAIL_PASSWORD') 47 | self.test_smtp_server = os.environ.get('EMAIL_SMTP_SERVER') 48 | self.test_from_email = 'nobody@nowhere.com' 49 | 50 | self.maximum_wait_seconds = 60 * 5 51 | 52 | settings.EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 53 | settings.EMAIL_HOST = self.test_smtp_server 54 | settings.EMAIL_PORT = 587 55 | settings.EMAIL_HOST_USER = self.test_account 56 | settings.EMAIL_HOST_PASSWORD = self.test_password 57 | settings.EMAIL_USE_TLS = True 58 | super().setUp() 59 | 60 | def _get_new_messages(self, mailbox, condition=None): 61 | start_time = time.time() 62 | # wait until there is at least one message 63 | while time.time() - start_time < self.maximum_wait_seconds: 64 | 65 | messages = self.mailbox.get_new_mail(condition) 66 | 67 | try: 68 | # check if generator contains at least one element 69 | message = next(messages) 70 | yield message 71 | yield from messages 72 | return 73 | 74 | except StopIteration: 75 | time.sleep(5) 76 | 77 | raise EmailIntegrationTimeout() 78 | 79 | def _get_email_as_text(self, name): 80 | with open( 81 | os.path.join( 82 | os.path.dirname(__file__), 83 | 'messages', 84 | name, 85 | ), 86 | 'rb' 87 | ) as f: 88 | return f.read() 89 | 90 | def _get_email_object(self, name): 91 | copy = self._get_email_as_text(name) 92 | return email.message_from_bytes(copy) 93 | 94 | def _headers_identical(self, left, right, header=None): 95 | """ Check if headers are (close enough to) identical. 96 | 97 | * This is particularly tricky because Python 2.6, Python 2.7 and 98 | Python 3 each handle header strings slightly differently. This 99 | should mash away all of the differences, though. 100 | * This also has a small loophole in that when re-writing e-mail 101 | payload encodings, we re-build the Content-Type header, so if the 102 | header was originally unquoted, it will be quoted when rehydrating 103 | the e-mail message. 104 | 105 | """ 106 | if header.lower() == 'content-type': 107 | # Special case; given that we re-write the header, we'll be quoting 108 | # the new content type; we need to make sure that doesn't cause 109 | # this comparison to fail. Also, the case of the encoding could 110 | # be changed, etc. etc. etc. 111 | left = left.replace('"', '').upper() 112 | right = right.replace('"', '').upper() 113 | left = left.replace('\n\t', ' ').replace('\n ', ' ') 114 | right = right.replace('\n\t', ' ').replace('\n ', ' ') 115 | if right != left: 116 | return False 117 | return True 118 | 119 | def compare_email_objects(self, left, right): 120 | # Compare headers 121 | for key, value in left.items(): 122 | if not right[key] and key in self.ALLOWED_EXTRA_HEADERS: 123 | continue 124 | if not right[key]: 125 | raise AssertionError("Extra header '%s'" % key) 126 | if not self._headers_identical(right[key], value, header=key): 127 | raise AssertionError( 128 | "Header '{}' unequal:\n{}\n{}".format( 129 | key, 130 | repr(value), 131 | repr(right[key]), 132 | ) 133 | ) 134 | for key, value in right.items(): 135 | if not left[key] and key in self.ALLOWED_EXTRA_HEADERS: 136 | continue 137 | if not left[key]: 138 | raise AssertionError("Extra header '%s'" % key) 139 | if not self._headers_identical(left[key], value, header=key): 140 | raise AssertionError( 141 | "Header '{}' unequal:\n{}\n{}".format( 142 | key, 143 | repr(value), 144 | repr(right[key]), 145 | ) 146 | ) 147 | if left.is_multipart() != right.is_multipart(): 148 | self._raise_mismatched(left, right) 149 | if left.is_multipart(): 150 | left_payloads = left.get_payload() 151 | right_payloads = right.get_payload() 152 | if len(left_payloads) != len(right_payloads): 153 | self._raise_mismatched(left, right) 154 | for n in range(len(left_payloads)): 155 | self.compare_email_objects( 156 | left_payloads[n], 157 | right_payloads[n] 158 | ) 159 | else: 160 | if left.get_payload() is None or right.get_payload() is None: 161 | if left.get_payload() is None: 162 | if right.get_payload is not None: 163 | self._raise_mismatched(left, right) 164 | if right.get_payload() is None: 165 | if left.get_payload is not None: 166 | self._raise_mismatched(left, right) 167 | elif left.get_payload().strip() != right.get_payload().strip(): 168 | self._raise_mismatched(left, right) 169 | 170 | def _raise_mismatched(self, left, right): 171 | raise AssertionError( 172 | "Message payloads do not match:\n{}\n{}".format( 173 | left.as_string(), 174 | right.as_string() 175 | ) 176 | ) 177 | 178 | def assertEqual(self, left, right): # noqa: N802 179 | if not isinstance(left, email.message.Message): 180 | return super().assertEqual(left, right) 181 | return self.compare_email_objects(left, right) 182 | 183 | def tearDown(self): 184 | for message in Message.objects.all(): 185 | message.delete() 186 | models.ALLOWED_MIMETYPES = self._ALLOWED_MIMETYPES 187 | models.STRIP_UNALLOWED_MIMETYPES = self._STRIP_UNALLOWED_MIMETYPES 188 | models.TEXT_STORED_MIMETYPES = self._TEXT_STORED_MIMETYPES 189 | 190 | self.mailbox.delete() 191 | super().tearDown() 192 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-mailbox documentation build configuration file, created by 3 | # sphinx-quickstart on Tue Jan 22 20:29:12 2013. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import sys, os 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | #sys.path.insert(0, os.path.abspath('.')) 19 | 20 | # -- General configuration ----------------------------------------------------- 21 | 22 | # If your documentation needs a minimal Sphinx version, state it here. 23 | #needs_sphinx = '1.0' 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['_templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.rst' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8-sig' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = 'django-mailbox' 43 | copyright = '2020, Adam Coddington' 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The short X.Y version. 50 | version = '3.3' 51 | # The full version, including alpha/beta/rc tags. 52 | release = '3.3' 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | exclude_patterns = ['_build'] 67 | 68 | # The reST default role (used for this markup: `text`) to use for all documents. 69 | #default_role = None 70 | 71 | # If true, '()' will be appended to :func: etc. cross-reference text. 72 | #add_function_parentheses = True 73 | 74 | # If true, the current module name will be prepended to all description 75 | # unit titles (such as .. function::). 76 | #add_module_names = True 77 | 78 | # If true, sectionauthor and moduleauthor directives will be shown in the 79 | # output. They are ignored by default. 80 | #show_authors = False 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = 'sphinx' 84 | 85 | # A list of ignored prefixes for module index sorting. 86 | #modindex_common_prefix = [] 87 | 88 | 89 | # -- Options for HTML output --------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | html_theme = 'default' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | #html_theme_options = {} 99 | 100 | # Add any paths that contain custom themes here, relative to this directory. 101 | #html_theme_path = [] 102 | 103 | # The name for this set of Sphinx documents. If None, it defaults to 104 | # " v documentation". 105 | #html_title = None 106 | 107 | # A shorter title for the navigation bar. Default is the same as html_title. 108 | #html_short_title = None 109 | 110 | # The name of an image file (relative to this directory) to place at the top 111 | # of the sidebar. 112 | #html_logo = None 113 | 114 | # The name of an image file (within the static path) to use as favicon of the 115 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 116 | # pixels large. 117 | #html_favicon = None 118 | 119 | # Add any paths that contain custom static files (such as style sheets) here, 120 | # relative to this directory. They are copied after the builtin static files, 121 | # so a file named "default.css" will overwrite the builtin "default.css". 122 | # html_static_path = ['_static'] 123 | 124 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 125 | # using the given strftime format. 126 | #html_last_updated_fmt = '%b %d, %Y' 127 | 128 | # If true, SmartyPants will be used to convert quotes and dashes to 129 | # typographically correct entities. 130 | #html_use_smartypants = True 131 | 132 | # Custom sidebar templates, maps document names to template names. 133 | #html_sidebars = {} 134 | 135 | # Additional templates that should be rendered to pages, maps page names to 136 | # template names. 137 | #html_additional_pages = {} 138 | 139 | # If false, no module index is generated. 140 | #html_domain_indices = True 141 | 142 | # If false, no index is generated. 143 | #html_use_index = True 144 | 145 | # If true, the index is split into individual pages for each letter. 146 | #html_split_index = False 147 | 148 | # If true, links to the reST sources are added to the pages. 149 | #html_show_sourcelink = True 150 | 151 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 152 | #html_show_sphinx = True 153 | 154 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 155 | #html_show_copyright = True 156 | 157 | # If true, an OpenSearch description file will be output, and all pages will 158 | # contain a tag referring to it. The value of this option must be the 159 | # base URL from which the finished HTML is served. 160 | #html_use_opensearch = '' 161 | 162 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 163 | #html_file_suffix = None 164 | 165 | # Output file base name for HTML help builder. 166 | htmlhelp_basename = 'django-mailboxdoc' 167 | 168 | 169 | # -- Options for LaTeX output -------------------------------------------------- 170 | 171 | latex_elements = { 172 | # The paper size ('letterpaper' or 'a4paper'). 173 | #'papersize': 'letterpaper', 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | #'pointsize': '10pt', 177 | 178 | # Additional stuff for the LaTeX preamble. 179 | #'preamble': '', 180 | } 181 | 182 | # Grouping the document tree into LaTeX files. List of tuples 183 | # (source start file, target name, title, author, documentclass [howto/manual]). 184 | latex_documents = [ 185 | ('index', 'django-mailbox.tex', 'django-mailbox Documentation', 186 | 'Adam Coddington', 'manual'), 187 | ] 188 | 189 | # The name of an image file (relative to this directory) to place at the top of 190 | # the title page. 191 | #latex_logo = None 192 | 193 | # For "manual" documents, if this is true, then toplevel headings are parts, 194 | # not chapters. 195 | #latex_use_parts = False 196 | 197 | # If true, show page references after internal links. 198 | #latex_show_pagerefs = False 199 | 200 | # If true, show URL addresses after external links. 201 | #latex_show_urls = False 202 | 203 | # Documents to append as an appendix to all manuals. 204 | #latex_appendices = [] 205 | 206 | # If false, no module index is generated. 207 | #latex_domain_indices = True 208 | 209 | 210 | # -- Options for manual page output -------------------------------------------- 211 | 212 | # One entry per manual page. List of tuples 213 | # (source start file, name, description, authors, manual section). 214 | man_pages = [ 215 | ('index', 'django-mailbox', 'django-mailbox Documentation', 216 | ['Adam Coddington'], 1) 217 | ] 218 | 219 | # If true, show URL addresses after external links. 220 | #man_show_urls = False 221 | 222 | 223 | # -- Options for Texinfo output ------------------------------------------------ 224 | 225 | # Grouping the document tree into Texinfo files. List of tuples 226 | # (source start file, target name, title, author, 227 | # dir menu entry, description, category) 228 | texinfo_documents = [ 229 | ('index', 'django-mailbox', 'django-mailbox Documentation', 230 | 'Adam Coddington', 'django-mailbox', 'One line description of project.', 231 | 'Miscellaneous'), 232 | ] 233 | 234 | # Documents to append as an appendix to all manuals. 235 | #texinfo_appendices = [] 236 | 237 | # If false, no module index is generated. 238 | #texinfo_domain_indices = True 239 | 240 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 241 | #texinfo_show_urls = 'footnote' 242 | 243 | sys.path.insert(0, os.path.abspath('..')) 244 | import inspect 245 | import django 246 | from django.utils.html import strip_tags 247 | from django.utils.encoding import force_str 248 | from django.conf import settings 249 | settings.configure(INSTALLED_APPS=['django_mailbox', ]) 250 | django.setup() 251 | 252 | def process_docstring(app, what, name, obj, options, lines): 253 | # Source: https://gist.github.com/abulka/48b54ea4cbc7eb014308 254 | # This causes import errors if left outside the function 255 | from django.db import models 256 | 257 | # Only look at objects that inherit from Django's base model class 258 | if inspect.isclass(obj) and issubclass(obj, models.Model): 259 | # Grab the field list from the meta class 260 | fields = obj._meta.get_fields() 261 | 262 | for field in fields: 263 | # Skip ManyToOneRel and ManyToManyRel fields which have no 'verbose_name' or 'help_text' 264 | if not hasattr(field, 'verbose_name'): 265 | continue 266 | 267 | # Decode and strip any html out of the field's help text 268 | help_text = strip_tags(force_str(field.help_text)) 269 | 270 | # Decode and capitalize the verbose name, for use if there isn't 271 | # any help text 272 | verbose_name = force_str(field.verbose_name).capitalize() 273 | 274 | if help_text: 275 | # Add the model field to the end of the docstring as a param 276 | # using the help text as the description 277 | lines.append(':param {}: {}'.format(field.attname, help_text)) 278 | else: 279 | # Add the model field to the end of the docstring as a param 280 | # using the verbose name as the description 281 | lines.append(':param {}: {}'.format(field.attname, verbose_name)) 282 | 283 | # Add the field's type to the docstring 284 | if isinstance(field, models.ForeignKey): 285 | to = field.related_model 286 | lines.append(':type {}: {} to :class:`~{}.{}`'.format(field.attname, type(field).__name__, to.__module__, to.__name__)) 287 | else: 288 | lines.append(':type {}: {}'.format(field.attname, type(field).__name__)) 289 | 290 | # Return the extended docstring 291 | return lines 292 | 293 | def setup(app): 294 | # Register the docstring processor with sphinx 295 | app.connect('autodoc-process-docstring', process_docstring) 296 | -------------------------------------------------------------------------------- /django_mailbox/tests/test_process_email.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import os.path 3 | 4 | import copy 5 | from unittest import mock 6 | 7 | from django_mailbox.models import Mailbox, Message 8 | from django_mailbox.utils import convert_header_to_unicode 9 | from django_mailbox import utils 10 | from django_mailbox.tests.base import EmailMessageTestCase 11 | from django.utils.encoding import force_str 12 | from django.core.mail import EmailMessage 13 | 14 | __all__ = ['TestProcessEmail'] 15 | 16 | 17 | class TestProcessEmail(EmailMessageTestCase): 18 | def test_message_without_attachments(self): 19 | message = self._get_email_object('generic_message.eml') 20 | 21 | mailbox = Mailbox.objects.create() 22 | msg = mailbox.process_incoming_message(message) 23 | 24 | self.assertEqual( 25 | msg.mailbox, 26 | mailbox 27 | ) 28 | self.assertEqual(msg.subject, 'Message Without Attachment') 29 | self.assertEqual( 30 | msg.message_id, 31 | ( 32 | '' 34 | ) 35 | ) 36 | self.assertEqual( 37 | msg.from_header, 38 | 'Adam Coddington ', 39 | ) 40 | self.assertEqual( 41 | msg.to_header, 42 | 'Adam Coddington ', 43 | ) 44 | 45 | def test_message_with_encoded_attachment_filenames(self): 46 | message = self._get_email_object( 47 | 'message_with_koi8r_filename_attachments.eml' 48 | ) 49 | 50 | mailbox = Mailbox.objects.create() 51 | msg = mailbox.process_incoming_message(message) 52 | 53 | attachments = msg.attachments.order_by('pk').all() 54 | self.assertEqual( 55 | '\u041f\u0430\u043a\u0435\u0442 \u043f\u0440\u0435\u0434\u043b' 56 | '\u043e\u0436\u0435\u043d\u0438\u0439 HSE Career Fair 8 \u0430' 57 | '\u043f\u0440\u0435\u043b\u044f 2016.pdf', 58 | attachments[0].get_filename() 59 | ) 60 | self.assertEqual( 61 | '\u0412\u0435\u0434\u043e\u043c\u043e\u0441\u0442\u0438.pdf', 62 | attachments[1].get_filename() 63 | ) 64 | self.assertEqual( 65 | '\u041f\u0430\u043a\u0435\u0442 \u043f\u0440\u0435\u0434\u043b' 66 | '\u043e\u0436\u0435\u043d\u0438\u0439 2016.pptx', 67 | attachments[2].get_filename() 68 | ) 69 | 70 | def test_message_with_attachments(self): 71 | message = self._get_email_object('message_with_attachment.eml') 72 | 73 | mailbox = Mailbox.objects.create() 74 | msg = mailbox.process_incoming_message(message) 75 | 76 | expected_count = 1 77 | actual_count = msg.attachments.count() 78 | 79 | self.assertEqual( 80 | expected_count, 81 | actual_count, 82 | ) 83 | 84 | attachment = msg.attachments.all()[0] 85 | self.assertEqual( 86 | attachment.get_filename(), 87 | 'heart.png', 88 | ) 89 | 90 | def test_message_with_rfc822_attachment(self): 91 | message = self._get_email_object('message_with_rfc822_attachment.eml') 92 | 93 | mailbox = Mailbox.objects.create() 94 | msg = mailbox.process_incoming_message(message) 95 | 96 | expected_count = 1 97 | actual_count = msg.attachments.count() 98 | 99 | self.assertEqual( 100 | expected_count, 101 | actual_count, 102 | ) 103 | 104 | attachment = msg.attachments.all()[0] 105 | self.assertIsNone(attachment.get_filename()) 106 | 107 | def test_message_with_utf8_attachment_header(self): 108 | """ Ensure that we properly handle UTF-8 encoded attachment 109 | 110 | Safe for regress of #104 too 111 | """ 112 | email_object = self._get_email_object( 113 | 'message_with_utf8_attachment.eml', 114 | ) 115 | mailbox = Mailbox.objects.create() 116 | msg = mailbox.process_incoming_message(email_object) 117 | 118 | expected_count = 2 119 | actual_count = msg.attachments.count() 120 | 121 | self.assertEqual( 122 | expected_count, 123 | actual_count, 124 | ) 125 | 126 | attachment = msg.attachments.all()[0] 127 | self.assertEqual( 128 | attachment.get_filename(), 129 | 'pi\u0142kochwyty.jpg' 130 | ) 131 | 132 | attachment = msg.attachments.all()[1] 133 | self.assertEqual( 134 | attachment.get_filename(), 135 | 'odpowied\u017a Burmistrza.jpg' 136 | ) 137 | 138 | def test_message_get_text_body(self): 139 | message = self._get_email_object('multipart_text.eml') 140 | 141 | mailbox = Mailbox.objects.create() 142 | msg = mailbox.process_incoming_message(message) 143 | 144 | expected_results = 'Hello there!' 145 | actual_results = msg.text.strip() 146 | 147 | self.assertEqual( 148 | expected_results, 149 | actual_results, 150 | ) 151 | 152 | def test_get_text_body_properly_recomposes_line_continuations(self): 153 | message = Message() 154 | email_object = self._get_email_object( 155 | 'message_with_long_text_lines.eml' 156 | ) 157 | 158 | message.get_email_object = lambda: email_object 159 | 160 | actual_text = message.text 161 | expected_text = ( 162 | 'The one of us with a bike pump is far ahead, ' 163 | 'but a man stopped to help us and gave us his pump.' 164 | ) 165 | 166 | self.assertEqual( 167 | actual_text, 168 | expected_text 169 | ) 170 | 171 | def test_get_body_properly_handles_unicode_body(self): 172 | with open( 173 | os.path.join( 174 | os.path.dirname(__file__), 175 | 'messages/generic_message.eml' 176 | ) 177 | ) as f: 178 | unicode_body = f.read() 179 | 180 | message = Message() 181 | message.body = unicode_body 182 | 183 | expected_body = unicode_body 184 | actual_body = message.get_email_object().as_string() 185 | 186 | self.assertEqual( 187 | expected_body, 188 | actual_body 189 | ) 190 | 191 | def test_message_issue_82(self): 192 | """ Ensure that we properly handle incorrectly encoded messages 193 | 194 | """ 195 | email_object = self._get_email_object('email_issue_82.eml') 196 | it = 'works' 197 | try: 198 | # it's ok to call as_string() before passing email_object 199 | # to _get_dehydrated_message() 200 | email_object.as_string() 201 | except: 202 | it = 'do not works' 203 | 204 | success = True 205 | try: 206 | self.mailbox.process_incoming_message(email_object) 207 | except ValueError: 208 | success = False 209 | 210 | self.assertEqual(it, 'works') 211 | self.assertEqual(True, success) 212 | 213 | def test_message_issue_82_bis(self): 214 | """ Ensure that the email object is good before and after 215 | calling _get_dehydrated_message() 216 | 217 | """ 218 | message = self._get_email_object('email_issue_82.eml') 219 | 220 | success = True 221 | 222 | # this is the code of _process_message() 223 | msg = Message() 224 | # if STORE_ORIGINAL_MESSAGE: 225 | # msg.eml.save('%s.eml' % uuid.uuid4(), ContentFile(message), 226 | # save=False) 227 | msg.mailbox = self.mailbox 228 | if 'subject' in message: 229 | msg.subject = convert_header_to_unicode(message['subject'])[0:255] 230 | if 'message-id' in message: 231 | msg.message_id = message['message-id'][0:255] 232 | if 'from' in message: 233 | msg.from_header = convert_header_to_unicode(message['from']) 234 | if 'to' in message: 235 | msg.to_header = convert_header_to_unicode(message['to']) 236 | elif 'Delivered-To' in message: 237 | msg.to_header = convert_header_to_unicode(message['Delivered-To']) 238 | msg.save() 239 | 240 | # here the message is ok 241 | str_msg = message.as_string() 242 | message = self.mailbox._get_dehydrated_message(message, msg) 243 | try: 244 | # here as_string raises UnicodeEncodeError 245 | str_msg = message.as_string() 246 | except: 247 | success = False 248 | 249 | msg.set_body(str_msg) 250 | if message['in-reply-to']: 251 | try: 252 | msg.in_reply_to = Message.objects.filter( 253 | message_id=message['in-reply-to'] 254 | )[0] 255 | except IndexError: 256 | pass 257 | msg.save() 258 | 259 | self.assertEqual(True, success) 260 | 261 | def test_message_with_misplaced_utf8_content(self): 262 | """ Ensure that we properly handle incorrectly encoded messages 263 | 264 | ``message_with_utf8_char.eml``'s primary text payload is marked 265 | as being iso-8859-1 data, but actually contains UTF-8 bytes. 266 | 267 | """ 268 | email_object = self._get_email_object('message_with_utf8_char.eml') 269 | 270 | msg = self.mailbox.process_incoming_message(email_object) 271 | 272 | expected_text = 'This message contains funny UTF16 characters ' + \ 273 | 'like this one: "\xc2\xa0" and this one "\xe2\x9c\xbf".' 274 | actual_text = msg.text 275 | 276 | self.assertEqual( 277 | expected_text, 278 | actual_text, 279 | ) 280 | 281 | def test_message_with_invalid_content_for_declared_encoding(self): 282 | """ Ensure that we gracefully handle mis-encoded bodies. 283 | 284 | Should a payload body be misencoded, we should: 285 | 286 | - Not explode 287 | 288 | Note: there is (intentionally) no assertion below; the only guarantee 289 | we make via this library is that processing this e-mail message will 290 | not cause an exception to be raised. 291 | 292 | """ 293 | email_object = self._get_email_object( 294 | 'message_with_invalid_content_for_declared_encoding.eml', 295 | ) 296 | 297 | msg = self.mailbox.process_incoming_message(email_object) 298 | 299 | msg.text 300 | 301 | def test_message_with_valid_content_in_single_byte_encoding(self): 302 | email_object = self._get_email_object( 303 | 'message_with_single_byte_encoding.eml', 304 | ) 305 | 306 | msg = self.mailbox.process_incoming_message(email_object) 307 | 308 | actual_text = msg.text 309 | expected_body = '\u042d\u0442\u043e ' + \ 310 | '\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 ' + \ 311 | '\u0438\u043c\u0435\u0435\u0442 ' + \ 312 | '\u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d' + \ 313 | '\u0443\u044e ' + \ 314 | '\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430.' 315 | 316 | self.assertEqual( 317 | actual_text, 318 | expected_body, 319 | ) 320 | 321 | def test_message_with_single_byte_subject_encoding(self): 322 | email_object = self._get_email_object( 323 | 'message_with_single_byte_extended_subject_encoding.eml', 324 | ) 325 | 326 | msg = self.mailbox.process_incoming_message(email_object) 327 | 328 | expected_subject = '\u00D3\u00E7\u00ED\u00E0\u00E9 \u00EA\u00E0\u00EA ' + \ 329 | '\u00E7\u00E0\u00F0\u00E0\u00E1\u00E0\u00F2\u00FB\u00E2' + \ 330 | '\u00E0\u00F2\u00FC \u00EE\u00F2 1000$ \u00E2 ' + \ 331 | '\u00ED\u00E5\u00E4\u00E5\u00EB\u00FE!' 332 | actual_subject = msg.subject 333 | self.assertEqual(actual_subject, expected_subject) 334 | 335 | expected_from = 'test test ' 336 | actual_from = msg.from_header 337 | self.assertEqual(expected_from, actual_from) 338 | 339 | def test_message_reply(self): 340 | email_object = EmailMessage( 341 | 'Test subject', # subject 342 | 'Test body', # body 343 | 'username@example.com', # from 344 | ['mr.test32@mail.ru'], # to 345 | ) 346 | msg = self.mailbox.record_outgoing_message(email_object.message()) 347 | 348 | with self.assertRaises(ValueError): 349 | msg.reply(Message(subject="ping", body="pong")) 350 | 351 | self.assertTrue(msg.outgoing) 352 | 353 | actual_from = 'username@example.com' 354 | reply_email_object = EmailMessage( 355 | 'Test subject', # subject 356 | 'Test body', # body 357 | actual_from, # from 358 | ['mr.test32@mail.ru'], # to 359 | ) 360 | 361 | with mock.patch.object(reply_email_object, 'send'): 362 | reply_msg = msg.reply(reply_email_object) 363 | 364 | self.assertEqual(reply_msg.in_reply_to, msg) 365 | 366 | self.assertEqual(actual_from, msg.from_header) 367 | 368 | reply_email_object.from_email = None 369 | 370 | with mock.patch.object(reply_email_object, 'send'): 371 | second_reply_msg = msg.reply(reply_email_object) 372 | 373 | self.assertEqual(self.mailbox.from_email, second_reply_msg.from_header) 374 | 375 | def test_message_with_text_attachment(self): 376 | email_object = self._get_email_object( 377 | 'message_with_text_attachment.eml', 378 | ) 379 | 380 | msg = self.mailbox.process_incoming_message(email_object) 381 | 382 | self.assertEqual(msg.attachments.all().count(), 1) 383 | self.assertEqual('Has an attached text document, too!', msg.text) 384 | 385 | def test_message_with_long_content(self): 386 | email_object = self._get_email_object( 387 | 'message_with_long_content.eml', 388 | ) 389 | size = len(force_str(email_object.as_string())) 390 | 391 | msg = self.mailbox.process_incoming_message(email_object) 392 | 393 | self.assertEqual(size, 394 | len(force_str(msg.get_email_object().as_string()))) 395 | 396 | def test_message_saved(self): 397 | message = self._get_email_object('generic_message.eml') 398 | 399 | default_settings = utils.get_settings() 400 | 401 | with mock.patch('django_mailbox.utils.get_settings') as get_settings: 402 | altered = copy.deepcopy(default_settings) 403 | altered['store_original_message'] = True 404 | get_settings.return_value = altered 405 | 406 | msg = self.mailbox.process_incoming_message(message) 407 | 408 | self.assertNotEqual(msg.eml, None) 409 | 410 | self.assertTrue(msg.eml.name.endswith('.eml')) 411 | 412 | with open(msg.eml.name, 'rb') as f: 413 | self.assertEqual( 414 | f.read(), 415 | self._get_email_as_text('generic_message.eml') 416 | ) 417 | 418 | def test_message_saving_ignored(self): 419 | message = self._get_email_object('generic_message.eml') 420 | 421 | default_settings = utils.get_settings() 422 | 423 | with mock.patch('django_mailbox.utils.get_settings') as get_settings: 424 | altered = copy.deepcopy(default_settings) 425 | altered['store_original_message'] = False 426 | get_settings.return_value = altered 427 | 428 | msg = self.mailbox.process_incoming_message(message) 429 | 430 | self.assertEqual(msg.eml, None) 431 | 432 | def test_message_compressed(self): 433 | message = self._get_email_object('generic_message.eml') 434 | 435 | default_settings = utils.get_settings() 436 | 437 | with mock.patch('django_mailbox.utils.get_settings') as get_settings: 438 | altered = copy.deepcopy(default_settings) 439 | altered['compress_original_message'] = True 440 | altered['store_original_message'] = True 441 | get_settings.return_value = altered 442 | 443 | msg = self.mailbox.process_incoming_message(message) 444 | 445 | _actual_email_object = msg.get_email_object() 446 | 447 | self.assertTrue(msg.eml.name.endswith('.eml.gz')) 448 | 449 | with gzip.open(msg.eml.name, 'rb') as f: 450 | self.assertEqual(f.read(), 451 | self._get_email_as_text('generic_message.eml')) 452 | 453 | def test_message_bad_character_in_attachment_filename(self): 454 | ''' 455 | Regression test for handling weird characters in attachment filename headers. 456 | Previously accessing msg.text threw an exception. 457 | ''' 458 | message = self._get_email_object('bad_character_attachment.eml') 459 | msg = self.mailbox.process_incoming_message(message) 460 | 461 | self.assertIsNotNone(msg.text) 462 | -------------------------------------------------------------------------------- /django_mailbox/tests/messages/message_with_long_content.eml: -------------------------------------------------------------------------------- 1 | Delivered-To: inbox+e1610728c9a39d45a39f6de9d3e7882d@appfluence.com 2 | Received: by 10.202.65.3 with SMTP id o3csp2439694oia; 3 | Mon, 1 Aug 2016 07:14:31 -0700 (PDT) 4 | X-Received: by 10.55.174.2 with SMTP id x2mr24289941qke.176.1470060871862; 5 | Mon, 01 Aug 2016 07:14:31 -0700 (PDT) 6 | Return-Path: 7 | Received: from mail-qk0-x231.google.com (mail-qk0-x231.google.com. 8 | [2607:f8b0:400d:c09::231]) 9 | by mx.google.com with ESMTPS id z24si20091602qtb.68.2016.08.01.07.14.31 10 | for 11 | (version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); 12 | Mon, 01 Aug 2016 07:14:31 -0700 (PDT) 13 | Received-SPF: fail (google.com: domain of samuel@appfluence.com does not 14 | designate 2607:f8b0:400d:c09::231 as permitted sender) 15 | client-ip=2607:f8b0:400d:c09::231; 16 | Authentication-Results: mx.google.com; 17 | dkim=pass (test mode) header.i=@appfluence.com; 18 | spf=fail (google.com: domain of samuel@appfluence.com does not designate 19 | 2607:f8b0:400d:c09::231 as permitted sender) 20 | smtp.mailfrom=samuel@appfluence.com 21 | Received: by mail-qk0-x231.google.com with SMTP id s63so146544737qkb.2 22 | for ; 23 | Mon, 01 Aug 2016 07:14:31 -0700 (PDT) 24 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 25 | d=appfluence.com; s=google; 26 | h=mime-version:in-reply-to:references:from:date:message-id:subject:to; 27 | bh=BdDzvT6vMuB8ml9Gj8Rpu5N56mm+mxDWmJj1C2qj1DI=; 28 | b=S3tMqWmjc/j5VI+G+OP0ZifdNuUhr0Oa7+vP+CM799JAVpLj2AHhxqAehiK4chdEBJ 29 | mqCjTDr8HD94+5sRPkXXITnG4e+Qbf+Ev2j7uSva3tzWqwstE16Dgk+5P90zD13Jcw2Z 30 | iR95lJM4P4pjAYPdvI+xIZJzUATYsFeGiLKCk= 31 | X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 32 | d=1e100.net; s=20130820; 33 | h=x-gm-message-state:mime-version:in-reply-to:references:from:date 34 | :message-id:subject:to; 35 | bh=BdDzvT6vMuB8ml9Gj8Rpu5N56mm+mxDWmJj1C2qj1DI=; 36 | b=H6WI5oNvUGYkwJYteWNZRQpbUZlsb93j7WHHuwMfC1LhTdJhVD8cdARx8BY9hfqqxh 37 | h8ENaNeUEUWvXZIQfqnZLZSBcB2i/7ma30MSRvQjr7fK64HG5as8YzypLHcoMX0tTy6M 38 | KTwejbdeL6Q+kj8LicwlBtW56P6/pNE2JAh+Tz2Jwk0lf8J+I+JAhzYw6Rcl4ePqlsaY 39 | +wEq+nw4L9JCSzcRuoTOXJ4XGuIUl88zqs3gD44fipFBbvksSvv9bcVklv7Zl70H8l49 40 | QFV5gso7Gd/ucv7GmdoII6P4nreumLzsbp8X2E1ITfmbpRj6ZNVfYCm4GJkNmPnIr92z 41 | wTSA== 42 | X-Gm-Message-State: AEkoouvaSnp95p/T1DJAbPeVKXqVUqN0vg8BJ2EGw9PYnyjFbr8scKge3m0yhdFoBpzeJvmDsIC7fHtZI7+kYg== 43 | X-Received: by 10.55.212.218 with SMTP id s87mr21265928qks.10.1470060870080; 44 | Mon, 01 Aug 2016 07:14:30 -0700 (PDT) 45 | MIME-Version: 1.0 46 | Received: by 10.55.214.148 with HTTP; Mon, 1 Aug 2016 07:14:29 -0700 (PDT) 47 | X-Originating-IP: [217.216.81.186] 48 | In-Reply-To: 49 | References: 50 | From: Samuel Carmona 51 | Date: Mon, 1 Aug 2016 16:14:29 +0200 52 | Message-ID: 53 | Subject: Fwd: Test 54 | To: inbox+e1610728c9a39d45a39f6de9d3e7882d@appfluence.com 55 | Content-Type: multipart/alternative; boundary=001a11479a7cf5bcb6053903371b 56 | 57 | --001a11479a7cf5bcb6053903371b 58 | Content-Type: text/plain; charset=UTF-8 59 | 60 | Geralt takes up a contract sent out by Olgierd von Everec, who tasks him 61 | with eliminating a giant toad monster in the sewers of Oxenfurt. While 62 | hunting the monster, Geralt runs into Shani, a Redanian medic and an old 63 | acquaintance of his, whom he has the option of romancing. Geralt then kills 64 | the toad monster, only to find it was actually a cursed Ofieri prince. The 65 | prince's guards capture Geralt with the intention of executing him. While 66 | awaiting his execution en route to Ofier, Geralt is approached by the 67 | mysterious Gaunter O'Dimm. O'Dimm helps Geralt escape, but in return Geralt 68 | must help O'Dimm recover a debt from von Everec, who had set up Geralt 69 | knowing the toad monster was an Ofieri prince. O'Dimm tells Geralt that 70 | according to terms of his contract with von Everec, he must fulfill three 71 | of von Everec's wishes. Geralt confronts von Everec and discovers that von 72 | Everec had obtained immortality at the cost of his emotions, giving him a 73 | "Heart of Stone". He admits to cursing the Ofieri prince since he was 74 | arranged to marry his true love Iris, and that he wished for immortality in 75 | order to be with her. He then tells Geralt his three wishes: to entertain 76 | his brother Vlodomir for one night, to get revenge on the Borsodi family by 77 | obtaining Maximillian Borsodi's house, and to obtain the violet rose he had 78 | given to Iris. O'Dimm tells Geralt that the three tasks are meant to be 79 | impossible, as Maximillian Borsodi's house is kept in a highly secure vault 80 | and both Vlodomir and Iris have been dead for years, but he agrees to 81 | assist Geralt. 82 | 83 | With O'Dimm's help, Geralt allows Vlodimir's spirit to possess his body for 84 | one night, allowing him to attend a wedding party and fulfilling the first 85 | wish. Geralt then participates in a heist to steal Maximillian Borsodi's 86 | house from its vault, and finds that it contains a will that would grant 87 | the entire Borsodi fortune to charity, fulfilling von Everec's revenge and 88 | second wish. To obtain Iris' rose, Geralt enlists the help of demonic 89 | entities to gain access to a supernatural realm where he witnesses von 90 | Everec and Iris' past. There, he learns that due to his "Heart of Stone", 91 | von Everec could not truly love Iris and she died neglected and unhappy. 92 | Geralt, on player's choice can either obtain the rose from Iris' spirit in 93 | order to free her from "pinned" into the world or let the rose be with her. 94 | Either way, Geralt fulfills von Everec's last wish and goes to meet with 95 | him. Along the way, he learns that O'Dimm is in fact an ancient entity of 96 | pure evil that thrives off tricking people into trading away their souls in 97 | return for wishes. When Geralt meets with von Everec, the three wishes are 98 | fulfilled and O'Dimm arrives to collect von Everec's soul. 99 | 100 | At this point, Geralt has the option of allowing O'Dimm to take von 101 | Everec's soul or intervening to save von Everec. If Geralt does nothing, 102 | O'Dimm kills von Everec, takes his soul, and rewards Geralt with one wish. 103 | If Geralt intervenes, he challenges O'Dimm by wagering his own soul to save 104 | von Everec. After Geralt solves O'Dimm's riddle, O'Dimm is forced to 105 | release both Geralt and von Everec from their pacts. von Everec, now mortal 106 | again, regains his emotions and immediately feels regret for his past 107 | actions and mistakes. He gives Geralt his family sword, and promises to 108 | start a new life free from O'Dimm's control. 109 | 110 | 111 | 11111111111111111 112 | 113 | 114 | 115 | Geralt takes up a contract sent out by Olgierd von Everec, who tasks him 116 | with eliminating a giant toad monster in the sewers of Oxenfurt. While 117 | hunting the monster, Geralt runs into Shani, a Redanian medic and an old 118 | acquaintance of his, whom he has the option of romancing. Geralt then kills 119 | the toad monster, only to find it was actually a cursed Ofieri prince. The 120 | prince's guards capture Geralt with the intention of executing him. While 121 | awaiting his execution en route to Ofier, Geralt is approached by the 122 | mysterious Gaunter O'Dimm. O'Dimm helps Geralt escape, but in return Geralt 123 | must help O'Dimm recover a debt from von Everec, who had set up Geralt 124 | knowing the toad monster was an Ofieri prince. O'Dimm tells Geralt that 125 | according to terms of his contract with von Everec, he must fulfill three 126 | of von Everec's wishes. Geralt confronts von Everec and discovers that von 127 | Everec had obtained immortality at the cost of his emotions, giving him a 128 | "Heart of Stone". He admits to cursing the Ofieri prince since he was 129 | arranged to marry his true love Iris, and that he wished for immortality in 130 | order to be with her. He then tells Geralt his three wishes: to entertain 131 | his brother Vlodomir for one night, to get revenge on the Borsodi family by 132 | obtaining Maximillian Borsodi's house, and to obtain the violet rose he had 133 | given to Iris. O'Dimm tells Geralt that the three tasks are meant to be 134 | impossible, as Maximillian Borsodi's house is kept in a highly secure vault 135 | and both Vlodomir and Iris have been dead for years, but he agrees to 136 | assist Geralt. 137 | 138 | With O'Dimm's help, Geralt allows Vlodimir's spirit to possess his body for 139 | one night, allowing him to attend a wedding party and fulfilling the first 140 | wish. Geralt then participates in a heist to steal Maximillian Borsodi's 141 | house from its vault, and finds that it contains a will that would grant 142 | the entire Borsodi fortune to charity, fulfilling von Everec's revenge and 143 | second wish. To obtain Iris' rose, Geralt enlists the help of demonic 144 | entities to gain access to a supernatural realm where he witnesses von 145 | Everec and Iris' past. There, he learns that due to his "Heart of Stone", 146 | von Everec could not truly love Iris and she died neglected and unhappy. 147 | Geralt, on player's choice can either obtain the rose from Iris' spirit in 148 | order to free her from "pinned" into the world or let the rose be with her. 149 | Either way, Geralt fulfills von Everec's last wish and goes to meet with 150 | him. Along the way, he learns that O'Dimm is in fact an ancient entity of 151 | pure evil that thrives off tricking people into trading away their souls in 152 | return for wishes. When Geralt meets with von Everec, the three wishes are 153 | fulfilled and O'Dimm arrives to collect von Everec's soul. 154 | 155 | At this point, Geralt has the option of allowing O'Dimm to take von 156 | Everec's soul or intervening to save von Everec. If Geralt does nothing, 157 | O'Dimm kills von Everec, takes his soul, and rewards Geralt with one wish. 158 | If Geralt intervenes, he challenges O'Dimm by wagering his own soul to save 159 | von Everec. After Geralt solves O'Dimm's riddle, O'Dimm is forced to 160 | release both Geralt and von Everec from their pacts. von Everec, now mortal 161 | again, regains his emotions and immediately feels regret for his past 162 | actions and mistakes. He gives Geralt his family sword, and promises to 163 | start a new life free from O'Dimm's control. 164 | 165 | 2222222222222222222 166 | 167 | 168 | Geralt takes up a contract sent out by Olgierd von Everec, who tasks him 169 | with eliminating a giant toad monster in the sewers of Oxenfurt. While 170 | hunting the monster, Geralt runs into Shani, a Redanian medic and an old 171 | acquaintance of his, whom he has the option of romancing. Geralt then kills 172 | the toad monster, only to find it was actually a cursed Ofieri prince. The 173 | prince's guards capture Geralt with the intention of executing him. While 174 | awaiting his execution en route to Ofier, Geralt is approached by the 175 | mysterious Gaunter O'Dimm. O'Dimm helps Geralt escape, but in return Geralt 176 | must help O'Dimm recover a debt from von Everec, who had set up Geralt 177 | knowing the toad monster was an Ofieri prince. O'Dimm tells Geralt that 178 | according to terms of his contract with von Everec, he must fulfill three 179 | of von Everec's wishes. Geralt confronts von Everec and discovers that von 180 | Everec had obtained immortality at the cost of his emotions, giving him a 181 | "Heart of Stone". He admits to cursing the Ofieri prince since he was 182 | arranged to marry his true love Iris, and that he wished for immortality in 183 | order to be with her. He then tells Geralt his three wishes: to entertain 184 | his brother Vlodomir for one night, to get revenge on the Borsodi family by 185 | obtaining Maximillian Borsodi's house, and to obtain the violet rose he had 186 | given to Iris. O'Dimm tells Geralt that the three tasks are meant to be 187 | impossible, as Maximillian Borsodi's house is kept in a highly secure vault 188 | and both Vlodomir and Iris have been dead for years, but he agrees to 189 | assist Geralt. 190 | 191 | With O'Dimm's help, Geralt allows Vlodimir's spirit to possess his body for 192 | one night, allowing him to attend a wedding party and fulfilling the first 193 | wish. Geralt then participates in a heist to steal Maximillian Borsodi's 194 | house from its vault, and finds that it contains a will that would grant 195 | the entire Borsodi fortune to charity, fulfilling von Everec's revenge and 196 | second wish. To obtain Iris' rose, Geralt enlists the help of demonic 197 | entities to gain access to a supernatural realm where he witnesses von 198 | Everec and Iris' past. There, he learns that due to his "Heart of Stone", 199 | von Everec could not truly love Iris and she died neglected and unhappy. 200 | Geralt, on player's choice can either obtain the rose from Iris' spirit in 201 | order to free her from "pinned" into the world or let the rose be with her. 202 | Either way, Geralt fulfills von Everec's last wish and goes to meet with 203 | him. Along the way, he learns that O'Dimm is in fact an ancient entity of 204 | pure evil that thrives off tricking people into trading away their souls in 205 | return for wishes. When Geralt meets with von Everec, the three wishes are 206 | fulfilled and O'Dimm arrives to collect von Everec's soul. 207 | 208 | At this point, Geralt has the option of allowing O'Dimm to take von 209 | Everec's soul or intervening to save von Everec. If Geralt does nothing, 210 | O'Dimm kills von Everec, takes his soul, and rewards Geralt with one wish. 211 | If Geralt intervenes, he challenges O'Dimm by wagering his own soul to save 212 | von Everec. After Geralt solves O'Dimm's riddle, O'Dimm is forced to 213 | release both Geralt and von Everec from their pacts. von Everec, now mortal 214 | again, regains his emotions and immediately feels regret for his past 215 | actions and mistakes. He gives Geralt his family sword, and promises to 216 | start a new life free from O'Dimm's control. 217 | 218 | 219 | 220 | 3333333333333333333333333 221 | 222 | Geralt takes up a contract sent out by Olgierd von Everec, who tasks him 223 | with eliminating a giant toad monster in the sewers of Oxenfurt. While 224 | hunting the monster, Geralt runs into Shani, a Redanian medic and an old 225 | acquaintance of his, whom he has the option of romancing. Geralt then kills 226 | the toad monster, only to find it was actually a cursed Ofieri prince. The 227 | prince's guards capture Geralt with the intention of executing him. While 228 | awaiting his execution en route to Ofier, Geralt is approached by the 229 | mysterious Gaunter O'Dimm. O'Dimm helps Geralt escape, but in return Geralt 230 | must help O'Dimm recover a debt from von Everec, who had set up Geralt 231 | knowing the toad monster was an Ofieri prince. O'Dimm tells Geralt that 232 | according to terms of his contract with von Everec, he must fulfill three 233 | of von Everec's wishes. Geralt confronts von Everec and discovers that von 234 | Everec had obtained immortality at the cost of his emotions, giving him a 235 | "Heart of Stone". He admits to cursing the Ofieri prince since he was 236 | arranged to marry his true love Iris, and that he wished for immortality in 237 | order to be with her. He then tells Geralt his three wishes: to entertain 238 | his brother Vlodomir for one night, to get revenge on the Borsodi family by 239 | obtaining Maximillian Borsodi's house, and to obtain the violet rose he had 240 | given to Iris. O'Dimm tells Geralt that the three tasks are meant to be 241 | impossible, as Maximillian Borsodi's house is kept in a highly secure vault 242 | and both Vlodomir and Iris have been dead for years, but he agrees to 243 | assist Geralt. 244 | 245 | With O'Dimm's help, Geralt allows Vlodimir's spirit to possess his body for 246 | one night, allowing him to attend a wedding party and fulfilling the first 247 | wish. Geralt then participates in a heist to steal Maximillian Borsodi's 248 | house from its vault, and finds that it contains a will that would grant 249 | the entire Borsodi fortune to charity, fulfilling von Everec's revenge and 250 | second wish. To obtain Iris' rose, Geralt enlists the help of demonic 251 | entities to gain access to a supernatural realm where he witnesses von 252 | Everec and Iris' past. There, he learns that due to his "Heart of Stone", 253 | von Everec could not truly love Iris and she died neglected and unhappy. 254 | Geralt, on player's choice can either obtain the rose from Iris' spirit in 255 | order to free her from "pinned" into the world or let the rose be with her. 256 | Either way, Geralt fulfills von Everec's last wish and goes to meet with 257 | him. Along the way, he learns that O'Dimm is in fact an ancient entity of 258 | pure evil that thrives off tricking people into trading away their souls in 259 | return for wishes. When Geralt meets with von Everec, the three wishes are 260 | fulfilled and O'Dimm arrives to collect von Everec's soul. 261 | 262 | At this point, Geralt has the option of allowing O'Dimm to take von 263 | Everec's soul or intervening to save von Everec. If Geralt does nothing, 264 | O'Dimm kills von Everec, takes his soul, and rewards Geralt with one wish. 265 | If Geralt intervenes, he challenges O'Dimm by wagering his own soul to save 266 | von Everec. After Geralt solves O'Dimm's riddle, O'Dimm is forced to 267 | release both Geralt and von Everec from their pacts. von Everec, now mortal 268 | again, regains his emotions and immediately feels regret for his past 269 | actions and mistakes. He gives Geralt his family sword, and promises to 270 | start a new life free from O'Dimm's control. 271 | 272 | 4444444444444444444444444 273 | 274 | 275 | Geralt takes up a contract sent out by Olgierd von Everec, who tasks him 276 | with eliminating a giant toad monster in the sewers of Oxenfurt. While 277 | hunting the monster, Geralt runs into Shani, a Redanian medic and an old 278 | acquaintance of his, whom he has the option of romancing. Geralt then kills 279 | the toad monster, only to find it was actually a cursed Ofieri prince. The 280 | prince's guards capture Geralt with the intention of executing him. While 281 | awaiting his execution en route to Ofier, Geralt is approached by the 282 | mysterious Gaunter O'Dimm. O'Dimm helps Geralt escape, but in return Geralt 283 | must help O'Dimm recover a debt from von Everec, who had set up Geralt 284 | knowing the toad monster was an Ofieri prince. O'Dimm tells Geralt that 285 | according to terms of his contract with von Everec, he must fulfill three 286 | of von Everec's wishes. Geralt confronts von Everec and discovers that von 287 | Everec had obtained immortality at the cost of his emotions, giving him a 288 | "Heart of Stone". He admits to cursing the Ofieri prince since he was 289 | arranged to marry his true love Iris, and that he wished for immortality in 290 | order to be with her. He then tells Geralt his three wishes: to entertain 291 | his brother Vlodomir for one night, to get revenge on the Borsodi family by 292 | obtaining Maximillian Borsodi's house, and to obtain the violet rose he had 293 | given to Iris. O'Dimm tells Geralt that the three tasks are meant to be 294 | impossible, as Maximillian Borsodi's house is kept in a highly secure vault 295 | and both Vlodomir and Iris have been dead for years, but he agrees to 296 | assist Geralt. 297 | 298 | With O'Dimm's help, Geralt allows Vlodimir's spirit to possess his body for 299 | one night, allowing him to attend a wedding party and fulfilling the first 300 | wish. Geralt then participates in a heist to steal Maximillian Borsodi's 301 | house from its vault, and finds that it contains a will that would grant 302 | the entire Borsodi fortune to charity, fulfilling von Everec's revenge and 303 | second wish. To obtain Iris' rose, Geralt enlists the help of demonic 304 | entities to gain access to a supernatural realm where he witnesses von 305 | Everec and Iris' past. There, he learns that due to his "Heart of Stone", 306 | von Everec could not truly love Iris and she died neglected and unhappy. 307 | Geralt, on player's choice can either obtain the rose from Iris' spirit in 308 | order to free her from "pinned" into the world or let the rose be with her. 309 | Either way, Geralt fulfills von Everec's last wish and goes to meet with 310 | him. Along the way, he learns that O'Dimm is in fact an ancient entity of 311 | pure evil that thrives off tricking people into trading away their souls in 312 | return for wishes. When Geralt meets with von Everec, the three wishes are 313 | fulfilled and O'Dimm arrives to collect von Everec's soul. 314 | 315 | At this point, Geralt has the option of allowing O'Dimm to take von 316 | Everec's soul or intervening to save von Everec. If Geralt does nothing, 317 | O'Dimm kills von Everec, takes his soul, and rewards Geralt with one wish. 318 | If Geralt intervenes, he challenges O'Dimm by wagering his own soul to save 319 | von Everec. After Geralt solves O'Dimm's riddle, O'Dimm is forced to 320 | release both Geralt and 321 | --001a11479a7cf5bcb6053903371b-- 322 | --------------------------------------------------------------------------------