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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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))
|