# -*- coding: utf-8 -*- """Functions searching in IMAP account""" import ast import codecs import datetime import email from email import header import logging import re import sys import docopt import six import imap_cli from imap_cli import config from imap_cli import const from imap_cli import fetch log = logging.getLogger('imap-cli-list') usage = """Usage: imap-cli-search [options] [-t <tags>] [-T <full-text>] [<directory>] Options: -a, --address=<address> Search for specified "FROM" address -c, --config-file=<FILE> Configuration file (`~/.config/imap-cli` by default) -d, --date=<date> Search mail receive since the specified date (format YYYY-MM-DD) -f, --format=<FMT> Output format -l, --limit=<limit> Limit number of mail displayed -s, --size=<SIZE> Search mails larger than specified size (in bytes) -S, --subject=<subject> Search by subject -t, --tags=<tags> Searched tags (Comma separated values) -T, --full-text=<text> Searched tags (Comma separated values) --thread Display mail by thread -v, --verbose Generate verbose messages -h, --help Show help options. --version Print program version. ---- imap-cli-search 0.7 Copyright (C) 2014 Romain Soufflet License MIT This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. """ FLAGS_RE = r'.*FLAGS \((?P<flags>[^\)]*)\)' MAIL_ID_RE = r'^(?P<mail_id>\d+) \(' UID_RE = r'.*UID (?P<uid>[^ ]*)' def combine_search_criterion(search_criterion, operator='AND'): """Return a single IMAP search string combining all criterion given. .. versionadded:: 0.4 :param operator: Possible values are : 'AND', 'OR' and 'NOT' """ if operator not in ['AND', 'OR', 'NOT']: operator = 'AND' log.warning(u''.join([ u'Wrong value for "operator" argument,', u'taking default value "{}"']).format(operator)) if operator == 'AND': return '({})'.format(' '.join(search_criterion)) if operator == 'OR': return 'OR {}'.format(' '.join(search_criterion)) if operator == 'NOT': return 'NOT {}'.format(' '.join(search_criterion)) def create_search_criterion(address=None, date=None, size=None, subject=None, tags=None, text=None, operator='AND'): """Create a list for all search criterion Wrapper helping developer to construct a list of search criterion with a single method. .. versionadded:: 0.2 """ search_criterion = [] if address is not None: search_criterion.append(create_search_criterion_by_mail_address( address)) if date is not None: search_criterion.append(create_search_criterion_by_date(date)) if tags is not None: search_criterion.append(create_search_criteria_by_tag(tags)) if text is not None: search_criterion.append(create_search_criteria_by_text(text)) if subject is not None: search_criterion.append(create_search_criterion_by_subject(subject)) if size is not None: search_criterion.append(create_search_criterion_by_size(size)) if len(search_criterion) == 0: search_criterion.append('ALL') return search_criterion def create_search_criterion_by_date(datetime, relative=None, sent=False): """Return a search criteria by date. .. versionadded:: 0.4 :param relative: Can be one of 'BEFORE', 'SINCE', 'ON'. :param sent: Search after "sent" date instead of "received" date. """ if relative not in ['BEFORE', 'ON', 'SINCE']: relative = 'SINCE' formated_date = datetime.strftime('%d-%h-%Y') return '{}{} {}'.format('SENT' if sent is True else '', relative, formated_date) def create_search_criterion_by_header(header_name, header_value): """Return search criteria by header. .. versionadded:: 0.4 """ return 'HEADER {} {}'.format(header_name, header_value) def create_search_criterion_by_mail_address(mail_address, header_name='FROM'): """Return a search criteria over mail address. .. versionadded:: 0.4 :param header_name: Specify in wich header address must be searched. \ Possible values are "FROM", "CC", "BCC" and "TO" """ if header_name not in ['BCC', 'CC', 'FROM', 'TO']: header_name = 'FROM' log.warning( 'Wrong "header_name" value, taking default value {}'.format( header_name)) return '{} "{}"'.format(header_name, mail_address) def create_search_criterion_by_size(size, relative='LARGER'): """Return a search criteria by size. .. versionadded:: 0.4 :param relative: Can be one of 'LARGER' or 'SMALLER' """ # TODO(rsoufflet) sannitize "size" arg if relative not in ['LARGER', 'SMALLER']: relative = 'LARGER' log.warning( 'Wrong "relative" argument, taking default value "{}"'.format( relative)) return '{} "{}"'.format(relative, size) def create_search_criterion_by_subject(subject): """Return search criteria by subject. .. versionadded:: 0.4 """ return 'SUBJECT "{}"'.format(subject) def create_search_criteria_by_tag(tags): """Return a search criteria for specified tags. .. versionadded:: 0.3 """ if len(tags) == 0: return '' criterion = [] for tag in tags: if tag.upper() in const.IMAP_SPECIAL_FLAGS: criterion.append(tag.upper()) else: criterion.append('KEYWORD "{}"'.format(tag)) return '({})'.format( ' '.join(criterion)) if len(criterion) > 1 else criterion[0] def create_search_criteria_by_text(text): """Return a search criteria for fulltext. .. versionadded: 0.4 """ return 'BODY "{}"'.format(text) def create_search_criterion_by_uid(uid): """Return a search criteria for UID. .. versionadded: 0.4 """ return 'UID {}'.format(uid) def display_mail_tree(imap_account, threads, mail_info_by_uid=None, depth=0, format_thread=None): """Generate indented string representing threads. .. versionadded:: 0.5 :param imap_account: imaplib.IMAP4 or imaplib.IMAP4_SSL instance :param threads: List containing other list or uids :param mail_info_by_uid: Dict of information for every mail listed in threads :param depth: Actual depth of indentation :param format_thread: Format string to apply to mail informations """ if mail_info_by_uid is None: mail_set = list(threads_to_mail_set(threads)) if len(mail_set) == 0: log.error('No mail found') mail_info_by_uid = {} for mail_info in fetch_mails_info(imap_account, mail_set=mail_set): mail_info_by_uid[int(mail_info['uid'])] = mail_info for idx, thread in enumerate(threads): if isinstance(thread, int): indent = depth if idx > 0 else depth - 1 if mail_info_by_uid.get(thread) is None: continue yield u'{}{}'.format(' ' * indent, format_thread.format( **mail_info_by_uid[thread]))[0:140] else: for output in display_mail_tree( imap_account, thread, mail_info_by_uid=mail_info_by_uid, depth=depth + 1, format_thread=format_thread): yield output def fetch_mails_info(imap_account, mail_set=None, decode=True, limit=None): """Retrieve information for every mail in mail_set .. versionadded:: 0.2 :param imap_account: imaplib.IMAP4 or imaplib.IMAP4_SSL instance :param mail_set: List of mail UID :param decode: Wether we must or mustn't decode mails informations :param limit: Return only last mails """ flags_re = re.compile(FLAGS_RE) mail_id_re = re.compile(MAIL_ID_RE) uid_re = re.compile(UID_RE) if mail_set is None: mail_set = fetch_uids(imap_account, limit=limit) elif isinstance(mail_set, six.string_types): mail_set = mail_set.split() mails_data = fetch.fetch(imap_account, mail_set, ['BODY.PEEK[HEADER]', 'FLAGS', 'UID']) if mails_data is None: return for mail_data in mails_data: flags_match = flags_re.match(mail_data[0]) mail_id_match = mail_id_re.match(mail_data[0]) uid_match = uid_re.match(mail_data[0]) if mail_id_match is None or flags_match is None or uid_match is None: continue flags = flags_match.groupdict().get('flags').split() mail_id = mail_id_match.groupdict().get('mail_id').split()[0] mail_uid = uid_match.groupdict().get('uid').split()[0] mail = email.message_from_string(mail_data[1]) if decode is True: for header_name, header_value in mail.items(): header_new_value = [] for value, encoding in header.decode_header(header_value): if value is None: continue try: decoded_value = codecs.decode(value, encoding or 'utf-8', 'ignore') except TypeError: log.debug(u'Can\'t decode {} with {} encoding'.format( value, encoding)) decoded_value = value header_new_value.append(decoded_value) mail.replace_header(header_name, ' '.join(header_new_value)) yield dict([ ('flags', flags), ('id', mail_id), ('uid', mail_uid), ('from', mail['from']), ('to', mail['to']), ('date', mail['date']), ('subject', mail.get('subject', '')), ]) def fetch_threads(imap_account, charset=None, limit=None, search_criterion=None): """Retrieve information for every mail search_criterion by thread. .. versionadded:: 0.5 :param imap_account: imaplib.IMAP4 or imaplib.IMAP4_SSL instance :param charset: Desired charset for IMAP response :param limit: Return only last mails :param search_criterion: List of criteria for IMAP Search """ request_search_criterion = search_criterion if search_criterion is None or search_criterion == ['ALL']: request_search_criterion = 'ALL' if charset is None: charset = 'UTF-8' if isinstance(search_criterion, list): request_search_criterion = combine_search_criterion(search_criterion) if imap_account.state != 'SELECTED': log.warning(u'No directory specified, selecting {}'.format( const.DEFAULT_DIRECTORY)) imap_cli.change_dir(imap_account, const.DEFAULT_DIRECTORY) status, data = imap_account.uid('THREAD', 'REFERENCES', charset, request_search_criterion) if status != const.STATUS_OK: return None threads = parse_thread_response(data[0]) return threads if limit is None else threads[-limit:] def fetch_uids(imap_account, charset=None, limit=None, search_criterion=None): """Retrieve information for every mail search_criterion. .. versionadded:: 0.3 :param imap_account: imaplib.IMAP4 or imaplib.IMAP4_SSL instance :param charset: Desired charset for IMAP response :param limit: Return only last mails :param search_criterion: List of criteria for IMAP Search """ request_search_criterion = search_criterion if search_criterion is None: request_search_criterion = 'ALL' elif isinstance(search_criterion, list): request_search_criterion = combine_search_criterion(search_criterion) if imap_account.state != 'SELECTED': log.warning(u'No directory specified, selecting {}'.format( const.DEFAULT_DIRECTORY)) imap_cli.change_dir(imap_account, const.DEFAULT_DIRECTORY) status, data_bytes = imap_account.uid( 'SEARCH', charset, request_search_criterion) data = [data_bytes[0].decode('utf-8')] if status == const.STATUS_OK: return data[0].split() if limit is None else data[0].split()[-limit:] def parse_thread_response(thread_string): """Parse IMAP THREAD response into a list of thread. We define thread as list of mail UID (int) which can contain other thread (nested list) Example: >>> imap_account = imap_cli.connect('serveur', 'login', 'password') >>> imap_response = fetch_threads(imap_account) >>> repr(parse_thread_response(imap_response)) '[[[6], [7]], [14, 19], [23, 58, 60, 61, 62, 63, 68, 69, 70]]' """ # FIXME(rsoufflet) Not sure the use of "ast" module is the right solution. # Any ideas are welcome here return ast.literal_eval('[{}]'.format( thread_string .decode('utf-8') .replace(' ', ', ') .replace('(', '[') .replace(')', '], '))) def threads_to_mail_set(threads): for value in threads: if isinstance(value, list): for sub_value in threads_to_mail_set(value): yield sub_value else: yield value def threads_to_mail_tree(threads): mail_tree = [] for thread in threads: if isinstance(thread, list): if len(thread) == 1: mail_tree.append(thread[0]) else: mail_tree.append(threads_to_mail_tree(thread)) else: mail_tree.append(thread) return mail_tree def main(): args = docopt.docopt('\n'.join(usage.split('\n')), version=const.VERSION) logging.basicConfig( level=logging.DEBUG if args['--verbose'] else logging.INFO, stream=sys.stdout, ) connect_conf = config.new_context_from_file(args['--config-file'], section='imap') if connect_conf is None: return 1 display_conf = config.new_context_from_file(args['--config-file'], section='display') if args['--format'] is not None: display_conf_key = ('format_thread' if args['--thread'] is True else 'format_list') display_conf[display_conf_key] = args['--format'] if args.get('--tags') is not None: args['--tags'] = args['--tags'].split(',') if args['--date'] is not None: try: date = datetime.datetime.strptime(args['--date'], '%Y-%m-%d') except ValueError: date = None else: date = None if args['--limit'] is not None: try: limit = int(args['--limit']) if limit < 1: raise ValueError except ValueError: log.error('Invalid argument limit : {}'.format(args['--limit'])) return 1 else: limit = None try: imap_account = imap_cli.connect(**connect_conf) imap_cli.change_dir( imap_account, directory=args['<directory>'] or const.DEFAULT_DIRECTORY) search_criterion = create_search_criterion( address=args['--address'], date=date, subject=args['--subject'], size=args['--size'], tags=args['--tags'], text=args['--full-text'], ) if args['--thread'] is False: mail_set = fetch_uids(imap_account, search_criterion=search_criterion) if len(mail_set) == 0: log.error('No mail found') return 0 for mail_info in fetch_mails_info(imap_account, limit=limit, mail_set=mail_set): sys.stdout.write( display_conf['format_list'].format(**mail_info)) sys.stdout.write('\n') else: threads = fetch_threads(imap_account, limit=limit, search_criterion=search_criterion) mail_tree = threads_to_mail_tree(threads) for output in display_mail_tree( imap_account, mail_tree, format_thread=display_conf['format_thread']): sys.stdout.write(output) sys.stdout.write('\n') imap_cli.disconnect(imap_account) except KeyboardInterrupt: log.info('Interrupt by user, exiting') return 0 if __name__ == '__main__': sys.exit(main())