[PATCH 6/8] CLI: refactor dumping of tags.
[notmuch-archives.git] / c4 / f91b52d6bd92dd01e43ac5a10a3b5f89f4dbbf
1 Return-Path: <ethan.glasser.camp@gmail.com>\r
2 X-Original-To: notmuch@notmuchmail.org\r
3 Delivered-To: notmuch@notmuchmail.org\r
4 Received: from localhost (localhost [127.0.0.1])\r
5         by olra.theworths.org (Postfix) with ESMTP id 22889431FAF\r
6         for <notmuch@notmuchmail.org>; Sun,  6 Oct 2013 21:49:52 -0700 (PDT)\r
7 X-Virus-Scanned: Debian amavisd-new at olra.theworths.org\r
8 X-Spam-Flag: NO\r
9 X-Spam-Score: -0.799\r
10 X-Spam-Level: \r
11 X-Spam-Status: No, score=-0.799 tagged_above=-999 required=5\r
12         tests=[DKIM_SIGNED=0.1, DKIM_VALID=-0.1, DKIM_VALID_AU=-0.1,\r
13         FREEMAIL_FROM=0.001, RCVD_IN_DNSWL_LOW=-0.7] autolearn=disabled\r
14 Received: from olra.theworths.org ([127.0.0.1])\r
15         by localhost (olra.theworths.org [127.0.0.1]) (amavisd-new, port 10024)\r
16         with ESMTP id l0dk1rHivO6h for <notmuch@notmuchmail.org>;\r
17         Sun,  6 Oct 2013 21:49:44 -0700 (PDT)\r
18 Received: from mail-qe0-f53.google.com (mail-qe0-f53.google.com\r
19         [209.85.128.53]) (using TLSv1 with cipher RC4-SHA (128/128 bits))\r
20         (No client certificate requested)\r
21         by olra.theworths.org (Postfix) with ESMTPS id 83F48431FAE\r
22         for <notmuch@notmuchmail.org>; Sun,  6 Oct 2013 21:49:44 -0700 (PDT)\r
23 Received: by mail-qe0-f53.google.com with SMTP id cy11so956017qeb.26\r
24         for <notmuch@notmuchmail.org>; Sun, 06 Oct 2013 21:49:42 -0700 (PDT)\r
25 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20120113;\r
26         h=from:to:subject:in-reply-to:references:user-agent:date:message-id\r
27         :mime-version:content-type;\r
28         bh=djCxR7kr3DGkKxSNNOoNrBe3lo2uI3tzo52A5puB6eI=;\r
29         b=W9YJGZc3BuQI5wlBCt6FgoIy59LdhOiQsMaW1yejCmq8RkflhNV3kWbL6q0NPI7P6/\r
30         8LBCDceH4hfL6x/wTr7q2bYrWlBgRkkyrBBVhax7MpiY0aGL1+pzuM5tpN01d0P65R9J\r
31         /6DXChty02CS4Z/fenIyWN1nWlkkZhuBYJpPjgNzpMpwNC3vZu11w56WD5u/xx7gKT5J\r
32         L3imy4r2lwNHTjWSxDD0yPDYuxQkxYWfgWCDQtBIjZtsKD7PGA3Sq9HT2a+BuOZ1tQIt\r
33         oHD28zD4wPfjyYD+f9Q3v4FH+Rhx0RQEGRKdijahPqJ4tsv1VBDVikKUw6j2wAWPIuug\r
34         3UmQ==\r
35 X-Received: by 10.224.11.133 with SMTP id t5mr35139503qat.34.1381121382839;\r
36         Sun, 06 Oct 2013 21:49:42 -0700 (PDT)\r
37 Received: from smtp.gmail.com ([66.114.71.21])\r
38         by mx.google.com with ESMTPSA id g2sm58448024qaf.12.1969.12.31.16.00.00\r
39         (version=TLSv1.2 cipher=RC4-SHA bits=128/128);\r
40         Sun, 06 Oct 2013 21:49:41 -0700 (PDT)\r
41 From: Ethan Glasser-Camp <ethan.glasser.camp@gmail.com>\r
42 To: David Bremner <david@tethera.net>,\r
43         notmuch mailing list <notmuch@notmuchmail.org>\r
44 Subject: Re: On disk tag storage format\r
45 In-Reply-To: <87fvsgh5g5.fsf@betacantrips.com>\r
46 References: <874nk8v9zw.fsf@zancas.localnet> <87vc9mtpxh.fsf@zancas.localnet>\r
47         <87fvsgh5g5.fsf@betacantrips.com>\r
48 User-Agent: Notmuch/0.16+80~g81ee785 (http://notmuchmail.org) Emacs/24.2.1\r
49         (x86_64-pc-linux-gnu)\r
50 Date: Mon, 07 Oct 2013 00:49:39 -0400\r
51 Message-ID: <87bo31heho.fsf@betacantrips.com>\r
52 MIME-Version: 1.0\r
53 Content-Type: multipart/mixed; boundary="=-=-="\r
54 X-BeenThere: notmuch@notmuchmail.org\r
55 X-Mailman-Version: 2.1.13\r
56 Precedence: list\r
57 List-Id: "Use and development of the notmuch mail system."\r
58         <notmuch.notmuchmail.org>\r
59 List-Unsubscribe: <http://notmuchmail.org/mailman/options/notmuch>,\r
60         <mailto:notmuch-request@notmuchmail.org?subject=unsubscribe>\r
61 List-Archive: <http://notmuchmail.org/pipermail/notmuch>\r
62 List-Post: <mailto:notmuch@notmuchmail.org>\r
63 List-Help: <mailto:notmuch-request@notmuchmail.org?subject=help>\r
64 List-Subscribe: <http://notmuchmail.org/mailman/listinfo/notmuch>,\r
65         <mailto:notmuch-request@notmuchmail.org?subject=subscribe>\r
66 X-List-Received-Date: Mon, 07 Oct 2013 04:49:52 -0000\r
67 \r
68 --=-=-=\r
69 Content-Type: text/plain\r
70 \r
71 Ethan Glasser-Camp <ethan.glasser.camp@gmail.com> writes:\r
72 \r
73 > I've modified the script so that it would run by mangling filenames,\r
74 > which is irreversible (the original tried to encode/decode filenames\r
75 > reversibly). Then I got a little carried away, adding --verbose and\r
76 > --dry-run options as well as removing a couple trailing\r
77 > semicolons. Here's my version, in case it should interest anyone else.\r
78 \r
79 Hi guys,\r
80 \r
81 There was a bug in the previous version I sent. It didn't handle\r
82 unlinking tags correctly. Also, I spotted a bug in syncing to untagged\r
83 messages. Maybe I should stop using emails as version control.\r
84 \r
85 ---- 8< ----\r
86 \r
87 \r
88 \r
89 --=-=-=\r
90 Content-Type: text/x-python\r
91 Content-Disposition: inline; filename=linksync.py\r
92 Content-Description: slightly more tested this time\r
93 \r
94 # Copyright 2013, David Bremner <david@tethera.net>\r
95 \r
96 # Licensed under the same terms as notmuch.\r
97 \r
98 import notmuch\r
99 import re\r
100 import os, errno\r
101 import sys\r
102 from collections import defaultdict\r
103 import argparse\r
104 import hashlib\r
105 \r
106 # skip automatic and maildir tags\r
107 \r
108 skiptags = re.compile(r"^(attachement|signed|encrypted|draft|flagged|passed|replied|unread)$")\r
109 \r
110 # some random person on stack overflow suggests:\r
111 \r
112 def mkdir_p(path):\r
113     try:\r
114         os.makedirs(path)\r
115     except OSError as exc: # Python >2.5\r
116         if exc.errno == errno.EEXIST and os.path.isdir(path):\r
117             pass\r
118         else: raise\r
119 \r
120 VERBOSE = False\r
121 \r
122 def log(msg):\r
123     if VERBOSE:\r
124         print(msg)\r
125 \r
126 CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+_@=.,-'\r
127 \r
128 encode_re = '([^{0}])'.format(CHARSET)\r
129 \r
130 decode_re = '[%]([0-7][0-9A-Fa-f])'\r
131 \r
132 def encode_one_char(match):\r
133     return('%{:02x}'.format(ord(match.group(1))))\r
134 \r
135 def encode_for_fs(str):\r
136     return re.sub(encode_re,encode_one_char, str,0)\r
137 \r
138 def mangle_message_id(msg_id):\r
139     """\r
140     Return a mangled version of the message id, suitable for use as a filename.\r
141     """\r
142     MAX_LENGTH = 143\r
143     FLAGS_LENGTH = 8    # :2,S...??\r
144     encoded = encode_for_fs(msg_id)\r
145     if len(encoded) < MAX_LENGTH - FLAGS_LENGTH:\r
146         return encoded\r
147 \r
148     SHA_LENGTH = 8\r
149     TRUNCATED_ID_LENGTH = MAX_LENGTH - SHA_LENGTH - FLAGS_LENGTH\r
150     PREFIX_LENGTH = SUFFIX_LENGTH = (TRUNCATED_ID_LENGTH - 3) // 2\r
151     prefix = encoded[:PREFIX_LENGTH]\r
152     suffix = encoded[-SUFFIX_LENGTH:]\r
153     sha = hashlib.sha256()\r
154     sha.update(encoded)\r
155     return prefix + '...' + suffix + sha.hexdigest()[:SHA_LENGTH]\r
156 \r
157 def decode_one_char(match):\r
158     return chr(int(match.group(1),16))\r
159 \r
160 def decode_from_fs(str):\r
161     return re.sub(decode_re,decode_one_char, str, 0)\r
162 \r
163 def mk_tag_dir(tagdir):\r
164 \r
165     mkdir_p (os.path.join(tagdir, 'cur'))\r
166     mkdir_p (os.path.join(tagdir, 'new'))\r
167     mkdir_p (os.path.join(tagdir, 'tmp'))\r
168 \r
169 \r
170 flagpart = '(:2,[^:]*)'\r
171 flagre = re.compile(flagpart + '$');\r
172 \r
173 def path_for_msg (dir, msg):\r
174     filename = msg.get_filename()\r
175     flagsmatch = flagre.search(filename)\r
176     if flagsmatch == None:\r
177         flags = ''\r
178     else:\r
179         flags = flagsmatch.group(1)\r
180 \r
181     return os.path.join(dir, 'cur', mangle_message_id(msg.get_message_id()) + flags)\r
182 \r
183 \r
184 def unlink_message(dir, msg):\r
185 \r
186     dir = os.path.join(dir, 'cur')\r
187 \r
188     mangled_id = mangle_message_id(msg.get_message_id())\r
189     filepattern = '^' + re.escape(mangled_id)  + flagpart +'?$'\r
190 \r
191     filere = re.compile(filepattern)\r
192 \r
193     for file in os.listdir(dir):\r
194         if filere.match(file):\r
195             log("Unlinking {}".format(os.path.join(dir, file)))\r
196             if not opts.dry_run:\r
197                 os.unlink(os.path.join(dir, file))\r
198 \r
199 def dir_for_tag(tag):\r
200     enc_tag = encode_for_fs (tag)\r
201     return os.path.join(tagroot, enc_tag)\r
202 \r
203 disk_tags = defaultdict(set)\r
204 disk_ids = set()\r
205 \r
206 def read_tags_from_disk(rootdir):\r
207 \r
208     for root, subFolders, files in os.walk(rootdir):\r
209         for filename in files:\r
210             mangled_id = filename.split(':')[0]\r
211             tag = root.split('/')[-2]\r
212             disk_ids.add(mangled_id)\r
213             disk_tags[mangled_id].add(decode_from_fs(tag))\r
214 \r
215 # Main program\r
216 \r
217 parser = argparse.ArgumentParser(description='Sync notmuch tag database to/from link farm')\r
218 parser.add_argument('-l','--link-style',choices=['hard','symbolic', 'adaptive'],\r
219                     default='adaptive')\r
220 parser.add_argument('-d','--destination',choices=['disk','notmuch'], default='disk')\r
221 parser.add_argument('-t','--threshold', default=50000L, type=int)\r
222 parser.add_argument('-n','--dry-run', default=False, action='store_true')\r
223 parser.add_argument('-v','--verbose', default=False, action='store_true')\r
224 \r
225 parser.add_argument('tagroot')\r
226 \r
227 opts=parser.parse_args()\r
228 VERBOSE = opts.verbose\r
229 \r
230 tagroot=opts.tagroot\r
231 \r
232 sync_from_links = (opts.destination == 'notmuch')\r
233 \r
234 read_tags_from_disk(tagroot)\r
235 \r
236 if sync_from_links:\r
237     db = notmuch.Database(mode=notmuch.Database.MODE.READ_WRITE)\r
238 else:\r
239     db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
240 \r
241 dbtags = filter (lambda tag: not skiptags.match(tag), db.get_all_tags())\r
242 \r
243 if sync_from_links:\r
244     # have to iterate over even untagged messages\r
245     querystr = '*'\r
246 else:\r
247     querystr = ' OR '.join(map (lambda tag: 'tag:'+tag,  dbtags))\r
248 \r
249 q_new = notmuch.Query(db, querystr)\r
250 q_new.set_sort(notmuch.Query.SORT.UNSORTED)\r
251 for msg in q_new.search_messages():\r
252 \r
253     # silently ignore empty tags\r
254     db_tags = set(filter (lambda tag: tag != '' and not skiptags.match(tag),\r
255                           msg.get_tags()))\r
256 \r
257     message_id = msg.get_message_id()\r
258 \r
259     mangled_id = mangle_message_id(message_id)\r
260 \r
261     disk_ids.discard(mangled_id)\r
262 \r
263     missing_on_disk = db_tags.difference(disk_tags[mangled_id])\r
264     missing_in_db = disk_tags[mangled_id].difference(db_tags)\r
265 \r
266     if sync_from_links:\r
267         msg.freeze()\r
268 \r
269     filename = msg.get_filename()\r
270 \r
271     if len(missing_on_disk) > 0:\r
272         if opts.link_style == 'adaptive':\r
273             statinfo = os.stat (filename)\r
274             symlink = (statinfo.st_size > opts.threshold)\r
275         else:\r
276             symlink = opts.link_style == 'symbolic'\r
277 \r
278     for tag in missing_on_disk:\r
279 \r
280         if sync_from_links:\r
281             log("Removing tag {} from {}".format(tag, message_id))\r
282             if not opts.dry_run:\r
283                 msg.remove_tag(tag,sync_maildir_flags=False)\r
284         else:\r
285             tagdir = dir_for_tag (tag)\r
286 \r
287             if not opts.dry_run:\r
288                 mk_tag_dir (tagdir)\r
289 \r
290             newlink = path_for_msg (tagdir, msg)\r
291 \r
292             log("Linking {} to {}".format(filename, newlink))\r
293             if not opts.dry_run:\r
294                 if symlink:\r
295                     os.symlink(filename, newlink)\r
296                 else:\r
297                     os.link(filename, newlink)\r
298 \r
299 \r
300     for tag in missing_in_db:\r
301         if sync_from_links:\r
302             log("Adding {} to message {}".format(tag, message_id))\r
303             if not opts.dry_run:\r
304                 msg.add_tag(tag,sync_maildir_flags=False)\r
305         else:\r
306             tagdir = dir_for_tag (tag)\r
307             unlink_message(tagdir,msg)\r
308 \r
309     if sync_from_links:\r
310         msg.thaw()\r
311 \r
312 # everything remaining in disk_ids is a deleted message\r
313 # unless we are syncing back to the database, in which case\r
314 # it just might not currently have any non maildir tags.\r
315 \r
316 if not sync_from_links:\r
317     for root, subFolders, files in os.walk(tagroot):\r
318         for filename in files:\r
319             mangled_id = filename.split(':')[0]\r
320             if mangled_id in disk_ids:\r
321                 os.unlink(os.path.join(root, filename))\r
322 \r
323 \r
324 db.close()\r
325 \r
326 # currently empty directories are not pruned.\r
327 \r
328 --=-=-=\r
329 Content-Type: text/plain\r
330 \r
331 \r
332 ---- 8< ----\r
333 \r
334 Of course, the next step is to sync using this mechanism. Rsync doesn't\r
335 really have a concept of history, which basically makes it unusable for\r
336 this purpose [1]. Unison doesn't really understand renames, so it gets\r
337 confused when you mark a message as read (which might move it from new\r
338 to cur, and definitely changes its tags). Bremner suggested\r
339 syncmaildir. Syncmaildir doesn't understand links at all. Bremner\r
340 suggested that we could use some parts of syncmaildir to implement the\r
341 tag syncing we need.\r
342 \r
343 I didn't have anything else going on this weekend so I tried to\r
344 prototype the approach. It turns out to be possible to leverage some\r
345 parts of syncmaildir. I translated a bunch of smd-client into a new\r
346 program, tagsync-client, that links to messages in an existing notmuch\r
347 DB. It seems like it's possible to use it in place of the existing\r
348 smd-client by putting lines like this in your config:\r
349 \r
350 SMDCLIENT=~/src/tagsync.git/tagsync-client.py\r
351 REMOTESMDCLIENT=~/src/tagsync.git/tagsync-client.py\r
352 \r
353 The sequence of commands I ran:\r
354 \r
355 - linksync.py to dump tags to ~/Mail/.notmuch/exported-tags\r
356 - smd-pull mail to sync ~/Mail but excluding .notmuch\r
357 - notmuch new\r
358 - smd-pull tagsync (using the above client) to sync ~/Mail/.notmuch/exported-tags\r
359 - linksync.py to pull tags from ~/Mail/.notmuch/exported-tags\r
360 \r
361 syncmaildir doesn't cope well with drafts, so it might choke on that,\r
362 and it doesn't like symlinks (it thinks they're always to directories),\r
363 so be sure to run linksync with -l hard.\r
364 \r
365 Here's the script. It's a work in progress; I have only tested it once in one direction.\r
366 \r
367 ---- 8< ----\r
368 \r
369 \r
370 --=-=-=\r
371 Content-Type: text/x-python\r
372 Content-Disposition: inline; filename=tagsync-client.py\r
373 Content-Description: client script\r
374 \r
375 #! /usr/bin/env python\r
376 import sys\r
377 from sys import stdout, stdin, stderr\r
378 import stat\r
379 import urllib\r
380 import hashlib\r
381 import re\r
382 import os.path\r
383 import argparse\r
384 import subprocess\r
385 import traceback\r
386 import notmuch\r
387 import time\r
388 PROTOCOL_VERSION = "1.1"\r
389 \r
390 # Not reproducing the autoconf logic\r
391 XDELTA = 'xdelta'\r
392 MDDIFF = 'mddiff'\r
393 \r
394 VERBOSE = False\r
395 \r
396 def log(msg):\r
397     if VERBOSE:\r
398         stderr.write("INFO: "+msg+"\n")\r
399 \r
400 def __error(msg):\r
401     raise ValueError(msg)\r
402 \r
403 def log_tags_and_fail(msg, *args):\r
404     log_tags(*args)\r
405     __error(msg)\r
406 \r
407 def log_internal_error_and_fail(msg, *args):\r
408     log_internal_error_tags(msg, *args)\r
409     __error(msg)\r
410 \r
411 def log_error(msg):\r
412     return stderr.write("ERROR: {}\n".format(msg))\r
413 \r
414 def log_tag(tag):\r
415     return stderr.write("TAGS: {}\n".format(tag))\r
416 \r
417 def log_progress(msg):\r
418     pass\r
419 \r
420 def log_tags(context='unknown', cause='unknown', human=False, *args):\r
421     if human:\r
422         human = "necessary"\r
423     else:\r
424         human = "avoidable"\r
425 \r
426     suggestions = {}\r
427     suggestions_string = ""\r
428     if len(args):\r
429         suggestions_string = ' suggested-actions({})'.format(' '.join(args))\r
430 \r
431     return log_tag("error::context({}) probable-cause({}) human-intervention({})".format(\r
432             context, cause, human) + suggestions_string)\r
433 \r
434 def mkdir_p(filename):\r
435     """Maildir-aware mkdir.\r
436 \r
437     Creates a directory and all parent directories.\r
438 \r
439     Moreover, if the last component is 'tmp', 'cur' or 'new', the\r
440     others are created too."""\r
441 \r
442     # The Lua function throws away the last path component if it\r
443     # doesn't end in /. This allows you to just call mkdir_p on any\r
444     # file and a directory for it to live will be created.\r
445     if not filename.endswith('/'):\r
446         filename, _ = os.path.split(filename)\r
447 \r
448     if not filename.startswith('/'):\r
449         # This path is relative to HOME, and needs to be translated\r
450         # too.\r
451         filename = translate(filename)\r
452         filename = os.path.expanduser('~/'+filename)\r
453 \r
454     dirname, basename = os.path.split(filename)\r
455     try:\r
456         os.makedirs(filename)\r
457     except OSError:\r
458         pass   # probably "File exists"\r
459     MAILDIR_SUBDIRS = ['tmp', 'cur', 'new']\r
460     if basename in MAILDIR_SUBDIRS:\r
461         for subdir in MAILDIR_SUBDIRS:\r
462             to_create = os.path.join(dirname, subdir)\r
463             if not os.path.exists(to_create):\r
464                 os.mkdir(to_create)\r
465 \r
466 \r
467 class FakeSubprocess(object):\r
468     def __init__(self, init_function):\r
469         self.init_function = init_function\r
470         self.input = None\r
471         self.output = None\r
472         self.pipe_name = None\r
473         self.removed = None\r
474         self.did_write = None\r
475         self.filter = {}\r
476 \r
477     def readline(self):\r
478         if not self.input:\r
479             log_internal_error_and_fail("read called before write",\r
480                                         "make_slave_filter_process")\r
481 \r
482 \r
483         if not self.removed and self.did_write:\r
484             self.removed = True\r
485             rc = self.input.readline()\r
486             os.unlink(self.pipe_name)\r
487             return rc\r
488         else:\r
489             return self.input.readline()\r
490 \r
491     def write(self, *args):\r
492         if not self.output:\r
493             self.init_function(self.filter)\r
494             self.input = self.filter['inf']\r
495             self.output = self.filter['outf']\r
496             self.pipe_name = self.filter['pipe']\r
497 \r
498         self.did_write = True\r
499         self.output.write(*args)\r
500 \r
501     def flush(self):\r
502         self.output.flush()\r
503 \r
504     def lines(self):\r
505         return self.input.readlines()\r
506 \r
507 def make_slave_filter_process(cmd, seed="no seed"):\r
508     def init(filter):\r
509         if 'inf' not in filter:\r
510             home = os.getenv('HOME')\r
511             user = os.getenv('USER') or 'nobody'\r
512             mangled_name = re.compile('[ %./]').sub('-', seed)\r
513             attempt = 0\r
514             if home:\r
515                 base_dir = home + '/.smd/fifo/'\r
516             else:\r
517                 base_dir = '/tmp/'\r
518 \r
519             rc = subprocess.call([MDDIFF, '--mkdir-p', base_dir])\r
520             if rc != 0:\r
521                 log_internal_error_and_fail('unable to create directory',\r
522                                             'make_slave_filter_process')\r
523 \r
524             while True:\r
525                 pipe_name = ''.join([base_dir, 'smd-', user, str(int(time.time())),\r
526                                      mangled_name, str(attempt)])\r
527                 attempt += 1\r
528                 rc = subprocess.call([MDDIFF, '--mkfifo', pipe_name])\r
529                 if rc == 0 or attempt > 10:\r
530                     break\r
531             if rc != 0:\r
532                 log_internal_error_and_fail('unable to create fifo',\r
533                                             "make_slave_filter_process")\r
534 \r
535             inferior = cmd(pipe_name)\r
536             filter['inf'] = inferior.stdout\r
537             filter['outf'] = file(pipe_name, 'w')\r
538             filter['pipe'] = pipe_name\r
539 \r
540     return FakeSubprocess(init)\r
541 \r
542 _translator = None\r
543 def set_translator(p):\r
544     global _translator\r
545     translator_filter = make_slave_filter_process(\r
546         lambda pipe: subprocess.Popen(p, stdin=file(pipe), stdout=subprocess.PIPE),\r
547         "translate")\r
548     if p == 'cat':\r
549         _translator = lambda x: x\r
550     else:\r
551         def translator_fn(x):\r
552             translator_filter.write(x + '\n')\r
553             translator_filter.flush()\r
554             line = translator_filter.readline()\r
555             if not line or line.strip() == 'ERROR':\r
556                 log_error("Translator {} on input {} gave an error".format(\r
557                         p, x))\r
558                 for l in translator_filter.readlines():\r
559                     log_error(l)\r
560                 log_tags_and_fail("Unable to translate mailbox",\r
561                                   'translate', 'bad-translator', True)\r
562             if '..' in line:\r
563                 log_error("Translator {} on input {} returned a path containing ..".format(\r
564                         p, x))\r
565                 log_tags_and_fail('Translator returned a path containing ..',\r
566                                   'translate', 'bad-translator', True)\r
567 \r
568             return line\r
569 \r
570         _translator = translator_fn\r
571 \r
572 def translate(x):\r
573     if _translator:\r
574         return _translator(x)\r
575     return x\r
576 \r
577 \r
578 mddiff_sha_handler = make_slave_filter_process(\r
579             lambda pipe: subprocess.Popen([MDDIFF, pipe], stdout=subprocess.PIPE),\r
580             "sha_file")\r
581 \r
582 def sha_file(name):\r
583     mddiff_sha_handler.write(name+'\n')\r
584     mddiff_sha_handler.flush()\r
585 \r
586     data = mddiff_sha_handler.readline()\r
587     if data.startswith('ERROR'):\r
588         log_tags_and_fail("Failed to sha1 message: " + (name or "nil"),\r
589                           'sha_file', 'modify-while-update', False, 'retry')\r
590 \r
591     hsha, bsha = data.split()\r
592     if not hsha or not bsha:\r
593         log_internal_error_and_fail('mddiff incorrect behavior', 'mddiff')\r
594 \r
595     return hsha, bsha\r
596 \r
597 def exists_and_sha(name):\r
598     if os.path.exists(name):\r
599         h, b = sha_file(name)\r
600         return True, h, b\r
601 \r
602     return False, False, False\r
603 \r
604 \r
605 def touch(f):\r
606     try:\r
607         file(f, 'r')\r
608     except IOError:\r
609         try:\r
610             file(f, 'w')\r
611         except IOError:\r
612             log_error('Unable to touch ' + quote(f))\r
613             log_tags("touch", "bad-permissions", True,\r
614                      "display-permissions(" + quote(f) + ")")\r
615             error("Unable to touch a file")\r
616 \r
617 def quote(s):\r
618     return repr(s)\r
619 \r
620 \r
621 def assert_exists(name):\r
622     assert os.exists(name), "Not found: "+repr(name)\r
623 \r
624 def url_quote(txt):\r
625     return urllib.quote(txt, safe='')\r
626 \r
627 def url_decode(s):\r
628     return urllib.unquote(s)\r
629 \r
630 def log_internal_error_tags(msg, ctx):\r
631     log_tags('internal-error', ctx, True)\r
632     # Blob of "run gnome-open" junk not copied\r
633 \r
634 def receive(inf, outfile):\r
635     try:\r
636         outf = file(outfile, 'w')\r
637     except IOError:\r
638         log_error("Unable to open " + outfile + " for writing.")\r
639         log_error('It may be caused by bad directory permissions, '+\r
640                   'please check.')\r
641         log_tags("receive", "non-writeable-file", True,\r
642                  "display-permissions(" + quote(outfile) +")")\r
643         error("Unable to write incoming data")\r
644 \r
645     line = inf.readline()\r
646     if not line or line.strip() == "ABORT":\r
647         log_error("Data transmission failed.")\r
648         log_error("This problem is transient, please retry.")\r
649         log_tags_and_fail('server sent ABORT or connection died',\r
650                           "receive", "network", False, "retry")\r
651 \r
652     # In the Lua version, this is called "len", but that's a builtin\r
653     # in Python\r
654     chunk_len = int(re.compile(r'^chunk (\d+)').match(line).group(1))\r
655     total = chunk_len\r
656     while chunk_len:\r
657         next_chunk = 16384\r
658         if chunk_len < next_chunk:\r
659             next_chunk = chunk_len\r
660         data = inf.read(next_chunk)\r
661         chunk_len -= len(data)\r
662         outf.write(data)\r
663 \r
664     # Probably not strictly speaking necessary in Python\r
665     outf.close()\r
666 \r
667     return total\r
668 \r
669 def handshake(dbfile):\r
670     stdout.write("protocol {}\n".format(PROTOCOL_VERSION))\r
671     touch(dbfile)\r
672     sha_output = subprocess.check_output([MDDIFF, '--sha1sum', dbfile])\r
673     db_sha = sha_output.split()[0]\r
674     err_msg = sha_output[sha_output.index(' ')+1:]\r
675 \r
676     if db_sha == 'ERROR':\r
677         log_internal_error_and_fail('unreadable db file: '+quote(dbfile), 'handshake')\r
678 \r
679     stdout.write("dbfile {}\n".format(db_sha))\r
680     stdout.flush()\r
681 \r
682     line = stdin.readline()\r
683     if not line:\r
684         log_error("Network error.")\r
685         log_error("Unable to get any data from the other endpoint.")\r
686         log_error("This problem may be transient, please retry.")\r
687         log_error("Hint: did you correctly setup the SERVERNAME variable")\r
688         log_error("on your client? Did you add an entry for it in your ssh")\r
689         log_error("configuration file?")\r
690         log_tags_and_fail('Network error', "handshake", "network", False, "retry")\r
691 \r
692     protocol = re.compile('^protocol (.+)$').match(line)\r
693     if not protocol or protocol.group(1) != PROTOCOL_VERSION:\r
694         log_error('Wrong protocol version.')\r
695         log_error('The same version of syncmaildir must be used on '+\r
696                   'both endpoints')\r
697         log_tags_and_fail('Protocol version mismatch', "handshake", "protocol-mismatch", True)\r
698 \r
699     line = stdin.readline()\r
700     if not line:\r
701         log_error("The client disconnected during handshake")\r
702         log_tags_and_fail('Network error', "handshake", "network", False, "retry")\r
703 \r
704     sha = re.compile(r'^dbfile (\S+)$').match(line)\r
705     if not sha or sha.group(1) != db_sha:\r
706         log_error('Local dbfile and remote db file differ.')\r
707         log_error('Remove both files and push/pull again.')\r
708         log_tags_and_fail('Database mismatch', "handshake", "db-mismatch", True, "run(rm "+\r
709                  quote(dbfile)+")")\r
710 \r
711 def dbfile_name(endpoint, mailboxes):\r
712     endpoint = endpoint.rstrip('/')\r
713     mailboxes = mailboxes.rstrip('/')\r
714     subprocess.check_call([MDDIFF, '--mkdir-p', os.path.expanduser('~/.smd/')])\r
715     return os.path.expanduser('~/.smd/{}__{}.db.txt'.format(\r
716             endpoint.replace('/', '_'),\r
717             mailboxes.replace('/', '_').replace('%', '_')\r
718             ))\r
719 \r
720 def receive_delta(inf):\r
721     cmds = []\r
722     while True:\r
723         line = inf.readline()\r
724         if line and line.strip() != "END":\r
725             cmds.append(line)\r
726 \r
727         if not line or line.strip() == "END":\r
728             break\r
729 \r
730     if line.strip() != "END":\r
731         log_error('Unable to receive a complete diff')\r
732         log_tags("receive-delta", "network", False, "retry")\r
733         error("network error while receiving delta")\r
734 \r
735     return cmds\r
736 \r
737 def homefy(filename):\r
738     return os.path.expanduser("~/"+filename)\r
739 \r
740 def execute_add(name, hsha, bsha):\r
741     dir, basename = os.path.split(name)\r
742     # The real smd creates symlinks from workarea to the target\r
743     # directory, I dunno why.\r
744     dest = homefy(name)\r
745     ex, hsha_1, bsha_1 = exists_and_sha(dest)\r
746     if ex:\r
747         if hsha_1 != hsha or bsha_1 != bsha:\r
748             log_error("Failed to add {} since a file with the same name".format(\r
749                     dest))\r
750             log_error('exists but its content is different.')\r
751             log_error("Current hash {}/{}, requested hash {}/{}".format(\r
752                     hsha_1, bsha_1, hsha, bsha))\r
753             log_error('To fix this problem you should rename '+dest)\r
754             log_error('Executing `cd; mv -n '+quote(name)+' '+\r
755                       'FIXME: tmp_for' +'` should work.')\r
756             log_tags("mail-addition", "concurrent-mailbox-edit", True,\r
757                      )\r
758                      #mk_act("mv", name))\r
759             return False\r
760 \r
761         return True   # already there\r
762     if ':2,' in basename:\r
763         basename = basename[:basename.index(':2,')]\r
764     filenames = original_message_filenames(basename)\r
765     for filename in filenames:\r
766         orig_exists, hsha_orig, bsha_orig = exists_and_sha(filename)\r
767         assert orig_exists\r
768         if hsha_orig == hsha or bsha_orig == bsha:\r
769             os.link(filename, dest)\r
770             return True\r
771 \r
772 \r
773     log_error("Something seriously wrong here: we tried to link {}".format(\r
774             filename))\r
775     log_error("to {} but the hashes were wrong. We wanted {}/{}".format(\r
776             dest, hsha, bsha))\r
777     log_error("but we didn't see that in {}".format(filenames))\r
778     log_tags_and_fail('Mail corpus wrong')\r
779     # FIXME: How do we decide whether to use symlinks or not?\r
780     # Seems like syncmaildir can't cope with symlinks, so let's just\r
781     # always use hard links\r
782     return False\r
783 \r
784 def execute_delete(name, hsha, bsha):\r
785     name = homefy(name)\r
786     ex, hsha_1, bsha_1 = exists_and_sha(name)\r
787     assert ex\r
788     assert hsha_1 == hsha\r
789     assert bsha_1 == bsha\r
790 \r
791     os.unlink(name)\r
792     return True\r
793 \r
794 def execute_copy(name_src, hsha, bsha, name_tgt):\r
795     name_src = homefy(name_src)\r
796     name_tgt = homefy(name_tgt)\r
797     ex_src, hsha_src, bsha_src = exists_and_sha(name_src)\r
798     ex_tgt, hsha_tgt, bsha_tgt = exists_and_sha(name_tgt)\r
799 \r
800     # Not reproducing all logic\r
801     assert ex_src\r
802     assert not ex_tgt\r
803 \r
804     assert hsha == hsha_src\r
805     assert bsha == bsha_src\r
806     if stat.S_ISLNK(os.stat(name_src).st_mode):\r
807         link_tgt = os.readlink(name_src)\r
808         os.symlink(link_tgt, name_tgt)\r
809     else:\r
810         os.link(name_src, name_tgt)\r
811     return True\r
812 \r
813 def execute_error(msg):\r
814     log_error('mddiff failed: '+msg)\r
815     if msg.startswith("Unable to open directory"):\r
816         log_tags("mddiff", "directory-disappeared", false)\r
817     else:\r
818         log_tags("mddiff", "unknown", true)\r
819 \r
820     # return (trace(false))\r
821     return False\r
822 \r
823 \r
824 def execute(cmd):\r
825     """The main switch, dispatching actions."""\r
826     opcode = cmd.split()[0]\r
827 \r
828     if opcode == "ADD":\r
829         name, hsha, bsha = re.compile(r'^ADD (\S+) (\S+) (\S+)$').match(cmd).groups()\r
830         name = url_decode(name)\r
831         mkdir_p(name)\r
832         return execute_add(name, hsha, bsha)\r
833 \r
834     elif opcode == "DELETE":\r
835         name, hsha, bsha = re.compile(r'^DELETE (\S+) (\S+) (\S+)$').match(cmd).groups()\r
836         name = url_decode(name)\r
837         mkdir_p(name)\r
838         return execute_delete(name, hsha, bsha)\r
839 \r
840     elif opcode == "COPY":\r
841         name_src, hsha, bsha, name_tgt = re.compile(\r
842             r'COPY (\S+) (\S+) (\S+) TO (\S+)$').match(cmd).groups()\r
843         name_src = url_decode(name_src)\r
844         name_tgt = url_decode(name_tgt)\r
845         mkdir_p(name_src)\r
846         mkdir_p(name_tgt)\r
847         return execute_copy(name_src, hsha, bsha, name_tgt)\r
848 \r
849     elif opcode in ['REPLACEHEADER', 'COPYBODY', 'REPLACE']:\r
850         log_internal_error_and_fail(opcode + ' was called: ' + cmd)\r
851         return False\r
852 \r
853     elif opcode == "ERROR":\r
854         msg = cmd[cmd.index(' ')+1:]\r
855         return execute_error(msg)\r
856 \r
857     else:\r
858         error("Unknown opcode " + opcode)\r
859         return False\r
860 \r
861 def main():\r
862     parser = argparse.ArgumentParser(description="")\r
863     parser.add_argument('-v', '--verbose', action='store_true', default=False)\r
864     parser.add_argument('-d', '--dry-run', action='store_true', default=False)\r
865     parser.add_argument('-t', '--translator', type=str, default='cat')\r
866     parser.add_argument('endpoint')\r
867     parser.add_argument('mailboxes')\r
868 \r
869     opts = parser.parse_args()\r
870 \r
871     set_translator(opts.translator)\r
872     read_message_ids()\r
873 \r
874     dbfile = dbfile_name(opts.endpoint, opts.mailboxes)\r
875     xdelta = dbfile + '.xdelta'\r
876     newdb = dbfile + '.new'\r
877 \r
878     if opts.mailboxes[0] == '/':\r
879         log_error("Absolute paths are not supported: " + opts.mailboxes)\r
880         log_tags_and_fail("Absolute path detected", "main", "mailbox-has--absolute-path", True)\r
881 \r
882     handshake(dbfile)\r
883     commands = receive_delta(stdin)\r
884     for cmd in commands:\r
885         try:\r
886             rc = execute(cmd)\r
887             # Just wrap the whole thing in try-except to abort "cleanly"\r
888         except Exception as e:\r
889             log_error("Got an exception when processing {}: {}".format(cmd.strip(), str(e)))\r
890             log_error(traceback.format_exc())\r
891             rc = False\r
892         if not rc:\r
893             stdout.write('ABORT\n')\r
894             stdout.flush()\r
895             sys.exit(3)\r
896 \r
897         # if len(get_full_email_queue) > queue_max_len:\r
898         #    process_pending_queue()\r
899 \r
900     # some commands may still be in the queue, we fire them now\r
901     # process_pending_queue()\r
902 \r
903     # we commit and update the dbfile\r
904     stdout.write('COMMIT\n')\r
905     stdout.flush()\r
906     receive(stdin, xdelta)\r
907 \r
908     rc = subprocess.call([XDELTA, 'patch', xdelta, dbfile, newdb])\r
909     if rc != 0 and rc != 256:\r
910         log_error('Unable to apply delta to dbfile.')\r
911         stdout.write('ABORT\n')\r
912         stdout.flush()\r
913         sys.exit(4)\r
914 \r
915     try:\r
916         os.rename(newdb, dbfile)\r
917     except OSError:\r
918         log_error('Unable to rename ' + newdb + ' to ' + dbfile)\r
919         stdout.write('ABORT\n')\r
920         stdout.flush()\r
921         sys.exit(5)\r
922 \r
923     os.unlink(xdelta)\r
924     stdout.write('DONE\n')\r
925     stdout.flush()\r
926 \r
927     #log_tag('stats::new-mails(' + statistics.added +\r
928     #'), del-mails(' + statistics.removed + ')')\r
929 \r
930 CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+_@=.,-'\r
931 \r
932 encode_re = '([^{0}])'.format(CHARSET)\r
933 \r
934 def encode_one_char(match):\r
935     return('%{:02x}'.format(ord(match.group(1))))\r
936 \r
937 def encode_for_fs(str):\r
938     return re.sub(encode_re,encode_one_char, str,0)\r
939 \r
940 def mangle_message_id(msg_id):\r
941     """\r
942     Return a mangled version of the message id, suitable for use as a filename.\r
943     """\r
944     MAX_LENGTH = 143\r
945     FLAGS_LENGTH = 8    # :2,S...??\r
946     encoded = encode_for_fs(msg_id)\r
947     if len(encoded) < MAX_LENGTH - FLAGS_LENGTH:\r
948         return encoded\r
949 \r
950     SHA_LENGTH = 8\r
951     TRUNCATED_ID_LENGTH = MAX_LENGTH - SHA_LENGTH - FLAGS_LENGTH\r
952     PREFIX_LENGTH = SUFFIX_LENGTH = (TRUNCATED_ID_LENGTH - 3) // 2\r
953     prefix = encoded[:PREFIX_LENGTH]\r
954     suffix = encoded[-SUFFIX_LENGTH:]\r
955     sha = hashlib.sha256()\r
956     sha.update(encoded)\r
957     return prefix + '...' + suffix + sha.hexdigest()[:SHA_LENGTH]\r
958 \r
959 MESSAGE_MANGLED_FILENAMES_TO_ORIGINAL_FILENAMES = {}\r
960 DB = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
961 def read_message_ids():\r
962     # We can't base this on tags at all because tags aren't applied yet\r
963     querystr = '*'\r
964 \r
965     q_new = notmuch.Query(DB, querystr)\r
966     q_new.set_sort(notmuch.Query.SORT.UNSORTED)\r
967     for msg in q_new.search_messages():\r
968         mangled_id = mangle_message_id(msg.get_message_id())\r
969         fiter = msg.get_filenames()\r
970         # list(fiter) gives me a NotInitializedException????\r
971         filenames = []\r
972         while True:\r
973             try:\r
974                 filename = next(fiter)\r
975                 filenames.append(filename)\r
976             except StopIteration:\r
977                 break\r
978 \r
979         MESSAGE_MANGLED_FILENAMES_TO_ORIGINAL_FILENAMES[mangled_id] = filenames\r
980 \r
981 def original_message_filenames(mangled_filename):\r
982     if mangled_filename not in MESSAGE_MANGLED_FILENAMES_TO_ORIGINAL_FILENAMES:\r
983         log_error("{} not in notmuch. Trying to tag nonexistant message?".format(\r
984                 mangled_filename))\r
985     return MESSAGE_MANGLED_FILENAMES_TO_ORIGINAL_FILENAMES[mangled_filename]\r
986 \r
987 if __name__ == '__main__':\r
988     try:\r
989         main()\r
990     except Exception as e:\r
991         log_error(str(e))\r
992         log_error(traceback.format_exc())\r
993         sys.exit(6)\r
994 \r
995 --=-=-=--\r