1 #!/usr/bin/env python 2 3 """ 4 Mail preparation support. 5 6 Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from imiptools.config import settings 23 from email.mime.message import MIMEMessage 24 from email.mime.multipart import MIMEMultipart 25 from email.mime.text import MIMEText 26 from smtplib import LMTP, SMTP 27 28 LOCAL_PREFIX = settings["LOCAL_PREFIX"] 29 MESSAGE_SENDER = settings["MESSAGE_SENDER"] 30 OUTGOING_PREFIX = settings["OUTGOING_PREFIX"] 31 32 # Fake gettext function for strings to be translated later. 33 34 _ = lambda s: s 35 36 MESSAGE_SUBJECT = _("Calendar system message") 37 38 PREAMBLE_TEXT = _("""\ 39 This message contains several different parts, one of which will contain 40 calendar information that will only be understood by a suitable program. 41 """) 42 43 class Messenger: 44 45 "Sending of outgoing messages." 46 47 def __init__(self, lmtp_socket=None, local_smtp=False, sender=None, 48 subject=None, preamble_text=None, suppress_bcc=False): 49 50 """ 51 Deliver to a local mail system using LMTP if 'lmtp_socket' is provided 52 or if 'local_smtp' is given as a true value. 53 54 Use 'sender' as the sender identity (or the configured default if not 55 specified). Use any explicitly specified 'subject' and 'preamble_text' 56 in sent messages. 57 58 Where 'suppress_bcc' is set to a true value, messages will not be sent 59 to an outgoing destination for the sender even if this is requested. 60 """ 61 62 self.lmtp_socket = lmtp_socket 63 self.local_smtp = local_smtp 64 self.sender = sender or MESSAGE_SENDER 65 self.subject = subject 66 self.preamble_text = preamble_text 67 self.suppress_bcc = suppress_bcc 68 69 # The translation method is set by the client once locale information is 70 # known. 71 72 self.gettext = None 73 74 def local_delivery(self): 75 76 "Return whether local delivery is performed using this messenger." 77 78 return self.lmtp_socket is not None or self.local_smtp 79 80 def sendmail(self, recipients, data, sender=None, outgoing_bcc=None): 81 82 """ 83 Send a mail to the given 'recipients' consisting of the given 'data', 84 using the given 'sender' identity if indicated, indicating an 85 'outgoing_bcc' identity if indicated. 86 87 The 'outgoing_bcc' argument is required when sending on behalf of a user 88 from the calendar@domain address, since this will not be detected as a 89 valid participant and handled using the outgoing transport. 90 """ 91 92 if self.lmtp_socket: 93 smtp = LMTP(self.lmtp_socket) 94 else: 95 smtp = SMTP("localhost") 96 97 if outgoing_bcc and not self.suppress_bcc: 98 recipients = list(recipients) + ["%s+%s" % (OUTGOING_PREFIX, outgoing_bcc)] 99 elif self.local_smtp: 100 recipients = [self.make_local(recipient) for recipient in recipients] 101 102 smtp.sendmail(sender or self.sender, recipients, data) 103 smtp.quit() 104 105 def make_local(self, recipient): 106 107 """ 108 Make the 'recipient' an address for local delivery. For this to function 109 correctly, a virtual alias or equivalent must be defined for addresses 110 of the following form: 111 112 local+NAME@DOMAIN 113 114 Such aliases should direct delivery to the local recipient. 115 """ 116 117 parts = recipient.split("+", 1) 118 return "%s+%s" % (LOCAL_PREFIX, parts[-1]) 119 120 def make_outgoing_message(self, parts, recipients, sender=None, outgoing_bcc=None): 121 122 """ 123 Make a message from the given 'parts' for the given 'recipients', using 124 the given 'sender' identity if indicated, indicating an 'outgoing_bcc' 125 identity if indicated. 126 """ 127 128 message = self._make_summary_for_parts(parts) 129 130 message["From"] = sender or self.sender 131 for recipient in recipients: 132 message["To"] = recipient 133 134 if outgoing_bcc and not self.suppress_bcc: 135 message["Bcc"] = "%s+%s" % (OUTGOING_PREFIX, outgoing_bcc) 136 137 message["Subject"] = self.subject or \ 138 self.gettext and self.gettext(MESSAGE_SUBJECT) or MESSAGE_SUBJECT 139 140 return message 141 142 def make_summary_message(self, msg, parts): 143 144 """ 145 Return a simple summary using details from 'msg' and the given 'parts'. 146 Information messages provided amongst the parts by the handlers will be 147 merged into the preamble so that mail programs will show them 148 immediately. 149 """ 150 151 message = self._make_summary_for_parts(parts, True) 152 self._copy_headers(message, msg) 153 return message 154 155 def wrap_message(self, msg, parts): 156 157 """ 158 Wrap 'msg' and provide the given 'parts' as the primary content. 159 Information messages provided amongst the parts by the handlers will be 160 merged into the preamble so that mail programs will show them 161 immediately. 162 """ 163 164 message = self._make_container_for_parts(parts, True) 165 payload = message.get_payload() 166 payload.append(MIMEMessage(msg)) 167 self._copy_headers(message, msg) 168 return message 169 170 def _make_summary_for_parts(self, parts, merge=False): 171 172 """ 173 Return a simple summary for the given 'parts', merging information parts if 174 'merge' is specified and set to a true value. 175 """ 176 177 if len(parts) == 1: 178 return parts[0] 179 else: 180 return self._make_container_for_parts(parts, merge) 181 182 def _make_container_for_parts(self, parts, merge=False): 183 184 """ 185 Return a container for the given 'parts', merging information parts if 186 'merge' is specified and set to a true value. 187 """ 188 189 # Merge calendar information if requested. 190 191 if merge: 192 info, parts = self._merge_calendar_info_parts(parts) 193 else: 194 info = [] 195 196 # Insert a preamble message before any calendar information messages. 197 198 info.insert(0, self.preamble_text or 199 self.gettext and self.gettext(PREAMBLE_TEXT) or PREAMBLE_TEXT) 200 201 message = MIMEMultipart("mixed", _subparts=parts) 202 message.preamble = "\n\n".join(info) 203 return message 204 205 def _merge_calendar_info_parts(self, parts): 206 207 """ 208 Return a collection of plain text calendar information messages from 209 'parts', together with a collection of the remaining parts. 210 """ 211 212 info = [] 213 remaining = [] 214 215 for part in parts: 216 217 # Attempt to acquire informational messages. 218 219 if part.get("X-IMIP-Agent") == "info": 220 221 # Ignore the preamble of any multipart message and just 222 # collect its parts. 223 224 if part.is_multipart(): 225 i, r = self._merge_calendar_info_parts(part.get_payload()) 226 remaining += r 227 228 # Obtain any single-part messages. 229 230 else: 231 info.append(part.get_payload(decode=True)) 232 233 # Accumulate other parts regardless of their purpose. 234 235 else: 236 remaining.append(part) 237 238 return info, remaining 239 240 def _copy_headers(self, message, msg): 241 242 "Copy to 'message' certain headers from 'msg'." 243 244 message["From"] = msg["From"] 245 message["To"] = msg["To"] 246 message["Subject"] = msg["Subject"] 247 248 # vim: tabstop=4 expandtab shiftwidth=4