git-ew 0.1.0__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.
@@ -0,0 +1,246 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ # Why does this file exist, and why not put this in `__main__`?
20
+ #
21
+ # You might be tempted to import things from `__main__` later,
22
+ # but that will cause problems: the code will get executed twice:
23
+ #
24
+ # - When you run `python -m git_ew` python will execute
25
+ # `__main__.py` as a script. That means there won't be any
26
+ # `git_ew.__main__` in `sys.modules`.
27
+ # - When you import `__main__` it will get executed again (as a module) because
28
+ # there's no `git_ew.__main__` in `sys.modules`.
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import asyncio
34
+ import logging
35
+ import sys
36
+ from datetime import date
37
+ from pathlib import Path
38
+ from typing import Any
39
+ from urllib.error import URLError
40
+
41
+ import uvicorn
42
+
43
+ from git_ew._internal import debug
44
+ from git_ew._internal.config import config_command
45
+ from git_ew._internal.database import Database
46
+ from git_ew._internal.mailing_lists.zsh_workers.ingest import ingest_archives
47
+ from git_ew._internal.mailing_lists.zsh_workers.sync_archives import (
48
+ _get_matching_archives,
49
+ download_archive,
50
+ fetch_archive_list,
51
+ get_missing_archives,
52
+ )
53
+ from git_ew._internal.sync import sync_command
54
+
55
+
56
+ class _DebugInfo(argparse.Action):
57
+ def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
58
+ super().__init__(nargs=nargs, **kwargs)
59
+
60
+ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
61
+ print(debug._format_debug_info())
62
+ sys.exit(0)
63
+
64
+
65
+ def _parse_archive_date(value: str, *, end_of_year: bool = False) -> date:
66
+ """Parse a CLI archive date."""
67
+ try:
68
+ if len(value) == 4: # noqa: PLR2004
69
+ year = int(value)
70
+ if end_of_year:
71
+ return date(year, 12, 31)
72
+ return date(year, 1, 1)
73
+ return date.fromisoformat(value)
74
+ except ValueError as error:
75
+ raise argparse.ArgumentTypeError(
76
+ f"invalid date {value!r}; use YYYY or YYYY-MM-DD",
77
+ ) from error
78
+
79
+
80
+ def get_parser() -> argparse.ArgumentParser:
81
+ """Return the CLI argument parser.
82
+
83
+ Returns:
84
+ An argparse parser.
85
+ """
86
+ parser = argparse.ArgumentParser(
87
+ prog="git-ew",
88
+ description="Git Email Workflow - A web interface for email-based git workflows",
89
+ )
90
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
91
+ parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
92
+
93
+ # Subcommands
94
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
95
+
96
+ # Server command
97
+ server_parser = subparsers.add_parser("serve", help="Start the web server")
98
+ server_parser.add_argument(
99
+ "--host",
100
+ default="127.0.0.1",
101
+ help="Host to bind to (default: 127.0.0.1)",
102
+ )
103
+ server_parser.add_argument(
104
+ "--port",
105
+ type=int,
106
+ default=8000,
107
+ help="Port to bind to (default: 8000)",
108
+ )
109
+ server_parser.add_argument(
110
+ "--reload",
111
+ action="store_true",
112
+ help="Enable auto-reload for development",
113
+ )
114
+
115
+ # Init command
116
+ subparsers.add_parser("init", help="Initialize the database")
117
+
118
+ # Sync command
119
+ subparsers.add_parser("sync", help="Sync emails from configured sources")
120
+
121
+ # Configuration command
122
+ subparsers.add_parser("config", help="Configure email accounts and sources")
123
+
124
+ # Archive ingestion commands
125
+ ingest_parser = subparsers.add_parser("ingest", help="Ingest mailing-list archives")
126
+ ingest_subparsers = ingest_parser.add_subparsers(dest="ingest_source", required=True)
127
+ zsh_workers_parser = ingest_subparsers.add_parser(
128
+ "zsh-workers",
129
+ help="Download and ingest zsh-workers archives",
130
+ )
131
+ zsh_workers_parser.add_argument(
132
+ "--since",
133
+ type=_parse_archive_date,
134
+ help="Only include archives from this date (YYYY or YYYY-MM-DD)",
135
+ )
136
+ zsh_workers_parser.add_argument(
137
+ "--until",
138
+ type=lambda value: _parse_archive_date(value, end_of_year=True),
139
+ help="Only include archives through this date (YYYY or YYYY-MM-DD)",
140
+ )
141
+ zsh_workers_parser.add_argument(
142
+ "--archive-dir",
143
+ type=Path,
144
+ default=Path(".archives"),
145
+ help="Directory for archives (default: .archives)",
146
+ )
147
+ zsh_workers_parser.add_argument(
148
+ "--database",
149
+ default="sqlite:///./git_ew.db",
150
+ help="SQLAlchemy database URL (default: sqlite:///./git_ew.db)",
151
+ )
152
+ zsh_workers_parser.add_argument(
153
+ "--dry-run",
154
+ action="store_true",
155
+ help="Show archives to download without downloading or ingesting",
156
+ )
157
+ zsh_workers_parser.add_argument(
158
+ "-v",
159
+ "--verbose",
160
+ action="store_true",
161
+ help="List archives selected for download",
162
+ )
163
+
164
+ return parser
165
+
166
+
167
+ def main(args: list[str] | None = None) -> int:
168
+ """Run the main program.
169
+
170
+ This function is executed when you type `git-ew` or `python -m git_ew`.
171
+
172
+ Parameters:
173
+ args: Arguments passed from the command line.
174
+
175
+ Returns:
176
+ An exit code.
177
+ """
178
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
179
+ parser = get_parser()
180
+ opts = parser.parse_args(args=args)
181
+
182
+ if opts.command == "serve":
183
+ # Start the web server
184
+ print(f"Starting git-ew server at http://{opts.host}:{opts.port}")
185
+ print("Press Ctrl+C to stop the server")
186
+
187
+ uvicorn.run(
188
+ "git_ew._internal.app:app",
189
+ host=opts.host,
190
+ port=opts.port,
191
+ reload=opts.reload,
192
+ )
193
+ return 0
194
+
195
+ if opts.command == "init":
196
+ # Initialize the database
197
+ async def init_database() -> None:
198
+ db = Database()
199
+ await db.init_db()
200
+ print("Database initialized successfully")
201
+
202
+ asyncio.run(init_database())
203
+ return 0
204
+
205
+ if opts.command == "sync":
206
+ # Sync emails from sources
207
+ return asyncio.run(sync_command())
208
+
209
+ if opts.command == "config":
210
+ asyncio.run(config_command())
211
+ return 0
212
+
213
+ if opts.command == "ingest" and opts.ingest_source == "zsh-workers":
214
+ opts.archive_dir.mkdir(parents=True, exist_ok=True)
215
+ try:
216
+ available = fetch_archive_list()
217
+ except URLError as error:
218
+ print(f"Error fetching zsh-workers archive list: {error}", file=sys.stderr)
219
+ return 1
220
+
221
+ matching = _get_matching_archives(available, opts.since, opts.until)
222
+ missing = get_missing_archives(opts.archive_dir, available, opts.since, opts.until)
223
+ print(f"Found {len(available)} archives available")
224
+ print(f"Found {len(matching)} matching archives")
225
+ print(f"Found {len(missing)} matching archives not downloaded")
226
+ if opts.verbose:
227
+ for filename in missing:
228
+ print(f" - {filename}")
229
+ if opts.dry_run:
230
+ print("Dry-run: no archives downloaded or ingested")
231
+ return 0
232
+
233
+ failed = 0
234
+ for filename in missing:
235
+ if not download_archive(filename, opts.archive_dir):
236
+ failed += 1
237
+ if failed:
238
+ print(f"Error: {failed} archive download(s) failed", file=sys.stderr)
239
+ return 1
240
+
241
+ ingest_archives(opts.archive_dir, opts.database, filenames=matching)
242
+ return 0
243
+
244
+ # No command specified, show help
245
+ parser.print_help()
246
+ return 0
@@ -0,0 +1,148 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ # Interactive git-ew configuration wizard.
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ from asyncio import to_thread
26
+ from getpass import getpass
27
+ from typing import Any
28
+
29
+ from git_ew._internal.database import Database
30
+ from git_ew._internal.models import EmailSource
31
+
32
+ _logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _prompt_password_config() -> dict[str, str]:
36
+ """Prompt for direct or command-based password storage."""
37
+ choice = input("Password storage: 1 direct, 2 command (default 1): ").strip() or "1"
38
+ if choice == "1":
39
+ return {"password": getpass("Password/App Password: ").strip()}
40
+ if choice == "2":
41
+ command = input("Password command: ").strip()
42
+ if not command:
43
+ raise ValueError("password command cannot be empty")
44
+ return {"password_command": command}
45
+ raise ValueError("password storage must be 1 or 2")
46
+
47
+
48
+ def _prompt_email_config() -> dict[str, Any]:
49
+ """Collect email delivery settings from the terminal."""
50
+ _logger.info("Email Configuration (for sending replies)")
51
+ _logger.info("-" * 50)
52
+ smtp_host = input("SMTP Host (e.g., smtp.fastmail.com): ").strip()
53
+ smtp_port = input("SMTP Port (default 587): ").strip() or "587"
54
+ from_email = input("Your Email Address: ").strip()
55
+ from_name = input("Your Name: ").strip()
56
+ username = input("SMTP Username (default: same as email): ").strip() or from_email
57
+ use_tls = input("Use TLS? (Y/n): ").strip().lower() != "n"
58
+
59
+ email_config: dict[str, Any] = {
60
+ "smtp_host": smtp_host,
61
+ "smtp_port": int(smtp_port),
62
+ "from_email": from_email,
63
+ "from_name": from_name,
64
+ "username": username,
65
+ "use_tls": use_tls,
66
+ **_prompt_password_config(),
67
+ }
68
+ return email_config
69
+
70
+
71
+ def _prompt_email_source() -> EmailSource | None:
72
+ """Collect an email source from the terminal."""
73
+ _logger.info("Email Source Configuration")
74
+ _logger.info("-" * 50)
75
+ _logger.info("Where should git-ew fetch emails from?")
76
+ _logger.info("1. Maildir (local maildir folder)")
77
+ _logger.info("2. Mbox (mbox archive file)")
78
+ _logger.info("3. IMAP (Fastmail or another IMAP server)")
79
+ _logger.info("4. Skip for now")
80
+
81
+ choice = input("\nChoice (1-4): ").strip()
82
+ if choice == "1":
83
+ source = EmailSource(
84
+ name=input("Source name (e.g., 'my-maildir'): ").strip(),
85
+ source_type="maildir",
86
+ config=json.dumps({"path": input("Path to maildir folder: ").strip()}),
87
+ enabled=True,
88
+ )
89
+ elif choice == "2":
90
+ source = EmailSource(
91
+ name=input("Source name (e.g., 'mailing-list'): ").strip(),
92
+ source_type="mbox",
93
+ config=json.dumps({"path": input("Path to mbox file: ").strip()}),
94
+ enabled=True,
95
+ )
96
+ elif choice == "3":
97
+ folders = input("Folders (comma-separated, default: INBOX,Sent): ").strip() or "INBOX,Sent"
98
+ addresses = input("Mailing-list addresses (default: zsh-workers@zsh.org): ").strip() or "zsh-workers@zsh.org"
99
+ list_ids = (
100
+ input("Mailing-list List-Id values (default: zsh-workers.zsh.org): ").strip() or "zsh-workers.zsh.org"
101
+ )
102
+ source = EmailSource(
103
+ name=input("Source name (e.g., 'fastmail'): ").strip(),
104
+ source_type="imap",
105
+ config=json.dumps(
106
+ {
107
+ "host": input("IMAP Host (default: imap.fastmail.com): ").strip() or "imap.fastmail.com",
108
+ "port": int(input("IMAP Port (default: 993): ").strip() or "993"),
109
+ "username": input("IMAP Username (your Fastmail address): ").strip(),
110
+ "folders": [folder.strip() for folder in folders.split(",") if folder.strip()],
111
+ "mailing_list": {
112
+ "addresses": [address.strip().lower() for address in addresses.split(",") if address.strip()],
113
+ "list_ids": [list_id.strip().lower() for list_id in list_ids.split(",") if list_id.strip()],
114
+ },
115
+ **_prompt_password_config(),
116
+ },
117
+ ),
118
+ enabled=True,
119
+ )
120
+ else:
121
+ _logger.info("Skipped email source configuration")
122
+ return None
123
+
124
+ return source
125
+
126
+
127
+ async def config_command() -> None:
128
+ """Configure email delivery and ingestion sources interactively."""
129
+ _logger.info("=== git-ew Configuration Wizard ===")
130
+
131
+ db = Database()
132
+ _logger.info("Initializing database")
133
+ await db.init_db()
134
+ _logger.info("Database initialized")
135
+
136
+ email_config = await to_thread(_prompt_email_config)
137
+ await db.set_config("email_config", email_config)
138
+ _logger.info("Email configuration saved")
139
+
140
+ source = await to_thread(_prompt_email_source)
141
+ if source is None:
142
+ return
143
+
144
+ async with db.session() as session:
145
+ session.add(source)
146
+ _logger.info("Email source added")
147
+ _logger.info("=" * 50)
148
+ _logger.info("Configuration complete")
@@ -0,0 +1,297 @@
1
+ # SPDX-License-Identifier: ISC
2
+ #
3
+ # ISC License
4
+ #
5
+ # Copyright (c) 2026, Timothée Mazzucotelli and contributors
6
+ #
7
+ # Permission to use, copy, modify, and/or distribute this software for any
8
+ # purpose with or without fee is hereby granted, provided that the above
9
+ # copyright notice and this permission notice appear in all copies.
10
+ #
11
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+
19
+ # Database operations for git-ew.
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from contextlib import asynccontextmanager
25
+ from datetime import UTC, datetime
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from sqlalchemy import select
29
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
30
+ from sqlalchemy.orm import selectinload
31
+
32
+ from git_ew._internal.models import Base, Configuration, EmailSource, Message, Thread
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import AsyncIterator
36
+
37
+
38
+ class Database:
39
+ """Database manager."""
40
+
41
+ def __init__(self, database_url: str = "sqlite+aiosqlite:///./git_ew.db"):
42
+ """Initialize database manager.
43
+
44
+ Args:
45
+ database_url: Database connection URL.
46
+ """
47
+ self.engine = create_async_engine(database_url, echo=False)
48
+ """SQLAlchemy async engine instance."""
49
+ self.session_maker = async_sessionmaker(self.engine, class_=AsyncSession, expire_on_commit=False)
50
+ """Async session factory."""
51
+
52
+ async def init_db(self) -> None:
53
+ """Initialize database tables."""
54
+ async with self.engine.begin() as conn:
55
+ await conn.run_sync(Base.metadata.create_all)
56
+
57
+ @asynccontextmanager
58
+ async def session(self) -> AsyncIterator[AsyncSession]:
59
+ """Get a database session."""
60
+ async with self.session_maker() as session:
61
+ try:
62
+ yield session
63
+ await session.commit()
64
+ except Exception:
65
+ await session.rollback()
66
+ raise
67
+
68
+ async def get_threads(
69
+ self,
70
+ status: str | None = None,
71
+ limit: int | None = None,
72
+ offset: int = 0,
73
+ ) -> list[Thread]:
74
+ """Get threads with optional filtering.
75
+
76
+ Args:
77
+ status: Filter by status (open/closed).
78
+ limit: Maximum number of threads to return.
79
+ offset: Number of threads to skip.
80
+
81
+ Returns:
82
+ List of threads.
83
+ """
84
+ async with self.session() as session:
85
+ query = select(Thread).order_by(Thread.updated_at.desc())
86
+ if status:
87
+ query = query.where(Thread.status == status)
88
+ if limit is not None:
89
+ query = query.limit(limit)
90
+ query = query.offset(offset)
91
+ result = await session.execute(query)
92
+ return list(result.scalars().all())
93
+
94
+ async def get_thread(self, thread_id: int) -> Thread | None:
95
+ """Get a thread by ID with all messages.
96
+
97
+ Args:
98
+ thread_id: Thread ID.
99
+
100
+ Returns:
101
+ Thread or None if not found.
102
+ """
103
+ async with self.session() as session:
104
+ query = select(Thread).where(Thread.id == thread_id).options(selectinload(Thread.messages))
105
+ result = await session.execute(query)
106
+ return result.scalar_one_or_none()
107
+
108
+ async def get_thread_by_message_id(self, message_id: str) -> Thread | None:
109
+ """Get a thread by first message ID.
110
+
111
+ Args:
112
+ message_id: Message ID.
113
+
114
+ Returns:
115
+ Thread or None if not found.
116
+ """
117
+ async with self.session() as session:
118
+ query = select(Thread).where(Thread.first_message_id == message_id)
119
+ result = await session.execute(query)
120
+ return result.scalar_one_or_none()
121
+
122
+ async def create_thread(
123
+ self,
124
+ subject: str,
125
+ first_message_id: str,
126
+ *,
127
+ is_patch: bool = False,
128
+ ) -> Thread:
129
+ """Create a new thread.
130
+
131
+ Args:
132
+ subject: Thread subject.
133
+ first_message_id: ID of the first message.
134
+ is_patch: Whether this thread contains patches.
135
+
136
+ Returns:
137
+ Created thread.
138
+ """
139
+ now = datetime.now(UTC)
140
+ thread = Thread(
141
+ subject=subject,
142
+ first_message_id=first_message_id,
143
+ created_at=now,
144
+ updated_at=now,
145
+ is_patch=is_patch,
146
+ status="open",
147
+ )
148
+ async with self.session() as session:
149
+ session.add(thread)
150
+ await session.flush()
151
+ await session.refresh(thread)
152
+ return thread
153
+
154
+ async def create_message( # noqa: PLR0917
155
+ self,
156
+ message_id: str,
157
+ thread_id: int,
158
+ from_email: str,
159
+ from_name: str,
160
+ subject: str,
161
+ date: datetime,
162
+ body: str,
163
+ *,
164
+ in_reply_to: str | None = None,
165
+ is_patch: bool = False,
166
+ patch_content: str | None = None,
167
+ raw_email: str | None = None,
168
+ ) -> Message:
169
+ """Create a new message.
170
+
171
+ Args:
172
+ message_id: Unique message ID.
173
+ thread_id: Thread ID this message belongs to.
174
+ from_email: Sender email.
175
+ from_name: Sender name.
176
+ subject: Message subject.
177
+ date: Message date.
178
+ body: Message body.
179
+ in_reply_to: ID of message this replies to.
180
+ is_patch: Whether this message contains a patch.
181
+ patch_content: Patch content if applicable.
182
+ raw_email: Raw email content.
183
+
184
+ Returns:
185
+ Created message.
186
+ """
187
+ message = Message(
188
+ message_id=message_id,
189
+ thread_id=thread_id,
190
+ from_email=from_email,
191
+ from_name=from_name,
192
+ subject=subject,
193
+ date=date,
194
+ body=body,
195
+ in_reply_to=in_reply_to,
196
+ is_patch=is_patch,
197
+ patch_content=patch_content,
198
+ raw_email=raw_email,
199
+ )
200
+ async with self.session() as session:
201
+ session.add(message)
202
+
203
+ # Update thread's updated_at
204
+ thread = await session.get(Thread, thread_id)
205
+ if thread:
206
+ thread.updated_at = datetime.now(UTC)
207
+
208
+ await session.flush()
209
+ await session.refresh(message)
210
+ return message
211
+
212
+ async def get_message_by_id(self, message_id: str) -> Message | None:
213
+ """Get a message by its message ID.
214
+
215
+ Args:
216
+ message_id: Message ID.
217
+
218
+ Returns:
219
+ Message or None if not found.
220
+ """
221
+ async with self.session() as session:
222
+ query = select(Message).where(Message.message_id == message_id)
223
+ result = await session.execute(query)
224
+ return result.scalar_one_or_none()
225
+
226
+ async def update_message_patch(self, message_id: str, patch_content: str) -> None:
227
+ """Add patch content to an existing message."""
228
+ async with self.session() as session:
229
+ query = select(Message).where(Message.message_id == message_id)
230
+ result = await session.execute(query)
231
+ message = result.scalar_one_or_none()
232
+ if message and not message.patch_content:
233
+ message.patch_content = patch_content
234
+ message.is_patch = True
235
+ thread = await session.get(Thread, message.thread_id)
236
+ if thread:
237
+ thread.is_patch = True
238
+
239
+ async def get_config(self, key: str, default: Any = None) -> Any:
240
+ """Get a configuration value.
241
+
242
+ Args:
243
+ key: Configuration key.
244
+ default: Default value if key not found.
245
+
246
+ Returns:
247
+ Configuration value or default.
248
+ """
249
+ async with self.session() as session:
250
+ query = select(Configuration).where(Configuration.key == key)
251
+ result = await session.execute(query)
252
+ config = result.scalar_one_or_none()
253
+ if config:
254
+ return json.loads(config.value)
255
+ return default
256
+
257
+ async def set_config(self, key: str, value: Any) -> None:
258
+ """Set a configuration value.
259
+
260
+ Args:
261
+ key: Configuration key.
262
+ value: Configuration value.
263
+ """
264
+ async with self.session() as session:
265
+ query = select(Configuration).where(Configuration.key == key)
266
+ result = await session.execute(query)
267
+ config = result.scalar_one_or_none()
268
+
269
+ if config:
270
+ config.value = json.dumps(value)
271
+ else:
272
+ config = Configuration(key=key, value=json.dumps(value))
273
+ session.add(config)
274
+
275
+ async def get_email_sources(self) -> list[EmailSource]:
276
+ """Get all email sources.
277
+
278
+ Returns:
279
+ List of email sources.
280
+ """
281
+ async with self.session() as session:
282
+ query = select(EmailSource)
283
+ result = await session.execute(query)
284
+ return list(result.scalars().all())
285
+
286
+ async def update_thread_status(self, thread_id: int, status: str) -> None:
287
+ """Update thread status.
288
+
289
+ Args:
290
+ thread_id: Thread ID.
291
+ status: New status (open/closed).
292
+ """
293
+ async with self.session() as session:
294
+ thread = await session.get(Thread, thread_id)
295
+ if thread:
296
+ thread.status = status
297
+ thread.updated_at = datetime.now(UTC)