Skip to content

Commit 9dd8fb3

Browse files
committed
Enhance Automatic Email Submission & Reply
+ add rfc3834 support + model changes + track creator of message, one of: human,auto-self,auto-custom,auto-generated,auto-replied,auto-notified + track external replies with in_reply_to + track smtp outgoing and smtp incoming msgid + tag outgoing email messages with "Auto-Submitted" = auto-generated or auto-replied + only forward human and auto-self messages via smtp forward + add email send filter exception list settings.EMAIL_UNFILTERED_INDIVIDUALS + set the default reply_receiver for all root@system.local messages to get_office_user(submission) + change default for SMTPD_CONFIG['store_exceptions'] to True + modify testcase test_plain to check for creator="human" + add testcases + test_forward_auto_submitted + test_incoming_auto_submitted + test_DEFAULT_FROM_EMAIL_is_marked_automatic_and_reply_to_default_contact + cleanup code, fix broken testcases
1 parent 045a64a commit 9dd8fb3

17 files changed

+368
-147
lines changed

ecs/checklists/workflow.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ def pre_perform(self, choice):
113113
if not c.pdf_document:
114114
c.render_pdf_document()
115115
presenting_parties = c.submission.current_submission_form.get_presenting_parties()
116-
presenting_parties.send_message(_('External Review'), 'checklists/external_review_publish.txt',
116+
presenting_parties.send_message(
117+
_('External Review'),
118+
'checklists/external_review_publish.txt',
117119
{'checklist': c, 'ABSOLUTE_URL_PREFIX': settings.ABSOLUTE_URL_PREFIX},
118120
submission=c.submission)
119121
elif c.status == 'review_fail':

ecs/communication/mailutils.py

+17-10
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,29 @@ def html2text(htmltext):
1818

1919

2020
def create_mail(subject, message, from_email, recipient, message_html=None,
21-
attachments=None, rfc2822_headers=None):
21+
attachments=None, rfc2822_headers=None):
2222

23-
headers = {'Message-ID': make_msgid()}
23+
from ecs.core.models import AdvancedSettings
24+
25+
headers = {'Message-ID': make_msgid(domain=settings.DOMAIN)}
26+
if from_email == settings.DEFAULT_FROM_EMAIL:
27+
# if empty, set Auto-Submitted and Reply-To for mails from DEFAULT_FROM_EMAIL
28+
if not rfc2822_headers or not rfc2822_headers.get('Auto-Submitted', None):
29+
headers.update({'Auto-Submitted': 'auto-generated'})
30+
if not rfc2822_headers or not rfc2822_headers.get('Reply-To', None):
31+
headers.update({'Reply-To': AdvancedSettings.objects.get(pk=1).default_contact})
2432

2533
if rfc2822_headers:
2634
headers.update(rfc2822_headers)
2735

28-
if message is None: # make text version out of html if text version is missing
36+
if message is None: # make text version out of html if text version is missing
2937
message = html2text(message_html)
3038

3139
if message_html:
32-
msg = EmailMultiAlternatives(subject, message, from_email, [recipient],
33-
headers=headers)
40+
msg = EmailMultiAlternatives(subject, message, from_email, [recipient], headers=headers)
3441
msg.attach_alternative(message_html, "text/html")
3542
else:
36-
msg = EmailMessage(subject, message, from_email, [recipient],
37-
headers=headers)
43+
msg = EmailMessage(subject, message, from_email, [recipient], headers=headers)
3844

3945
if attachments:
4046
for filename, content, mimetype in attachments:
@@ -62,12 +68,13 @@ def deliver(recipient_list, *args, **kwargs):
6268
def deliver_to_recipient(recipient, subject, message, from_email,
6369
message_html=None, attachments=None, nofilter=False, rfc2822_headers=None):
6470

65-
msg = create_mail(subject, message, from_email, recipient, message_html,
66-
attachments, rfc2822_headers)
71+
msg = create_mail(subject, message, from_email, recipient,
72+
message_html, attachments, rfc2822_headers)
6773
msgid = msg.extra_headers['Message-ID']
6874

6975
backend = settings.EMAIL_BACKEND
70-
if nofilter or recipient.split('@')[1] in settings.EMAIL_UNFILTERED_DOMAINS:
76+
if (nofilter or recipient.split('@')[1] in settings.EMAIL_UNFILTERED_DOMAINS or
77+
recipient in settings.EMAIL_UNFILTERED_INDIVIDUALS):
7178
backend = settings.EMAIL_BACKEND_UNFILTERED
7279

7380
connection = mail.get_connection(backend=backend)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('communication', '0013_search_tsvector'),
11+
]
12+
13+
operations = [
14+
migrations.RenameField(
15+
model_name='message',
16+
old_name='rawmsg_msgid',
17+
new_name='outgoing_msgid',
18+
),
19+
migrations.AddField(
20+
model_name='message',
21+
name='creator',
22+
field=models.CharField(choices=[('human', 'human'), ('auto-self', 'auto-self'), ('auto-custom', 'auto-custom'), ('auto-generated', 'auto-generated'), ('auto-replied', 'auto-replied'), ('auto-notified', 'auto-notified')], max_length=14, default='human'),
23+
),
24+
migrations.AddField(
25+
model_name='message',
26+
name='in_reply_to',
27+
field=models.ForeignKey(related_name='is_replied_in', null=True, default=None, to='communication.Message'),
28+
),
29+
migrations.AddField(
30+
model_name='message',
31+
name='incoming_msgid',
32+
field=models.CharField(max_length=250, null=True),
33+
),
34+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('communication', '0014_auto_reply'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='message',
16+
name='outgoing_msgid',
17+
field=models.CharField(max_length=250, null=True),
18+
),
19+
]

ecs/communication/models.py

+93-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import uuid
2+
import traceback
23

34
from django.conf import settings
45
from django.db import models
56
from django.db.models import Q
67
from django.contrib.auth.models import User
8+
from django.utils.translation import ugettext as _
9+
10+
from ecs.communication.mailutils import deliver_to_recipient
11+
from ecs.users.utils import get_full_name
712

813

914
DELIVERY_STATES = (
@@ -13,6 +18,18 @@
1318
("failure", "failure"),
1419
)
1520

21+
# https://tools.ietf.org/html/rfc3834
22+
# https://tools.ietf.org/html/rfc5436
23+
CREATOR_CHOICES = (
24+
("human", "human"),
25+
("auto-self", "auto-self"),
26+
("auto-custom", "auto-custom"),
27+
# rfc3834 compatible
28+
("auto-generated", "auto-generated"),
29+
("auto-replied", "auto-replied"),
30+
("auto-notified", "auto-notified"),
31+
)
32+
1633

1734
class ThreadQuerySet(models.QuerySet):
1835
def by_user(self, user):
@@ -60,7 +77,10 @@ def unstar(self, user):
6077
self.starred_by_receiver = False
6178
self.save(update_fields=('starred_by_sender', 'starred_by_receiver'))
6279

63-
def add_message(self, user, text, rawmsg_msgid=None, rawmsg=None, reply_receiver=None):
80+
def add_message(self, user, text,
81+
reply_receiver=None, rawmsg=None,
82+
outgoing_msgid=None, incoming_msgid=None,
83+
in_reply_to=None, creator=None):
6484
assert user.id in (self.sender_id, self.receiver_id)
6585

6686
if user.id == self.sender_id:
@@ -80,14 +100,20 @@ def add_message(self, user, text, rawmsg_msgid=None, rawmsg=None, reply_receiver
80100
else:
81101
self.sender = receiver
82102

103+
if creator is None:
104+
creator = 'human'
105+
83106
msg = self.messages.create(
84107
sender=user,
85108
receiver=receiver,
86109
text=text,
87110
smtp_delivery_state='new',
88111
rawmsg=rawmsg,
89-
rawmsg_msgid=rawmsg_msgid,
90-
reply_receiver=reply_receiver
112+
outgoing_msgid=outgoing_msgid,
113+
incoming_msgid=incoming_msgid,
114+
reply_receiver=reply_receiver,
115+
in_reply_to=in_reply_to,
116+
creator=creator,
91117
)
92118
self.last_message = msg
93119
self.closed_by_sender = False
@@ -110,24 +136,81 @@ def message_list(self):
110136

111137

112138
class Message(models.Model):
139+
uuid = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True)
113140
thread = models.ForeignKey(Thread, related_name='messages')
114141
sender = models.ForeignKey(User, related_name='outgoing_messages')
115142
receiver = models.ForeignKey(User, related_name='incoming_messages')
143+
reply_receiver = models.ForeignKey(User, null=True,
144+
related_name='reply_receiver_for_messages')
116145
timestamp = models.DateTimeField(auto_now_add=True)
117146
unread = models.BooleanField(default=True, db_index=True)
118147
text = models.TextField()
119148

120149
rawmsg = models.TextField(null=True)
121-
rawmsg_msgid = models.CharField(max_length=250, null=True, db_index=True)
150+
outgoing_msgid = models.CharField(max_length=250, null=True)
151+
incoming_msgid = models.CharField(max_length=250, null=True)
122152

123153
smtp_delivery_state = models.CharField(max_length=7,
124154
choices=DELIVERY_STATES, default='new', db_index=True)
125-
126-
uuid = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True)
127-
128-
reply_receiver = models.ForeignKey(User, null=True, related_name='reply_receiver_for_messages')
155+
in_reply_to = models.ForeignKey('self', related_name='is_replied_in',
156+
null=True, default=None)
157+
creator = models.CharField(max_length=14,
158+
choices=CREATOR_CHOICES, default='human')
129159

130160
@property
131161
def return_address(self):
132-
return 'ecs-{}@{}'.format(self.uuid.hex,
133-
settings.DOMAIN)
162+
return 'ecs-{}@{}'.format(self.uuid.hex, settings.DOMAIN)
163+
164+
@property
165+
def smtp_subject(self):
166+
submission = self.thread.submission
167+
ec_number = ''
168+
if submission:
169+
ec_number = ' ' + submission.get_ec_number_display()
170+
subject = _('[ECS{ec_number}] {subject}.').format(
171+
ec_number=ec_number, subject=self.thread.subject)
172+
return subject
173+
174+
def forward_smtp(self):
175+
try:
176+
forwarded = False
177+
subject = self.smtp_subject
178+
headers = {}
179+
180+
if self.creator in ("human", "auto-self"):
181+
self.smtp_delivery_state = 'started'
182+
self.save()
183+
184+
if self.creator == "auto-self":
185+
headers.update({"Auto-Submitted": "auto-generated", })
186+
else:
187+
headers.update({"Auto-Submitted": "auto-replied", })
188+
if self.incoming_msgid:
189+
headers.update({
190+
'In-Reply-To': self.incoming_msgid,
191+
'References': " ".join((self.incoming_msgid,
192+
self.in_reply_to.outgoing_msgid)),
193+
})
194+
195+
msgid, rawmsg = deliver_to_recipient(
196+
self.receiver.email,
197+
subject=subject,
198+
message=self.text,
199+
from_email='{0} <{1}>'.format(get_full_name(self.sender), self.return_address),
200+
rfc2822_headers=headers,
201+
)
202+
203+
forwarded = True
204+
self.outgoing_msgid = msgid
205+
# do not overwrite rawmsg on forward, if it originated from smtp
206+
if not self.incoming_msgid:
207+
self.rawmsg = rawmsg.as_string()
208+
209+
self.smtp_delivery_state = 'success'
210+
except:
211+
traceback.print_exc()
212+
self.smtp_delivery_state = 'failure'
213+
raise
214+
finally:
215+
self.save()
216+
return forwarded

ecs/communication/smtpd.py

+31-15
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,10 @@ def handle_error(self):
3838

3939
def _get_content(message_part):
4040
payload = message_part.get_payload(decode=True)
41-
41+
4242
if message_part.get_content_charset() is None:
4343
charset = chardet.detect(payload)['encoding']
44-
logger.info(
45-
'no content charset declared, detection result: {0}'.format(charset))
44+
logger.debug('no content charset declared, detection result: {0}'.format(charset))
4645
else:
4746
charset = message_part.get_content_charset()
4847

@@ -53,10 +52,9 @@ def _get_content(message_part):
5352

5453
logger.debug('message-part: type: {0} charset: {1}'.format(
5554
message_part.get_content_type(), charset))
56-
content = str(payload, charset, "replace")
55+
content = str(payload, charset, 'replace')
5756
return content
5857

59-
6058
class EcsMailReceiver(smtpd.SMTPServer):
6159
channel_class = EcsSMTPChannel
6260

@@ -68,7 +66,7 @@ def __init__(self):
6866
smtpd.SMTPServer.__init__(self, settings.SMTPD_CONFIG['listen_addr'], None,
6967
data_size_limit=self.MAX_MSGSIZE, decode_data=False)
7068
self.logger = logging.getLogger('EcsMailReceiver')
71-
self.store_exceptions = settings.SMTPD_CONFIG.get('store_exceptions', False)
69+
self.store_exceptions = settings.SMTPD_CONFIG.get('store_exceptions', True)
7270
if self.store_exceptions:
7371
self.undeliverable_maildir = mailbox.Maildir(
7472
os.path.join(settings.PROJECT_DIR, '..', 'ecs-undeliverable'))
@@ -104,8 +102,8 @@ def _get_text(self, msg):
104102
else:
105103
raise SMTPError(554,
106104
'Invalid message format - invalid content type {0}'.format(
107-
part.get_content_type()))
108-
105+
part.get_content_type()))
106+
109107
if not plain and not html:
110108
raise SMTPError(554, 'Invalid message format - empty message')
111109

@@ -121,18 +119,36 @@ def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
121119
text = self._get_text(msg)
122120
orig_msg = self._find_msg(rcpttos[0])
123121
thread = orig_msg.thread
124-
# XXX: rawmsg can include multiple content-charsets and should be a binaryfield
125-
# as a workaround we convert to base64
122+
123+
creator = msg.get('Auto-Submitted', None)
124+
# XXX email header are case insentitiv matched,
125+
# 'auto-submitted' will be matched too it there is no 'Auto-Submitted'
126+
if creator in (None, '', 'no'):
127+
creator = 'human'
128+
elif creator[11] == 'auto-notify':
129+
creator = 'auto-notify'
130+
elif creator in ('auto-generated', 'auto-replied'):
131+
pass
132+
else:
133+
creator = 'auto-custom'
134+
126135
thread.messages.filter(
127136
receiver=orig_msg.receiver).update(unread=False)
137+
138+
# TODO rawmsg can include multiple content-charsets and should be a binaryfield
139+
# as a workaround we convert to base64
128140
thread_msg = thread.add_message(orig_msg.receiver, text,
129141
rawmsg=base64.b64encode(data),
130-
rawmsg_msgid=msg['Message-ID'])
142+
incoming_msgid=msg['Message-ID'],
143+
in_reply_to=orig_msg,
144+
creator=creator)
145+
131146
logger.info(
132-
'Accepted email from {0} via {1} to {2} id {3} thread {4} orig_msg {5} message {6}'.format(
133-
mailfrom, orig_msg.receiver.email, orig_msg.sender.email,
134-
msg['Message-ID'], thread.pk, orig_msg.pk, thread_msg.pk))
135-
147+
'Accepted email (creator= {8})from {0} via {1} to {2} id {3} in-reply-to {4} thread {5} orig_msg {6} message {7}'.format(
148+
mailfrom, orig_msg.receiver.email, orig_msg.sender.email,
149+
msg['Message-ID'], orig_msg.outgoing_msgid, thread.pk,
150+
orig_msg.pk, thread_msg.pk, creator))
151+
136152
except SMTPError as e:
137153
logger.info('Rejected email: {0}'.format(e))
138154
return str(e)

0 commit comments

Comments
 (0)