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 a single text part 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 a single text part 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 167 # The original message is placed in a mailbox part for reference, 168 # attached to the rest of the primary content parts. 169 170 payload.append(MIMEMessage(msg)) 171 self._copy_headers(message, msg) 172 return message 173 174 def _make_summary_for_parts(self, parts, merge=False): 175 176 """ 177 Return a simple summary for the given 'parts', merging information parts if 178 'merge' is specified and set to a true value. 179 """ 180 181 if len(parts) == 1: 182 return parts[0] 183 else: 184 return self._make_container_for_parts(parts, merge) 185 186 def _make_container_for_parts(self, parts, merge=False): 187 188 """ 189 Return a container for the given 'parts', merging information parts if 190 'merge' is specified and set to a true value. 191 """ 192 193 # Merge calendar information if requested. 194 195 if merge: 196 info, parts = self._merge_calendar_info_parts(parts) 197 else: 198 info = [] 199 200 # Provide a preamble message before any other data. 201 202 preamble = self.preamble_text or \ 203 self.gettext and self.gettext(PREAMBLE_TEXT) or PREAMBLE_TEXT 204 205 # Provide a mixture of aggregated message parts. 206 207 message = parts and MIMEMultipart("mixed", _subparts=parts) or None 208 209 # Provide an informational message using the aggregated information. 210 211 info_message = info and MIMEText("\n\n".join(info)) or None 212 213 # Wrap the information and message parts. 214 215 subparts = [] 216 if info_message: 217 subparts.append(info_message) 218 if message: 219 subparts.append(message) 220 221 top = MIMEMultipart("alternative", _subparts=subparts) 222 top.preamble = preamble 223 return top 224 225 def _merge_calendar_info_parts(self, parts): 226 227 """ 228 Return a collection of plain text calendar information messages from 229 'parts', together with a collection of the remaining parts. 230 """ 231 232 info = [] 233 remaining = [] 234 235 for part in parts: 236 237 # Attempt to acquire informational messages. 238 239 if part.get("X-IMIP-Agent") == "info": 240 241 # Ignore the preamble of any multipart message and just 242 # collect its parts. 243 244 if part.is_multipart(): 245 i, r = self._merge_calendar_info_parts(part.get_payload()) 246 remaining += r 247 248 # Obtain any single-part messages. 249 250 else: 251 info.append(part.get_payload(decode=True)) 252 253 # Accumulate other parts regardless of their purpose. 254 255 else: 256 remaining.append(part) 257 258 return info, remaining 259 260 def _copy_headers(self, message, msg): 261 262 "Copy to 'message' certain headers from 'msg'." 263 264 message["From"] = msg["From"] 265 message["To"] = msg["To"] 266 message["Subject"] = msg["Subject"] 267 268 # vim: tabstop=4 expandtab shiftwidth=4