diff options
Diffstat (limited to 'email_assistant/assistant.py')
| -rw-r--r-- | email_assistant/assistant.py | 217 | 
1 files changed, 217 insertions, 0 deletions
diff --git a/email_assistant/assistant.py b/email_assistant/assistant.py new file mode 100644 index 0000000..a784077 --- /dev/null +++ b/email_assistant/assistant.py  | |||
| @@ -0,0 +1,217 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | |||
| 3 | # Copyright (C) 2019 James E. Blair <corvus@gnu.org> | ||
| 4 | # | ||
| 5 | # This file is part of Email-assistant. | ||
| 6 | # | ||
| 7 | # Email-assistant is free software: you can redistribute it and/or | ||
| 8 | # modify it under the terms of the GNU Affero General Public License | ||
| 9 | # as published by the Free Software Foundation, either version 3 of | ||
| 10 | # the License, or (at your option) any later version. | ||
| 11 | # | ||
| 12 | # Email-assistant is distributed in the hope that it will be useful, | ||
| 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | ||
| 15 | # General Public License for more details. | ||
| 16 | # | ||
| 17 | # You should have received a copy of the GNU General Public License | ||
| 18 | # along with Email-assistant. If not, see | ||
| 19 | # <https://www.gnu.org/licenses/>. | ||
| 20 | |||
| 21 | import os | ||
| 22 | import argparse | ||
| 23 | import configparser | ||
| 24 | import email | ||
| 25 | import logging | ||
| 26 | import imaplib | ||
| 27 | import json | ||
| 28 | |||
| 29 | import caldav | ||
| 30 | from geopy.geocoders import Nominatim | ||
| 31 | from timezonefinder import TimezoneFinder | ||
| 32 | import dateutil.tz | ||
| 33 | import pbr.version | ||
| 34 | |||
| 35 | from email_assistant import plugins | ||
| 36 | |||
| 37 | # Number of days to look backwards when scanning a mailbox for the first time: | ||
| 38 | IMAP_BACKFILL = 180 | ||
| 39 | |||
| 40 | class Mailbox: | ||
| 41 | def __init__(self, name, host, username, password, folders): | ||
| 42 | self.log = logging.getLogger('assistant.mailbox') | ||
| 43 | self.name = name | ||
| 44 | self.imap = imaplib.IMAP4_SSL(host) | ||
| 45 | self.imap.login(username, password) | ||
| 46 | self.folders = folders | ||
| 47 | self.uidinfo = {} | ||
| 48 | self.state_file = os.path.expanduser('~/.config/email-assistant/%s.mailbox' % name) | ||
| 49 | if os.path.exists(self.state_file): | ||
| 50 | with open(self.state_file) as f: | ||
| 51 | self.uidinfo = json.load(f) | ||
| 52 | |||
| 53 | def get_messages(self): | ||
| 54 | for folder in self.folders: | ||
| 55 | status = self.imap.status(folder, '(UIDNEXT UIDVALIDITY)') | ||
| 56 | status = status[1][0].split(b' ', 1)[1][1:-1].split() | ||
| 57 | uidnext = int(status[1]) | ||
| 58 | uidvalidity = int(status[3]) | ||
| 59 | self.log.debug("%s uidnext:%s uidvalidity:%s", folder, uidnext, uidvalidity) | ||
| 60 | self.imap.select(folder) | ||
| 61 | state = self.uidinfo.setdefault(folder, {}) | ||
| 62 | if uidvalidity != state.get('uidvalidity'): | ||
| 63 | # fetch backlog of msgs | ||
| 64 | self.log.info("%s uidvalidity changed", folder) | ||
| 65 | uids = self.imap.uid('search', 'younger %i' % (IMAP_BACKFILL*24*60*60)) | ||
| 66 | prev = None | ||
| 67 | else: | ||
| 68 | # fetch new msgs | ||
| 69 | prev = state.get('uidnext') | ||
| 70 | uids = self.imap.uid('search', 'uid %s:*' % prev) | ||
| 71 | uids = [int(x) for x in uids[1][0].split()] | ||
| 72 | if prev is not None: | ||
| 73 | if prev-1 in uids: | ||
| 74 | uids.remove(prev-1) | ||
| 75 | self.log.debug("uids: %s", uids) | ||
| 76 | state['uidnext'] = uidnext | ||
| 77 | state['uidvalidity'] = uidvalidity | ||
| 78 | for uid in uids: | ||
| 79 | msg = self.imap.uid('fetch', str(uid), '(BODY.PEEK[])') | ||
| 80 | msg = msg[1][0][1] | ||
| 81 | self.log.debug("fetch %s %s" % (uid, len(msg))) | ||
| 82 | yield msg | ||
| 83 | |||
| 84 | def save(self): | ||
| 85 | with open(self.state_file, 'w') as f: | ||
| 86 | json.dump(self.uidinfo, f) | ||
| 87 | |||
| 88 | class Calendar: | ||
| 89 | def __init__(self, url, username, password, calendar): | ||
| 90 | self.log = logging.getLogger('assistant.calendar') | ||
| 91 | self.client = caldav.DAVClient(url, username=username, password=password) | ||
| 92 | self.calendar_name = calendar | ||
| 93 | principal = self.client.principal() | ||
| 94 | self.calendar = None | ||
| 95 | for c in principal.calendars(): | ||
| 96 | if c.name == self.calendar_name: | ||
| 97 | self.calendar = c | ||
| 98 | if not self.calendar: | ||
| 99 | raise Exception("Unable to find calendar %s" % (self.calendar,)) | ||
| 100 | |||
| 101 | def get_events(self): | ||
| 102 | return self.calendar.events() | ||
| 103 | |||
| 104 | def add_events(self, events): | ||
| 105 | for event in events: | ||
| 106 | try: | ||
| 107 | self.calendar.event_by_uid(event.vevent.uid.value) | ||
| 108 | found = True | ||
| 109 | except caldav.lib.error.NotFoundError: | ||
| 110 | found = False | ||
| 111 | if found: | ||
| 112 | self.log.info("Found existing event: %s", event.vevent.summary.value) | ||
| 113 | else: | ||
| 114 | self.log.info("Adding event: %s", event.vevent.summary.value) | ||
| 115 | self.calendar.add_event(event.serialize()) | ||
| 116 | |||
| 117 | class Assistant: | ||
| 118 | def __init__(self): | ||
| 119 | self.log = logging.getLogger('main') | ||
| 120 | self.geolocator = None | ||
| 121 | self.tzfinder = None | ||
| 122 | self.plugins = [] | ||
| 123 | for p in plugins.plugins: | ||
| 124 | self.plugins.append(p(self)) | ||
| 125 | |||
| 126 | def get_tzinfo(self, location): | ||
| 127 | if self.geolocator is None: | ||
| 128 | return None | ||
| 129 | try: | ||
| 130 | loc = self.geolocator.geocode(location) | ||
| 131 | tzname = self.tzfinder.timezone_at(lat=loc.point[0], lng=loc.point[1]) | ||
| 132 | return(dateutil.tz.gettz(tzname)) | ||
| 133 | except Exception: | ||
| 134 | self.log.exception("Unable to geolocate %s", location) | ||
| 135 | return None | ||
| 136 | |||
| 137 | def main(self): | ||
| 138 | config = configparser.ConfigParser() | ||
| 139 | config.read(os.path.expanduser('~/.config/email-assistant/config')) | ||
| 140 | |||
| 141 | if config.has_option('general', 'geocode'): | ||
| 142 | if config['general']['geocode'].lower() == 'nominatim': | ||
| 143 | version_info = pbr.version.VersionInfo('email-assistant') | ||
| 144 | release_string = version_info.release_string() | ||
| 145 | self.geolocator = Nominatim(user_agent="email-assistant %s" % release_string) | ||
| 146 | self.tzfinder = TimezoneFinder() | ||
| 147 | |||
| 148 | calendars = {} | ||
| 149 | mailboxes = {} | ||
| 150 | targets = {} | ||
| 151 | for section in config.sections(): | ||
| 152 | if section.startswith('mailbox '): | ||
| 153 | name = section.split()[1] | ||
| 154 | mailboxes[name] = Mailbox( | ||
| 155 | name, | ||
| 156 | config[section]['host'], | ||
| 157 | config[section]['username'], | ||
| 158 | config[section]['password'], | ||
| 159 | config[section]['folders'].split(',')) | ||
| 160 | elif section.startswith('calendar '): | ||
| 161 | name = section.split()[1] | ||
| 162 | calendars[name] = Calendar( | ||
| 163 | config[section]['url'], | ||
| 164 | config[section]['username'], | ||
| 165 | config[section]['password'], | ||
| 166 | config[section]['calendar']) | ||
| 167 | elif section.startswith('pair'): | ||
| 168 | t = targets.setdefault(config[section]['mailbox'], set()) | ||
| 169 | t.add(config[section]['calendar']) | ||
| 170 | for mb, cs in targets.items(): | ||
| 171 | self.sync(mailboxes[mb], [calendars[c] for c in cs]) | ||
| 172 | |||
| 173 | def sync(self, mailbox, calendars): | ||
| 174 | for msg in mailbox.get_messages(): | ||
| 175 | msg = email.message_from_bytes(msg) | ||
| 176 | self.log.debug("Processing %s", msg['subject']) | ||
| 177 | events = None | ||
| 178 | try: | ||
| 179 | for p in self.plugins: | ||
| 180 | if p.match(msg): | ||
| 181 | events = p.get_events(msg) | ||
| 182 | break | ||
| 183 | except Exception: | ||
| 184 | self.log.exception("Error parsing %s", msg['subject']) | ||
| 185 | if events: | ||
| 186 | for calendar in calendars: | ||
| 187 | calendar.add_events(events) | ||
| 188 | mailbox.save() | ||
| 189 | |||
| 190 | def main(): | ||
| 191 | parser = argparse.ArgumentParser( | ||
| 192 | description='Create calendar events from emails.') | ||
| 193 | parser.add_argument('-v', dest='verbose', action='store_true', | ||
| 194 | help='Output verbose debug info') | ||
| 195 | parser.add_argument('-vv', dest='very_verbose', action='store_true', | ||
| 196 | help='Output verbose debug info (including client libraries)') | ||
| 197 | parser.add_argument('-q', dest='quiet', action='store_true', | ||
| 198 | help='Only output errors') | ||
| 199 | args = parser.parse_args() | ||
| 200 | level = None | ||
| 201 | if args.quiet: | ||
| 202 | level = logging.ERROR | ||
| 203 | elif args.very_verbose: | ||
| 204 | logging.basicConfig(level=logging.DEBUG) | ||
| 205 | elif args.verbose: | ||
| 206 | level = logging.DEBUG | ||
| 207 | else: | ||
| 208 | level = logging.INFO | ||
| 209 | if level: | ||
| 210 | logger = logging.getLogger('assistant') | ||
| 211 | logger.setLevel(level) | ||
| 212 | handler = logging.StreamHandler() | ||
| 213 | handler.setLevel(level) | ||
| 214 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') | ||
| 215 | handler.setFormatter(formatter) | ||
| 216 | logger.addHandler(handler) | ||
| 217 | Assistant().main() | ||
