email-to-calendar 20250826010803.dev0__py3-none-any.whl → 20251125165904.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
__init__.py CHANGED
@@ -1,16 +1,19 @@
1
1
  import os
2
2
  import sys
3
3
  import logging
4
+ from pathlib import Path
4
5
 
5
- if not os.path.isdir("../data"):
6
- os.makedirs("../data")
6
+ from src.util.env import get_settings
7
7
 
8
- db_file = os.environ.get("DB_FILE", "../data/emails.db")
8
+ settings = get_settings()
9
9
 
10
- if not os.path.isdir("../logs"):
11
- os.makedirs("../logs")
10
+ db_file = Path(settings.DB_FILE)
12
11
 
13
- log_file = os.path.join(os.path.dirname(__file__), "../logs/emails.log")
12
+ db_file.parent.mkdir(parents=True, exist_ok=True)
13
+
14
+ log_file_path = Path(os.path.dirname(__file__), "../logs/emails.log")
15
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
16
+ log_file_path.touch(exist_ok=True)
14
17
 
15
18
 
16
19
  class StdoutFilter(logging.Filter):
@@ -43,7 +46,7 @@ stderr_handler.addFilter(StderrFilter())
43
46
  stderr_handler.setFormatter(logging.Formatter(log_format))
44
47
 
45
48
  # Handler for file (all levels)
46
- file_handler = logging.FileHandler(log_file)
49
+ file_handler = logging.FileHandler(log_file_path)
47
50
  file_handler.setLevel(logging.DEBUG)
48
51
  file_handler.setFormatter(logging.Formatter(log_format))
49
52
 
@@ -52,8 +55,3 @@ logger.handlers.clear()
52
55
  logger.addHandler(stdout_handler)
53
56
  logger.addHandler(stderr_handler)
54
57
  logger.addHandler(file_handler)
55
-
56
- # Usage: from src import logger
57
- # logger.info("Info message")
58
- # logger.warning("Warning message")
59
- # logger.error("Error message")
db.py CHANGED
@@ -1,17 +1,16 @@
1
- from sqlalchemy import create_engine
2
- from sqlalchemy.orm import sessionmaker, declarative_base
1
+ from sqlmodel import create_engine, Session
3
2
 
4
- from src import db_file
3
+ from src.util.env import get_settings
5
4
 
6
- DATABASE_URL = f"sqlite:///{db_file}"
5
+ settings = get_settings()
7
6
 
8
- engine = create_engine(DATABASE_URL, echo=False)
9
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
10
- Base = declarative_base()
7
+ DATABASE_URL = f"sqlite:///{settings.DB_FILE}"
8
+
9
+ engine = create_engine(DATABASE_URL)
11
10
 
12
11
 
13
12
  def get_db():
14
- db = SessionLocal()
13
+ db = Session(engine)
15
14
  try:
16
15
  yield db
17
16
  finally:
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: email-to-calendar
3
+ Version: 20251125165904.dev0
4
+ Summary: Takes emails from an IMAP server, parses the body, and creates event(s) in a CalDAV calendar
5
+ Requires-Python: <4.0,>=3.13
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: apprise==1.9.5
9
+ Requires-Dist: caldav==2.1.2
10
+ Requires-Dist: imapclient==3.0.1
11
+ Requires-Dist: pydantic-ai-slim[openai]==1.22.0
12
+ Requires-Dist: pydantic-settings==2.12.0
13
+ Requires-Dist: pydantic[email]==2.12.4
14
+ Requires-Dist: sqlmodel==0.0.27
15
+ Requires-Dist: tzlocal==5.3.1
16
+ Dynamic: license-file
17
+
18
+ # E-Mail to calendar Converter
19
+ The point of this application is to search an IMAP account, look for emails based on certain criteria(s), and parse
20
+ the content, using regex, and automatically create calendar events in an iCal account.
21
+
22
+ ## TO-DO
23
+ - [X] Get e-mails, and save ID to sqlite db to avoid duplicates
24
+ - [X] Save calendar events to sqlite db to avoid duplicates
25
+ - [X] Add config to backfill (check all emails from an optional certain date), or use most recent email
26
+ - [X] If using most recent, when new email arrives, remove events not present, and add new ones
27
+ - [ ] If new email comes in with updated events, update event in calendar instead of creating a new one
28
+ - [ ] Using email summary check for words like `Cancelled`, etc. to delete events
29
+ - [ ] If event already exists, check if details have changed, and update if necessary
30
+ - [ ] Investigate IMAP IDLE (push instead of poll)
31
+ - [X] Make sure all day events are handled correctly
32
+ - [ ] Add Docker Model Runner support
33
+ - [ ] Add 'validate' function for events, and if it fails, have AI re-process that event
34
+
35
+
36
+ ## Environment Variables
37
+ | Name | Description | Type | Default Value | Allowed Values |
38
+ |------|-------------|------|---------------|----------------|
39
+ | | | | | |
40
+ | | | | | |
41
+ | | | | | |
@@ -0,0 +1,20 @@
1
+ __init__.py,sha256=iI9fqY9bavUE4S2CxDks8-Q7OBGKl96xQVbYRmAm36E,1572
2
+ db.py,sha256=apQrwaEp0ncFNn5WIoDi7ndMe4Q-VYRnXvCJT_iV0O0,295
3
+ main.py,sha256=CZxXR163RvFC_7C8CKK39JDEOQJlpobK1Mi7y-fUeR8,6308
4
+ email_to_calendar-20251125165904.dev0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
5
+ events/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ events/caldav.py,sha256=Tf-v_yuHr6SBzQ4f-AOdKGG_eoIVuedZ5AKCv-hKWG8,1713
7
+ mail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ mail/mail.py,sha256=Kq50o2kpkovvfNcSnDQZTnOZ6TV7ZSR8KOIRvdXkGao,5825
9
+ mail/mail_idle.py,sha256=o_lOVO9VPm88si3qvEQhttSH2N6cRZF7AWV2kU8uXDw,6860
10
+ model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ model/email.py,sha256=GRpqoOQikfZAWofPOYpHGVIbkNVr3_XahMsorwsagis,3647
12
+ model/event.py,sha256=WQPVKPAuebALqalZYHvl1sAmQ3BPnu6XDDmpvrgNTWM,3917
13
+ util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ util/ai.py,sha256=Px1OU2VLbnXr9Fk7VHj66b0_-hyvrGKsInAmZigwIag,7367
15
+ util/env.py,sha256=gTgydkmnEgOYvltMqt0hlYWErVf08ZUssbhHzA0GCiE,3780
16
+ util/notifications.py,sha256=WKSgBfVeDOg1tqIEOwUgMgb10qxrzjsDvh5bmYi9-Hw,640
17
+ email_to_calendar-20251125165904.dev0.dist-info/METADATA,sha256=y5QEaKOKPcheZdUjljcyyHrgQMarhSK1lyT_ZGAGC8I,1989
18
+ email_to_calendar-20251125165904.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
+ email_to_calendar-20251125165904.dev0.dist-info/top_level.txt,sha256=635ZTCyzc_2xtdJz_2Q50A96mBoIUrfORy5SdfGYabo,40
20
+ email_to_calendar-20251125165904.dev0.dist-info/RECORD,,
events/caldav.py CHANGED
@@ -1,15 +1,22 @@
1
1
  from caldav import DAVClient, Calendar
2
+ from pydantic import AnyUrl
3
+
2
4
  from src import logger
3
5
 
4
6
  from src.model.event import Event
5
7
 
6
8
 
7
- def authenticate_caldav(url: str, username: str, password: str) -> DAVClient:
8
- return DAVClient(url, username=username, password=password)
9
+ def authenticate_caldav(url: AnyUrl, username: str, password: str) -> DAVClient:
10
+ return DAVClient(
11
+ url.encoded_string(),
12
+ username=username,
13
+ password=password,
14
+ headers={"User-Agent": "email-to-calendar/1.0"},
15
+ )
9
16
 
10
17
 
11
18
  def add_to_caldav(
12
- url: str, username: str, password: str, calendar_name: str, events: list[Event]
19
+ url: AnyUrl, username: str, password: str, calendar_name: str, events: list[Event]
13
20
  ):
14
21
  with authenticate_caldav(url, username, password) as client:
15
22
  principal = client.principal()
@@ -30,6 +37,9 @@ def add_to_caldav(
30
37
  logger.info(
31
38
  f"Adding event {event.summary} to CalDAV calendar '{calendar_name}'"
32
39
  )
40
+ if event.all_day:
41
+ event.start = event.start.date()
42
+ event.end = event.end.date()
33
43
  calendar.add_event(
34
44
  dtstart=event.start, dtend=event.end, summary=event.summary
35
45
  )
mail/mail_idle.py ADDED
@@ -0,0 +1,187 @@
1
+ import time
2
+ import re
3
+ import email
4
+ from email import policy
5
+ from imapclient import IMAPClient
6
+
7
+
8
+ def idle_print_emails(
9
+ host,
10
+ port,
11
+ username,
12
+ password,
13
+ folder="INBOX",
14
+ idle_timeout=30,
15
+ payload_timeout=30,
16
+ retry_interval=1,
17
+ ):
18
+ """
19
+ Connects to IMAP server, enters IDLE loop and prints new messages to console.
20
+ Only prints a message once its payload is actually available. Retries fetching
21
+ the payload for up to `payload_timeout` seconds (checking every `retry_interval` seconds).
22
+ """
23
+
24
+ processed_uids = set()
25
+
26
+ def extract_text(msg):
27
+ if msg.is_multipart():
28
+ for part in msg.walk():
29
+ if (
30
+ part.get_content_type() == "text/plain"
31
+ and part.get_content_disposition() != "attachment"
32
+ ):
33
+ return part.get_content()
34
+ for part in msg.walk():
35
+ if (
36
+ part.get_content_type() == "text/html"
37
+ and part.get_content_disposition() != "attachment"
38
+ ):
39
+ return part.get_content()
40
+ return ""
41
+ else:
42
+ return msg.get_content()
43
+
44
+ def _raw_from_fetch_entry(data):
45
+ # fetch response keys vary; try common ones
46
+ for key in (
47
+ b"BODY.PEEK[]",
48
+ "BODY.PEEK[]",
49
+ b"BODY[]",
50
+ "BODY[]",
51
+ b"RFC822",
52
+ "RFC822",
53
+ ):
54
+ raw = data.get(key)
55
+ if raw:
56
+ return raw
57
+ return None
58
+
59
+ def fetch_payload_with_retry(client, uid):
60
+ start = time.time()
61
+ while time.time() - start < payload_timeout:
62
+ try:
63
+ fetch_data = client.fetch([uid], ["BODY.PEEK[]", "FLAGS"])
64
+ except Exception:
65
+ # transient fetch error, wait and retry
66
+ time.sleep(retry_interval)
67
+ continue
68
+
69
+ entry = fetch_data.get(uid) or fetch_data.get(int(uid)) or {}
70
+ raw = _raw_from_fetch_entry(entry)
71
+ if raw:
72
+ # normalize
73
+ if isinstance(raw, tuple):
74
+ raw = raw[1]
75
+ if isinstance(raw, str):
76
+ raw = raw.encode()
77
+ return raw
78
+ # not yet present, wait and retry
79
+ time.sleep(retry_interval)
80
+ return None
81
+
82
+ def process_uids(client, uids):
83
+ for uid in uids:
84
+ if uid in processed_uids:
85
+ continue
86
+ raw = fetch_payload_with_retry(client, uid)
87
+ if not raw:
88
+ # payload never became available within timeout; skip printing
89
+ print(
90
+ f"UID {uid}: payload not available after {payload_timeout}s, skipping."
91
+ )
92
+ continue
93
+ try:
94
+ msg = email.message_from_bytes(raw, policy=policy.default)
95
+ except Exception as e:
96
+ print(f"UID {uid}: failed to parse message bytes:", e)
97
+ processed_uids.add(uid)
98
+ continue
99
+
100
+ subject = msg.get("Subject", "(no subject)")
101
+ _from = msg.get("From", "(no from)")
102
+ _to = msg.get("To", "(no to)")
103
+ date = msg.get("Date", "(no date)")
104
+ body = extract_text(msg) or "(no body)"
105
+
106
+ print("----- MESSAGE START -----")
107
+ print("UID:", uid)
108
+ print("From:", _from)
109
+ print("To:", _to)
110
+ print("Date:", date)
111
+ print("Subject:", subject)
112
+ print("Body:\n", body)
113
+ print("----- MESSAGE END -------")
114
+
115
+ processed_uids.add(uid)
116
+
117
+ with IMAPClient(host, port, use_uid=True, ssl=True) as client:
118
+ client.login(username, password)
119
+ client.select_folder(folder)
120
+ print("Connected and monitoring folder:", folder)
121
+
122
+ try:
123
+ while True:
124
+ print("Entering IDLE...")
125
+ started_idle = False
126
+ try:
127
+ client.idle()
128
+ started_idle = True
129
+ except Exception as e:
130
+ # servers can send unsolicited FETCH while entering IDLE; extract UIDs and process
131
+ msg = str(e)
132
+ print("IDLE start error (handled):", msg)
133
+ found = re.findall(r"UID (\d+)", msg)
134
+ if found:
135
+ uids = [int(x) for x in found]
136
+ print("Processing UIDs from unsolicited response:", uids)
137
+ process_uids(client, uids)
138
+
139
+ responses = []
140
+ if started_idle:
141
+ try:
142
+ responses = client.idle_check(timeout=idle_timeout)
143
+ finally:
144
+ try:
145
+ client.idle_done()
146
+ except Exception as e:
147
+ print("Failed to exit IDLE:", e)
148
+
149
+ if responses:
150
+ print("Server notifications:", responses)
151
+ uids_from_responses = []
152
+ for resp in responses:
153
+ try:
154
+ if (
155
+ isinstance(resp, tuple)
156
+ and len(resp) >= 3
157
+ and resp[1] in (b"FETCH", "FETCH")
158
+ ):
159
+ nested = resp[2]
160
+ if isinstance(nested, tuple):
161
+ for i in range(len(nested)):
162
+ if nested[i] == b"UID" or nested[i] == "UID":
163
+ maybe_uid = nested[i + 1]
164
+ uids_from_responses.append(int(maybe_uid))
165
+ except Exception:
166
+ continue
167
+
168
+ if uids_from_responses:
169
+ process_uids(client, uids_from_responses)
170
+ else:
171
+ # fallback: search for UNSEEN messages
172
+ try:
173
+ uids = client.search(["UNSEEN"])
174
+ if not uids:
175
+ print("No unseen messages found.")
176
+ else:
177
+ print(f"Found {len(uids)} unseen message(s): {uids}")
178
+ process_uids(client, uids)
179
+ except Exception as e:
180
+ print("Error fetching or parsing messages:", e)
181
+
182
+ time.sleep(1)
183
+ except KeyboardInterrupt:
184
+ print("Interrupted by user, closing connection.")
185
+ except Exception as e:
186
+ print("Error in IDLE loop:", e)
187
+ raise
main.py CHANGED
@@ -1,45 +1,133 @@
1
+ import asyncio
2
+ import datetime
1
3
  import os
4
+ import time
2
5
 
3
6
  from sqlalchemy import inspect
7
+ from sqlmodel import SQLModel
4
8
 
5
9
  from src import db_file, logger
6
10
  from src.events.caldav import add_to_caldav
7
- from src.model.event import Event
8
- from src.util import text
9
11
  from src.mail import mail
10
- from src.db import Base, engine, SessionLocal
12
+ from src.db import Session, engine
11
13
  from src.model.email import EMail
14
+ from src.model.event import Event
15
+ from src.util.ai import parse_email
16
+ from src.util.env import get_settings, Settings
17
+ from src.util.notifications import send_success_notification
18
+
19
+
20
+ async def populate_events(settings: Settings):
21
+ backfill = settings.BACKFILL
22
+ provider = settings.AI_PROVIDER
23
+ model = settings.AI_MODEL
24
+ ollama_host = settings.OLLAMA_HOST
25
+ ollama_port = settings.OLLAMA_PORT
26
+ ollama_secure = settings.OLLAMA_SECURE
27
+ open_ai_api_key = settings.OPEN_AI_API_KEY
28
+ max_retries = settings.AI_MAX_RETRIES
29
+ system_prompt = settings.AI_SYSTEM_PROMPT
30
+
31
+ if backfill:
32
+ logger.info("Backfilling events from all emails without events")
33
+ for email in EMail.get_without_events():
34
+ events = await parse_email(
35
+ email,
36
+ provider,
37
+ model,
38
+ ollama_host,
39
+ ollama_port,
40
+ ollama_secure,
41
+ open_ai_api_key,
42
+ max_retries,
43
+ system_prompt,
44
+ )
45
+ for event in events:
46
+ logger.debug(f"Backfilling event: {event}")
47
+ try:
48
+ event.save()
49
+ except Exception as e:
50
+ logger.error(f"Error saving event {event}: {e}", exc_info=True)
51
+ for e in events:
52
+ e.delete()
53
+ logger.info("Backfilled events from all emails")
54
+ else:
55
+ most_recent_email = EMail.get_most_recent_without_events()
56
+ if most_recent_email:
57
+ logger.info("Parsing most recent email with id %s", most_recent_email.id)
58
+ events = await parse_email(
59
+ most_recent_email,
60
+ provider,
61
+ model,
62
+ ollama_host,
63
+ ollama_port,
64
+ ollama_secure,
65
+ open_ai_api_key,
66
+ max_retries,
67
+ system_prompt,
68
+ )
69
+ for event in events:
70
+ logger.debug(f"Saving event: {event}")
71
+ try:
72
+ event.save()
73
+ except Exception as e:
74
+ logger.error(f"Error saving event {event}: {e}", exc_info=True)
75
+ for e in events:
76
+ e.delete()
77
+ logger.info(
78
+ "Parsed and saved events from most recent email with date %s",
79
+ most_recent_email.delivery_date,
80
+ )
81
+ else:
82
+ logger.info("No new emails without events to parse")
83
+
84
+ events = Event.get_not_in_calendar()
85
+
86
+ caldav_url = settings.CALDAV_URL
87
+ caldav_username = settings.CALDAV_USERNAME
88
+ caldav_password = settings.CALDAV_PASSWORD
89
+ calendar_name = settings.CALDAV_CALENDAR
90
+
91
+ if events:
92
+ logger.info("Adding %d new events to CalDAV calendar", len(events))
93
+ add_to_caldav(
94
+ caldav_url, caldav_username, caldav_password, calendar_name, events
95
+ )
96
+ if settings.APPRISE_URL:
97
+ send_success_notification(settings.APPRISE_URL, events)
12
98
 
13
99
 
14
100
  def main():
15
101
  logger.info("Starting email retrieval process")
102
+ settings = get_settings()
16
103
 
17
104
  # Create tables if they don't exist
18
- Base.metadata.create_all(bind=engine)
105
+ SQLModel.metadata.create_all(engine)
19
106
 
20
- imap_host = os.environ["IMAP_HOST"]
21
- imap_port = int(os.environ["IMAP_PORT"])
22
- imap_username = os.environ["IMAP_USERNAME"]
23
- imap_password = os.environ["IMAP_PASSWORD"]
107
+ imap_host = settings.IMAP_HOST
108
+ imap_port = settings.IMAP_PORT
109
+ imap_username = settings.IMAP_USERNAME
110
+ imap_password = settings.IMAP_PASSWORD
111
+ mailbox = settings.IMAP_MAILBOX
112
+ ssl = settings.IMAP_SSL
24
113
 
25
- from_email = os.environ["FILTER_FROM_EMAIL"]
26
- subject = os.environ["FILTER_SUBJECT"]
27
- backfill: bool = os.environ.get("BACKFILL", "false").lower() == "true"
114
+ from_email = settings.FILTER_FROM_EMAIL
115
+ subject = settings.FILTER_SUBJECT
28
116
 
29
117
  db_path = os.path.join(os.path.dirname(__file__), db_file)
30
118
  db_exists = os.path.exists(db_path)
31
119
  inspector = inspect(engine)
32
- table_exists = inspector.has_table("emails")
120
+ table_exists = inspector.has_table("email")
33
121
  has_record = False
34
122
  if db_exists and table_exists:
35
123
  logger.info("Database and table exist, checking for records")
36
- session = SessionLocal()
124
+ session = Session(engine)
37
125
  try:
38
126
  has_record = len(EMail.get_all()) > 0
39
127
  finally:
40
128
  session.close()
41
129
 
42
- client = mail.authenticate(imap_host, imap_port, imap_username, imap_password)
130
+ client = mail.authenticate(imap_host, imap_port, imap_username, imap_password, ssl)
43
131
 
44
132
  try:
45
133
  if has_record:
@@ -55,6 +143,7 @@ def main():
55
143
  from_email=from_email,
56
144
  subject=subject,
57
145
  since=most_recent_email.delivery_date,
146
+ mailbox=mailbox,
58
147
  )
59
148
  else:
60
149
  logger.info("No existing records found, retrieving all emails")
@@ -67,31 +156,23 @@ def main():
67
156
  for email in emails:
68
157
  email.save()
69
158
 
70
- if backfill:
71
- for email in EMail.get_all():
72
- events: list[Event] = text.parse_email_events(email)
73
- for event_obj in events:
74
- event_obj.save()
75
- logger.info("Backfilled events from all emails")
76
- else:
77
- most_recent_email = EMail.get_most_recent()
78
- events: list[Event] = text.parse_email_events(most_recent_email)
79
- for event_obj in events:
80
- event_obj.save()
81
- logger.info(
82
- "Parsed and saved events from most recent email with date %s",
83
- most_recent_email.delivery_date,
84
- )
85
- events = Event.get_all()
86
-
87
- caldav_url = os.environ["CALDAV_URL"]
88
- caldav_username = os.environ["CALDAV_USERNAME"]
89
- caldav_password = os.environ["CALDAV_PASSWORD"]
90
- calendar_name = os.environ["CALDAV_CALENDAR"]
91
-
92
- add_to_caldav(
93
- caldav_url, caldav_username, caldav_password, calendar_name, events
94
- )
159
+ while True:
160
+ logger.info("Starting event population process...")
161
+ start_time = datetime.datetime.now()
162
+ try:
163
+ asyncio.run(populate_events(settings))
164
+ except Exception as e:
165
+ logger.error("Error populating events: %s", e, e)
166
+ finally:
167
+ end_time = datetime.datetime.now()
168
+ duration = (end_time - start_time).total_seconds()
169
+ logger.info(
170
+ "Event population process completed in %.2f seconds", duration
171
+ )
172
+ sleep_duration = settings.INTERVAL_MINUTES * 60
173
+ remaining_time = max(0, sleep_duration - int(duration))
174
+ logger.info("Sleeping for %d seconds", remaining_time)
175
+ time.sleep(remaining_time)
95
176
 
96
177
  except Exception as e:
97
178
  logger.error("An error occurred while retrieving emails: %s", e)