home » zplus/clif.git
Author zPlus <zplus@peers.community> 2023-03-21 08:38:43
Committer zPlus <zplus@peers.community> 2023-03-21 08:38:43
Commit e4109bc (patch)
Tree 0aa2836
Parent(s)

Minor changes to emails.py - subscribe/unsubscribe via "Subject:" instead of +subscribe/+unsubscribe in the address - set default settings to None - add Return-Path header to forwarded emails - replace multiple try..except with only one - replace logging.warning with logging.info - change some comments


commits diff: 31cb0da..e4109bc
1 file changed, 96 insertions, 116 deletionsdownload


Diffstat
-rwxr-xr-x emails.py 212

Diff options
View
Side
Whitespace
Context lines
Inter-hunk lines
+96/-116 M   emails.py
index 4a6de1a..334b6f3
old size: 11K - new size: 11K
@@ -20,11 +20,13 @@ import sys
20 20 # SETTINGS
21 21 ###############################################################################
22 22
23 - # The "domain" part in address@domain that we're expecting to see.
23 + # The "domain" part in address@domain for the mailing lists.
24 24 # All emails addressed to another domain will be ignored.
25 - SERVER_DOMAIN = 'domain.local'
25 + SERVER_DOMAIN = None
26 26
27 - REPOSITORIES_PATH = '/home/git/repositories'
27 + # The folder containing the git repositories.
28 + # If using Gitolite, this is the Gitolite's "repositories" folder
29 + REPOSITORIES_PATH = None
28 30
29 31 # Level | Numeric value
30 32 # ---------|--------------
@@ -39,83 +41,72 @@ logging.basicConfig(filename='/home/git/clif/emails.log',
39 41 format='[%(asctime)s] %(levelname)s - %(message)s',
40 42 datefmt='%Y-%m-%d %H:%M:%S%z')
41 43
44 + assert SERVER_DOMAIN
45 + assert REPOSITORIES_PATH
42 46
47 + ###############################################################################
43 48
44 49
45 - ###############################################################################
46 - # ACCEPT/VALIDATE INCOMING EMAIL
50 +
51 +
52 + # Validate incoming email
47 53 ###############################################################################
48 54
49 - # Retrieve the email message from stdin (Postfix has piped this script)
55 + # Retrieve the email message from stdin (piped from Postfix to this script)
50 56 message_raw = sys.stdin.read()
51 - message = email.message_from_string(message_raw, policy=email.policy.default)
52 57
53 58 try:
54 - email_id = message.get('message-id').strip()
55 - except:
56 - logging.error('Refuting email without a Message-ID: {}'.format(email_subject))
57 - exit()
59 + message = email.message_from_string(message_raw, policy=email.policy.default)
58 60
59 - email_id_hash = hashlib.sha256(email_id.encode('utf-8')).hexdigest()[:8] # This will be used as thread ID
61 + email_id = message.get('message-id').strip()
62 + email_id_hash = hashlib.sha256(email_id.encode('utf-8')).hexdigest()[:8] # This will be used as thread ID
60 63
61 - try:
62 64 email_from = email.utils.parseaddr(message.get('from'))
63 65 assert len(email_from[1]) > 0
64 - except:
65 - logging.error('Refuting email with From header: {}'.format(email_from))
66 - exit()
67 66
68 - try:
69 67 email_to = email.utils.parseaddr(message.get('to'))
70 68 assert len(email_from[1]) > 0
71 69 assert email_to[1].endswith('@' + SERVER_DOMAIN)
72 - except:
73 - logging.error('Refuting email with To header: {}'.format(email_to))
74 - exit()
75 70
76 - email_in_reply_to = message.get('in-reply-to')
77 - if email_in_reply_to:
78 - email_in_reply_to = email_in_reply_to.strip()
71 + email_in_reply_to = message.get('in-reply-to')
72 + if email_in_reply_to:
73 + email_in_reply_to = email_in_reply_to.strip()
79 74
80 - try:
81 - email_subject = message.get('subject').strip()
82 - except:
83 - email_subject = ''
75 + email_subject = message.get('subject', '').strip()
84 76
85 - try:
86 77 # Accept plaintext only!
87 - email_body = message.get_body(('plain',)).get_content()
88 - except:
89 - email_body = ''
78 + email_body = message.get_body(('plain',)).get_content().strip()
90 79
91 - logging.info('Received email from {} to {} with subject "{}"'.format(email_from, email_to, email_subject))
80 + # Get the repository name. We use email addresses formatted as <repository>@SERVER_DOMAIN
81 + repository_name = email_to[1].rsplit('@', 1)[0]
92 82
93 - # Get the repository name. We use email addresses formatted as <repository>@SERVER_DOMAIN
94 - repository_name = email_to[1].rsplit('@', 1)[0]
83 + repository_path = os.path.join(REPOSITORIES_PATH, repository_name + '.mlist.git')
95 84
96 - # Is this a request for subscription?
97 - request_subscribe = repository_name.endswith('+subscribe')
98 - request_unsubscribe = repository_name.endswith('+unsubscribe')
85 + # Repository names must not contain ".." otherwise it would be possible to
86 + # point to folders outside REPOSITORIES_PATH
87 + assert '..' not in repository_name
88 + # Repositories must be <username>/<reponame>
89 + assert '/' in repository_name
99 90
100 - # Remove command from address
101 - if request_subscribe: repository_name = repository_name[:-10]
102 - if request_unsubscribe: repository_name = repository_name[:-12]
91 + assert os.path.isdir(repository_path)
103 92
104 - repository_path = os.path.join(REPOSITORIES_PATH, repository_name + '.mlist.git')
93 + logging.info('Received valid email UID:{} From:{} To:{} Subject:"{}"'.format(
94 + uid, email_from, email_to, email_subject))
105 95
106 - if '..' in repository_name:
107 - logging.error('Refuting email because the repository name contains "..": {}'.format(repository_name))
108 - exit()
96 + except Exception as e:
109 97
110 - # All repositories should be <username>/<reponame>
111 - if '/' not in repository_name:
112 - logging.error('Refuting email because the repository name does not contain a namespace: {}'.format(repository_name))
113 - exit()
98 + logging.info('Received invalid email UID:{} From:{} To:{} Subject:"{}"'.format(
99 + uid, email_from, email_to, email_subject))
114 100
115 - if not os.path.isdir(repository_path):
116 - logging.error('Repository path does not exist: {}'.format(repository_path))
101 + logging.info(e)
117 102 exit()
118 103
104 +
105 +
106 +
107 + # Load repository from disk
108 + ###############################################################################
109 +
119 110 try:
120 111 repo = pygit2.Repository(repository_path)
121 112 except:
@@ -125,7 +116,7 @@ except:
125 116 try:
126 117 head_tree = repo.revparse_single('HEAD').tree
127 118 except:
128 - logging.warning('Could not find HEAD ref: {}'.format(repository_path))
119 + logging.info('Could not find HEAD ref for repository {}. A new tree will be created.'.format(repository_path))
129 120 head_tree = None
130 121
131 122 try:
@@ -136,36 +127,38 @@ try:
136 127 subscribers.append(addr)
137 128 except:
138 129 subscribers = []
139 - logging.info('Subscribers file not found or invalid: {}'.format(repository_path))
130 + logging.info('Subscribers file not found or invalid for repository {}. A new one will be created.'.format(repository_path))
140 131
141 132
142 133
143 134
144 - ###############################################################################
145 - # LISTS SUBSCRIPTION
135 + # Handle subscription requests
146 136 ###############################################################################
147 137
148 - if request_subscribe and (email_from[1] in subscribers):
149 - # Already subscribed
150 -
151 - logging.info('Already subscribed to {}: {}'.format(repository_path, email_from))
152 - exit()
138 + # Is this a request for subscription?
139 + request_subscribe = email_subject.upper('SUBSCRIBE')
140 + request_unsubscribe = email_subject.upper('UNSUBSCRIBE')
153 141
154 - if request_unsubscribe and (email_from[1] not in subscribers):
155 - # No address to remove
156 -
157 - logging.info('Already unsubscribed from {}: {}'.format(repository_path, email_from))
158 - exit()
142 + if request_subscribe:
143 + # Already subscribed?
144 + if email_from[1] in subscribers:
145 + logging.info('{} already subscribed to {}'.format(email_from, repository_path))
146 + exit()
159 147
160 - if request_subscribe or request_unsubscribe:
161 - if request_subscribe:
162 - subscribers.append(email_from[1])
163 - commit_message = 'Subscribe'
148 + subscribers.append(email_from[1])
149 + commit_message = 'Subscribe'
164 150
165 - if request_unsubscribe:
166 - subscribers = [ address for address in subscribers if address != email_from[1] ]
167 - commit_message = 'Unsubscribe'
151 + if request_unsubscribe
152 + # Already unsubscribed?
153 + if email_from[1] not in subscribers:
154 + logging.info('{} already unsubscribed from {}'.format(email_from, repository_path))
155 + exit()
168 156
157 + subscribers = [ address for address in subscribers if address != email_from[1] ]
158 + commit_message = 'Unsubscribe'
159 +
160 + # Commit the new list of subscribers to the git repository
161 + if request_subscribe or request_unsubscribe:
169 162 # Add a new BLOB to the git store
170 163 oid = repo.create_blob('\n'.join(subscribers).encode('UTF-8'))
171 164
@@ -182,39 +175,27 @@ if request_subscribe or request_unsubscribe:
182 175 head_tree_oid, # tree of this commit
183 176 [] if repo.is_empty else [ repo.head.target ] # parents commit
184 177 )
185 -
178 +
186 179 if request_subscribe:
187 - logging.info('Subscribed to {}: {}'.format(repository_path, email_from))
180 + logging.info('{} is now subscribed to {}'.format(email_from, repository_path))
188 181 if request_unsubscribe:
189 - logging.info('Unsubscribed from {}: {}'.format(repository_path, email_from))
190 -
182 + logging.info('{} is now unsubscribed from {}'.format(email_from, repository_path))
183 +
191 184 exit()
192 185
193 186
194 187
195 188
196 - ###############################################################################
197 - # ADD EMAIL TO USER REPOSITORY
189 + # If it was not a subscription request, then it's a message. Add it to the
190 + # repository.
191 + # If the email contains the In-Reply-To header, we retrieve the existing tree
192 + # for the thread. Otherwise, we will create a new tree.
198 193 ###############################################################################
199 194
200 - if len(email_subject) == 0:
201 - logging.info('Refuting email with no subject: {}'.format(email_id))
195 + if not email_body or len(email_body) == 0:
196 + logging.info('Refuting email without plaintext body: {}'.format(email_subject))
202 197 exit()
203 198
204 - if not email_body:
205 - logging.warning('Refuting email without plaintext body: {}'.format(email_subject))
206 - exit()
207 -
208 - if len(email_body.strip()) == 0:
209 - logging.info('Refuting email with empty body: {}'.format(email_id))
210 - exit()
211 -
212 - logging.debug('Accepting email from {} to {} with subject {}'.format(email_from, email_to, email_subject))
213 -
214 - # At this point we need to add the incoming email to the repository.
215 - # If the email is a reply (ie. it contains the In-Reply-To header, we retrieve the
216 - # existing tree for the thread. Otherwise, we will create a new tree.
217 -
218 199 thread_tree = None
219 200 thread_title = '{} {} {}'.format(
220 201 datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
@@ -225,7 +206,7 @@ thread_title = '{} {} {}'.format(
225 206 if email_in_reply_to:
226 207 try:
227 208 assert head_tree
228 -
209 +
229 210 # The hash of the email that is being replied to
230 211 parent_message_hash = hashlib.sha256(email_in_reply_to.encode('utf-8')).hexdigest()[:8]
231 212
@@ -235,9 +216,9 @@ if email_in_reply_to:
235 216 thread_tree = obj
236 217 thread_title = obj.name
237 218 break
238 -
219 +
239 220 assert thread_tree
240 -
221 +
241 222 except:
242 223 # We only accept emails as reply to existing messages
243 224 logging.debug('In-Reply-To message ID not found in repository: {}'.format(email_in_reply_to))
@@ -268,8 +249,7 @@ repo.create_commit(
268 249
269 250
270 251
271 - ###############################################################################
272 - # FORWARD EMAIL TO THREAD PARTICIPANTS AND TO LIST SUBSCRIBERS
252 + # Forward email to list subscribers
273 253 ###############################################################################
274 254
275 255 # Remove duplicates, if any
@@ -281,7 +261,7 @@ for obj in thread_tree:
281 261 try:
282 262 obj_message = email.message_from_string(obj.data.decode('UTF-8'), policy=email.policy.default)
283 263 obj_email_from = email.utils.parseaddr(obj_message.get('from'))[1]
284 -
264 +
285 265 if obj_email_from not in participants:
286 266 participants.append(obj_email_from)
287 267 except:
@@ -291,37 +271,37 @@ for obj in thread_tree:
291 271 while email_to[1] in participants:
292 272 participants.remove(email_to[1])
293 273
294 - # Modify some headers before forwarding.
295 - # Note: we need to delete them first because the SMTP client will only accept one
296 - # of them at most, but message[] is an append operator ("message" is an instance of
297 - # email.message.EmailMessage())
298 - # https://docs.python.org/3/library/email.message.html#email.message.EmailMessage
299 - # TODO Some ISPs add the client IP to the email headers. Should we remove *all*
300 - # unnecessary headers instead?
301 - for header in [ 'Sender', 'Reply-To',
302 - 'List-Id', 'List-Subscribe', 'List-Unsubscribe', 'List-Post' ]:
274 + # Edit some headers before forwarding.
275 + # Note: we need to delete them first because message[] is an append operator
276 + # (the variable "message" is an instance of email.message.EmailMessage()).
277 + for header in [ 'Sender', 'Reply-To', 'Return-Path', 'List-Archive', 'List-Id',
278 + 'List-Subscribe', 'List-Unsubscribe', 'List-Post' ]:
303 279 del message[header]
304 280
305 281 message['Sender'] = '{}@{}'.format(repository_name, SERVER_DOMAIN)
306 282 message['Reply-To'] = '{}@{}'.format(repository_name, SERVER_DOMAIN)
283 + # TODO if an email is bounced to this address, it should be removed from the address list
284 + message['Return-Path'] = 'bounces@{}'.format(SERVER_DOMAIN)
285 + message['List-Archive'] = ''
307 286 message['List-Id'] = '<{}@{}>'.format(repository_name, SERVER_DOMAIN)
308 287 # message['List-Subscribe'] = '<>'
309 288 # message['List-Unsubscribe'] = '<>'
310 289 message['List-Post'] = '<{}@{}>'.format(repository_name, SERVER_DOMAIN)
311 290
312 - # Forward email to participants
291 + # Send emails
313 292 try:
314 293 smtp_client = smtplib.SMTP('localhost')
315 -
316 - # "The from_addr and to_addrs parameters are used to construct the message envelope
317 - # used by the transport agents. sendmail does not modify the message headers in any way."
318 - # - https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.sendmail
294 +
295 + # From https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.sendmail:
296 + # The from_addr and to_addrs parameters are used to construct the message
297 + # envelope used by the transport agents. sendmail does not modify the message
298 + # headers in any way.
319 299 smtp_client.sendmail(
320 300 '{}@{}'.format(repository_name, SERVER_DOMAIN), # Envelope From
321 301 participants, # Envelope To
322 302 str(message)) # Message
323 -
324 - logging.debug("Successfully sent emails.")
303 +
304 + logging.debug("Sent email {} to {}".format(email_subject, participants))
325 305 except Exception as e:
326 - logging.error("Error sending emails.")
306 + logging.debug("Cannot send email {} to {}".format(email_subject, participants))
327 307 logging.error(str(e))