-
Notifications
You must be signed in to change notification settings - Fork 9
/
Copy pathMail.py
1061 lines (937 loc) · 45.2 KB
/
Mail.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# PageMailSupport mixin
import re, sys
from types import *
import string
import email
from email.Message import Message
from email.Utils import parseaddr, getaddresses
from email.Iterators import typed_subpart_iterator
from email.Header import Header, decode_header
from Globals import InitializeClass
from i18n import _
from TextFormatter import TextFormatter
from Utils import html_unquote,BLATHER,DEBUG,formattedTraceback,stripList, \
isIpAddress,isEmailAddress,isUsername,safe_hasattr,tounicode,toencoded
from Defaults import AUTO_UPGRADE, PAGE_METATYPE
from Regexps import bracketedexpr,urlchars
from plugins.tracker.tracker import ISSUE_SEVERITIES
WIKIADDREXP = r'(wiki|mailin)@' # for comments and new pages
TRACKERADDREXP = r'(tracker|bugs|issues)@' # for new tracker issues
MAILINADDREXP = r'(%s|%s)' % (WIKIADDREXP,TRACKERADDREXP)
PAGEINSUBJECTEXP = bracketedexpr
DEFAULT_SEVERITY = ISSUE_SEVERITIES[len(ISSUE_SEVERITIES)/2]
MAX_SIGNATURE_STRIP_SIZE = 500
ORIGINAL_MESSAGE_HEADER = '(?:Original Message|message d\'origine)' # XXX i18n ?
class PageSubscriptionSupport:
"""
This mixin class adds subscriber management to a wiki page (and folder).
Responsibilities: manage a list of subscribers for both this page and
it's folder, and expose these in the ZMI; also do auto-upgrading.
A "subscriber" is a string which may be either an email address or a
CMF member username. A list of these is kept in the page's and/or
folder's subscriber_list property.
For the moment, it's still called "email" in arguments to avoid
breaking legacy dtml (eg subscribeform).
"""
subscriber_list = []
_properties=(
{'id':'subscriber_list', 'type': 'lines', 'mode': 'w'},
)
## private ###########################################################
def _getSubscribers(self, parent=0): # -> [string]; depends on self, folder; modifies self, folder
"""
Return a copy of this page's subscriber list, as a list.
With parent flag, manage the parent folder's subscriber list instead.
"""
if AUTO_UPGRADE: self._upgradeSubscribers()
if parent:
if safe_hasattr(self.folder(),'subscriber_list'):
return stripList(self.folder().subscriber_list)
else:
return []
else:
return list(self.subscriber_list)
def _setSubscribers(self, subscriberlist, parent=0): # -> none; depends on self, folder; modifies self, folder
"""
Set this page's subscriber list.
With parent flag, manage the parent folder's subscriber list instead.
"""
if AUTO_UPGRADE: self._upgradeSubscribers()
if parent:
self.folder().subscriber_list = subscriberlist
else:
self.subscriber_list = subscriberlist
def _resetSubscribers(self, parent=0): # -> none; modifies self, folder
"""
Clear this page's subscriber list.
With parent flag, manage the parent folder's subscriber list instead.
"""
self._setSubscribers([],parent)
def _upgradeSubscribers(self): # -> none; depends on self, folder; modifies self, folder
"""
Upgrade old subscriber lists, both this page's and the folder's.
Called as needed, ie on each access and also from ZWikiPage.upgrade()
(set AUTO_UPGRADE=0 in Default.py to disable).
XXX Lord have mercy! couldn't this be simpler
"""
# upgrade the folder first; we'll check attributes then properties
changed = 0
f = self.folder().aq_base
# migrate an old zwiki subscribers or wikifornow _subscribers attribute
oldsubs = None
if (safe_hasattr(f, 'subscribers') and
type(f.subscribers) is StringType):
if f.subscribers:
oldsubs = re.sub(r'[ \t]+',r'',f.subscribers).split(',')
try:
del f.subscribers
except KeyError:
BLATHER('failed to delete self.folder().subscribers')
changed = 1
elif safe_hasattr(f, '_subscribers'):
oldsubs = f._subscribers.keys()
try:
del f._subscribers
except KeyError:
BLATHER('failed to delete self.folder()._subscribers')
changed = 1
# ensure a subscriber_list attribute
if not safe_hasattr(f, 'subscriber_list'): f.subscriber_list = []
# transfer old subscribers to subscriber_list, unless it's already
# populated in which case discard them
if oldsubs and not f.subscriber_list: f.subscriber_list = oldsubs
# update _properties
props = map(lambda x:x['id'], f._properties)
if 'subscribers' in props:
f._properties = filter(lambda x:x['id'] != 'subscribers',
f._properties)
changed = 1
if not 'subscriber_list' in props:
f._properties = f._properties + \
({'id':'subscriber_list','type':'lines','mode':'w'},)
if changed:
BLATHER('upgraded %s folder subscriber list' % (f.id))
# now do the page..
changed = 0
self = self.aq_base
# migrate an old zwiki subscribers attribute
oldsubs = None
if (safe_hasattr(self, 'subscribers') and
type(self.subscribers) is StringType):
if self.subscribers:
oldsubs = re.sub(r'[ \t]+',r'',self.subscribers).split(',')
try:
del self.subscribers
except KeyError:
BLATHER('failed to delete %s.subscribers' % (self.id()))
changed = 1
# copy old subscribers to subscriber_list, unless it's already
# got some
# XXX merge instead
if oldsubs and not self.subscriber_list:
self.subscriber_list = oldsubs
# migrate a wikifornow _subscribers attribute
oldsubs = None
if safe_hasattr(self, '_subscribers'):
oldsubs = self._subscribers.keys()
try:
del self._subscribers
except KeyError:
BLATHER('failed to delete %s._subscribers' % (self.id()))
changed = 1
if oldsubs and not self.subscriber_list:
self.subscriber_list = oldsubs
# update _properties
props = map(lambda x:x['id'], self._properties)
if 'subscribers' in props:
self._properties = filter(lambda x:x['id'] != 'subscribers',
self._properties)
changed = 1
if not 'subscriber_list' in props:
self._properties = self._properties + \
({'id':'subscriber_list','type':'lines','mode':'w'},)
if changed:
BLATHER('upgraded %s subscriber list' % (self.id()))
## page subscription api #############################################
# XXX rename to subscribers() & wikiSubscribers() ?
# XXX and add editSubscribers & wikiEditSubscribers
def subscriberList(self, parent=0, edits=0): # -> [string]; depends on self, folder
"""
Return a list of this page's subscribers, without the :edits suffix.
A subscriber is represented by a string containing an email
address or a CMF username, and an optional :edits suffix
indicating they have requested all edits. Note this method strips
the :edits suffix.
If edits flag is true, return only the subscribers who have
requested all edits; otherwise, return all subscribers.
If parent flag is true, query the parent folder's subscriber list
instead.
"""
return [re.sub(r':edits$','',s)
for s in stripList(self._getSubscribers(parent))
if (not edits) or s.endswith(':edits')]
def isSubscriber(self, email, parent=0): # -> boolean; depends on self, folder
"""
Is this email address or member id subscribed to this page ?
With parent flag, check the parent folder's subscriber list
instead. Note "email" may be either an email address
(case-insensitive) or a CMF member id. We'll accept either, and
find subscriptions using either.
"""
subscriber = email
if subscriber:
email = self.emailAddressFrom(subscriber)
usernames = self.usernamesFrom(subscriber)
for sub in self.subscriberList(parent):
if not sub: continue
if ((email and (self.emailAddressFrom(sub) == email)) or
(usernames and (sub in usernames))):
return 1
return 0
def subscribe(self, email, REQUEST=None, parent=0, edits=0): # -> none; redirects; depends on self, folder; modifies self, folder, catalog
"""
Add an email subscriber to this page.
subscriber may be an email address or a CMF member id.
With parent flag, add to the parent folder's subscriber list instead.
With edits flag, mark this subscriber as one who wants
notification of all edits.
"""
subscriber = email
if subscriber:
if not self.isSubscriber(subscriber,parent):
BLATHER('subscribed',subscriber,'to',self.id(),
edits and '(all edits)' or '')
subs = self._getSubscribers(parent)
subs.append(subscriber + (edits and ':edits' or ''))
self._setSubscribers(subs,parent)
if not parent: self.index_object()
if REQUEST:
REQUEST.RESPONSE.redirect(
REQUEST.get('redirectURL',
REQUEST['URL1']+'/subscribeform?email='+subscriber))
def unsubscribe(self, email, REQUEST=None, parent=0): # -> none; redirects; depends on self, folder; modifies self, folder, catalog
"""
Remove email from this page's subscriber list.
email may be an email address or CMF username, we try to convert
usernames to email addresses as needed.
If parent flag is true, remove it from the parent folder's
subscriber list instead.
"""
subscriber = email.lower()
if self.isSubscriber(subscriber,parent):
sl = self._getSubscribers(parent)
for s in sl:
if (self.emailAddressFrom(s) ==
self.emailAddressFrom(subscriber)):
BLATHER('unsubscribed',subscriber,'from',self.id())
sl.remove(s)
self._setSubscribers(sl,parent)
if not parent: self.index_object()
if REQUEST:
REQUEST.RESPONSE.redirect(
REQUEST.get('redirectURL',
REQUEST['URL1']+'/subscribeform?email='+subscriber))
## folder subscription api ###########################################
def wikiSubscriberList(self, edits=0): # -> [string]; depends on folder
"""whole-wiki version of subscriberList"""
return self.subscriberList(parent=1,edits=edits)
def isWikiSubscriber(self,email): # -> boolean; depends on folder
"""whole-wiki version of isSubscriber"""
return self.isSubscriber(email,parent=1)
def wikiSubscribe(self, email, REQUEST=None, edits=0): # -> none; redirects; depends on self, folder; modifies self, folder, catalog
"""whole-wiki version of subscribe"""
return self.subscribe(email,REQUEST,parent=1,edits=edits)
def wikiUnsubscribe(self, email, REQUEST=None): # -> none; redirects; depends on self, folder; modifies self, folder, catalog
"""whole-wiki version of unsubscribe"""
return self.unsubscribe(email,REQUEST,parent=1)
## misc api methods ##################################################
def pageSubscriberCount(self, edits=0): # -> integer; depends on self
"""The number of subscribers to this page. With edits flag, count only
subscribers who have requested all edits."""
return len(self.subscriberList(parent=0,edits=edits))
def wikiSubscriberCount(self, edits=0): # -> integer; depends on folder
"""The number of subscribers to the whole wiki. With edits flag, count
only subscribers who have requested all edits."""
return len(self.subscriberList(parent=1,edits=edits))
def subscriberCount(self, edits=0): # -> integer; depends on self, folder
"""The total number of subscribers to this page, including wiki
subscribers. With edits flag, count only subscribers who have
requested all edits."""
return self.pageSubscriberCount(edits) + self.wikiSubscriberCount(edits)
def subscribeThisUser(self,REQUEST): # -> nothing; depends on self, folder, cmf/plone site, request; modifies self
"""
Subscribe the current user to this page.
We'll use their username if appropriate, otherwise their email
address cookie.
"""
if not REQUEST: return
user = ((self.inCMF() and str(REQUEST.get('AUTHENTICATED_USER'))) or
REQUEST.cookies.get('email',None))
if user and not (self.isSubscriber(user) or self.isWikiSubscriber(user)):
self.subscribe(user)
def allSubscriptionsFor(self, email): # -> [string]; depends on self, wiki, catalog
"""
Return the ids of all pages to which a subscriber is subscribed
('whole_wiki' indicates a wiki subscription).
XXX catalog case duplicates isSubscriber code
"""
subscriber = email
subscriptions = []
# subscriber may be an email address or a member id, and
# they may be subscribed as either
email = self.emailAddressFrom(subscriber)
usernames = self.usernamesFrom(subscriber)
if not (email or usernames):
return []
if self.isWikiSubscriber(subscriber):
subscriptions.append('whole_wiki')
# optimization: try to use catalog for memory efficiency..
# XXX obsolete, always have a catalog now ?
if self.hasCatalogIndexesMetadata(
(['meta_type','path'], ['subscriber_list'])):
pages = self.pages()
for page in pages:
for sub in page.subscriber_list:
if not sub: continue
if ((email and (self.emailAddressFrom(sub) == email)) or
(usernames and (sub in usernames))):
subscriptions.append(page.id)
else:
# poor caching
for id, page in self.folder().objectItems(spec=PAGE_METATYPE):
if page.isSubscriber(subscriber):
subscriptions.append(id)
return subscriptions
def otherPageSubscriptionsFor(self, email): # -> [string]; depends on self, wiki
"""
Ack, this was too hard in DTML. Return the ids of all pages to
which a subscriber is subscribed, excluding the current page and
'whole_wiki'.
"""
subscriber = email
subs = self.allSubscriptionsFor(subscriber)
thispage = self.id()
if thispage in subs: subs.remove(thispage)
if 'whole_wiki' in subs: subs.remove('whole_wiki')
return subs
def autoSubscriptionEnabled(self): # -> boolean; depends on self, folder
return getattr(self,'auto_subscribe',0) and 1
def usernameOrEmailOfSubscriber(self): # -> string; depends on cmf/plone site, request
"""
If the user is logged into the CMF, return his/her username
else return his/her email address cookie.
"""
if self.inCMF():
username = str(self.portal_membership.getAuthenticatedMember())
if username and not self.portal_membership.isAnonymousUser():
return username
return self.REQUEST.get('email',None)
# utilities
def emailAddressFrom(self,subscriber): # -> string; depends on cmf/plone site
"""
Convert a zwiki subscriber list entry to an email address.
A zwiki subscriber list entry can be: an email address, or a CMF
member id (if we are in a CMF/Plone site), or either of those with
':edits' appended. We figure out the bare email address and
return it (lower-cased), or if we can't, return None.
"""
if not subscriber or type(subscriber) != StringType:
return None
subscriber = re.sub(r':edits$','',subscriber)
if isEmailAddress(subscriber):
email = subscriber
elif self.inCMF():
#and not self.portal_membership.isAnonymousUser()
# don't look up member email addresses if user is anonymous ?
# No I think it's better to minimise confusion due to
# authenticated vs. unauthenticated (un)subscriptions, even if
# it allows an anonymous visitor to unsubscribe a member whose
# address they know
from Products.CMFCore.utils import getToolByName
membership = getToolByName(self,'portal_membership')
memberdata = getToolByName(self,'portal_memberdata')
member = membership.getMemberById(subscriber)
if not member:
# also check for a pseudo-member (a user acquired from above)
# NB doesn't work with CMFMember
if safe_hasattr(memberdata,'_members'):
member = memberdata._members.get(subscriber,None)
# dumb robust fix for http://zwiki.org/1400
try:
email = member.getProperty('email',getattr(member,'email',''))
except AttributeError:
email = getattr(member,'email','')
else:
email = ''
return email.lower() or None
def emailAddressesFrom(self,subscribers): # -> [string]
"""
Convert a list of subscribers to a list of email addresses.
Any of these which are usernames for which we can't find an
address are converted to an obvious bogus address to help
troubleshooting.
"""
emails = []
for s in subscribers:
e = self.emailAddressFrom(s)
# for troubleshooting, but breaks some MTAs
#emails.append(e or 'NO_ADDRESS_FOR_%s' % s)
if e: emails.append(e)
return emails
def usernamesFrom(self,subscriber): # -> [string]
"""
Convert subscriber to username(s) if needed and return as a list.
Ie if subscriber is a username, return that username; if
subscriber is an email address, return the usernames of any CMF
members with that email address.
XXX too expensive, disabled; on plone.org with 7k members, this
maxed out cpu for 10 minutes. Refactor.
"""
if isUsername(subscriber):
return [subscriber]
else:
return []
# XXX plone.org performance issue
#email = string.lower(subscriber)
#usernames = []
#folder = self.folder()
#try:
# for user in folder.portal_membership.listMembers():
# member = folder.portal_memberdata.wrapUser(user)
# if string.lower(member.email) == email:
# usernames.append(member.name)
#except AttributeError:
# pass
#return usernames
InitializeClass(PageSubscriptionSupport)
class PageMailSupport:
"""
This mixin class provides mail-out support and general mail utilities.
"""
def isMailoutEnabled(self): # -> string; depends on self, folder, mailhost
"""
Has mailout been configured ?
"""
if (self.mailhost() and
(self.fromProperty() or self.replyToProperty())):
return 1
else:
return 0
def mailoutPolicy(self): # -> string; depends on self, folder
"""
Get my mail-out policy - comments or edits ?
"""
return getattr(self,'mailout_policy','comments')
def fromProperty(self): # -> string; depends on self, folder
"""
Give the mail_from property for this page.
Usually acquires from the folder.
"""
return getattr(self,'mail_from','')
def replyToProperty(self): # -> string; depends on self, folder
"""
Give the mail_replyto property for this page.
Usually acquires from the folder.
"""
return getattr(self,'mail_replyto','')
def toProperty(self): # -> string; depends on self, folder
"""
Give the mail_to property for this page.
Usually acquires from the folder.
"""
return getattr(self,'mail_to','')
def fromHeader(self,REQUEST=None): # -> string; depends on self, folder
"""
Give the appropriate From: header for mail-outs from this page.
Tries to give the best attribution based on configuration and
available information. XXX todo: use an authenticated CMF
member's email property
"""
address = (self.fromProperty() or
#self.usersEmailAddress() or
self.replyToProperty())
# splitlines to fend off header injection attacks from spammers
lines = self.usernameFrom(REQUEST,ip_address=0).splitlines()
realname = lines and lines[0] or _('anonymous')
return '%s (%s)' % (address, realname)
def replyToHeader(self): # -> string; depends on self, folder
"""
Give the appropriate Reply-to: header for mail-outs from this page.
"""
return self.replyToProperty() or self.fromProperty()
def listId(self): # -> string; depends on self, folder
"""
Give the "list id" for mail-outs from this page.
"""
return self.fromProperty() or self.replyToProperty()
def listPostHeader(self): # -> string; depends on self, folder
"""
Give the appropriate List-Post: header for mail-outs from this page.
"""
return '<mailto:%s>' % (self.listId())
def listIdHeader(self): # -> string; depends on self, folder
"""
Give the appropriate List-ID: header for mail-outs from this page.
"""
return '%s <%s>' % (self.folder().title,self.listId())
def xBeenThereHeader(self): # -> string; depends on self, folder
"""
Give the appropriate X-Been-There: header for mail-outs from this page.
"""
return self.listId()
def bccHeader(self,recipients): # -> string
"""
Give the appropriate Bcc: header for mail-outs from this page.
Expects a list of recipient addresses.
"""
return ', '.join(stripList(recipients))
def subjectHeader(self,subject='',subjectSuffix=''): # -> string; depends on self, folder, time
"""
Give the appropriate Subject: header for mail-outs from this page.
- adds a prefix if configured in mail_subject_prefix;
- includes page name in brackets unless disabled with mail_page_name
- abbreviates issue tracker page names to just the number, except
when creating the page or when overridden with mail_issue_name.
Temp kludge: we assume the page is being created if it's less than
30s old.
(XXX tracker plugin dependency)
- appends subjectSuffix if provided
"""
if getattr(self.folder(),'mail_page_name',1):
# we will add the page name
if (self.issueNumber()
and (self.getPhysicalRoot().ZopeTime()-self.creationTime()) > 30.0/24/60/60
and not getattr(self.folder(),'mail_issue_name',0)):
# we will truncate it to just the issue number
pagename = '[#%s] ' % self.issueNumber()
else:
pagename = '[%s] ' % self.pageName()
else:
# page name has been suppressed
pagename = ''
return (
self.tounicode(getattr(self.folder(),'mail_subject_prefix','').strip()) +
self.tounicode(pagename) +
self.tounicode(subject) +
self.tounicode(subjectSuffix.strip()))
def toHeader(self): # -> string; depends on self, folder
"""
Give the appropriate To: header for mail-outs from this page.
When sending a mail-out, we put the subscribers in Bcc for privacy.
Something is needed in To, what should we use ?
1. if there is a mail_to property, use that
2. if there is a mail_replyto or mail_from property, use that.
NB if you use a real address and also subscribe with it you may
get duplicates; also when using the wiki mailin address a copy
is sent quickly back to the wiki, possible cause of conflicts
leading to slow comments ? Not recently.
3. or use ";" which is a legal "nowhere" address but causes messy cc
header in replies
"""
return (self.toProperty() or
self.replyToProperty() or
self.fromProperty() or
';')
def signature(self, message_id=None): # -> string; depends on self, folder
"""
Give the appropriate signature to add to mail-outs from this page.
That is:
- the contents of the mail_signature property
- or a semi-permalink to a comment if its message id is provided
- or a link to this page
"""
url = self.pageUrl()
if message_id:
# sync with makeCommentHeading
url += '#msg%s' % re.sub(r'^<(.*)>$',r'\1',message_id)
return getattr(self.folder(),'mail_signature',
'--\nforwarded from %s' % url) # XXX i18n
def mailhost(self): # -> mailhost; depends on: folder context
"""
Give the MailHost that should be used for sending mail, or None.
This needs to just work, as follows: we want to find a real
mailhost in a robust way, ie not relying only on a MailHost id,
and acquiring it from a parent folder if necessary. NB there are
at least two kinds, a MaildropHost can be transaction-safe and
prevents duplicates, a MailHost sends immediately and almost never
sends duplicates in practice; we won't favour one or the other.
So: look for the first object with Maildrop Host or Mail Host
meta_type in this folder, then in the parent folder, and so on.
When multiple mailhosts are found in one folder, choose the
alphabetically first.
"""
mhost = None
folder = self.folder()
# XXX folder might not have objectValues, don't know why (#938)
while (not mhost) and folder and safe_hasattr(folder,'objectValues'):
mhostids = sorted(folder.objectIds(
spec=['Mail Host', 'Secure Mail Host', 'Maildrop Host', 'Secure Maildrop Host']))
if mhostids: mhost = folder[mhostids[0]]
folder = getattr(folder,'aq_parent',None)
return mhost
def sendMailToSubscribers(self, text, REQUEST, subjectSuffix='',
subject='',message_id=None,in_reply_to=None,
exclude_address=None): # -> none; depends on self, wiki, mailhost; other effects: sends mail
"""
Send mail to this page's and the wiki's subscribers, if any.
If a mailhost and mail_from property have been configured and
there are subscribers to this page, email text to them. So as not
to prevent page edits, catch any mail-sending errors (and log them
and try to mail them to an admin).
This is used for sending things of interest to all subscribers,
like comments and page creations. To reduce noise we apply a few
special cases:
- if text is empty, don't send
- if this is a boring page, don't send to wiki subscribers unless
they've requested all edits
"""
if text:
self.sendMailTo(
self.emailAddressesFrom(
self.subscriberList() + \
self.wikiSubscriberList(edits=self.isBoring())),
text,
REQUEST,
subjectSuffix=subjectSuffix,
subject=subject,
message_id=message_id,
in_reply_to=in_reply_to,
exclude_address=exclude_address)
def sendMailToEditSubscribers(self, text, REQUEST, subjectSuffix='',
subject='',message_id=None,in_reply_to=None,
exclude_address=None): # -> none; depends on self, wiki, mailhost; other effects: sends mail
"""
Send mail to this page's and the wiki's all edits subscribers, if any.
Like sendMailToSubscribers, but sends only to the subscribers who
have requested notification of all edits. If text is empty, send
nothing.
For backwards compatibility, a mailout_policy property with value
edits on the wiki folder will override this and send to all
subscribers. I think that needs to go away as it makes the user's
choice on subscribeform useless. During upgrade we could remove it
and convert all subscribers to edits subscribers.
"""
if not text: return
if self.mailoutPolicy() == 'edits': #XXX deprecate
recipients = self.subscriberList() + \
self.wikiSubscriberList()
else:
recipients = self.subscriberList(edits=1) + \
self.wikiSubscriberList(edits=1)
self.sendMailTo(
self.emailAddressesFrom(recipients),
text,
REQUEST,
subjectSuffix=subjectSuffix,
subject=subject,
message_id=message_id,
in_reply_to=in_reply_to,
exclude_address=exclude_address)
def sendMailTo(self, recipients, text, REQUEST,
subjectSuffix='',
subject='',
message_id=None,
in_reply_to=None,
to=None,
exclude_address=None,
): # -> none; depends on self, wiki, mailhost, time; other effects: sends encoded msg
"""Send a mail-out containing text to a list of email addresses.
If mail-out is not configured in this wiki or there are no valid
recipients, do nothing. Log any errors but don't stop.
text can be body text or rfc-822 message text.
"""
if not self.isMailoutEnabled(): return
if exclude_address in recipients: recipients.remove(exclude_address) # help mailin.py avoid loops
if not recipients: return
try:
msgid = message_id or self.messageIdFromTime(self.ZopeTime())
# encode subject with RFC 2047
subj = str(Header(self.subjectHeader(subject,subjectSuffix), self.encoding()))
fields = {
'body':'%s\n\n%s' % (self.toencoded(text),self.toencoded(self.signature(msgid))),
'From':self.toencoded(self.fromHeader(REQUEST)),
'Reply-To':self.toencoded(self.replyToHeader()),
'To':self.toencoded(to or self.toHeader()),
'Bcc':self.toencoded(self.bccHeader(recipients)),
'Subject':subj,
'Message-ID':self.toencoded(msgid),
'In-Reply-To':self.toencoded((in_reply_to and '\nIn-reply-to: %s' % in_reply_to.splitlines()[0]) or ''),
'Content-Type':'text/plain; charset="%s"' % self.encoding(),
'charset':self.encoding(),
'X-Zwiki-Version':self.zwiki_version(),
'X-BeenThere':self.toencoded(self.xBeenThereHeader()),
'List-Id':self.toencoded(self.listIdHeader()),
'List-Post':self.toencoded(self.listPostHeader()),
'List-Subscribe':'<'+self.pageUrl()+'/subscribeform>',
'List-Unsubscribe': '<'+self.pageUrl()+'/subscribeform>',
'List-Archive':'<'+self.pageUrl()+'>',
'List-Help':'<'+self.wikiUrl()+'>',
}
GenericMailHost(self.mailhost()).send(fields)
BLATHER('sent mail to subscribers:\nTo: %s\nBcc: %s' % (fields['To'],fields['Bcc']))
except:
BLATHER('**** failed to send mail to %s: %s' % (recipients,formattedTraceback()))
InitializeClass(PageMailSupport)
class GenericMailHost:
"""Adapts the available [Secure] Mail[drop] Host to a generic one."""
def __init__(self, mailhost): # -> none
self.context = mailhost
def send(self,fields): # -> none; depends on: self, mailhost; other effects: sends msg
if self.context.meta_type in ('Secure Mail Host', 'Secure Maildrop Host'):
r = self.context.secureSend(
fields['body'],
mto=fields['To'],
mfrom=fields['From'],
subject=fields['Subject'],
mbcc=fields['Bcc'],
charset=fields['charset'],
**fields)
else:
msg = """\
From: %(From)s
Reply-To: %(Reply-To)s
To: %(To)s
Bcc: %(Bcc)s
Subject: %(Subject)s%(In-Reply-To)s
Message-ID: %(Message-ID)s
X-Zwiki-Version: %(X-Zwiki-Version)s
X-BeenThere: %(X-BeenThere)s
List-Id: %(List-Id)s
List-Post: %(List-Post)s
List-Subscribe: %(List-Subscribe)s
List-Unsubscribe: %(List-Unsubscribe)s
List-Archive: %(List-Archive)s
List-Help: %(List-Help)s
Content-Type: text/plain; charset="%(charset)s"
%(body)s
""" % fields
r = self.context.send(msg)
if r: BLATHER(r)
class PageMailinSupport:
def mailin(self, msg): # -> string | none; depends on self, wiki; modifies wiki
"""Handle an incoming email message, eg by posting a comment or
creating a page. See the MailIn helper class for the precise
delivery rules. msg is a string containing a RFC2822 message.
This is usually invoked by a mail server alias like::
wiki:|curl -n -F 'msg=<-' http://site/wikifolder/ANYPAGE/mailin
See http://zwiki.org/HowToSetUpMailin for more help.
XXX todo:
pass through original mail with attachments
non-subscriber bounce messages ?
"""
m = MailIn(self, msg)
action, info = m.decideMailinAction()
if not action in ('COMMENT','CREATE','ISSUE'): return info
self.REQUEST.set('MAILIN_USERNAME', m.FromUserName) # a hint for usernameFrom
subject = m.realSubject
if action == 'CREATE':
subjectprefix = '(new) '
pagename = self.create(info,text='',sendmail=0)
elif action == 'ISSUE':
subjectprefix = '(new) '
pagename = self.createNextIssue(subject,severity=DEFAULT_SEVERITY,REQUEST=self.REQUEST,sendmail=0)
else:
subjectprefix = ''
pagename = info
self.pageWithName(pagename).comment(text=m.body,
username=m.FromUserName,
REQUEST=self.REQUEST,
subject_heading=subjectprefix+subject,
message_id=m.messageid,
in_reply_to=m.inreplyto,
)
# # handle the mail-out ourselves, to pass through the original message
# m.destpage.sendMailToSubscribers(
# m.original,
# self.REQUEST,
# subject=subjectPrefix+subject,
# message_id=m.messageid,
# in_reply_to=m.inreplyto,
# # mailing list support: when a list and wiki are mutually subscribed,
# # and a mail comes in from the list, we want to forward it out to all
# # subscribers except the list, which has done it's own delivery.
# # Some lists will detect the duplicate automatically, for others we
# # expect the X-BeenThere header and tell zwiki to exclude that address.
# exclude_address=m.xbeenthere,
# )
# return None
def defaultMailinPageName(self): # -> string | none; depends on self, folder
"""The name of the wiki's default destination page for mailins, or
None. This is specified by the default_mailin_page property, or
is None if that property is blank, otherwise is the current page.
"""
if safe_hasattr(self.folder(),'default_mailin_page'):
return self.folder().default_mailin_page or None
else:
return self.pageName()
InitializeClass(PageMailinSupport)
class MailIn:
"""
I represent an incoming mail message being posted to a wiki. I parse
the rfc2822 message string and figure out how to deliver myself within
the provided wiki context.
"""
def __init__(self, context, message): # -> none
"""Extract the bits of interest from an RFC2822 message string.
context should be a wiki page. This perhaps should do the isJunk
test up front to avoid unnecessary resource usage.
"""
DEBUG('mailin.py processing incoming message:\n%s' % message)
self.context = context
self.original = message
self.msg = email.message_from_string(self.original)
self.date = self.msg['Date']
# flatten a multi-line subject into one line
s = re.sub('\n','',self.msg.get('Subject',''))
# convert the possibly RFC2047-encoded subject to unicode.
# Only the first encoded part is used if there is more than one.
# misencoded subjects are ignored.
(s,enc) = decode_header(s)[0]
try:
self.subject = tounicode(s,enc or 'ascii')
except UnicodeDecodeError:
self.subject = ''
self.realSubject = re.sub(r'.*?\[.*?\] ?(.*)',r'\1',self.subject)
self.messageid = self.msg.get('Message-id','')
self.inreplyto = self.msg.get('In-reply-to','')
self.From = self.msg.get('From')
self.FromRealName = parseaddr(self.From)[0]
self.FromEmail = parseaddr(self.From)[1]
self.FromUserName = (self.FromRealName or re.sub(r'@.*$',r'',self.FromEmail))
self.sender = self.msg.get('Sender')
self.senderEmail = (self.sender and parseaddr(self.sender)[1]) or None
tos = self.msg.get_all('to', [])
ccs = self.msg.get_all('cc', [])
resent_tos = self.msg.get_all('resent-to', [])
resent_ccs = self.msg.get_all('resent-cc', [])
self.recipients = getaddresses(tos + ccs + resent_tos + resent_ccs)
# mailing list support
# XXX x-beenthere is mailman-specific - need to support ezmlm & others here
#self.xbeenthere = (self.msg.get('X-BeenThere') or
# re.search(r'[^\s<]+@[^\s>]+',self.msg.get('Delivered-To')).group())
# ..Type Error - configured ezmlm to provide beenthere instead (?)
self.xbeenthere = self.msg.get('X-BeenThere')
# the mailin body will be the message's first text/plain part
# (or a null string if there is none or it's misencoded)
try:
firstplaintextpart = typed_subpart_iterator(self.msg,
'text',
'plain').next()
# as I understand it:
# first decoding, from the content-transfer-encoding, eg quoted-printabe
payload = firstplaintextpart.get_payload(decode=1)
# second decoding, from utf8 or whatever to unicode
charset = self.msg.get_content_charset('ascii')
payloadutf8 = payload.decode(charset).encode('utf-8')
except (StopIteration, UnicodeDecodeError):
payloadutf8 = ''
self.body = cleanupBody(payloadutf8)
def decideMailinAction(self): # -> (string, string|none); depends on: self, wiki context
"""
Figure out what to do with this mail-in. Returns an (action, info)
pair where action is one of 'ERROR', 'ISSUE', 'CREATE', 'COMMENT'
and info is an error message, None, or page name. Here are the
delivery rules:
- if the message appears to be a zwiki mailout or from an auto-responder
or junk, or it doesn't have a plain text part, DISCARD.
- check that the sender is either subscribed somewhere in the wiki
or listed in the mail_accept_nonmembers property, or the
folder's mailin_policy property (possibly acquired) is 'open';
otherwise BOUNCE.
- if the recipient looks like a tracker mailin address (.*TRACKERADDREXP),
CREATE AN ISSUE PAGE.
- identify the destination page name: the last [bracketed page name]
in the message subject, or the folder's default_mailin_page
property (possibly acquired) or the current page (unless
default_mailin_page was blank in which case DISCARD.)
- if the destination page does not exist (partial fuzzy matches
allowed), CREATE it..
- and post the message there as a COMMENT.
"""
if self.isJunk(): return ('ERROR','\nDiscarding junk mailin.\n\n\n')
if not self.isMailinAllowed():
DEBUG('ignoring mail from non-subscriber',self.FromEmail)
return ('ERROR', '\nSorry, you must be a subscriber to send mail to this wiki.\n\n\n')
if re.search(TRACKERADDREXP,self.recipientAddress()): return ('ISSUE',None)
pagename = pageNameFromSubject(self.subject) or self.context.defaultMailinPageName()
if not pagename: return ('ERROR','\nMessage has no destination page, ignored.\n\n\n')
page = self.context.pageWithFuzzyName(pagename,allow_partial=1)
if page: return ('COMMENT',page.pageName())
else: return ('CREATE',pagename)
def isMailinAllowed(self): # -> boolean; depends on self, folder
"""Check if this mailin is permitted to the sender. They must be
subscribed somewhere in the wiki, or be in the
mail_accept_nonmembers property, or the mailin_policy property
must be 'open'.
"""
def is_subscriber(e): return len(self.context.allSubscriptionsFor(e)) > 0 # XXX poor caching
postingpolicy = getattr(self.context.folder(),'mailin_policy',None)
allowlist = getattr(self.context.folder(),'mail_accept_nonmembers',[])
return (postingpolicy == 'open'
or self.FromEmail in allowlist
or self.senderEmail in allowlist
or is_subscriber(self.FromEmail)
or is_subscriber(self.senderEmail))
def isJunk(self): # -> boolean; depends on: self
"""Return true if this mail message should be silently ignored.
Ideally, this should block mail loops, auto-responders and spam,
but allow mailing list messages and mailouts from other zwikis.
qmail-autoresponder's bot-filtering procedure is reportedly good - see
http://untroubled.org/qmail-autoresponder/procedure.txt .
TMDA and spamassassin are two good spam filters - see
http://software.libertine.org/tmda ,
http://spamassassin.taint.org .