email-to-calendar 20250825235519.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.
model/email.py CHANGED
@@ -2,10 +2,10 @@ from datetime import datetime
2
2
  import enum
3
3
 
4
4
  import tzlocal
5
- from sqlalchemy import Column, Integer, String, DateTime, Enum
6
- from sqlalchemy.orm import relationship
5
+ from sqlmodel import SQLModel, Field, select
7
6
 
8
- from src.db import Base, SessionLocal
7
+ from src.db import Session, engine
8
+ from src.model.event import Event
9
9
 
10
10
 
11
11
  class EMailType(enum.Enum):
@@ -13,19 +13,16 @@ class EMailType(enum.Enum):
13
13
  HTML = "html"
14
14
 
15
15
 
16
- class EMail(Base):
17
- __tablename__ = "emails"
18
- id = Column(Integer, primary_key=True, autoincrement=True)
19
- subject = Column(String, nullable=False)
20
- from_address = Column(String, nullable=False)
21
- delivery_date = Column(DateTime, nullable=False)
22
- body = Column(String, nullable=False)
23
- retrieved_date = Column(
24
- DateTime, nullable=False, default=lambda: datetime.now(tzlocal.get_localzone())
16
+ class EMail(SQLModel, table=True):
17
+ id: int = Field(primary_key=True)
18
+ subject: str = Field(nullable=False)
19
+ from_address: str = Field(nullable=False)
20
+ delivery_date: datetime = Field(nullable=False)
21
+ body: str = Field(nullable=False)
22
+ retrieved_date: datetime = Field(
23
+ nullable=False, default=lambda: datetime.now(tzlocal.get_localzone())
25
24
  )
26
- email_type = Column(Enum(EMailType), nullable=False)
27
-
28
- events = relationship("Event", back_populates="email", cascade="all, delete-orphan")
25
+ email_type: EMailType = Field(nullable=False)
29
26
 
30
27
  def __repr__(self):
31
28
  return f"<EMail(id={self.id}, subject={self.subject}, from_address={self.from_address}, delivery_date={self.delivery_date})>"
@@ -34,7 +31,7 @@ class EMail(Base):
34
31
  return f"EMail(id={self.id}, subject={self.subject}, from_address={self.from_address}, delivery_date={self.delivery_date})"
35
32
 
36
33
  def save(self):
37
- session = SessionLocal()
34
+ session = Session(engine)
38
35
  try:
39
36
  self.retrieved_date = datetime.now(tzlocal.get_localzone())
40
37
  session.merge(self)
@@ -43,50 +40,84 @@ class EMail(Base):
43
40
  session.close()
44
41
 
45
42
  def get(self):
46
- session = SessionLocal()
43
+ session = Session(engine)
47
44
  try:
48
- return session.query(EMail).filter(EMail.id == self.id).first()
45
+ return session.exec(select(EMail).where(EMail.id == self.id)).first()
49
46
  finally:
50
47
  session.close()
51
48
 
52
49
  def delete(self):
53
- session = SessionLocal()
50
+ session = Session(engine)
54
51
  try:
55
- session.query(EMail).filter(EMail.id == self.id).delete()
52
+ session.delete(EMail.where(EMail.id == self.id))
56
53
  session.commit()
57
54
  finally:
58
55
  session.close()
59
56
 
60
57
  @staticmethod
61
58
  def get_by_id(email_id: int):
62
- session = SessionLocal()
59
+ session = Session(engine)
63
60
  try:
64
- return session.query(EMail).filter(EMail.id == email_id).first()
61
+ return session.exec(select(EMail).where(EMail.id == email_id)).first()
65
62
  finally:
66
63
  session.close()
67
64
 
68
65
  @staticmethod
69
66
  def get_all():
70
- session = SessionLocal()
67
+ session = Session(engine)
71
68
  try:
72
- return session.query(EMail).all()
69
+ return session.exec(select(EMail)).all()
73
70
  finally:
74
71
  session.close()
75
72
 
76
73
  @staticmethod
77
74
  def get_by_delivery_date(delivery_date: datetime):
78
- session = SessionLocal()
75
+ session = Session(engine)
79
76
  try:
80
- return (
81
- session.query(EMail).filter(EMail.delivery_date == delivery_date).all()
82
- )
77
+ return session.exec(
78
+ select(EMail).where(EMail.delivery_date == delivery_date)
79
+ ).all()
83
80
  finally:
84
81
  session.close()
85
82
 
86
83
  @staticmethod
87
84
  def get_most_recent():
88
- session = SessionLocal()
85
+ session = Session(engine)
86
+ try:
87
+ return session.exec(
88
+ select(EMail).order_by(EMail.delivery_date.desc())
89
+ ).first()
90
+ finally:
91
+ session.close()
92
+
93
+ @staticmethod
94
+ def get_most_recent_without_events():
95
+ session = Session(engine)
96
+ try:
97
+ email: EMail = session.exec(
98
+ select(EMail).order_by(EMail.delivery_date.desc())
99
+ ).first()
100
+ # Check if this email has any associated events
101
+ if email:
102
+ event = session.exec(
103
+ select(Event).where(Event.email_id == email.id)
104
+ ).first()
105
+ if event:
106
+ return None # The most recent email has associated events
107
+ return email
108
+ finally:
109
+ session.close()
110
+
111
+ @staticmethod
112
+ def get_without_events():
113
+ session = Session(engine)
89
114
  try:
90
- return session.query(EMail).order_by(EMail.delivery_date.desc()).first()
115
+ # Use NOT EXISTS to find emails with no events referencing them
116
+ result = session.exec(
117
+ select(EMail).where(
118
+ ~select(Event).where(Event.email_id == EMail.id).exists()
119
+ )
120
+ ).all()
121
+ return result
91
122
  finally:
92
123
  session.close()
model/event.py CHANGED
@@ -1,32 +1,34 @@
1
1
  from datetime import datetime
2
2
 
3
3
  from sqlalchemy import (
4
- Column,
5
- Integer,
6
- String,
7
- DateTime,
8
- Boolean,
9
4
  UniqueConstraint,
10
- ForeignKey,
11
5
  )
12
- from sqlalchemy.orm import relationship
6
+ from sqlmodel import SQLModel, Field, select
13
7
 
14
- from src.db import Base, SessionLocal
8
+ from src.db import Session, engine
15
9
 
16
10
 
17
- class Event(Base):
18
- __tablename__ = "events"
11
+ class Event(SQLModel, table=True):
19
12
  __table_args__ = (
20
13
  UniqueConstraint("start", "end", "summary", name="uq_event_start_end_summary"),
21
14
  )
22
- id = Column(Integer, primary_key=True, autoincrement=True)
23
- start = Column(DateTime, nullable=False)
24
- end = Column(DateTime, nullable=False)
25
- summary = Column(String, nullable=False)
26
- email_id = Column(Integer, ForeignKey("emails.id"), nullable=True, index=True)
27
- in_calendar = Column(Boolean, nullable=False, default=False)
28
-
29
- email = relationship("EMail", back_populates="events")
15
+ id: int = Field(primary_key=True)
16
+ start: datetime = Field(
17
+ nullable=False,
18
+ description="The start date and time of the event, must be a Python datetime object, cannot be None",
19
+ )
20
+ end: datetime = Field(
21
+ nullable=False,
22
+ description="The end date and time of the event, must be a Python datetime object, cannot be None",
23
+ )
24
+ all_day: bool = Field(
25
+ nullable=False,
26
+ default=False,
27
+ description="Whether the event lasts all day or not",
28
+ )
29
+ summary: str = Field(nullable=False)
30
+ email_id: int = Field(foreign_key="email.id", nullable=True)
31
+ in_calendar: bool = Field(nullable=False, default=False)
30
32
 
31
33
  def __repr__(self):
32
34
  return f"<Event(id={self.id}, start={self.start}, end={self.end}, summary={self.summary})>"
@@ -35,40 +37,51 @@ class Event(Base):
35
37
  return f"Event(id={self.id}, start={self.start}, end={self.end}, summary={self.summary})"
36
38
 
37
39
  def save(self):
38
- session = SessionLocal()
40
+ session = Session(engine)
39
41
  try:
40
- existing = (
41
- session.query(Event)
42
- .filter_by(start=self.start, end=self.end, summary=self.summary)
43
- .one_or_none()
44
- )
45
- if existing:
46
- # Preserve email linkage if existing record has it but new instance doesn't
47
- if self.email_id is None and existing.email_id is not None:
48
- self.email_id = existing.email_id
49
- self.id = existing.id # Update the ID to match the existing record
50
- session.merge(self)
42
+ if isinstance(self.start, str):
43
+ self.start = datetime.fromisoformat(self.start)
44
+ if isinstance(self.end, str):
45
+ self.end = datetime.fromisoformat(self.end)
46
+ if self.id is not None:
47
+ # Update existing event
48
+ existing_event = session.exec(
49
+ select(Event).where(Event.id == self.id)
50
+ ).first()
51
+ if existing_event:
52
+ existing_event.start = self.start
53
+ existing_event.end = self.end
54
+ existing_event.summary = self.summary
55
+ existing_event.email_id = self.email_id
56
+ existing_event.in_calendar = self.in_calendar
57
+ session.add(existing_event)
58
+ else:
59
+ # If id is set but not found, treat as new
60
+ session.add(self)
61
+ else:
62
+ session.add(self)
51
63
  session.commit()
64
+ session.flush()
52
65
  finally:
53
66
  session.close()
54
67
 
55
68
  def get(self):
56
- session = SessionLocal()
69
+ session = Session(engine)
57
70
  try:
58
- return session.query(Event).filter(Event.id == self.id).first()
71
+ return session.exec(select(Event).where(Event.id == self.id)).first()
59
72
  finally:
60
73
  session.close()
61
74
 
62
75
  def delete(self):
63
- session = SessionLocal()
76
+ session = Session(engine)
64
77
  try:
65
- session.query(Event).filter(Event.id == self.id).delete()
78
+ session.delete(Event.where(Event.id == self.id))
66
79
  session.commit()
67
80
  finally:
68
81
  session.close()
69
82
 
70
83
  def save_to_caldav(self):
71
- session = SessionLocal()
84
+ session = Session(engine)
72
85
  try:
73
86
  self.in_calendar = True
74
87
  session.merge(self)
@@ -78,28 +91,34 @@ class Event(Base):
78
91
 
79
92
  @staticmethod
80
93
  def get_by_id(event_id: int):
81
- session = SessionLocal()
94
+ session = Session(engine)
82
95
  try:
83
- return session.query(Event).filter(Event.id == event_id).first()
96
+ return session.exec(select(Event).where(Event.id == event_id)).first()
84
97
  finally:
85
98
  session.close()
86
99
 
87
100
  @staticmethod
88
101
  def get_all():
89
- session = SessionLocal()
102
+ session = Session(engine)
90
103
  try:
91
- return session.query(Event).all()
104
+ return session.exec(select(Event)).all()
92
105
  finally:
93
106
  session.close()
94
107
 
95
108
  @staticmethod
96
109
  def get_by_date(date: datetime):
97
- session = SessionLocal()
110
+ session = Session(engine)
111
+ try:
112
+ return session.exec(
113
+ select(Event).where(Event.start == date or Event.end == date)
114
+ ).all()
115
+ finally:
116
+ session.close()
117
+
118
+ @staticmethod
119
+ def get_not_in_calendar():
120
+ session = Session(engine)
98
121
  try:
99
- return (
100
- session.query(Event)
101
- .filter(Event.start == date or Event.end == date)
102
- .all()
103
- )
122
+ return session.exec(select(Event).where(Event.in_calendar == False)).all()
104
123
  finally:
105
124
  session.close()
util/ai.py ADDED
@@ -0,0 +1,163 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+ from pydantic_ai import Agent, RunContext
7
+ from pydantic_ai.models.openai import OpenAIChatModel
8
+ from pydantic_ai.providers.ollama import OllamaProvider
9
+ from pydantic_ai.providers.openai import OpenAIProvider
10
+ from sqlmodel import select
11
+
12
+ from src import logger
13
+ from src.db import Session, engine
14
+ from src.model.email import EMail
15
+ from src.model.event import Event
16
+ from src.util.env import AIProvider, get_settings
17
+
18
+ DEFAULT_SYSTEM_PROMPT = """You are an assistant that extracts calendar events from an email body. Produce only a single JSON object that strictly conforms to the schema below. Do not include any explanatory text, code fences, or comments—only the final JSON.
19
+ Context you will receive via earlier messages:
20
+ Current year to assume if no year is present
21
+ The email ID to assign to the email_id field
22
+ A list of existing events from the database
23
+ Output schema
24
+ Top-level object: { "events": [Event, ...] }
25
+ Event object fields and types:
26
+ id: integer or null
27
+ If you can confidently match an existing DB event by start, end, and summary (case-insensitive, trimmed), set to that event’s id; otherwise null.
28
+ start: string, ISO 8601 datetime (e.g., 2025-11-16T09:00:00 or 2025-11-16T09:00:00-05:00)
29
+ end: string, ISO 8601 datetime
30
+ all_day: boolean
31
+ summary: string
32
+ A concise title/description with all date/time tokens removed.
33
+ email_id: integer (must be the provided email ID)
34
+ in_calendar: boolean (always false for new extractions)
35
+ Event extraction and inference rules
36
+ Treat the email body as lines:
37
+ Most non-blank lines are events; lines that are clearly headers (month or year) set context for following lines until superseded.
38
+ Month/year headers: use them to resolve dates for subsequent event lines.
39
+ Dates:
40
+ A date may be on the same line as an event or inherited from the last resolvable date context above.
41
+ Multi-day ranges (e.g., “Oct 10–12”, “10-12” under an October header) mean start is the first day at start time and end is the last day at end time.
42
+ If no year is present, use the provided “current year” context.
43
+ Times:
44
+ Recognize formats such as: 12:50, 6:30, 9am, 8, 820, 130, 10-12, 10:30-12:30, 9am-11am, 9-11, 9-11am, 9am-11
45
+ If a single time is provided for a single-day event, assume a 1-hour duration.
46
+ If no time is present, the event is all-day: start 00:00:00, end 23:59:00, all_day = true.
47
+ If a multi-day event provides only a start time, start at that time on the first day and end at 23:59:00 on the last day, all_day = false.
48
+ Normalize 8 → 08:00:00; 820 → 08:20:00; 130 → 01:30:00; add seconds as :00 if missing.
49
+ Respect am/pm; if none given and a 12-hour ambiguity exists, prefer a sensible local interpretation (e.g., 9 → 09:00).
50
+ Summary:
51
+ Remove all date/time expressions and markers from the line; keep a clear human title.
52
+ Trim extra punctuation and whitespace.
53
+ Deduplication and matching:
54
+ Avoid emitting duplicates within this run (same start, end, summary).
55
+ To fill id from DB, match by exact start, end, and normalized summary (case-insensitive, trimmed); otherwise id = null.
56
+ Defaults:
57
+ email_id must equal the provided email ID.
58
+ in_calendar must be false.
59
+ If a line cannot be reliably parsed into an event, you may skip it (do not invent events).
60
+ Formatting requirements
61
+ Emit exactly one JSON object with this shape: { "events": [ { "id": null or integer, "start": "YYYY-MM-DDTHH:MM:SS[±HH:MM]", "end": "YYYY-MM-DDTHH:MM:SS[±HH:MM]", "all_day": true|false, "summary": "string", "email_id": integer, "in_calendar": false }, ... ] }
62
+ Key names must be lower_snake_case exactly as above.
63
+ No markdown, code fences, or extra prose—only the JSON.
64
+ Self-check before answering
65
+ Validate that:
66
+ All events have start < end, both in ISO 8601
67
+ all_day is true only when start is 00:00:00 and end is 23:59:00 for that day (or the multi-day all-day case)
68
+ email_id equals the provided number
69
+ in_calendar is false for all events
70
+ id is integer only when a clear DB match exists; otherwise null
71
+ summary has no date/time remnants
72
+ If any item fails validation, correct the output and re-validate before responding.
73
+ If still invalid, correct again; keep correcting until the output matches the schema and rules."""
74
+
75
+ @dataclass
76
+ class AgentDependencies:
77
+ email: EMail
78
+ max_result_retries: int = get_settings().AI_MAX_RETRIES
79
+ db = Session(engine)
80
+
81
+
82
+ class Events(BaseModel):
83
+ events: list[Event] = Field(description="A list of events parsed from the email")
84
+
85
+
86
+ async def parse_email(
87
+ email: EMail,
88
+ provider: AIProvider,
89
+ model: str = "gpt-oss:20b",
90
+ ollama_host: str = "localhost",
91
+ ollama_port: int = 11434,
92
+ ollama_secure: bool = False,
93
+ open_ai_api_key: str = None,
94
+ max_retries: int = 3,
95
+ system_prompt: str = None,
96
+ ) -> list[Event]:
97
+ if provider == AIProvider.OLLAMA:
98
+ logger.info(
99
+ f"Creating events from email id {email.id}, using model: {model} at ollama host: {'https://' if ollama_secure else 'http://'}{ollama_host}:{ollama_port}"
100
+ )
101
+ ai_model = OpenAIChatModel(
102
+ model_name=model,
103
+ provider=OllamaProvider(
104
+ base_url=f"{'https://' if ollama_secure else 'http://'}{ollama_host}:{ollama_port}/v1"
105
+ ),
106
+ )
107
+ elif provider == AIProvider.OPENAI:
108
+ logger.info(
109
+ f"Creating events from email id {email.id}, using OpenAI model: {model}"
110
+ )
111
+ ai_model = OpenAIChatModel(
112
+ model_name=model, provider=OpenAIProvider(api_key=open_ai_api_key)
113
+ )
114
+ else:
115
+ raise ValueError(f"Unsupported AI provider: {provider}")
116
+
117
+ deps = AgentDependencies(email=email)
118
+
119
+ system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
120
+
121
+ agent = Agent(
122
+ ai_model,
123
+ deps_type=AgentDependencies,
124
+ output_type=Events,
125
+ system_prompt=system_prompt,
126
+ retries=max_retries,
127
+ )
128
+
129
+ #@agent.system_prompt
130
+ #async def get_current_year(ctx: RunContext[AgentDependencies]):
131
+ # return f"If there is no year provided, use this year: {ctx.deps.email.delivery_date.year}"
132
+
133
+
134
+ @agent.system_prompt
135
+ async def get_email_id(ctx: RunContext[AgentDependencies]):
136
+ return f"The email ID is {ctx.deps.email.id}. Use this ID for the email_id attribute of the event(s)."
137
+
138
+ @agent.system_prompt
139
+ async def get_events(ctx: RunContext[AgentDependencies]):
140
+ logger.info("Checking existing events in the database...")
141
+ events = ctx.deps.db.exec(select(Event)).all()
142
+ logger.debug("Found %d existing events in the database", len(events))
143
+ if not events:
144
+ return "There are no current events in the database, assume all events are new."
145
+ return f"The currents events in the database are: {events}"
146
+
147
+ logger.info("Generating events...")
148
+ start_time = datetime.now()
149
+
150
+ task = asyncio.create_task(agent.run(email.body, deps=deps))
151
+ while not task.done():
152
+ logger.debug("Waiting for AI to finish generating events...")
153
+ await asyncio.sleep(10)
154
+
155
+ result = await task
156
+ events: Events = result.output
157
+
158
+ end_time = datetime.now()
159
+ elapsed = (end_time - start_time).total_seconds()
160
+ logger.info(
161
+ f"Took {elapsed:.3f} seconds to generate {len(events.events)} events from email id: {email.id}"
162
+ )
163
+ return events.events
util/env.py ADDED
@@ -0,0 +1,97 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+ from enum import Enum
4
+
5
+ from pydantic import (
6
+ Field,
7
+ ValidationError,
8
+ AnyUrl,
9
+ EmailStr,
10
+ )
11
+ from pydantic_settings import BaseSettings
12
+
13
+
14
+ class AIProvider(str, Enum):
15
+ OLLAMA = "ollama"
16
+ OPENAI = "openai"
17
+
18
+
19
+ class Settings(BaseSettings):
20
+ IMAP_HOST: str = Field(default=None, description="IMAP server host")
21
+ IMAP_PORT: int = Field(993, ge=1, le=65535, description="IMAP server port")
22
+ IMAP_USERNAME: str = Field(default=None, description="IMAP username")
23
+ IMAP_PASSWORD: str = Field(default=None, description="IMAP password")
24
+ IMAP_MAILBOX: str = Field("INBOX", description="IMAP mailbox to check")
25
+ IMAP_SSL: bool = Field(True, description="Whether to use SSL for IMAP connection")
26
+
27
+ FILTER_FROM_EMAIL: Optional[EmailStr] = Field(
28
+ None, description="Email address to filter messages from (optional)"
29
+ )
30
+ FILTER_SUBJECT: Optional[str] = Field(None, description="Subject filter (optional)")
31
+ BACKFILL: bool = Field(False, description="Whether to backfill all emails")
32
+ INTERVAL_MINUTES: int = Field(
33
+ 15, ge=1, description="Interval in minutes to check for new emails"
34
+ )
35
+
36
+ CALDAV_URL: AnyUrl = Field(default=None, description="CalDAV server URL")
37
+ CALDAV_USERNAME: str = Field(default=None, description="CalDAV username")
38
+ CALDAV_PASSWORD: str = Field(default=None, description="CalDAV password")
39
+ CALDAV_CALENDAR: str = Field(default=None, description="CalDAV calendar name")
40
+
41
+ AI_PROVIDER: AIProvider = Field(
42
+ default=None, description="AI provider to use (ollama, openai, none)"
43
+ )
44
+
45
+ OLLAMA_SECURE: bool = Field(
46
+ False, description="Whether to use HTTPS for Ollama connection"
47
+ )
48
+ OLLAMA_HOST: str = Field("localhost", description="Ollama base URL")
49
+ OLLAMA_PORT: int = Field(11434, ge=1, le=65535, description="Ollama port")
50
+
51
+ OPEN_AI_API_KEY: Optional[str] = Field(
52
+ None, description="OpenAI API key (required if AI_PROVIDER is openai)"
53
+ )
54
+
55
+ AI_MODEL: str = Field(default=None, description="Model to use for parsing")
56
+ AI_MAX_RETRIES: int = Field(3, ge=0, description="Maximum retries for AI parsing")
57
+
58
+ AI_SYSTEM_PROMPT: Optional[str] = Field(
59
+ None, description="Custom system prompt for the AI model (optional)"
60
+ )
61
+ AI_SYSTEM_PROMPT_FILE: Optional[str] = Field(
62
+ None, description="Custom system prompt for the AI model (optional)"
63
+ )
64
+
65
+ DB_FILE: str = Field(f"{Path.cwd()}/data/emails.db", description="SQLite database file path")
66
+
67
+ APPRISE_URL: Optional[AnyUrl] = Field(
68
+ None, description="Apprise notification service URL (optional)"
69
+ )
70
+
71
+ class Config:
72
+ env_file = ".env"
73
+ env_file_encoding = "utf-8"
74
+ case_sensitive = True
75
+
76
+
77
+ def get_settings() -> Settings:
78
+ try:
79
+ settings = Settings()
80
+ if settings.AI_PROVIDER == AIProvider.OLLAMA and not settings.AI_MODEL:
81
+ settings.AI_MODEL = "gpt-oss:20b"
82
+ assert settings.OLLAMA_HOST is not None
83
+ assert settings.OLLAMA_PORT is not None
84
+ elif settings.AI_PROVIDER == AIProvider.OPENAI and not settings.AI_MODEL:
85
+ settings.AI_MODEL = "gpt-5-mini"
86
+ assert settings.OPEN_AI_API_KEY is not None
87
+
88
+ if settings.AI_SYSTEM_PROMPT_FILE:
89
+ try:
90
+ with open(settings.AI_SYSTEM_PROMPT_FILE, "r") as f:
91
+ settings.AI_SYSTEM_PROMPT = f.read()
92
+ except Exception as e:
93
+ raise ValueError(f"Error reading system prompt file: {e}")
94
+ return settings
95
+ except ValidationError as exc:
96
+ # Fail fast with a clear error so startup doesn't proceed with bad config
97
+ raise SystemExit(f"Environment validation error:\n{exc}")
util/notifications.py ADDED
@@ -0,0 +1,20 @@
1
+ import apprise
2
+ from pydantic import AnyUrl
3
+
4
+ from src.model.event import Event
5
+
6
+
7
+ def send_success_notification(apprise_url: AnyUrl, events: list[Event]):
8
+ # Create an Apprise instance
9
+ apobj = apprise.Apprise()
10
+
11
+ # Add all the notification services by their server url.
12
+ # A sample email notification:
13
+ apobj.add(apprise_url.encoded_string())
14
+
15
+ # Then notify these services any time you desire. The below would
16
+ # notify all the services loaded into our Apprise object.
17
+ apobj.notify(
18
+ body=f"The following new events were added to your calendar: {events}",
19
+ title="New Events Added to Calendar",
20
+ )
@@ -1,25 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: email-to-calendar
3
- Version: 20250825235519.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: caldav>=2.0.1
9
- Requires-Dist: sqlalchemy>=2.0.43
10
- Requires-Dist: tzlocal>=5.3.1
11
- Requires-Dist: beautifulsoup4>=4.12.0
12
- Requires-Dist: python-dateutil>=2.8.0
13
- Requires-Dist: html2text>=2020.1.16
14
- Dynamic: license-file
15
-
16
- # E-Mail to calendar Converter
17
- The point of this application is to search an IMAP account, look for emails based on certain criteria(s), and parse
18
- the content, using regex, and automatically create calendar events in an iCal account.
19
-
20
- ## TO-DO
21
- - [ ] Get e-mails, and save ID to sqlite db to avoid duplicates
22
- - [ ] Save calendar events to sqlite db to avoid duplicates
23
- - [ ] Parse e-mail using user defined regex
24
- - [ ] Add config to backfill (check all emails from an optional certain date), or use most recent email
25
- - [ ] If using most recent, when new email arrives, remove events not present, and add new ones
@@ -1,17 +0,0 @@
1
- __init__.py,sha256=_q4_C_j2SVDpSObgvEsY-32HvvN0do8IpcPx9Zb8EfI,1614
2
- db.py,sha256=1RDVWTDbgYXcATiwAE1V1sWW3duDIOBdxswPT3ADSZ8,409
3
- main.py,sha256=2OZK51rctR0wJ5V30x_rZAwQJT-xVC5-avGTGii99jc,3424
4
- email_to_calendar-20250825235519.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=NzegleP5xRzvhI2e8ao8fys_FseASIwUvN4CaCqwg7g,1437
7
- mail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- mail/mail.py,sha256=Kq50o2kpkovvfNcSnDQZTnOZ6TV7ZSR8KOIRvdXkGao,5825
9
- model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- model/email.py,sha256=g6-QbUy_xrwEtWfw0KIfVpLXcLZtH-PbC7oH8rrH-jI,2707
11
- model/event.py,sha256=sauGFuvaWQcQaB8tL_Os9HfjVA--Kbh8LcoM3voontg,3072
12
- util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- util/text.py,sha256=y-lUnrm5Q9erft9CK5Kus2TI3YoD_7Fs2MJkCDyGKfU,34009
14
- email_to_calendar-20250825235519.dev0.dist-info/METADATA,sha256=MJQM82MeL85Mt6M6HwsZsllZE6oXd8TtQdH2q_uNP2U,1111
15
- email_to_calendar-20250825235519.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- email_to_calendar-20250825235519.dev0.dist-info/top_level.txt,sha256=635ZTCyzc_2xtdJz_2Q50A96mBoIUrfORy5SdfGYabo,40
17
- email_to_calendar-20250825235519.dev0.dist-info/RECORD,,