• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Products.MailHost / 4062103177

pending completion
4062103177

push

github

GitHub
Merge pull request #44 from zopefoundation/config-with-zope-product-template-e876cee6

114 of 169 branches covered (67.46%)

Branch coverage included in aggregate %.

66 of 66 new or added lines in 7 files covered. (100.0%)

753 of 832 relevant lines covered (90.5%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.06
/src/Products/MailHost/MailHost.py
1
##############################################################################
2
#
3
# Copyright (c) 2002 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE.
11
#
12
##############################################################################
13

14
import email.charset
1✔
15
import logging
1✔
16
import os
1✔
17
import re
1✔
18
import time
1✔
19
from copy import copy
1✔
20
from copy import deepcopy
1✔
21
from email import encoders
1✔
22
from email import message_from_string
1✔
23
from email import policy
1✔
24
from email._policybase import Compat32
1✔
25
from email.charset import Charset
1✔
26
from email.generator import BytesGenerator
1✔
27
from email.generator import _has_surrogates
1✔
28
from email.header import Header
1✔
29
from email.message import Message
1✔
30
from email.utils import formataddr
1✔
31
from email.utils import getaddresses
1✔
32
from email.utils import parseaddr
1✔
33
from functools import partial
1✔
34
from io import BytesIO
1✔
35
from os.path import realpath
1✔
36
from threading import Lock
1✔
37

38
from AccessControl.class_init import InitializeClass
1✔
39
from AccessControl.Permissions import change_configuration
1✔
40
from AccessControl.Permissions import use_mailhost_services
1✔
41
from AccessControl.Permissions import view
1✔
42
from AccessControl.SecurityInfo import ClassSecurityInfo
1✔
43
from Acquisition import Implicit
1✔
44
from App.special_dtml import DTMLFile
1✔
45
from DateTime.DateTime import DateTime
1✔
46
from OFS.role import RoleManager
1✔
47
from OFS.SimpleItem import Item
1✔
48
from Persistence import Persistent
1✔
49
from zope.interface import implementer
1✔
50
from zope.sendmail.delivery import DirectMailDelivery
1✔
51
from zope.sendmail.delivery import QueuedMailDelivery
1✔
52
from zope.sendmail.delivery import QueueProcessorThread
1✔
53
from zope.sendmail.maildir import Maildir
1✔
54
from zope.sendmail.mailer import SMTPMailer
1✔
55

56
from Products.MailHost.decorator import synchronized
1✔
57
from Products.MailHost.interfaces import IMailHost
1✔
58

59

60
queue_threads = {}  # maps MailHost path -> queue processor threads
1✔
61

62
LOG = logging.getLogger('MailHost')
1✔
63

64
# Encode utf-8 emails as Quoted Printable by default
65
email.charset.add_charset('utf-8', email.charset.QP, email.charset.QP, 'utf-8')
1✔
66
CHARSET_RE = re.compile(r'charset=[\'"]?([\w-]+)[\'"]?', re.IGNORECASE)
1✔
67

68

69
class MailHostError(Exception):
1✔
70
    pass
1✔
71

72

73
manage_addMailHostForm = DTMLFile('dtml/addMailHost_form', globals())
1✔
74

75

76
def manage_addMailHost(self,
1✔
77
                       id,
78
                       title='',
79
                       smtp_host='localhost',
80
                       localhost='localhost',
81
                       smtp_port=25,
82
                       timeout=1.0,
83
                       REQUEST=None):
84
    """ Add a MailHost into the system.
85
    """
86
    i = MailHost(id, title, smtp_host, smtp_port)
×
87
    self._setObject(id, i)
×
88

89
    if REQUEST is not None:
×
90
        REQUEST['RESPONSE'].redirect(self.absolute_url() + '/manage_main')
×
91

92

93
add = manage_addMailHost
1✔
94

95

96
@implementer(IMailHost)
1✔
97
class MailBase(Implicit, Item, RoleManager):
1✔
98
    """a mailhost...?"""
99

100
    meta_type = 'Mail Host'
1✔
101
    zmi_icon = 'far fa-envelope'
1✔
102
    manage = manage_main = DTMLFile('dtml/manageMailHost', globals())
1✔
103
    manage_main._setName('manage_main')
1✔
104
    index_html = None
1✔
105
    security = ClassSecurityInfo()
1✔
106
    smtp_uid = ''  # Class attributes for smooth upgrades
1✔
107
    smtp_pwd = ''
1✔
108
    smtp_queue = False
1✔
109
    smtp_queue_directory = '/tmp'
1✔
110
    force_tls = False
1✔
111
    lock = Lock()
1✔
112

113
    manage_options = ((
1✔
114
        {'icon': '', 'label': 'Edit', 'action': 'manage_main'},
115
    ) + RoleManager.manage_options + Item.manage_options)
116

117
    def __init__(self,
1✔
118
                 id='',
119
                 title='',
120
                 smtp_host='localhost',
121
                 smtp_port=25,
122
                 force_tls=False,
123
                 smtp_uid='',
124
                 smtp_pwd='',
125
                 smtp_queue=False,
126
                 smtp_queue_directory='/tmp'):
127
        """Initialize a new MailHost instance.
128
        """
129
        self.id = id
1✔
130
        self.title = title
1✔
131
        self.smtp_host = str(smtp_host)
1✔
132
        self.smtp_port = int(smtp_port)
1✔
133
        self.smtp_uid = smtp_uid
1✔
134
        self.smtp_pwd = smtp_pwd
1✔
135
        self.force_tls = force_tls
1✔
136
        self.smtp_queue = smtp_queue
1✔
137
        self.smtp_queue_directory = smtp_queue_directory
1✔
138

139
    def _init(self, smtp_host, smtp_port):
1✔
140
        # staying for now... (backwards compatibility)
141
        self.smtp_host = smtp_host
×
142
        self.smtp_port = smtp_port
×
143

144
    @security.protected(change_configuration)
1✔
145
    def manage_makeChanges(self,
1✔
146
                           title,
147
                           smtp_host,
148
                           smtp_port,
149
                           smtp_uid='',
150
                           smtp_pwd='',
151
                           smtp_queue=False,
152
                           smtp_queue_directory='/tmp',
153
                           force_tls=False,
154
                           REQUEST=None):
155
        """Make the changes.
156
        """
157
        title = str(title)
1✔
158
        smtp_host = str(smtp_host)
1✔
159
        smtp_port = int(smtp_port)
1✔
160

161
        self.title = title
1✔
162
        self.smtp_host = smtp_host
1✔
163
        self.smtp_port = smtp_port
1✔
164
        self.smtp_uid = smtp_uid
1✔
165
        self.smtp_pwd = smtp_pwd
1✔
166
        self.force_tls = force_tls
1✔
167
        self.smtp_queue = smtp_queue
1✔
168
        self.smtp_queue_directory = smtp_queue_directory
1✔
169

170
        if REQUEST is not None:
1!
171
            msg = 'MailHost %s updated' % self.id
×
172
            return self.manage_main(self, REQUEST, manage_tabs_message=msg)
×
173

174
    @security.protected(use_mailhost_services)
1✔
175
    def sendTemplate(trueself,
1✔
176
                     self,
177
                     messageTemplate,
178
                     statusTemplate=None,
179
                     mto=None,
180
                     mfrom=None,
181
                     encode=None,
182
                     REQUEST=None,
183
                     immediate=False,
184
                     charset=None,
185
                     msg_type=None):
186
        """Render a mail template, then send it...
187
        """
188
        mtemplate = getattr(self, messageTemplate)
1✔
189
        messageText = mtemplate(self, trueself.REQUEST)
1✔
190
        trueself.send(messageText, mto=mto, mfrom=mfrom,
1✔
191
                      encode=encode, immediate=immediate,
192
                      charset=charset, msg_type=msg_type)
193

194
        if not statusTemplate:
1✔
195
            return 'SEND OK'
1✔
196
        try:
1✔
197
            stemplate = getattr(self, statusTemplate)
1✔
198
            return stemplate(self, trueself.REQUEST)
1✔
199
        except Exception:
1✔
200
            return 'SEND OK'
1✔
201

202
    @security.protected(use_mailhost_services)
1✔
203
    def send(self,
1✔
204
             messageText,
205
             mto=None,
206
             mfrom=None,
207
             subject=None,
208
             encode=None,
209
             immediate=False,
210
             charset=None,
211
             msg_type=None):
212
        """send *messageText* modified by the other parameters.
213

214
        *messageText* can either be an ``email.message.Message``
215
        or a string.
216
        """
217
        msg, mto, mfrom = _mungeHeaders(messageText, mto, mfrom,
1✔
218
                                        subject, charset, msg_type,
219
                                        encode)
220
        self._send(mfrom, mto, msg, immediate)
1✔
221

222
    # This is here for backwards compatibility only. Possibly it could
223
    # be used to send messages at a scheduled future time, or via a mail queue?
224
    security.declareProtected(use_mailhost_services,  # noqa: D001
1✔
225
                              'scheduledSend')
226
    scheduledSend = send
1✔
227

228
    @security.protected(use_mailhost_services)
1✔
229
    def simple_send(self, mto, mfrom, subject, body, immediate=False):
1✔
230
        body = f'From: {mfrom}\nTo: {mto}\nSubject: {subject}\n\n{body}'
1✔
231
        self._send(mfrom, mto, body, immediate)
1✔
232

233
    def _makeMailer(self):
1✔
234
        """ Create a SMTPMailer """
235
        return SMTPMailer(hostname=self.smtp_host,
×
236
                          port=int(self.smtp_port),
237
                          username=self.smtp_uid or None,
238
                          password=self.smtp_pwd or None,
239
                          force_tls=self.force_tls)
240

241
    @security.private
1✔
242
    def _getThreadKey(self):
1✔
243
        """ Return the key used to find our processor thread.
244
        """
245
        return realpath(self.smtp_queue_directory)
1✔
246

247
    @synchronized(lock)
1✔
248
    def _stopQueueProcessorThread(self):
1✔
249
        """ Stop thread for processing the mail queue.
250
        """
251
        key = self._getThreadKey()
×
252
        if key in queue_threads:
×
253
            thread = queue_threads[key]
×
254
            thread.stop()
×
255
            while thread.is_alive():
×
256
                # wait until thread is really dead
257
                time.sleep(0.3)
×
258
            del queue_threads[key]
×
259
            LOG.info('Thread for %s stopped' % key)
×
260

261
    @synchronized(lock)
1✔
262
    def _startQueueProcessorThread(self):
1✔
263
        """ Start thread for processing the mail queue.
264
        """
265
        key = self._getThreadKey()
×
266
        if key not in queue_threads:
×
267
            thread = QueueProcessorThread()
×
268
            thread.setMailer(self._makeMailer())
×
269
            thread.setQueuePath(self.smtp_queue_directory)
×
270
            thread.start()
×
271
            queue_threads[key] = thread
×
272
            LOG.info('Thread for %s started' % key)
×
273

274
    @security.protected(view)
1✔
275
    def queueLength(self):
1✔
276
        """ return length of mail queue """
277

278
        try:
×
279
            maildir = Maildir(self.smtp_queue_directory)
×
280
            return len([item for item in maildir])
×
281
        except ValueError:
×
282
            return 'n/a - %s is not a maildir - please verify your ' \
×
283
                   'configuration' % self.smtp_queue_directory
284

285
    @security.protected(view)
1✔
286
    def queueThreadAlive(self):
1✔
287
        """ return True/False is queue thread is working
288
        """
289
        th = queue_threads.get(self._getThreadKey())
×
290
        if th:
×
291
            return th.is_alive()
×
292
        return False
×
293

294
    @security.protected(view)
1✔
295
    def queueNonDeliveryMode(self):
1✔
296
        """ Return the queue delivery mode as a boolean flag
297

298
        Returns:
299
            - ``True`` if the queue is in queue-only non-delivery mode
300
            - ``False`` if the queue is in active delivery mode
301
        """
302
        return bool(os.environ.get('MAILHOST_QUEUE_ONLY', None))
×
303

304
    @security.protected(change_configuration)
1✔
305
    def manage_restartQueueThread(self, action='start', REQUEST=None):
1✔
306
        """ Restart the queue processor thread """
307

308
        if action == 'stop':
×
309
            self._stopQueueProcessorThread()
×
310
        elif action == 'start':
×
311
            self._startQueueProcessorThread()
×
312
        else:
313
            raise ValueError('Unsupported action %s' % action)
×
314

315
        if REQUEST is not None:
×
316
            msg = 'Queue processor thread %s' % \
×
317
                  (action == 'stop' and 'stopped' or 'started')
318
            return self.manage_main(self, REQUEST, manage_tabs_message=msg)
×
319

320
    @security.private
1✔
321
    def _send(self, mfrom, mto, messageText, immediate=False):
1✔
322
        """ Send the message """
323

324
        if immediate:
1!
325
            self._makeMailer().send(mfrom, mto, messageText)
×
326
        else:
327
            if self.smtp_queue:
1!
328
                # Start queue processor thread, if necessary
329
                if not os.environ.get('MAILHOST_QUEUE_ONLY', False):
1✔
330
                    self._startQueueProcessorThread()
1✔
331
                delivery = QueuedMailDelivery(self.smtp_queue_directory)
1✔
332

333
                # The queued mail delivery breaks if the To address is just
334
                # a string. All other delivery mechanisms work fine.
335
                if isinstance(mto, str):
1!
336
                    mto = [mto]
×
337
            else:
338
                delivery = DirectMailDelivery(self._makeMailer())
×
339

340
            delivery.send(mfrom, mto, messageText)
1✔
341

342

343
InitializeClass(MailBase)
1✔
344

345

346
class MailHost(Persistent, MailBase):
1✔
347
    """persistent version"""
348

349

350
# All encodings supported by mimetools for BBB
351
ENCODERS = {
1✔
352
    'base64': encoders.encode_base64,
353
    'quoted-printable': encoders.encode_quopri,
354
    '7bit': encoders.encode_7or8bit,
355
    '8bit': encoders.encode_7or8bit,
356
}
357

358

359
def _string_transform(text, charset=None):
1✔
360
    """converts *text* to a native string."""
361
    if isinstance(text, bytes):
1✔
362
        # Already-encoded byte strings which the email module does not like
363
        return text.decode(charset)
1✔
364

365
    return text
1✔
366

367

368
def _mungeHeaders(messageText, mto=None, mfrom=None, subject=None,
1✔
369
                  charset=None, msg_type=None, encode=None):
370
    """Sets missing message headers, and deletes Bcc.
371
       returns fixed message, fixed mto and fixed mfrom.
372

373
       *messageText* can be either a ``Message`` or a
374
       string representation for a message.
375
       In the latter case, the representation is converted to
376
       a native string using *charset*, if necessary, and then
377
       parsed into a ``Message`` object.
378
    """
379
    mto = _string_transform(mto, charset)
1✔
380
    mfrom = _string_transform(mfrom, charset)
1✔
381
    subject = _string_transform(subject, charset)
1✔
382

383
    if isinstance(messageText, Message):
1✔
384
        # We already have a message, make a copy to operate on
385
        mo = deepcopy(messageText)
1✔
386
    else:
387
        # Otherwise parse the input message
388
        mo = message_from_string(_string_transform(messageText, charset))
1✔
389

390
    if msg_type and not mo.get('Content-Type'):
1!
391
        # we don't use get_content_type because that has a default
392
        # value of 'text/plain'
393
        mo.set_type(msg_type)
×
394

395
    charset = _set_recursive_charset(mo, charset=charset)
1✔
396

397
    # Parameters given will *always* override headers in the messageText.
398
    # This is so that you can't override or add to subscribers by adding
399
    # them to # the message text.
400
    if subject:
1✔
401
        # remove any existing header otherwise we get two
402
        del mo['Subject']
1✔
403
        # Perhaps we should ignore errors here and pass 8bit strings
404
        # on encoding errors
405
        mo['Subject'] = Header(subject, charset, errors='replace')
1✔
406
    elif not mo.get('Subject'):
1✔
407
        mo['Subject'] = '[No Subject]'
1✔
408

409
    if mto:
1✔
410
        if isinstance(mto, str):
1✔
411
            mto = [formataddr(addr) for addr in getaddresses((mto, ))]
1✔
412
        # this violates what is said above (parameters always override)
413
        # if not mo.get('To'):
414
        if mto:
1!
415
            del mo['To']
1✔
416
            mo['To'] = ', '.join(str(_encode_address_string(e, charset))
1✔
417
                                 for e in mto)
418
    else:
419
        # If we don't have recipients, extract them from the message
420
        mto = []
1✔
421
        for header in ('To', 'Cc', 'Bcc'):
1✔
422
            v = ','.join(mo.get_all(header) or [])
1✔
423
            if v:
1✔
424
                mto += [formataddr(addr) for addr in getaddresses((v, ))]
1✔
425
        if not mto:
1✔
426
            raise MailHostError('No message recipients designated')
1✔
427

428
    if mfrom:
1✔
429
        # ??? do we really want to override an explicitly set From
430
        # header in the messageText
431
        del mo['From']
1✔
432
        mo['From'] = _encode_address_string(mfrom, charset)
1✔
433
    else:
434
        if mo.get('From') is None:
1✔
435
            raise MailHostError("Message missing SMTP Header 'From'")
1✔
436
        mfrom = mo['From']
1✔
437

438
    if mo.get('Bcc'):
1✔
439
        del mo['Bcc']
1✔
440

441
    if not mo.get('Date'):
1✔
442
        mo['Date'] = DateTime().rfc822()
1✔
443

444
    if encode:
1✔
445
        current_coding = mo['Content-Transfer-Encoding']
1✔
446
        if current_coding == encode:
1!
447
            # already encoded correctly, may have been automated
448
            pass
×
449
        elif mo['Content-Transfer-Encoding'] not in ['7bit', None]:
1!
450
            raise MailHostError('Message already encoded')
×
451
        elif encode in ENCODERS:
1!
452
            ENCODERS[encode](mo)
1✔
453
            if not mo['Content-Transfer-Encoding']:
1!
454
                mo['Content-Transfer-Encoding'] = encode
×
455
            if not mo['Mime-Version']:
1!
456
                mo['Mime-Version'] = '1.0'
1✔
457

458
    return as_bytes(mo), mto, mfrom
1✔
459

460

461
def _set_recursive_charset(payload, charset=None):
1✔
462
    """Set charset for all parts of an multipart message."""
463
    def _set_payload_charset(payload, charset=None, index=None):
1✔
464
        payload_from_string = False
1✔
465
        if not isinstance(payload, Message):
1!
466
            payload = message_from_string(payload)
×
467
            payload_from_string = True
×
468
        charset_match = CHARSET_RE.search(payload['Content-Type'] or '')
1✔
469
        if charset and not charset_match:
1✔
470
            # Don't change the charset if already set
471
            # This encodes the payload automatically based on the default
472
            # encoding for the charset
473
            if payload_from_string:
1!
474
                payload.get_payload()[index] = payload
×
475
            else:
476
                payload.set_charset(charset)
1✔
477
        elif charset_match and not charset:
1✔
478
            # If a charset parameter was provided use it for header encoding
479
            # below, otherwise, try to use the charset provided in the message.
480
            charset = charset_match.groups()[0]
1✔
481
        return charset
1✔
482
    if payload.is_multipart():
1✔
483
        for index, payload in enumerate(payload.get_payload()):
1✔
484
            if payload.get_filename() is None:
1✔
485
                if not payload.is_multipart():
1✔
486
                    charset = _set_payload_charset(payload,
1✔
487
                                                   charset=charset,
488
                                                   index=index)
489
                else:
490
                    _set_recursive_charset(payload, charset=charset)
1✔
491
    else:
492
        charset = _set_payload_charset(payload, charset=charset)
1✔
493
    return charset
1✔
494

495

496
def _try_encode(text, charset):
1✔
497
    """Attempt to encode using the default charset if none is
498
    provided.  Should we permit encoding errors?"""
499
    if charset:
×
500
        return text.encode(charset)
×
501
    else:
502
        return text.encode()
×
503

504

505
def _encode_address_string(text, charset):
1✔
506
    """Split the email into parts and use header encoding on the name
507
    part if needed. We do this because the actual addresses need to be
508
    ASCII with no encoding for most SMTP servers, but the non-address
509
    parts should be encoded appropriately."""
510
    header = Header()
1✔
511
    name, addr = parseaddr(text)
1✔
512
    if isinstance(name, bytes):
1!
513
        try:
×
514
            name.decode('us-ascii')
×
515
        except UnicodeDecodeError:
×
516
            if charset:
×
517
                charset = Charset(charset)
×
518
                name = charset.header_encode(name)
×
519
    # We again replace rather than raise an error or pass an 8bit string
520
    header.append(formataddr((name, addr)), errors='replace')
1✔
521
    return header
1✔
522

523

524
def as_bytes(msg):
1✔
525
    return msg.as_bytes()
1✔
526

527

528
# work around https://github.com/python/cpython/issues/85479
529

530

531
class FixedBytesGenerator(BytesGenerator):
1✔
532
    def _handle_text(self, msg):
1✔
533
        payload = msg._payload
1✔
534
        if payload is None:
1!
535
            return
×
536
        charset = msg.get_param('charset', 'utf-8')
1✔
537
        if (charset is not None
1!
538
                and not self.policy.cte_type == '7bit'
539
                and not _has_surrogates(payload)):
540
            msg = copy(msg)
1✔
541
            msg._payload = payload.encode(charset).decode(
1✔
542
                'ascii', 'surrogateescape')
543
        super()._handle_text(msg)
1✔
544

545
    _writeBody = _handle_text
1✔
546

547

548
class FixedMessage(Message):
1✔
549
    def as_bytes(self, unixfrom=False, policy=None):
1✔
550
        policy = self.policy if policy is None else policy
1✔
551
        fp = BytesIO()
1✔
552
        g = FixedBytesGenerator(fp, mangle_from_=False, policy=policy)
1✔
553
        g.flatten(self, unixfrom=unixfrom)
1✔
554
        return fp.getvalue()
1✔
555

556

557
if hasattr(Compat32, 'message_factory'):
1!
558
    fixed_policy = policy.compat32.clone(
1✔
559
        linesep='\r\n', message_factory=FixedMessage)
560
else:
561
    fixed_policy = policy.compat32.clone(linesep='\r\n')
×
562

563
message_from_string = partial(message_from_string, policy=fixed_policy)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc