Code

Ticket #6667: mail_wrapping.diff

File mail_wrapping.diff, 9.8 KB (added by SmileyChris, 6 years ago)
Line 
1Index: django/core/mail.py
2===================================================================
3--- django/core/mail.py (revision 7141)
4+++ django/core/mail.py (working copy)
5@@ -4,6 +4,7 @@
6 
7 from django.conf import settings
8 from django.utils.encoding import smart_str, force_unicode
9+from django.utils.mail import wrap
10 from email import Charset, Encoders
11 from email.MIMEText import MIMEText
12 from email.MIMEMultipart import MIMEMultipart
13@@ -86,6 +87,20 @@
14     return name, val
15 
16 class SafeMIMEText(MIMEText):
17+    def __init__(self, _text, _subtype='plain', *args, **kwargs):
18+        format_flowed = False
19+        if 'wrap_text' in kwargs:
20+            if kwargs.pop('wrap_text') and _subtype == 'plain':
21+                _text = wrap(_text)
22+                format_flowed = True
23+        if 'format_flowed' in kwargs:
24+            if kwargs.pop('format_flowed') and _subtype == 'plain':
25+                format_flowed = True
26+        MIMEText.__init__(self, _text, _subtype, *args, **kwargs)
27+        if format_flowed:
28+            self.set_param('format', 'flowed', 'Content-Type')
29+            self.set_param('delsp', 'yes', 'Content-Type')
30+
31     def __setitem__(self, name, val):
32         name, val = forbid_multi_line_headers(name, val)
33         MIMEText.__setitem__(self, name, val)
34@@ -190,7 +205,7 @@
35     encoding = None     # None => use settings default
36 
37     def __init__(self, subject='', body='', from_email=None, to=None, bcc=None,
38-            connection=None, attachments=None, headers=None):
39+            connection=None, attachments=None, headers=None, wrap_text=False):
40         """
41         Initialise a single email message (which can be sent to multiple
42         recipients).
43@@ -213,6 +228,7 @@
44         self.attachments = attachments or []
45         self.extra_headers = headers or {}
46         self.connection = connection
47+        self.wrap_text = wrap_text
48 
49     def get_connection(self, fail_silently=False):
50         if not self.connection:
51@@ -221,7 +237,9 @@
52 
53     def message(self):
54         encoding = self.encoding or settings.DEFAULT_CHARSET
55-        msg = SafeMIMEText(smart_str(self.body, settings.DEFAULT_CHARSET), self.content_subtype, encoding)
56+        msg = SafeMIMEText(smart_str(self.body, settings.DEFAULT_CHARSET),
57+                           self.content_subtype, encoding,
58+                           wrap_text=self.wrap_text)
59         if self.attachments:
60             body_msg = msg
61             msg = SafeMIMEMultipart(_subtype=self.multipart_subtype)
62@@ -354,4 +372,3 @@
63     EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message,
64             settings.SERVER_EMAIL, [a[1] for a in
65                 settings.MANAGERS]).send(fail_silently=fail_silently)
66-
67Index: django/utils/mail.py
68===================================================================
69--- django/utils/mail.py        (revision 0)
70+++ django/utils/mail.py        (revision 0)
71@@ -0,0 +1,56 @@
72+import re
73+from django.utils.encoding import force_unicode
74+from django.utils.functional import allow_lazy
75+
76+def wrap(text, width=78):
77+    """
78+    An email based word-wrap function that preserves existing line breaks and
79+    adds soft line breaks (' \n').
80+
81+    Long words are not wrapped, so the output text may have lines longer than
82+    ``width``.
83+
84+    Trailing spaces are stripped from each line so they are not confused with
85+    soft line breaks. All other white space is preserved.
86+
87+    Soft-broken lines are parsed for quotes and space-stuffed in accordance
88+    with RFC 3676. A break is forced at 998 characters (RFC 2822 2.1.1).
89+    """
90+    text = force_unicode(text)
91+    # Signature lines aren't stripped -- see RFC 3676 4.3
92+    text = re.sub(r'(?m)(?<!^--) +$', '', text)
93+    def _parse_line(line, quote, new_line):
94+        if new_line and line.startswith('>'):
95+            quote = re.match('>+', line).group()
96+        elif quote:
97+            line = '%s %s' % (quote, line)
98+        elif (line.startswith('>') or line.startswith('From ')):
99+            # Space stuff -- see RFC 3676 4.4
100+            line = ' %s' % line
101+        max_width = (line.endswith('\n') and width + 1 or width)
102+        return line, quote, max_width
103+    def _generator():
104+        for line in text.splitlines(True):   # True keeps trailing linebreaks
105+            quote = ''
106+            line, quote, max_width = _parse_line(line, quote, new_line=True)
107+            while len(line) > max_width:
108+                space = line[:max_width].rfind(' ') + 1
109+                if space == 0:
110+                    space = line.find(' ') + 1
111+                    space = min(space, 998)
112+                    if space == 0:
113+                        if len(line) > 998:
114+                            space = 998
115+                        else:
116+                            yield line
117+                            line = ''
118+                            break
119+                if space >= max_width:
120+                    space -= 1
121+                yield '%s \n' % line[:space]
122+                line = line[space:]
123+                line, quote, max_width = _parse_line(line, quote, False)
124+            if line:
125+                yield line
126+    return u''.join(_generator())
127+wrap = allow_lazy(wrap, unicode)
128Index: django/utils/text.py
129===================================================================
130--- django/utils/text.py        (revision 7141)
131+++ django/utils/text.py        (working copy)
132@@ -10,29 +10,33 @@
133 
134 def wrap(text, width):
135     """
136-    A word-wrap function that preserves existing line breaks and most spaces in
137-    the text. Expects that existing line breaks are posix newlines.
138+    A word-wrap function that preserves existing line breaks. Expects that
139+    existing line breaks are posix newlines.
140+
141+    All white space is preserved except added line breaks consume the space on
142+    which they break the line.
143+
144+    Long words are not wrapped, so the output text may have lines longer than
145+    ``width``.
146     """
147     text = force_unicode(text)
148     def _generator():
149-        it = iter(text.split(' '))
150-        word = it.next()
151-        yield word
152-        pos = len(word) - word.rfind('\n') - 1
153-        for word in it:
154-            if "\n" in word:
155-                lines = word.split('\n')
156-            else:
157-                lines = (word,)
158-            pos += len(lines[0]) + 1
159-            if pos > width:
160-                yield '\n'
161-                pos = len(lines[-1])
162-            else:
163-                yield ' '
164-                if len(lines) > 1:
165-                    pos = len(lines[-1])
166-            yield word
167+        for line in text.splitlines(True):   # True keeps trailing linebreaks
168+            quote = ''
169+            max_width = (line.endswith('\n') and width + 1 or width)
170+            while len(line) > max_width:
171+                space = line[:max_width+1].rfind(' ') + 1
172+                if space == 0:
173+                    space = line.find(' ') + 1
174+                    if space == 0:
175+                        yield line
176+                        line = ''
177+                        break
178+                yield '%s\n' % line[:space-1]
179+                line = line[space:]
180+                max_width = (line.endswith('\n') and width + 1 or width)
181+            if line:
182+                yield line
183     return u''.join(_generator())
184 wrap = allow_lazy(wrap, unicode)
185 
186Index: tests/regressiontests/utils/tests.py
187===================================================================
188--- tests/regressiontests/utils/tests.py        (revision 7141)
189+++ tests/regressiontests/utils/tests.py        (working copy)
190@@ -8,11 +8,15 @@
191 
192 import timesince
193 import datastructures
194+import text
195+import mail
196 
197 # Extra tests
198 __test__ = {
199     'timesince': timesince,
200     'datastructures': datastructures,
201+    'text': text,
202+    'mail': mail,
203 }
204 
205 class TestUtilsHtml(TestCase):
206Index: tests/regressiontests/utils/mail.py
207===================================================================
208--- tests/regressiontests/utils/mail.py (revision 0)
209+++ tests/regressiontests/utils/mail.py (revision 0)
210@@ -0,0 +1,39 @@
211+r"""
212+>>> from django.utils.mail import wrap
213+
214+>>> wrap('1234 67 9', 100)
215+u'1234 67 9'
216+>>> wrap('1234 67 9', 9)
217+u'1234 67 9'
218+>>> wrap('1234 67 9', 8)
219+u'1234 67 \n 9'
220+
221+>>> wrap('short\na long line', 7)
222+u'short\na long \n line'
223+
224+>>> wrap('email wrapping: \nStrips spaces before lines and uses soft spacing for line breaks', 30)
225+u'email wrapping:\nStrips spaces before lines  \nand uses soft spacing for  \nline breaks'
226+
227+>>> long_word = 'l%sng' % ('0123456789'*100)
228+
229+# Long word broken at 997 (998 - 1 char for space)
230+>>> wrap(long_word) == '%s \n%s' % (long_word[:997], long_word[997:])
231+True
232+>>> out = wrap('a %s word' % long_word, 11)
233+>>> '%s...%s' % (out[:20], out[-20:])
234+u'a  \nl012345678901234...9012345 \n6789ng word'
235+
236+>>> wrap('>test me out', 10)
237+u'>test me  \n> out'
238+>>> wrap('test me >out', 10)
239+u'test me  \n >out'
240+
241+>>> wrap('> one\n>> two which will wrap\n>>> three', 15)
242+u'> one\n>> two which  \n>> will wrap\n>>> three'
243+
244+>>> wrap('preserve  whitespace good  and\n  proper\n', 9)
245+u'preserve  \n  \nwhitespace \n good   \nand\n  proper\n'
246+
247+>>> wrap('do not touch -- \nreal signatures\n-- \nThe boss', 13)
248+u'do not touch  \n--\nreal  \nsignatures\n-- \nThe boss'
249+"""
250Index: tests/regressiontests/utils/text.py
251===================================================================
252--- tests/regressiontests/utils/text.py (revision 0)
253+++ tests/regressiontests/utils/text.py (revision 0)
254@@ -0,0 +1,23 @@
255+r"""
256+>>> from django.utils.text import wrap
257+
258+>>> wrap('1234 67 9', 100)
259+u'1234 67 9'
260+>>> wrap('1234 67 9', 9)
261+u'1234 67 9'
262+>>> wrap('1234 67 9', 8)
263+u'1234 67\n9'
264+
265+>>> wrap('short\na long line', 7)
266+u'short\na long\nline'
267+
268+>>> wrap('do-not-break-long-words please? ok', 8)
269+u'do-not-break-long-words\nplease?\nok'
270+
271+>>> long_word = 'l%sng' % ('0123456789'*100)
272+>>> wrap(long_word, 50) == long_word
273+True
274+>>> out = wrap('a %s word' % long_word, 10)
275+>>> '%s...%s' % (out[:20], out[-20:])
276+u'a\nl01234567890123456...7890123456789ng\nword'
277+"""