summaryrefslogtreecommitdiff
path: root/email_assistant/assistant.py
diff options
context:
space:
mode:
Diffstat (limited to 'email_assistant/assistant.py')
-rw-r--r--email_assistant/assistant.py217
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
21import os
22import argparse
23import configparser
24import email
25import logging
26import imaplib
27import json
28
29import caldav
30from geopy.geocoders import Nominatim
31from timezonefinder import TimezoneFinder
32import dateutil.tz
33import pbr.version
34
35from email_assistant import plugins
36
37# Number of days to look backwards when scanning a mailbox for the first time:
38IMAP_BACKFILL = 180
39
40class 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
88class 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
117class 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
190def 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()