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