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, subject=None, preamble_text=None): 48 49 """ 50 Deliver to a local mail system using LMTP if 'lmtp_socket' is provided 51 or if 'local_smtp' is given as a true value. 52 """ 53 54 self.lmtp_socket = lmtp_socket 55 self.local_smtp = local_smtp 56 self.sender = sender or MESSAGE_SENDER 57 self.subject = subject 58 self.preamble_text = preamble_text 59 60 # The translation method is set by the client once locale information is 61 # known. 62 63 self.gettext = None 64 65 def local_delivery(self): 66 67 "Return whether local delivery is performed using this messenger." 68 69 return self.lmtp_socket is not None or self.local_smtp 70 71 def sendmail(self, recipients, data, sender=None, outgoing_bcc=None): 72 73 """ 74 Send a mail to the given 'recipients' consisting of the given 'data', 75 using the given 'sender' identity if indicated, indicating an 76 'outgoing_bcc' identity if indicated. 77 78 The 'outgoing_bcc' argument is required when sending on behalf of a user 79 from the calendar@domain address, since this will not be detected as a 80 valid participant and handled using the outgoing transport. 81 """ 82 83 if self.lmtp_socket: 84 smtp = LMTP(self.lmtp_socket) 85 else: 86 smtp = SMTP("localhost") 87 88 if outgoing_bcc: 89 recipients = list(recipients) + ["%s+%s" % (OUTGOING_PREFIX, outgoing_bcc)] 90 elif self.local_smtp: 91 recipients = [self.make_local(recipient) for recipient in recipients] 92 93 smtp.sendmail(sender or self.sender, recipients, data) 94 smtp.quit() 95 96 def make_local(self, recipient): 97 98 """ 99 Make the 'recipient' an address for local delivery. For this to function 100 correctly, a virtual alias or equivalent must be defined for addresses 101 of the following form: 102 103 local+NAME@DOMAIN 104 105 Such aliases should direct delivery to the local recipient. 106 """ 107 108 parts = recipient.split("+", 1) 109 return "%s+%s" % (LOCAL_PREFIX, parts[-1]) 110 111 def make_outgoing_message(self, parts, recipients, sender=None, outgoing_bcc=None): 112 113 """ 114 Make a message from the given 'parts' for the given 'recipients', using 115 the given 'sender' identity if indicated, indicating an 'outgoing_bcc' 116 identity if indicated. 117 """ 118 119 message = self._make_summary_for_parts(parts) 120 121 message["From"] = sender or self.sender 122 for recipient in recipients: 123 message["To"] = recipient 124 if outgoing_bcc: 125 message["Bcc"] = "%s+%s" % (OUTGOING_PREFIX, outgoing_bcc) 126 message["Subject"] = self.subject or \ 127 self.gettext and self.gettext(MESSAGE_SUBJECT) or MESSAGE_SUBJECT 128 129 return message 130 131 def make_summary_message(self, msg, parts): 132 133 """ 134 Return a simple summary using details from 'msg' and the given 'parts'. 135 Information messages provided amongst the parts by the handlers will be 136 merged into the preamble so that mail programs will show them 137 immediately. 138 """ 139 140 message = self._make_summary_for_parts(parts, True) 141 self._copy_headers(message, msg) 142 return message 143 144 def wrap_message(self, msg, parts): 145 146 """ 147 Wrap 'msg' and provide the given 'parts' as the primary content. 148 Information messages provided amongst the parts by the handlers will be 149 merged into the preamble so that mail programs will show them 150 immediately. 151 """ 152 153 message = self._make_container_for_parts(parts, True) 154 payload = message.get_payload() 155 payload.append(MIMEMessage(msg)) 156 self._copy_headers(message, msg) 157 return message 158 159 def _make_summary_for_parts(self, parts, merge=False): 160 161 """ 162 Return a simple summary for the given 'parts', merging information parts if 163 'merge' is specified and set to a true value. 164 """ 165 166 if len(parts) == 1: 167 return parts[0] 168 else: 169 return self._make_container_for_parts(parts, merge) 170 171 def _make_container_for_parts(self, parts, merge=False): 172 173 """ 174 Return a container for the given 'parts', merging information parts if 175 'merge' is specified and set to a true value. 176 """ 177 178 # Merge calendar information if requested. 179 180 if merge: 181 info, parts = self._merge_calendar_info_parts(parts) 182 else: 183 info = [] 184 185 # Insert a preamble message before any calendar information messages. 186 187 info.insert(0, self.preamble_text or 188 self.gettext and self.gettext(PREAMBLE_TEXT) or PREAMBLE_TEXT) 189 190 message = MIMEMultipart("mixed", _subparts=parts) 191 message.preamble = "\n\n".join(info) 192 return message 193 194 def _merge_calendar_info_parts(self, parts): 195 196 """ 197 Return a collection of plain text calendar information messages from 198 'parts', together with a collection of the remaining parts. 199 """ 200 201 info = [] 202 remaining = [] 203 204 for part in parts: 205 206 # Attempt to acquire informational messages. 207 208 if part.get("X-IMIP-Agent") == "info": 209 210 # Ignore the preamble of any multipart message and just 211 # collect its parts. 212 213 if part.is_multipart(): 214 i, r = self._merge_calendar_info_parts(part.get_payload()) 215 remaining += r 216 217 # Obtain any single-part messages. 218 219 else: 220 info.append(part.get_payload(decode=True)) 221 222 # Accumulate other parts regardless of their purpose. 223 224 else: 225 remaining.append(part) 226 227 return info, remaining 228 229 def _copy_headers(self, message, msg): 230 231 "Copy to 'message' certain headers from 'msg'." 232 233 message["From"] = msg["From"] 234 message["To"] = msg["To"] 235 message["Subject"] = msg["Subject"] 236 237 # vim: tabstop=4 expandtab shiftwidth=4