This is an automated email from the ASF dual-hosted git repository.

sebb pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-ponymail-foal.git


The following commit(s) were added to refs/heads/master by this push:
     new ab506de  Copy utility files from PonyMail 12
ab506de is described below

commit ab506de268ff45d1303912934d67ff811f51d131
Author: Sebb <[email protected]>
AuthorDate: Mon Aug 17 11:59:37 2020 +0100

    Copy utility files from PonyMail 12
---
 tools/elastic.py        | 183 +++++++++++++
 tools/import-mbox.py    | 667 ++++++++++++++++++++++++++++++++++++++++++++++++
 tools/mboxo_patch.py    |  97 +++++++
 tools/ponymailconfig.py |  56 ++++
 4 files changed, 1003 insertions(+)

diff --git a/tools/elastic.py b/tools/elastic.py
new file mode 100755
index 0000000..85496c5
--- /dev/null
+++ b/tools/elastic.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+    common elasticsearch database setup
+    also adds defaults for most methods
+"""
+
+from ponymailconfig import PonymailConfig
+import sys
+import logging
+import certifi
+
+try:
+    from elasticsearch import Elasticsearch, helpers
+    from elasticsearch import VERSION as ES_VERSION
+    from elasticsearch import ConnectionError as ES_ConnectionError
+except ImportError as e:
+    sys.exit("Sorry, you need to install the elasticsearch module from pip 
first. (%s)" % str(e))
+
+class Elastic:
+    def __init__(self, dbname=None):
+        # Fetch config
+        config = PonymailConfig()
+        self.dbname = dbname or config.get("elasticsearch", "dbname")
+        ssl = config.get("elasticsearch", "ssl", fallback="false").lower() == 
'true'
+        uri = config.get("elasticsearch", "uri", fallback="")
+        auth = None
+        if config.has_option('elasticsearch', 'user'):
+            auth = (config.get('elasticsearch','user'), 
config.get('elasticsearch','password'))
+
+
+        # elasticsearch logs lots of warnings on retries/connection failure
+        logging.getLogger("elasticsearch").setLevel(logging.ERROR)
+
+#         # add debug
+#         trace = logging.getLogger("elasticsearch.trace")
+#         trace.setLevel(logging.DEBUG)
+#         # create console handler
+#         consoleHandler = logging.StreamHandler()
+#         trace.addHandler(consoleHandler)
+
+        self.es = Elasticsearch([
+            {
+                'host': config.get("elasticsearch", "hostname"),
+                'port': int(config.get("elasticsearch", "port")),
+                'use_ssl': ssl,
+                'url_prefix': uri,
+                'auth': auth,
+                'ca_certs': certifi.where()
+            }],
+            max_retries=5,
+            retry_on_timeout=True
+            )
+        self.dbVersion = None
+        # Mimic ES hierarchy: es.indices.xyz()
+        self.indices=_indices_wrap(self)
+
+    def libraryVersion(self):
+        return ES_VERSION
+    def libraryMajor(self):
+        return ES_VERSION[0]
+
+    def engineVersion(self):
+        if not self.dbVersion:
+            try:
+                self.dbVersion = self.info()['version']['number']
+            except ES_ConnectionError:
+                # default if cannot connect; allows retry
+                return '0.0.0'
+        return self.dbVersion
+    def engineMajor(self):
+        return int(self.engineVersion().split('.')[0])
+
+    def getdbname(self):
+        return self.dbname
+
+    def search(self, doc_type='mbox', **kwargs):
+        return self.es.search(
+            index=self.dbname,
+            doc_type=doc_type,
+            **kwargs
+        )
+
+    def index(self, **kwargs):
+        return self.es.index(
+            index=self.dbname,
+            **kwargs
+        )
+
+    def update(self, **kwargs):
+        return self.es.update(
+            index=self.dbname,
+            **kwargs
+        )
+
+    def scan(self, doc_type='mbox', scroll='3m', size = 100, **kwargs):
+        return self.es.search(
+            index=self.dbname,
+            doc_type=doc_type,
+            search_type = 'scan',
+            size = size,
+            scroll = scroll,
+            **kwargs
+        )
+    
+    def scan_and_scroll(self, doc_type='mbox', scroll='3m', size = 100, 
**kwargs):
+        """ Run a backwards compatible scan/scroll, passing an iterator
+            that returns one page of hits per iteration. This
+            incorporates es.scroll for continuous iteration, and thus the
+            scroll() does NOT need to be called at all by the calling
+            process. """
+        results = self.es.search(
+            index=self.dbname,
+            doc_type=doc_type,
+            size = size,
+            scroll = scroll,
+            **kwargs
+        )
+        if results['hits'].get('hits', []): # Might not be there in 2.x?
+            yield results
+        
+        # While we have hits waiting, scroll...
+        scroll_size = results['hits']['total']
+        while (scroll_size > 0):
+            results = self.scroll(scroll_id = results['_scroll_id'], scroll = 
scroll)
+            scroll_size = len(results['hits']['hits']) # If >0, try another 
scroll next.
+            yield results
+            
+    def get(self, **kwargs):
+        return self.es.get(index=self.dbname, **kwargs)
+
+    def scroll(self, **kwargs):
+        return self.es.scroll(**kwargs)
+
+    def info(self, **kwargs):
+        return self.es.info(**kwargs)
+
+    def bulk(self, actions, **kwargs):
+        return helpers.bulk(self.es, actions, **kwargs)
+
+    def clear_scroll(self, *args, **kwargs):
+        """
+            Call this to release the scroll id and its resources
+
+            It looks like the Python library already releases the SID
+            if the caller scrolls to the end of the results, so only need to 
call this
+            when terminating scrolling early.
+        """
+        return self.es.clear_scroll(*args, **kwargs)
+
+class _indices_wrap(object):
+    """
+        Wrapper for the ES indices methods we use
+    """
+    def __init__(self, parent):
+        self.es = parent.es
+
+    def exists(self, *args, **kwargs):
+        return self.es.indices.exists(*args, **kwargs)
+
+if __name__ == '__main__':
+    es = Elastic()
+    print("Versions: Library: %d %s Engine: %d (%s)" % (es.libraryMajor(), 
es.libraryVersion(), es.engineMajor(), es.engineVersion()))
+    try:
+        print(es.indices.exists(index='ponymail'))
+        print(es.indices.exists('test'))
+    except ES_ConnectionError as e:
+        print(type(e))
diff --git a/tools/import-mbox.py b/tools/import-mbox.py
new file mode 100755
index 0000000..051b05a
--- /dev/null
+++ b/tools/import-mbox.py
@@ -0,0 +1,667 @@
+#!/usr/bin/env python3
+
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+import random
+import time
+import hashlib
+import os
+from threading import Thread, Lock
+import mailbox
+import email.errors, email.utils, email.header
+from urllib.request import urlopen
+import re
+from elastic import Elastic
+import argparse
+from os import listdir
+from os.path import isfile, join, isdir
+import glob
+import multiprocessing
+import tempfile
+import gzip
+
+import archiver
+
+
+goodies = 0
+baddies = 0
+duplicates={} # detect if mid is re-used this run
+block = Lock()
+lists = [] # N.B. the entries in this list depend on the import type:
+# globDir: [filename, list-id]
+# modMbox: [list-id, mbox]
+# piperMail: [filename, list-id]
+# imap(s): [uids, listname, imap4]
+# other: [filename, list-override]
+start = time.time()
+quickmode = False
+private = False
+appender = "apache.org"
+
+
+source = "./"
+maildir = False
+imap = False
+list_override = None
+project = ""
+filebased = False
+fileToLID = {}
+interactive = False
+extension = ".mbox"
+piperWeirdness = False
+parseHTML = False
+resendTo = None
+timeout = 600
+fromFilter = None
+dedup = False
+dedupped = 0
+noMboxo = False # Don't skip MBoxo patch
+
+# Fetch config and set up ES
+es = Elastic()
+# We need the index name for bulk actions
+dbname = es.getdbname()
+
+rootURL = ""
+
+def bulk_insert(name, json, xes, dtype, wc = 'quorum'):
+    if args.dry:
+        return
+
+    sys.stderr.flush()
+
+    js_arr = []
+    for entry in json:
+        js = entry
+        mid = js['mid']
+        if dtype == 'mbox_source':
+            del js['mid']
+        js_arr.append({
+            '_op_type': 'index',
+            '_consistency': wc,
+            '_index': dbname,
+            '_type': dtype,
+            '_id': mid,
+            'doc': js,
+            '_source': js
+        })
+    try:
+        xes.bulk(js_arr,ignore=404)
+#       print("%s: Inserted %u entries into %s" % (name, len(js_arr),dtype))
+    except Exception as err:
+        print("%s: Warning: Could not bulk insert: %s into %s" % 
(name,err,dtype))
+
+class SlurpThread(Thread):
+
+    def printid(self, message):
+        print("%s: %s" % (self.name, message))
+
+    def run(self):
+        global goodies, baddies, dedupped
+        self.name = Thread.getName(self)
+        ja = []
+        jas = []
+        self.printid("Thread started")
+        mla = None
+        ml = ""
+        mboxfile = ""
+        filename = ""
+
+        if args.generator:
+            archie = archiver.Archiver(generator=args.generator, 
parse_html=parseHTML)            
+        else:
+            archie = archiver.Archiver(parse_html=parseHTML)            
+
+        while len(lists) > 0:
+            self.printid("%u elements left to slurp" % len(lists))
+
+            block.acquire()
+            try:
+                mla = lists.pop(0)
+                if not mla:
+                    self.printid("Nothing more to do here")
+                    return
+            except Exception as err:
+                self.printid("Could not pop list: %s" % err)
+                return
+            finally:
+                block.release()
+
+            stime = time.time()
+            dFile = False
+            if imap:
+                imap4 = mla[2]
+                def mailgen(_list):
+                    for uid in _list:
+                        msgbytes = imap4.uid('fetch', uid, '(RFC822)')[1][0][1]
+                        yield email.message_from_bytes(msgbytes)
+                messages = mailgen(mla[0])
+            elif filebased:
+
+                tmpname = mla[0]
+                filename = mla[0]
+                if filename.find(".gz") != -1:
+                    self.printid("Decompressing %s..." % filename)
+                    try:
+                        with open(filename, "rb") as bf:
+                            bmd = bf.read()
+                            bf.close() # explicit early close
+                            bmd = gzip.decompress(bmd)
+                            tmpfile = tempfile.NamedTemporaryFile(mode='w+b', 
buffering=1, delete=False)
+                            tmpfile.write(bmd)
+                            tmpfile.flush()
+                            tmpfile.close()
+                            tmpname = tmpfile.name
+                            dFile = True # Slated for deletion upon having 
been read
+                            self.printid("%s -> %u bytes" % (tmpname, 
len(bmd)))
+                    except Exception as err:
+                        self.printid("This wasn't a gzip file: %s" % err )
+                self.printid("Slurping %s" % filename)
+                if maildir:
+                    messages = mailbox.Maildir(tmpname, create=False)
+                else:
+                    messages = mailbox.mbox(tmpname, None if noMboxo else 
MboxoFactory, create=False)
+
+            else:
+                ml = mla[0]
+                mboxfile = mla[1]
+                self.printid("Slurping %s/%s" % (ml, mboxfile))
+                ctx = urlopen("%s%s/%s" % (source, ml, mboxfile ))
+                inp = ctx.read().decode(ctx.headers.get_content_charset() or 
'utf-8', errors='ignore')
+
+                tmpname = hashlib.sha224(("%f-%f-%s-%s.mbox" % 
(random.random(), time.time(), ml, mboxfile)).encode('utf-8') ).hexdigest()
+                with open(tmpname, "w") as f:
+                    f.write(inp)
+                if maildir:
+                    messages = mailbox.Maildir(tmpname, create=False)
+                else:
+                    messages = mailbox.mbox(tmpname, None if noMboxo else 
MboxoFactory, create=False)
+
+            count = 0
+            bad = 0
+
+
+            for key in messages.iterkeys():
+                message_raw = messages.get_bytes(key)  # True raw format, as 
opposed to calling .as_bytes()
+                message=messages.get(key)
+                # If --filter is set, discard any messages not matching by 
continuing to next email
+                if fromFilter and 'from' in message and 
message['from'].find(fromFilter) == -1:
+                    continue
+                if resendTo:
+                    self.printid("Delivering message %s via MTA" % 
message['message-id'] if 'message-id' in message else '??')
+                    s = SMTP('localhost')
+                    try:
+                        if list_override:
+                            message.replace_header('List-ID', list_override)
+                        message.replace_header('To', resendTo)
+                    except:
+                        if list_override:
+                            message['List-ID'] = list_override
+                    message['cc'] = None
+                    s.send_message(message, from_addr=None, 
to_addrs=(resendTo))
+                    continue
+                if (time.time() - stime > timeout): # break out after N 
seconds, it shouldn't take this long..!
+                    self.printid("Whoa, this is taking way too long, ignoring 
%s for now" % tmpname)
+                    break
+
+                # Don't pass message to archiver unless we have a list id
+                if not (list_override or message['list-id']):
+                    self.printid("No list id found for %s " % 
message['message-id'])
+                    bad += 1
+                    continue
+
+                json, contents, _msgdata, _irt = archie.compute_updates(args, 
list_override, private, message, message_raw)
+
+                # Not sure this can ever happen
+                if json and not (json['list'] and json['list_raw']):
+                    self.printid("No list id found for %s " % 
json['message-id'])
+                    bad += 1
+                    continue
+
+                # If --dedup is active, try to filter out any messages that 
already exist on the list
+                if json and dedup and message.get('message-id', None):
+                    res = es.search(
+                        doc_type="mbox",
+                        size = 1,
+                        _source = ['mid'], # so can report the match source
+                        body = {
+                            'query': {
+                                'bool': {
+                                    'must': [
+                                        {
+                                            'term': {
+                                                'message-id': 
message.get('message-id', None)
+                                            }
+                                        },
+                                        {
+                                            'term': {
+                                                'list_raw': json['list']
+                                            }
+                                        }
+                                    ]
+                                }
+                            }
+                        }
+                    )
+                    if res and res['hits']['total'] > 0:
+                        self.printid("Dedupping %s - matched in %s" % 
(json['message-id'], res['hits']['hits'][0]['_source']['mid']))
+                        dedupped += 1
+                        continue
+
+                if json:
+                    file=messages.get_file(key, True)
+                    # If the parsed data is filtered, also need to filter the 
raw input
+                    # so the source agrees with the summary info
+                    if message.__class__.__name__ == 'MboxoFactory':
+                        file=MboxoReader(file)
+                    raw_msg=file.read()
+                    file.close()
+                    if args.dups:
+                        try:
+                            duplicates[json['mid']].append(json['message-id'] 
+ " in " + filename)
+                        except:
+                            duplicates[json['mid']]=[json['message-id'] + " in 
" + filename]
+
+                    try: # temporary hack to try and find an encoding issue
+                        # needs to be replaced by proper exception handling
+                        json_source = {
+                            'mid': json['mid'], # needed for bulk-insert only, 
not needed in database
+                            'message-id': json['message-id'],
+                            'source': archie.mbox_source(raw_msg)
+                        }
+                    except Exception as e:
+                        self.printid("Error '%s' processing id %s msg %s " % 
(e, json['mid'], json['message-id']))
+                        bad += 1
+                        continue
+
+                    count += 1
+                    ja.append(json)
+                    jas.append(json_source)
+                    if args.verbose and verbose_logger:
+                        # TODO optionally show other fields (e.g. From_ line)
+                        verbose_logger.info("MID:%(mid)s MSGID:%(message-id)s" 
, json)
+                    if contents:
+                        if not args.dry:
+                            for key in contents:
+                                es.index(
+                                    doc_type="attachment",
+                                    id=key,
+                                    body = {
+                                        'source': contents[key]
+                                    }
+                                )
+                    if len(ja) >= 40:
+                        bulk_insert(self.name, ja, es, 'mbox')
+                        ja = []
+
+                        bulk_insert(self.name, jas, es, 'mbox_source')
+                        jas = []
+                else:
+                    self.printid("Failed to parse: Return=%s Message-Id=%s" % 
(message.get('Return-Path'), message.get('Message-Id')))
+                    bad += 1
+
+            if filebased:
+                self.printid("Parsed %u records (failed: %u) from %s" % 
(count, bad, filename))
+                if dFile:
+                    os.unlink(tmpname)
+            elif imap:
+                self.printid("Parsed %u records (failed: %u) from imap" % 
(count, bad))
+            else:
+                self.printid("Parsed %s/%s: %u records (failed: %u) from %s" % 
(ml, mboxfile, count, bad, tmpname))
+                os.unlink(tmpname)
+
+            goodies += count
+            baddies += bad
+            if len(ja) > 0:
+                bulk_insert(self.name, ja, es, 'mbox')
+            ja = []
+
+            if len(jas) > 0:
+                bulk_insert(self.name, jas, es, 'mbox_source')
+            jas = []
+        self.printid("Done, %u elements left to slurp" % len(lists))
+
+parser = argparse.ArgumentParser(description='Command line options.')
+parser.add_argument('--source', dest='source', type=str, nargs=1,
+                   help='Source to scan (http(s)://, imap(s):// or file path)')
+parser.add_argument('--dir', dest='dir', action='store_true',
+                   help='Input is in Maildir format')
+parser.add_argument('--interactive', dest='interactive', action='store_true',
+                   help='Ask for help when possible')
+parser.add_argument('--quick', dest='quick', action='store_true',
+                   help='Only grab the first file you can find')
+parser.add_argument('--mod-mbox', dest='modmbox', action='store_true',
+                   help='This is mod_mbox, derive list-id and files from it')
+parser.add_argument('--pipermail', dest='pipermail', action='store_true',
+                   help='This is pipermail, derive files from it (list ID has 
to be set!)')
+parser.add_argument('--lid', dest='listid', type=str, nargs=1,
+                   help='Optional List-ID to override source with. Format: 
<list-name>@<domain>')
+parser.add_argument('--project', dest='project', type=str, nargs=1,
+                   help='Optional project to look for ($project-* will be 
imported as well)')
+parser.add_argument('--ext', dest='ext', type=str, nargs=1,
+                   help='Optional file extension e.g. ".gz" (or call it with 
an empty string to not care)')
+parser.add_argument('--domain', dest='domain', type=str, nargs=1,
+                   help='Optional domain extension for MIDs and List ID 
reconstruction)')
+parser.add_argument('--private', dest='private', action='store_true',
+                   help='This is a privately archived list. Filter through 
auth proxy.')
+parser.add_argument('--dry', dest='dry', action='store_true',
+                   help='Do not save emails to elasticsearch, only test 
importing')
+parser.add_argument('--verbose', dest='verbose', action='store_true',
+                   help='Show details of generated id (for use with --dry)')
+parser.add_argument('--duplicates', dest='dups', action='store_true',
+                   help='Detect duplicate mids in this run')
+parser.add_argument('--html2text', dest='html2text', action='store_true',
+                   help='If no text/plain is found, try to parse HTML using 
html2text')
+parser.add_argument('--requirelid', dest='requirelid', action='store_true',
+                   help='Require a List ID to be present, ignore otherwise')
+parser.add_argument('--dedup', dest='dedup', action='store_true',
+                   help='Try to dedup messages based on ID before importing')
+parser.add_argument('--ignorebody', dest='ibody', type=str, nargs=1,
+                   help='Optional email bodies to treat as empty (in 
conjunction with --html2text)')
+parser.add_argument('--resend', dest='resend', type=str, nargs=1,
+                   help='DANGER ZONE: Resend every read email to this 
recipient as a new email')
+parser.add_argument('--timeout', dest='timeout', type=int, nargs=1,
+                   help='Optional timeout in secs for importing an 
mbox/maildir file (default is 600 seconds)')
+parser.add_argument('--filter', dest = 'fromfilter', type=str, nargs=1,
+                    help = 'Optional sender filter: Only import emails from 
this address')
+parser.add_argument('--nomboxo', dest = 'nomboxo', action='store_true',
+                    help = 'Skip Mboxo processing')
+parser.add_argument('--generator', dest='generator',
+                   help='Override the generator.')
+
+args = parser.parse_args()
+
+if len(sys.argv) <= 2:
+    parser.print_help()
+    sys.exit(-1)
+
+
+
+if args.source:
+    source = args.source[0]
+if args.dir:
+    maildir = args.dir
+if args.listid:
+    list_override = archiver.normalize_lid(args.listid[0])
+if args.project:
+    project = args.project[0]
+if args.domain:
+    appender = args.domain[0]
+if args.interactive:
+    interactive = args.interactive
+if args.quick:
+    quickmode = args.quick
+if args.private:
+    private = args.private
+if args.dedup:
+    dedup = args.dedup
+if args.ext:
+    extension = args.ext[0]
+if args.html2text:
+    parseHTML = True
+if args.ibody:
+    archiver.iBody = args.ibody[0]
+if args.fromfilter:
+    fromFilter = args.fromfilter[0]
+if args.nomboxo:
+    noMboxo = args.nomboxo
+else:
+    # Temporary patch to fix Python email package limitation
+    # It must be removed when the Python package is fixed
+    from mboxo_patch import MboxoFactory, MboxoReader
+
+if args.resend:
+    resendTo = args.resend[0]
+    from smtplib import SMTP
+if args.timeout:
+    timeout = args.timeout[0]
+baddies = 0
+
+# No point continuing if the index does not exist
+print("Checking that the database index %s exists ... " % dbname)
+
+# elasticsearch logs lots of warnings on retries/connection failure
+import logging
+logging.getLogger("elasticsearch").setLevel(logging.ERROR)
+
+verbose_logger = None
+if args.verbose:
+    verbose_logger = logging.getLogger("verbose")
+    verbose_logger.setLevel(logging.INFO)
+    # The default handler is set to WARN level
+    verbose_logger.addHandler(logging.StreamHandler(sys.stdout))
+    archiver.logger = verbose_logger
+
+if args.dry:
+    print("Dry-run; continuing to check input data")
+else:
+    # Need to check the index before starting bulk operations
+    try:
+        if not es.indices.exists(index=dbname):
+            print("Error: the index '%s' does not exist!" % (dbname))
+            sys.exit(1)
+        print("Database exists OK")
+    except Exception as err:
+        print("Error: unable to check if the index %s exists!: %s" % (dbname, 
err))
+        sys.exit(1)
+
+def globDir(d):
+    dirs = [ f for f in listdir(d) if isdir(join(d,f)) ]
+    mboxes = [ f for f in glob.glob(join(d,"*" + extension)) if isfile(f) ]
+    if not d in fileToLID and len(mboxes) > 0 and interactive:
+        print("Would you like to set a list-ID override for %s?:" % d)
+        lo = sys.stdin.readline()
+        if lo and len(lo) > 3:
+            fileToLID[d] = archiver.normalize_lid(lo.strip("\r\n"))
+            print("Righto, setting it to %s." % fileToLID[d])
+        else:
+            print("alright, I'll try to figure it out myself!")
+    for fi in sorted(mboxes):
+        lists.append([fi, fileToLID.get(d) if fileToLID.get(d) else 
list_override])
+
+    for nd in sorted(dirs):
+        globDir(join(d,nd))
+
+
+# HTTP(S) based import?
+if re.match(r"https?://", source):
+    data = urlopen(source).read().decode('utf-8')
+    print("Fetched %u bytes of main data, parsing month lists" % len(data))
+
+    if project:
+        # ensure there is a '-' between project and list name otherwise we 
match too much
+        # Note: It looks like mod_mbox always uses single quoted hrefs
+        ns = r"<a href='(%s-[-a-z0-9]+)/'" % project
+        if project.find("-") != -1:
+            ns = r"<a href='(%s)/'" % project
+    else: # match all possible project names
+        ns = r"<a href='([-a-z0-9]+)/'"
+
+    if args.modmbox:
+        for mlist in re.finditer(ns, data):
+            ml = mlist.group(1)
+            mldata = urlopen("%s%s/" % (source, ml)).read().decode('utf-8')
+            present = re.search(r"<th colspan=\"3\">Year 20[\d]{2}</th>", 
mldata) # Check that year 2014-2017 exists, otherwise why keep it?
+            if present:
+                qn = 0
+                for mbox in re.finditer(r"(\d+\.mbox)/thread", mldata):
+                    qn += 1
+                    mboxfile = mbox.group(1)
+                    lists.append([ml, mboxfile])
+                    print("Adding %s/%s to slurp list" % (ml, mboxfile))
+                    if quickmode and qn >= 2:
+                        break
+
+    if args.pipermail:
+        filebased = True
+        piperWeirdness = True
+        if not list_override:
+            print("You need to specify a list ID with --lid when importing 
from Pipermail!")
+            sys.exit(-1)
+        ns = r"href=\"(\d+-[a-zA-Z]+\.txt(\.gz)?)\""
+        qn = 0
+        for mlist in re.finditer(ns, data):
+            ml = mlist.group(1)
+            mldata = urlopen("%s%s" % (source, ml)).read()
+            tmpfile = tempfile.NamedTemporaryFile(mode='w+b', buffering=1, 
delete=False)
+            try:
+                if ml.find(".gz") != -1:
+                    mldata = gzip.decompress(mldata)
+            except Exception as err:
+                print("This wasn't a gzip file: %s" % err )
+            print(len(mldata))
+            tmpfile.write(mldata)
+            tmpfile.flush()
+            tmpfile.close()
+            lists.append([tmpfile.name, list_override])
+            print("Adding %s/%s to slurp list as %s" % (source, ml, 
tmpfile.name))
+            qn += 1
+            if quickmode and qn >= 2:
+                break
+
+# IMAP(S) based import?
+elif re.match(r"imaps?://", source):
+    imap = True
+    import urllib, getpass, imaplib
+    url = urllib.parse.urlparse(source)
+
+    port = url.port or (143 if url.scheme == 'imap' else 993)
+    user = url.username or getpass.getuser()
+    password = url.password or getpass.getpass('IMAP Password: ')
+    folder = url.path.strip('/') or 'INBOX'
+    listname = list_override or "<%s/%s.%s>" % (user, folder, url.hostname)
+
+    # fetch message-id => _id pairs from elasticsearch
+
+    result = es.search(scroll = '5m',
+        body = {
+            'size': 1024,
+            'fields': ['message-id'],
+            'query': {'match': {'list': listname}}
+        }
+    )
+
+    db = {}
+    while len(result['hits']['hits']) > 0:
+        for hit in result['hits']['hits']:
+            db[hit['fields']['message-id'][0]] = hit['_id']
+        result = es.scroll(scroll='5m', scroll_id=result['_scroll_id'])
+
+    # fetch message-id => uid pairs from imap
+
+    if url.scheme == 'imaps':
+        imap4 = imaplib.IMAP4_SSL(url.hostname, port)
+    else:
+        imap4 = imaplib.IMAP4(url.hostname, port)
+    imap4.login(user, password)
+    imap4.select(folder, readonly=True)
+    results = imap4.uid('search', None, 'ALL')
+    uids = b','.join(results[1][0].split())
+    results = imap4.uid('fetch', uids, '(BODY[HEADER.FIELDS (MESSAGE-ID)])')
+
+    mail = {}
+    uid_re = re.compile(b'^\d+ \(UID (\d+) BODY\[')
+    mid_re = re.compile(b'^Message-ID:\s*(.*?)\s*$', re.I)
+    uid = None
+    for result in results[1]:
+        for line in result:
+            if isinstance(line, bytes):
+                match = uid_re.match(line)
+                if match:
+                    uid = match.group(1)
+                else:
+                    match = mid_re.match(line)
+                    if match:
+                        try:
+                            mail[match.group(1).decode('utf-8')] = uid
+                            uid = None
+                        except ValueError:
+                            pass
+
+    # delete items from elasticsearch that are not present in imap
+
+    queue1 = []
+    queue2 = []
+    for mid, _id in db.items():
+        if not mid in mail:
+            queue1.append({
+                '_op_type': 'delete',
+                '_index': dbname,
+                '_type': 'mbox',
+                '_id': _id
+            })
+            queue2.append({
+                '_op_type': 'delete',
+                '_index': dbname,
+                '_type': 'mbox_source',
+                '_id': _id
+            })
+            print("deleting: " + mid)
+
+    while len(queue1) > 0:
+        es.bulk(queue1[0:1024])
+        del queue1[0:1024]
+
+    while len(queue2) > 0:
+        es.bulk(queue2[0:1024])
+        del queue2[0:1024]
+
+    # add new items to elasticsearch from imap
+
+    uids = []
+    for mid, uid in mail.items():
+        if not mid in db:
+            uids.append(uid)
+    lists.append([uids, listname, imap4])
+else:
+    # File based import??
+    print("Doing file based import")
+    filebased = True
+    if maildir:
+        lists.append([source, fileToLID.get(source) if fileToLID.get(source) 
else list_override])
+    else:
+        if os.path.isfile(source):
+            lists.append([source, fileToLID.get(source) if 
fileToLID.get(source) else list_override])
+        else:
+            globDir(source)
+
+
+threads = []
+# Don't start more threads than there are lists
+cc = min(len(lists), int( multiprocessing.cpu_count() / 2) + 1)
+print("Starting up to %u threads to fetch the %u %s lists" % (cc, len(lists), 
project))
+for i in range(1,cc+1):
+    t = SlurpThread()
+    threads.append(t)
+    t.start()
+    print("Started no. %u" % i)
+
+for t in threads:
+    t.join()
+
+if args.dups:
+    print("Showing duplicate ids:")
+    for mid in duplicates:
+        if len(duplicates[mid]) > 1:
+            print("The mid %s was used by:" % mid)
+            for msg in duplicates[mid]:
+                print(msg)
+
+print("All done! %u records inserted/updated after %u seconds. %u records were 
bad and ignored" % (goodies, int(time.time() - start), baddies))
+if dedupped > 0:
+    print("%u records were not inserted due to deduplication" % dedupped)
diff --git a/tools/mboxo_patch.py b/tools/mboxo_patch.py
new file mode 100644
index 0000000..a577567
--- /dev/null
+++ b/tools/mboxo_patch.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Byte stream reader to process mboxo style mailbox files.
+These are not currently handled by the Python email package.
+
+It replaces any occurrence of b'\n>From ' with b'\nFrom '
+
+The class handles matching across read boundaries.
+
+To use:
+
+from mboxo_patch import MboxoFactory
+...
+messages = mailbox.mbox(filename, MboxoFactory)
+
+N.B.
+To simplify the code, the MboxoReader class changes the
+size parameter to 7 if (and only if): 0 <= size < 7
+The return byte buffer can thus be larger than expected.
+However this is only a theoretical possibility
+as the mailbox code uses a size of 8192 (or None)
+
+"""
+import mailbox
+
+FROM_MANGLED  =b'\n>From '
+FROM_MANGLED_LEN=len(FROM_MANGLED)
+FROM_UNMANGLED=b'\nFrom '
+# We want to match the 7 bytes b'\n>From ' in the input stream
+# However this can be split over multiple reads.
+# The split can occur anywhere after the leading b'\n'
+# and the trailing b' '. If we match any of these
+# we keep the trailing part of the buffer for next time
+# The following are all the possible prefixes for a split:
+FROMS=(FROM_MANGLED[:-1],
+       FROM_MANGLED[:-2],
+       FROM_MANGLED[:-3],
+       FROM_MANGLED[:-4],
+       FROM_MANGLED[:-5],
+       FROM_MANGLED[:-6],
+       )
+
+class MboxoReader(mailbox._PartialFile): # pylint: disable=W0212
+    def __init__(self, f, start=None, stop=None):
+        self.remain=0 # number of bytes to keep for next read
+        super().__init__(f._file, start=f._start, stop=f._stop) # pylint: 
disable=W0212
+
+    # Override the read method to provide mboxo filtering
+    def _read(self, size, read_method):
+        # get the next chunk, resetting if necessary
+        if self.remain != 0:
+            super().seek(whence=1, offset=-self.remain)
+        # if size is None or negative, then read returns everything.
+        # in which case there is no need to wory about matching across reads
+        limited_read = size and size >= 0
+        # ensure we get enough to match successfully when refilling
+        if limited_read and size < FROM_MANGLED_LEN:
+            size = FROM_MANGLED_LEN
+        buff = super()._read(size, read_method)
+        bufflen=len(buff)
+        # did we get anything new?
+        if limited_read and bufflen > self.remain:
+            # is there a potential cross-boundary match?
+            if buff.endswith(FROMS):
+                # yes, work out what to keep
+                # N.B. rindex will fail if it cannot find the LF;
+                # this should be impossible
+                self.remain=bufflen - buff.rindex(b'\n')
+            else:
+                # don't need to keep anything back
+                self.remain=0
+        else:
+            # EOF
+            self.remain=0
+        # we cannot use -0 to mean end of array...
+        end = bufflen if self.remain == 0 else -self.remain
+        # exclude the potential split match from the return
+        return buff[:end].replace(FROM_MANGLED, FROM_UNMANGLED)
+
+class MboxoFactory(mailbox.mboxMessage):
+    def __init__(self, message=None):
+        super().__init__(message=MboxoReader(message))
diff --git a/tools/ponymailconfig.py b/tools/ponymailconfig.py
new file mode 100755
index 0000000..a994287
--- /dev/null
+++ b/tools/ponymailconfig.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+    common config parsing
+    reads the file ponymail.yaml from the same directory as this file
+    Emulates parts of the API of configparser/RawConfigParser
+
+    How to use:
+
+    from ponymailconfig import PonymailConfig
+    config=PonymailConfig()
+    if config.has_option("elasticsearch", "user"):
+        ...
+"""
+
+import os.path
+import yaml
+
+class PonymailConfig(object):
+
+    def __init__(self):
+        config_path = 
os.path.join(os.path.dirname(os.path.realpath(__file__)), "ponymail.yaml")
+        self.config = yaml.safe_load(open(config_path))
+
+    def has_section(self, section):
+        val = section in self.config
+#         print("?", section, val)
+        return val
+
+    def has_option(self, section, option):
+        val = section in self.config and option in self.config[section]
+#         print("?", section, option, val)
+        return val
+
+    def get(self, section, option, fallback=None):
+        try:
+            val = self.config[section][option] or fallback
+        except KeyError:
+            val = fallback
+#         print(">", section, option, fallback, val)
+        return val

Reply via email to