Skip to content

git_ew ¤

git-ew package.

Git Email Workflow

Classes:

Functions:

Attributes:

  • BASE_URL –

    Base URL for zsh-workers mailing list archives.

  • app –

    FastAPI application instance.

  • db (Database | None) –

    Global database instance.

  • static_dir –

    Path to static files directory.

  • template_dir –

    Path to templates directory.

  • templates –

    Jinja2 templates renderer.

BASE_URL module-attribute ¤

BASE_URL = 'https://www.zsh.org/mla/zsh-workers/'

Base URL for zsh-workers mailing list archives.

app module-attribute ¤

app = FastAPI(
    title="git-ew",
    description="Git Email Workflow",
    lifespan=lifespan,
)

FastAPI application instance.

db module-attribute ¤

db: Database | None = None

Global database instance.

static_dir module-attribute ¤

static_dir = Path(__file__).parent.parent / 'static'

Path to static files directory.

template_dir module-attribute ¤

template_dir = Path(__file__).parent.parent / 'templates'

Path to templates directory.

templates module-attribute ¤

templates = Jinja2Templates(directory=str(template_dir))

Jinja2 templates renderer.

Base ¤

Bases: DeclarativeBase

Base class for all models.

CommentCreate ¤

Bases: BaseModel

Model for creating a comment.

Attributes:

body instance-attribute ¤

body: str

Comment body text.

in_reply_to class-attribute instance-attribute ¤

in_reply_to: str | None = None

Message ID this comment is replying to.

Configuration ¤

Bases: Base

Stores application configuration.

Attributes:

  • __tablename__ –

    Database table name.

  • id (Mapped[int]) –

    Configuration unique identifier.

  • key (Mapped[str]) –

    Configuration key.

  • value (Mapped[str]) –

    Configuration value.

__tablename__ class-attribute instance-attribute ¤

__tablename__ = 'configuration'

Database table name.

id class-attribute instance-attribute ¤

id: Mapped[int] = mapped_column(Integer, primary_key=True)

Configuration unique identifier.

key class-attribute instance-attribute ¤

key: Mapped[str] = mapped_column(
    String(255), nullable=False, unique=True
)

Configuration key.

value class-attribute instance-attribute ¤

value: Mapped[str] = mapped_column(Text, nullable=False)

Configuration value.

Database ¤

Database(
    database_url: str = "sqlite+aiosqlite:///./git_ew.db",
)

Database manager.

Parameters:

  • database_url ¤

    (str, default: 'sqlite+aiosqlite:///./git_ew.db' ) –

    Database connection URL.

Methods:

Attributes:

Source code in src/git_ew/_internal/database.py
41
42
43
44
45
46
47
48
49
50
def __init__(self, database_url: str = "sqlite+aiosqlite:///./git_ew.db"):
    """Initialize database manager.

    Args:
        database_url: Database connection URL.
    """
    self.engine = create_async_engine(database_url, echo=False)
    """SQLAlchemy async engine instance."""
    self.session_maker = async_sessionmaker(self.engine, class_=AsyncSession, expire_on_commit=False)
    """Async session factory."""

engine instance-attribute ¤

engine = create_async_engine(database_url, echo=False)

SQLAlchemy async engine instance.

session_maker instance-attribute ¤

session_maker = async_sessionmaker(
    self.engine, class_=AsyncSession, expire_on_commit=False
)

Async session factory.

create_message async ¤

create_message(
    message_id: str,
    thread_id: int,
    from_email: str,
    from_name: str,
    subject: str,
    date: datetime,
    body: str,
    *,
    in_reply_to: str | None = None,
    is_patch: bool = False,
    patch_content: str | None = None,
    raw_email: str | None = None,
) -> Message

Create a new message.

Parameters:

  • message_id ¤

    (str) –

    Unique message ID.

  • thread_id ¤

    (int) –

    Thread ID this message belongs to.

  • from_email ¤

    (str) –

    Sender email.

  • from_name ¤

    (str) –

    Sender name.

  • subject ¤

    (str) –

    Message subject.

  • date ¤

    (datetime) –

    Message date.

  • body ¤

    (str) –

    Message body.

  • in_reply_to ¤

    (str | None, default: None ) –

    ID of message this replies to.

  • is_patch ¤

    (bool, default: False ) –

    Whether this message contains a patch.

  • patch_content ¤

    (str | None, default: None ) –

    Patch content if applicable.

  • raw_email ¤

    (str | None, default: None ) –

    Raw email content.

Returns:

Source code in src/git_ew/_internal/database.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
async def create_message(  # noqa: PLR0917
    self,
    message_id: str,
    thread_id: int,
    from_email: str,
    from_name: str,
    subject: str,
    date: datetime,
    body: str,
    *,
    in_reply_to: str | None = None,
    is_patch: bool = False,
    patch_content: str | None = None,
    raw_email: str | None = None,
) -> Message:
    """Create a new message.

    Args:
        message_id: Unique message ID.
        thread_id: Thread ID this message belongs to.
        from_email: Sender email.
        from_name: Sender name.
        subject: Message subject.
        date: Message date.
        body: Message body.
        in_reply_to: ID of message this replies to.
        is_patch: Whether this message contains a patch.
        patch_content: Patch content if applicable.
        raw_email: Raw email content.

    Returns:
        Created message.
    """
    message = Message(
        message_id=message_id,
        thread_id=thread_id,
        from_email=from_email,
        from_name=from_name,
        subject=subject,
        date=date,
        body=body,
        in_reply_to=in_reply_to,
        is_patch=is_patch,
        patch_content=patch_content,
        raw_email=raw_email,
    )
    async with self.session() as session:
        session.add(message)

        # Update thread's updated_at
        thread = await session.get(Thread, thread_id)
        if thread:
            thread.updated_at = datetime.now(UTC)

        await session.flush()
        await session.refresh(message)
        return message

create_thread async ¤

create_thread(
    subject: str,
    first_message_id: str,
    *,
    is_patch: bool = False,
) -> Thread

Create a new thread.

Parameters:

  • subject ¤

    (str) –

    Thread subject.

  • first_message_id ¤

    (str) –

    ID of the first message.

  • is_patch ¤

    (bool, default: False ) –

    Whether this thread contains patches.

Returns:

Source code in src/git_ew/_internal/database.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
async def create_thread(
    self,
    subject: str,
    first_message_id: str,
    *,
    is_patch: bool = False,
) -> Thread:
    """Create a new thread.

    Args:
        subject: Thread subject.
        first_message_id: ID of the first message.
        is_patch: Whether this thread contains patches.

    Returns:
        Created thread.
    """
    now = datetime.now(UTC)
    thread = Thread(
        subject=subject,
        first_message_id=first_message_id,
        created_at=now,
        updated_at=now,
        is_patch=is_patch,
        status="open",
    )
    async with self.session() as session:
        session.add(thread)
        await session.flush()
        await session.refresh(thread)
        return thread

get_config async ¤

get_config(key: str, default: Any = None) -> Any

Get a configuration value.

Parameters:

  • key ¤

    (str) –

    Configuration key.

  • default ¤

    (Any, default: None ) –

    Default value if key not found.

Returns:

  • Any –

    Configuration value or default.

Source code in src/git_ew/_internal/database.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
async def get_config(self, key: str, default: Any = None) -> Any:
    """Get a configuration value.

    Args:
        key: Configuration key.
        default: Default value if key not found.

    Returns:
        Configuration value or default.
    """
    async with self.session() as session:
        query = select(Configuration).where(Configuration.key == key)
        result = await session.execute(query)
        config = result.scalar_one_or_none()
        if config:
            return json.loads(config.value)
        return default

get_email_sources async ¤

get_email_sources() -> list[EmailSource]

Get all email sources.

Returns:

Source code in src/git_ew/_internal/database.py
275
276
277
278
279
280
281
282
283
284
async def get_email_sources(self) -> list[EmailSource]:
    """Get all email sources.

    Returns:
        List of email sources.
    """
    async with self.session() as session:
        query = select(EmailSource)
        result = await session.execute(query)
        return list(result.scalars().all())

get_message_by_id async ¤

get_message_by_id(message_id: str) -> Message | None

Get a message by its message ID.

Parameters:

  • message_id ¤

    (str) –

    Message ID.

Returns:

  • Message | None –

    Message or None if not found.

Source code in src/git_ew/_internal/database.py
212
213
214
215
216
217
218
219
220
221
222
223
224
async def get_message_by_id(self, message_id: str) -> Message | None:
    """Get a message by its message ID.

    Args:
        message_id: Message ID.

    Returns:
        Message or None if not found.
    """
    async with self.session() as session:
        query = select(Message).where(Message.message_id == message_id)
        result = await session.execute(query)
        return result.scalar_one_or_none()

get_thread async ¤

get_thread(thread_id: int) -> Thread | None

Get a thread by ID with all messages.

Parameters:

  • thread_id ¤

    (int) –

    Thread ID.

Returns:

  • Thread | None –

    Thread or None if not found.

Source code in src/git_ew/_internal/database.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
async def get_thread(self, thread_id: int) -> Thread | None:
    """Get a thread by ID with all messages.

    Args:
        thread_id: Thread ID.

    Returns:
        Thread or None if not found.
    """
    async with self.session() as session:
        query = select(Thread).where(Thread.id == thread_id).options(selectinload(Thread.messages))
        result = await session.execute(query)
        return result.scalar_one_or_none()

get_thread_by_message_id async ¤

get_thread_by_message_id(message_id: str) -> Thread | None

Get a thread by first message ID.

Parameters:

  • message_id ¤

    (str) –

    Message ID.

Returns:

  • Thread | None –

    Thread or None if not found.

Source code in src/git_ew/_internal/database.py
108
109
110
111
112
113
114
115
116
117
118
119
120
async def get_thread_by_message_id(self, message_id: str) -> Thread | None:
    """Get a thread by first message ID.

    Args:
        message_id: Message ID.

    Returns:
        Thread or None if not found.
    """
    async with self.session() as session:
        query = select(Thread).where(Thread.first_message_id == message_id)
        result = await session.execute(query)
        return result.scalar_one_or_none()

get_threads async ¤

get_threads(
    status: str | None = None,
    limit: int | None = None,
    offset: int = 0,
) -> list[Thread]

Get threads with optional filtering.

Parameters:

  • status ¤

    (str | None, default: None ) –

    Filter by status (open/closed).

  • limit ¤

    (int | None, default: None ) –

    Maximum number of threads to return.

  • offset ¤

    (int, default: 0 ) –

    Number of threads to skip.

Returns:

Source code in src/git_ew/_internal/database.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
async def get_threads(
    self,
    status: str | None = None,
    limit: int | None = None,
    offset: int = 0,
) -> list[Thread]:
    """Get threads with optional filtering.

    Args:
        status: Filter by status (open/closed).
        limit: Maximum number of threads to return.
        offset: Number of threads to skip.

    Returns:
        List of threads.
    """
    async with self.session() as session:
        query = select(Thread).order_by(Thread.updated_at.desc())
        if status:
            query = query.where(Thread.status == status)
        if limit is not None:
            query = query.limit(limit)
        query = query.offset(offset)
        result = await session.execute(query)
        return list(result.scalars().all())

init_db async ¤

init_db() -> None

Initialize database tables.

Source code in src/git_ew/_internal/database.py
52
53
54
55
async def init_db(self) -> None:
    """Initialize database tables."""
    async with self.engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

session async ¤

session() -> AsyncIterator[AsyncSession]

Get a database session.

Source code in src/git_ew/_internal/database.py
57
58
59
60
61
62
63
64
65
66
@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
    """Get a database session."""
    async with self.session_maker() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

set_config async ¤

set_config(key: str, value: Any) -> None

Set a configuration value.

Parameters:

  • key ¤

    (str) –

    Configuration key.

  • value ¤

    (Any) –

    Configuration value.

Source code in src/git_ew/_internal/database.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
async def set_config(self, key: str, value: Any) -> None:
    """Set a configuration value.

    Args:
        key: Configuration key.
        value: Configuration value.
    """
    async with self.session() as session:
        query = select(Configuration).where(Configuration.key == key)
        result = await session.execute(query)
        config = result.scalar_one_or_none()

        if config:
            config.value = json.dumps(value)
        else:
            config = Configuration(key=key, value=json.dumps(value))
            session.add(config)

update_message_patch async ¤

update_message_patch(
    message_id: str, patch_content: str
) -> None

Add patch content to an existing message.

Source code in src/git_ew/_internal/database.py
226
227
228
229
230
231
232
233
234
235
236
237
async def update_message_patch(self, message_id: str, patch_content: str) -> None:
    """Add patch content to an existing message."""
    async with self.session() as session:
        query = select(Message).where(Message.message_id == message_id)
        result = await session.execute(query)
        message = result.scalar_one_or_none()
        if message and not message.patch_content:
            message.patch_content = patch_content
            message.is_patch = True
            thread = await session.get(Thread, message.thread_id)
            if thread:
                thread.is_patch = True

update_thread_status async ¤

update_thread_status(thread_id: int, status: str) -> None

Update thread status.

Parameters:

  • thread_id ¤

    (int) –

    Thread ID.

  • status ¤

    (str) –

    New status (open/closed).

Source code in src/git_ew/_internal/database.py
286
287
288
289
290
291
292
293
294
295
296
297
async def update_thread_status(self, thread_id: int, status: str) -> None:
    """Update thread status.

    Args:
        thread_id: Thread ID.
        status: New status (open/closed).
    """
    async with self.session() as session:
        thread = await session.get(Thread, thread_id)
        if thread:
            thread.status = status
            thread.updated_at = datetime.now(UTC)

EmailFetcher ¤

Base class for email fetchers.

Methods:

fetch_emails ¤

fetch_emails(
    since: str | None = None,
) -> AsyncIterator[ParsedEmail]

Fetch emails from the source.

Parameters:

  • since ¤

    (str | None, default: None ) –

    Fetch emails since this date (ISO format).

Yields:

Source code in src/git_ew/_internal/email_fetcher.py
44
45
46
47
48
49
50
51
52
53
def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
    """Fetch emails from the source.

    Args:
        since: Fetch emails since this date (ISO format).

    Yields:
        ParsedEmail objects.
    """
    raise NotImplementedError

EmailSender ¤

EmailSender(
    smtp_host: str,
    smtp_port: int,
    from_email: str,
    from_name: str,
    *,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = True,
)

Handle sending emails.

Parameters:

  • smtp_host ¤

    (str) –

    SMTP server hostname.

  • smtp_port ¤

    (int) –

    SMTP server port.

  • from_email ¤

    (str) –

    Sender email address.

  • from_name ¤

    (str) –

    Sender name.

  • username ¤

    (str | None, default: None ) –

    SMTP username (if required).

  • password ¤

    (str | None, default: None ) –

    SMTP password (if required).

  • use_tls ¤

    (bool, default: True ) –

    Whether to use TLS.

Methods:

Attributes:

Source code in src/git_ew/_internal/email_sender.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def __init__(
    self,
    smtp_host: str,
    smtp_port: int,
    from_email: str,
    from_name: str,
    *,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = True,
):
    """Initialize email sender.

    Args:
        smtp_host: SMTP server hostname.
        smtp_port: SMTP server port.
        from_email: Sender email address.
        from_name: Sender name.
        username: SMTP username (if required).
        password: SMTP password (if required).
        use_tls: Whether to use TLS.
    """
    self.smtp_host = smtp_host
    """SMTP server hostname."""
    self.smtp_port = smtp_port
    """SMTP server port."""
    self.from_email = from_email
    """Sender email address."""
    self.from_name = from_name
    """Sender name."""
    self.username = username
    """SMTP username."""
    self.password = password
    """SMTP password."""
    self.use_tls = use_tls
    """Whether to use TLS."""

from_email instance-attribute ¤

from_email = from_email

Sender email address.

from_name instance-attribute ¤

from_name = from_name

Sender name.

password instance-attribute ¤

password = password

SMTP password.

smtp_host instance-attribute ¤

smtp_host = smtp_host

SMTP server hostname.

smtp_port instance-attribute ¤

smtp_port = smtp_port

SMTP server port.

use_tls instance-attribute ¤

use_tls = use_tls

Whether to use TLS.

username instance-attribute ¤

username = username

SMTP username.

build_email ¤

build_email(
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> EmailMessage

Build an email without sending it.

Source code in src/git_ew/_internal/email_sender.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def build_email(  # noqa: PLR0917
    self,
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> EmailMessage:
    """Build an email without sending it."""
    msg = EmailMessage()
    msg.set_content(body)

    # Set headers
    msg["From"] = f"{self.from_name} <{self.from_email}>"
    msg["To"] = to_email
    msg["Subject"] = subject
    msg["Date"] = formatdate(localtime=True)
    msg["Message-ID"] = make_msgid(domain=self.from_email.split("@")[1])

    if cc:
        msg["Cc"] = ", ".join(cc)

    if in_reply_to:
        msg["In-Reply-To"] = f"<{in_reply_to}>"

    if references:
        msg["References"] = " ".join(f"<{ref}>" for ref in references)

    return msg

build_reply ¤

build_reply(
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> EmailMessage

Build a reply email without sending it.

Source code in src/git_ew/_internal/email_sender.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def build_reply(  # noqa: PLR0917
    self,
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> EmailMessage:
    """Build a reply email without sending it."""
    if not subject.lower().startswith("re:"):
        subject = f"Re: {subject}"

    references = list(references or [])
    if in_reply_to not in references:
        references.append(in_reply_to)

    return self.build_email(
        to_email=to_email,
        subject=subject,
        body=body,
        in_reply_to=in_reply_to,
        references=references,
        cc=cc,
    )

send_email ¤

send_email(
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> str

Send a plain text email.

Parameters:

  • to_email ¤

    (str) –

    Recipient email address.

  • subject ¤

    (str) –

    Email subject.

  • body ¤

    (str) –

    Email body (plain text).

  • in_reply_to ¤

    (str | None, default: None ) –

    Message ID this replies to.

  • references ¤

    (list[str] | None, default: None ) –

    List of message IDs in the thread.

  • cc ¤

    (list[str] | None, default: None ) –

    List of CC recipients.

Returns:

  • str –

    Message ID of sent email.

Source code in src/git_ew/_internal/email_sender.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def send_email(  # noqa: PLR0917
    self,
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> str:
    """Send a plain text email.

    Args:
        to_email: Recipient email address.
        subject: Email subject.
        body: Email body (plain text).
        in_reply_to: Message ID this replies to.
        references: List of message IDs in the thread.
        cc: List of CC recipients.

    Returns:
        Message ID of sent email.
    """
    msg = self.build_email(to_email, subject, body, in_reply_to, references, cc)
    self.send_message(msg)
    return msg["Message-ID"].strip("<>")

send_message ¤

send_message(msg: EmailMessage) -> None

Send a prepared email over SMTP.

Source code in src/git_ew/_internal/email_sender.py
130
131
132
133
134
135
136
137
138
139
def send_message(self, msg: EmailMessage) -> None:
    """Send a prepared email over SMTP."""
    with smtplib.SMTP(self.smtp_host, self.smtp_port) as smtp:
        if self.use_tls:
            smtp.starttls()

        if self.username and self.password:
            smtp.login(self.username, self.password)

        smtp.send_message(msg)

send_reply ¤

send_reply(
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> str

Send a reply email.

Parameters:

  • to_email ¤

    (str) –

    Recipient email address.

  • subject ¤

    (str) –

    Email subject (will be prefixed with "Re:" if not present).

  • body ¤

    (str) –

    Email body (plain text).

  • in_reply_to ¤

    (str) –

    Message ID this replies to.

  • references ¤

    (list[str] | None, default: None ) –

    List of message IDs in the thread.

  • cc ¤

    (list[str] | None, default: None ) –

    List of CC recipients.

Returns:

  • str –

    Message ID of sent email.

Source code in src/git_ew/_internal/email_sender.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def send_reply(  # noqa: PLR0917
    self,
    to_email: str,
    subject: str,
    body: str,
    in_reply_to: str,
    references: list[str] | None = None,
    cc: list[str] | None = None,
) -> str:
    """Send a reply email.

    Args:
        to_email: Recipient email address.
        subject: Email subject (will be prefixed with "Re:" if not present).
        body: Email body (plain text).
        in_reply_to: Message ID this replies to.
        references: List of message IDs in the thread.
        cc: List of CC recipients.

    Returns:
        Message ID of sent email.
    """
    msg = self.build_reply(to_email, subject, body, in_reply_to, references, cc)
    self.send_message(msg)
    return msg["Message-ID"].strip("<>")

EmailSource ¤

Bases: Base

Represents an email archive source to fetch from.

Attributes:

  • __tablename__ –

    Database table name.

  • config (Mapped[str]) –

    JSON configuration for the source.

  • enabled (Mapped[bool]) –

    Whether this source is enabled.

  • id (Mapped[int]) –

    Source unique identifier.

  • last_synced (Mapped[datetime | None]) –

    When this source was last synced.

  • name (Mapped[str]) –

    Source name.

  • source_type (Mapped[str]) –

    Type of email source (maildir, mbox, imap, etc).

__tablename__ class-attribute instance-attribute ¤

__tablename__ = 'email_sources'

Database table name.

config class-attribute instance-attribute ¤

config: Mapped[str] = mapped_column(Text, nullable=False)

JSON configuration for the source.

enabled class-attribute instance-attribute ¤

enabled: Mapped[bool] = mapped_column(
    Boolean, default=True, nullable=False
)

Whether this source is enabled.

id class-attribute instance-attribute ¤

id: Mapped[int] = mapped_column(Integer, primary_key=True)

Source unique identifier.

last_synced class-attribute instance-attribute ¤

last_synced: Mapped[datetime | None] = mapped_column(
    DateTime, nullable=True
)

When this source was last synced.

name class-attribute instance-attribute ¤

name: Mapped[str] = mapped_column(
    String(255), nullable=False, unique=True
)

Source name.

source_type class-attribute instance-attribute ¤

source_type: Mapped[str] = mapped_column(
    String(50), nullable=False
)

Type of email source (maildir, mbox, imap, etc).

IMAPFetcher ¤

IMAPFetcher(
    host: str,
    username: str,
    password: str,
    *,
    port: int = 993,
    folders: list[str] | None = None,
    mailing_list: dict[str, list[str]] | None = None,
)

Bases: EmailFetcher

Fetch emails from IMAP folders, including Fastmail accounts.

Methods:

  • fetch_emails –

    Fetch and parse messages from configured IMAP folders.

Attributes:

  • folders –

    Folders to fetch messages from.

  • host –

    IMAP server hostname.

  • mailing_list –

    Mailing list headers used to filter messages.

  • password –

    IMAP account password.

  • port –

    IMAP server port.

  • username –

    IMAP account username.

Source code in src/git_ew/_internal/email_fetcher.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def __init__(
    self,
    host: str,
    username: str,
    password: str,
    *,
    port: int = 993,
    folders: list[str] | None = None,
    mailing_list: dict[str, list[str]] | None = None,
):
    """Initialize an IMAP fetcher."""
    self.host = host
    """IMAP server hostname."""
    self.port = port
    """IMAP server port."""
    self.username = username
    """IMAP account username."""
    self.password = password
    """IMAP account password."""
    self.folders = folders or ["INBOX", "Sent"]
    """Folders to fetch messages from."""
    self.mailing_list = mailing_list or {}
    """Mailing list headers used to filter messages."""

folders instance-attribute ¤

folders = folders or ['INBOX', 'Sent']

Folders to fetch messages from.

host instance-attribute ¤

host = host

IMAP server hostname.

mailing_list instance-attribute ¤

mailing_list = mailing_list or {}

Mailing list headers used to filter messages.

password instance-attribute ¤

password = password

IMAP account password.

port instance-attribute ¤

port = port

IMAP server port.

username instance-attribute ¤

username = username

IMAP account username.

fetch_emails async ¤

fetch_emails(
    since: str | None = None,
) -> AsyncIterator[ParsedEmail]

Fetch and parse messages from configured IMAP folders.

Source code in src/git_ew/_internal/email_fetcher.py
168
169
170
171
172
173
174
175
176
async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
    """Fetch and parse messages from configured IMAP folders."""
    raw_messages = await asyncio.to_thread(self._fetch_messages, since)
    for raw_message in raw_messages:
        try:
            yield parse_email(raw_message)
        except Exception as e:  # noqa: BLE001
            _logger.debug("Skipping malformed IMAP message: %s", e)
        await asyncio.sleep(0)

LinkExtractor ¤

LinkExtractor()

Bases: HTMLParser

Extract .tgz archive links and dates from HTML.

Methods:

Attributes:

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
37
38
39
40
41
42
def __init__(self):
    super().__init__()
    self.archives: dict[str, date | None] = {}
    """Archive filenames and their dates found in the page."""
    self._in_pre = False
    self._current_line = ""

archives instance-attribute ¤

archives: dict[str, date | None] = {}

Archive filenames and their dates found in the page.

handle_data ¤

handle_data(data: str) -> None

Extract date information from pre-formatted text.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def handle_data(self, data: str) -> None:
    """Extract date information from pre-formatted text."""
    if not self._in_pre:
        return

    self._current_line += data

    # Look for date patterns in the line (DD-MMM-YYYY format)
    # Example: "12-Jun-1995"
    if len(self._current_line) > 50:  # Approximate line length  # noqa: PLR2004
        parts = self._current_line.split()
        for i, part in enumerate(parts):
            # Try to parse date from parts
            if "-" in part and len(parts) > i + 1:
                try:
                    # Try common date formats
                    date_str = part
                    # Convert French month names to English
                    date_str = date_str.replace("janv.", "Jan")
                    date_str = date_str.replace("févr.", "Feb")
                    date_str = date_str.replace("mars", "Mar")
                    date_str = date_str.replace("avril", "Apr")
                    date_str = date_str.replace("mai", "May")
                    date_str = date_str.replace("juin", "Jun")
                    date_str = date_str.replace("juil.", "Jul")
                    date_str = date_str.replace("août", "Aug")
                    date_str = date_str.replace("sept.", "Sep")
                    date_str = date_str.replace("oct.", "Oct")
                    date_str = date_str.replace("nov.", "Nov")
                    date_str = date_str.replace("déc.", "Dec")

                    parsed_time = strptime(date_str, "%d-%b-%Y")
                    parsed = date(parsed_time.tm_year, parsed_time.tm_mon, parsed_time.tm_mday)
                    # Associate this date with the last archived filename found
                    if self.archives:
                        last_filename = list(self.archives.keys())[-1]
                        if self.archives[last_filename] is None:
                            self.archives[last_filename] = parsed
                except (ValueError, IndexError):
                    _logger.debug("Could not parse archive date from %r", part, exc_info=True)

        self._current_line = ""

handle_endtag ¤

handle_endtag(tag: str) -> None

Track end of pre tag.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
55
56
57
58
def handle_endtag(self, tag: str) -> None:
    """Track end of pre tag."""
    if tag == "pre":
        self._in_pre = False

handle_starttag ¤

handle_starttag(
    tag: str, attrs: list[tuple[str, str | None]]
) -> None

Track pre tags and extract href attributes.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
44
45
46
47
48
49
50
51
52
53
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
    """Track pre tags and extract href attributes."""
    if tag == "pre":
        self._in_pre = True
    elif tag == "a" and self._in_pre:
        for attr, value in attrs:
            if attr == "href" and value and value.endswith(".tgz"):
                filename = value.split("/")[-1]
                # Date will be extracted from text content after the link
                self.archives[filename] = None

MaildirFetcher ¤

MaildirFetcher(maildir_path: str)

Bases: EmailFetcher

Fetch emails from a Maildir directory.

Parameters:

  • maildir_path ¤

    (str) –

    Path to Maildir directory.

Methods:

Attributes:

Source code in src/git_ew/_internal/email_fetcher.py
59
60
61
62
63
64
65
66
def __init__(self, maildir_path: str):
    """Initialize Maildir fetcher.

    Args:
        maildir_path: Path to Maildir directory.
    """
    self.maildir_path = Path(maildir_path)
    """Path to the Maildir directory."""

maildir_path instance-attribute ¤

maildir_path = Path(maildir_path)

Path to the Maildir directory.

fetch_emails async ¤

fetch_emails(
    since: str | None = None,
) -> AsyncIterator[ParsedEmail]

Fetch emails from Maildir.

Parameters:

  • since ¤

    (str | None, default: None ) –

    Fetch emails since this date (ISO format).

Yields:

Source code in src/git_ew/_internal/email_fetcher.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:  # noqa: ARG002
    """Fetch emails from Maildir.

    Args:
        since: Fetch emails since this date (ISO format).

    Yields:
        ParsedEmail objects.
    """
    if not self.maildir_path.exists():
        return

    # Use mailbox.Maildir to read the maildir
    mbox = mailbox.Maildir(str(self.maildir_path))
    try:
        for message in mbox:
            try:
                raw = message.as_bytes()
                parsed = parse_email(raw)
                yield parsed
            except Exception as e:  # noqa: BLE001
                # Skip malformed emails
                _logger.debug("Skipping malformed Maildir message: %s", e)
                continue

            # Allow event loop to process
            await asyncio.sleep(0)
    finally:
        mbox.close()

MboxFetcher ¤

MboxFetcher(mbox_path: str)

Bases: EmailFetcher

Fetch emails from an mbox file.

Parameters:

  • mbox_path ¤

    (str) –

    Path to mbox file.

Methods:

Attributes:

Source code in src/git_ew/_internal/email_fetcher.py
102
103
104
105
106
107
108
109
def __init__(self, mbox_path: str):
    """Initialize mbox fetcher.

    Args:
        mbox_path: Path to mbox file.
    """
    self.mbox_path = Path(mbox_path)
    """Path to the mbox file."""

mbox_path instance-attribute ¤

mbox_path = Path(mbox_path)

Path to the mbox file.

fetch_emails async ¤

fetch_emails(
    since: str | None = None,
) -> AsyncIterator[ParsedEmail]

Fetch emails from mbox.

Parameters:

  • since ¤

    (str | None, default: None ) –

    Fetch emails since this date (ISO format).

Yields:

Source code in src/git_ew/_internal/email_fetcher.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
async def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:  # noqa: ARG002
    """Fetch emails from mbox.

    Args:
        since: Fetch emails since this date (ISO format).

    Yields:
        ParsedEmail objects.
    """
    if not self.mbox_path.exists():
        return

    mbox = mailbox.mbox(str(self.mbox_path))
    try:
        for message in mbox:
            try:
                raw = message.as_bytes()
                parsed = parse_email(raw)
                yield parsed
            except Exception as e:  # noqa: BLE001
                # Skip malformed emails
                _logger.debug("Skipping malformed mbox message: %s", e)
                continue

            # Allow event loop to process
            await asyncio.sleep(0)
    finally:
        mbox.close()

Message ¤

Bases: Base

Represents an individual email message in a thread.

Attributes:

__tablename__ class-attribute instance-attribute ¤

__tablename__ = 'messages'

Database table name.

body class-attribute instance-attribute ¤

body: Mapped[str] = mapped_column(Text, nullable=False)

Message body text.

date class-attribute instance-attribute ¤

date: Mapped[datetime] = mapped_column(
    DateTime, nullable=False, index=True
)

Email date.

from_email class-attribute instance-attribute ¤

from_email: Mapped[str] = mapped_column(
    String(255), nullable=False
)

Sender email address.

from_name class-attribute instance-attribute ¤

from_name: Mapped[str] = mapped_column(
    String(255), nullable=False
)

Sender name.

id class-attribute instance-attribute ¤

id: Mapped[int] = mapped_column(Integer, primary_key=True)

Message unique identifier.

in_reply_to class-attribute instance-attribute ¤

in_reply_to: Mapped[str | None] = mapped_column(
    String(500), nullable=True, index=True
)

Message ID this reply refers to.

is_patch class-attribute instance-attribute ¤

is_patch: Mapped[bool] = mapped_column(
    Boolean, default=False, nullable=False
)

Whether this message contains a patch.

message_id class-attribute instance-attribute ¤

message_id: Mapped[str] = mapped_column(
    String(500), unique=True, nullable=False, index=True
)

Email message ID header.

patch_content class-attribute instance-attribute ¤

patch_content: Mapped[str | None] = mapped_column(
    Text, nullable=True
)

Extracted patch content.

raw_email class-attribute instance-attribute ¤

raw_email: Mapped[str] = mapped_column(Text, nullable=True)

Raw email content.

subject class-attribute instance-attribute ¤

subject: Mapped[str] = mapped_column(
    String(500), nullable=False
)

Email subject.

thread class-attribute instance-attribute ¤

thread: Mapped[Thread] = relationship(
    "Thread", back_populates="messages"
)

Thread this message belongs to.

thread_id class-attribute instance-attribute ¤

thread_id: Mapped[int] = mapped_column(
    Integer,
    ForeignKey("threads.id"),
    nullable=False,
    index=True,
)

Thread this message belongs to.

ParsedEmail ¤

ParsedEmail(
    message_id: str,
    from_email: str,
    from_name: str,
    subject: str,
    date: datetime,
    body: str,
    *,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    is_patch: bool = False,
    patch_content: str | None = None,
    raw: str | None = None,
)

Represents a parsed email message.

Parameters:

  • message_id ¤

    (str) –

    Unique message ID.

  • from_email ¤

    (str) –

    Sender email address.

  • from_name ¤

    (str) –

    Sender name.

  • subject ¤

    (str) –

    Email subject.

  • date ¤

    (datetime) –

    Email date.

  • body ¤

    (str) –

    Email body text.

  • in_reply_to ¤

    (str | None, default: None ) –

    Message ID this replies to.

  • references ¤

    (list[str] | None, default: None ) –

    List of referenced message IDs.

  • is_patch ¤

    (bool, default: False ) –

    Whether this email contains a patch.

  • patch_content ¤

    (str | None, default: None ) –

    Extracted patch content.

  • raw ¤

    (str | None, default: None ) –

    Raw email content.

Methods:

Attributes:

Source code in src/git_ew/_internal/email_parser.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __init__(  # noqa: PLR0917
    self,
    message_id: str,
    from_email: str,
    from_name: str,
    subject: str,
    date: datetime,
    body: str,
    *,
    in_reply_to: str | None = None,
    references: list[str] | None = None,
    is_patch: bool = False,
    patch_content: str | None = None,
    raw: str | None = None,
):
    """Initialize parsed email.

    Args:
        message_id: Unique message ID.
        from_email: Sender email address.
        from_name: Sender name.
        subject: Email subject.
        date: Email date.
        body: Email body text.
        in_reply_to: Message ID this replies to.
        references: List of referenced message IDs.
        is_patch: Whether this email contains a patch.
        patch_content: Extracted patch content.
        raw: Raw email content.
    """
    self.message_id = message_id
    """Unique message ID."""
    self.from_email = from_email
    """Sender email address."""
    self.from_name = from_name
    """Sender name."""
    self.subject = subject
    """Email subject."""
    self.date = date
    """Email date."""
    self.body = body
    """Email body text."""
    self.in_reply_to = in_reply_to
    """Message ID this replies to."""
    self.references = references or []
    """List of referenced message IDs."""
    self.is_patch = is_patch
    """Whether this email contains a patch."""
    self.patch_content = patch_content
    """Extracted patch content."""
    self.raw = raw
    """Raw email content."""

body instance-attribute ¤

body = body

Email body text.

clean_subject property ¤

clean_subject: str

Get subject with RE: and similar prefixes removed.

Returns:

  • str –

    Clean subject.

date instance-attribute ¤

date = date

Email date.

from_email instance-attribute ¤

from_email = from_email

Sender email address.

from_name instance-attribute ¤

from_name = from_name

Sender name.

in_reply_to instance-attribute ¤

in_reply_to = in_reply_to

Message ID this replies to.

is_patch instance-attribute ¤

is_patch = is_patch

Whether this email contains a patch.

message_id instance-attribute ¤

message_id = message_id

Unique message ID.

patch_content instance-attribute ¤

patch_content = patch_content

Extracted patch content.

raw instance-attribute ¤

raw = raw

Raw email content.

references instance-attribute ¤

references = references or []

List of referenced message IDs.

subject instance-attribute ¤

subject = subject

Email subject.

get_thread_id ¤

get_thread_id() -> str

Get the thread ID (first message in chain).

Returns:

  • str –

    Thread ID (the first message ID in the references chain).

Source code in src/git_ew/_internal/email_parser.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def get_thread_id(self) -> str:
    """Get the thread ID (first message in chain).

    Returns:
        Thread ID (the first message ID in the references chain).
    """
    if self.references:
        return self.references[0]
    if self.in_reply_to:
        return self.in_reply_to
    return self.message_id

PublicInboxFetcher ¤

PublicInboxFetcher(archive_url: str)

Bases: EmailFetcher

Fetch emails from a public-inbox archive URL.

Parameters:

  • archive_url ¤

    (str) –

    Base URL of the public-inbox archive.

Methods:

Attributes:

  • archive_url –

    Base URL of the public-inbox archive.

Source code in src/git_ew/_internal/email_fetcher.py
249
250
251
252
253
254
255
256
def __init__(self, archive_url: str):
    """Initialize public-inbox fetcher.

    Args:
        archive_url: Base URL of the public-inbox archive.
    """
    self.archive_url = archive_url.rstrip("/")
    """Base URL of the public-inbox archive."""

archive_url instance-attribute ¤

archive_url = archive_url.rstrip('/')

Base URL of the public-inbox archive.

fetch_emails ¤

fetch_emails(
    since: str | None = None,
) -> AsyncIterator[ParsedEmail]

Fetch emails from public-inbox archive.

Parameters:

  • since ¤

    (str | None, default: None ) –

    Fetch emails since this date (ISO format).

Yields:

Source code in src/git_ew/_internal/email_fetcher.py
258
259
260
261
262
263
264
265
266
267
268
269
270
def fetch_emails(self, since: str | None = None) -> AsyncIterator[ParsedEmail]:
    """Fetch emails from public-inbox archive.

    Args:
        since: Fetch emails since this date (ISO format).

    Yields:
        ParsedEmail objects.
    """
    # This would require implementing HTTP requests to fetch mbox files
    # from public-inbox archives. For now, this is a placeholder.
    # Real implementation would use httpx to fetch and parse mbox files
    raise NotImplementedError("Public-inbox fetching not yet implemented")

Thread ¤

Bases: Base

Represents an email thread (like a GitHub issue or PR).

Attributes:

__tablename__ class-attribute instance-attribute ¤

__tablename__ = 'threads'

Database table name.

created_at class-attribute instance-attribute ¤

created_at: Mapped[datetime] = mapped_column(
    DateTime, nullable=False
)

When the thread was created.

first_message_id class-attribute instance-attribute ¤

first_message_id: Mapped[str] = mapped_column(
    String(500), unique=True, nullable=False
)

ID of the first message in the thread.

id class-attribute instance-attribute ¤

id: Mapped[int] = mapped_column(Integer, primary_key=True)

Thread unique identifier.

is_patch class-attribute instance-attribute ¤

is_patch: Mapped[bool] = mapped_column(
    Boolean, default=False, nullable=False
)

Whether this thread contains patches.

messages class-attribute instance-attribute ¤

messages: Mapped[list[Message]] = relationship(
    "Message",
    back_populates="thread",
    cascade="all, delete-orphan",
)

Messages in this thread.

status class-attribute instance-attribute ¤

status: Mapped[str] = mapped_column(
    String(50), default="open", nullable=False
)

Thread status (open or closed).

subject class-attribute instance-attribute ¤

subject: Mapped[str] = mapped_column(
    String(500), nullable=False
)

Thread subject line.

updated_at class-attribute instance-attribute ¤

updated_at: Mapped[datetime] = mapped_column(
    DateTime, nullable=False
)

When the thread was last updated.

ThreadNode dataclass ¤

ThreadNode(message: Message, children: list[ThreadNode])

Represents a node in a thread tree.

Attributes:

children instance-attribute ¤

children: list[ThreadNode]

Child nodes in the thread tree.

message instance-attribute ¤

message: Message

The message at this node.

ThreadUpdate ¤

Bases: BaseModel

Model for updating a thread.

Attributes:

  • status (str | None) –

    New status for the thread (open or closed).

status class-attribute instance-attribute ¤

status: str | None = None

New status for the thread (open or closed).

append_sent_message ¤

append_sent_message(
    msg: EmailMessage,
    config: dict[str, Any],
    folder: str = "Sent",
) -> None

Append a sent message to an IMAP folder using a resolved password.

Source code in src/git_ew/_internal/email_sender.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def append_sent_message(msg: EmailMessage, config: dict[str, Any], folder: str = "Sent") -> None:
    """Append a sent message to an IMAP folder using a resolved password."""
    host = config.get("host", "imap.fastmail.com")
    port = int(config.get("port", 993))
    with imaplib.IMAP4_SSL(host, port) as client:
        client.login(config["username"], resolve_password(config))
        status, _ = client.append(
            folder,
            "\\Seen",
            imaplib.Time2Internaldate(datetime.now(UTC).timestamp()),
            msg.as_bytes(),
        )
        if status != "OK":
            raise RuntimeError(f"Unable to append sent message to IMAP folder {folder!r}")

build_thread_tree ¤

build_thread_tree(
    messages: list[Message],
) -> list[ThreadNode]

Build a tree structure from a flat list of messages.

Parameters:

  • messages ¤

    (list[Message]) –

    List of messages in the thread.

Returns:

Source code in src/git_ew/_internal/thread_utils.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def build_thread_tree(messages: list[Message]) -> list[ThreadNode]:
    """Build a tree structure from a flat list of messages.

    Args:
        messages: List of messages in the thread.

    Returns:
        List of root ThreadNodes.
    """
    # Create nodes for each message
    nodes = {msg.message_id: ThreadNode(message=msg, children=[]) for msg in messages}

    # Build the tree by linking children to parents
    roots = []

    for msg in messages:
        node = nodes[msg.message_id]

        if msg.in_reply_to and msg.in_reply_to in nodes:
            # This message is a reply to another message
            parent_node = nodes[msg.in_reply_to]
            parent_node.children.append(node)
        else:
            # This is a root message
            roots.append(node)

    return roots

config_command async ¤

config_command() -> None

Configure email delivery and ingestion sources interactively.

Source code in src/git_ew/_internal/config.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
async def config_command() -> None:
    """Configure email delivery and ingestion sources interactively."""
    _logger.info("=== git-ew Configuration Wizard ===")

    db = Database()
    _logger.info("Initializing database")
    await db.init_db()
    _logger.info("Database initialized")

    email_config = await to_thread(_prompt_email_config)
    await db.set_config("email_config", email_config)
    _logger.info("Email configuration saved")

    source = await to_thread(_prompt_email_source)
    if source is None:
        return

    async with db.session() as session:
        session.add(source)
    _logger.info("Email source added")
    _logger.info("=" * 50)
    _logger.info("Configuration complete")

create_app ¤

create_app() -> FastAPI

Create and configure the FastAPI application.

Returns:

  • FastAPI –

    FastAPI application instance.

Source code in src/git_ew/_internal/app.py
412
413
414
415
416
417
418
def create_app() -> FastAPI:
    """Create and configure the FastAPI application.

    Returns:
        FastAPI application instance.
    """
    return app

create_email_sender ¤

create_email_sender(config: dict[str, Any]) -> EmailSender

Create an email sender with a resolved password.

Source code in src/git_ew/_internal/email_sender.py
210
211
212
213
214
215
216
217
218
219
220
def create_email_sender(config: dict[str, Any]) -> EmailSender:
    """Create an email sender with a resolved password."""
    return EmailSender(
        smtp_host=config.get("smtp_host", "localhost"),
        smtp_port=config.get("smtp_port", 587),
        from_email=config["from_email"],
        from_name=config.get("from_name", config["from_email"]),
        username=config.get("username"),
        password=resolve_password(config, required=False),
        use_tls=config.get("use_tls", True),
    )

decode_email_header ¤

decode_email_header(header_value: str) -> str

Decode RFC 2047 encoded-word headers to plain text.

Parameters:

  • header_value ¤

    (str) –

    Raw header value (may contain encoded-words like =?UTF-8?Q?...?=).

Returns:

  • str –

    Decoded string.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def decode_email_header(header_value: str) -> str:
    """Decode RFC 2047 encoded-word headers to plain text.

    Args:
        header_value: Raw header value (may contain encoded-words like =?UTF-8?Q?...?=).

    Returns:
        Decoded string.
    """
    if not header_value:
        return ""

    try:
        # decode_header returns a list of tuples: (decoded_bytes, charset)
        decoded_parts = []
        for decoded_bytes, charset in decode_header(header_value):
            if isinstance(decoded_bytes, bytes):
                # If we got bytes, decode with the specified charset (or utf-8 as fallback)
                try:
                    decoded_str = decoded_bytes.decode(charset or "utf-8", errors="replace")
                except (TypeError, LookupError):
                    decoded_str = decoded_bytes.decode("utf-8", errors="replace")
            else:
                # Already a string
                decoded_str = decoded_bytes or ""
            decoded_parts.append(decoded_str)
        return "".join(decoded_parts)
    except Exception:
        _logger.debug("Could not decode email header %r", header_value, exc_info=True)
        return header_value

download_archive ¤

download_archive(filename: str, archive_dir: Path) -> bool

Download a single archive.

Parameters:

  • filename ¤

    (str) –

    Archive filename to download.

  • archive_dir ¤

    (Path) –

    Directory to save the archive to.

Returns:

  • bool –

    True if download succeeded, False otherwise.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def download_archive(filename: str, archive_dir: Path) -> bool:
    """Download a single archive.

    Args:
        filename: Archive filename to download.
        archive_dir: Directory to save the archive to.

    Returns:
        True if download succeeded, False otherwise.
    """
    url = BASE_URL + filename
    output_path = archive_dir / filename

    try:
        _logger.info("Downloading %s", filename)
        urlretrieve(url, output_path)  # noqa: S310
        _logger.info("Downloaded %s", filename)
    except URLError as error:
        _logger.error("Failed to download %s: %s", filename, error)  # noqa: TRY400
        # Clean up partially downloaded file
        if output_path.exists():
            output_path.unlink()
        return False
    else:
        return True

extract_body_and_patch ¤

extract_body_and_patch(
    message: Message,
) -> tuple[str, str | None]

Extract the plain-text body and any MIME patch attachment.

Source code in src/git_ew/_internal/email_parser.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def extract_body_and_patch(message: Message) -> tuple[str, str | None]:
    """Extract the plain-text body and any MIME patch attachment."""
    body = ""
    patch_content = None

    for part in message.walk():
        if part.is_multipart():
            continue

        content_type = part.get_content_type().lower()
        filename = (part.get_filename() or "").lower()
        content = _decode_part_payload(part)

        if content_type in {
            "text/x-patch",
            "text/x-diff",
            "application/x-patch",
            "application/x-diff",
        } or filename.endswith(
            (".patch", ".diff"),
        ):
            if patch_content is None:
                patch_content = content
        elif content_type == "text/plain" and not body:
            body = content

    return body, patch_content

extract_emails_from_archive ¤

extract_emails_from_archive(
    archive_path: Path,
) -> Iterator[tuple[str, Message]]

Extract email files from a .tgz archive.

Parameters:

  • archive_path ¤

    (Path) –

    Path to the .tgz archive file.

Yields:

  • tuple[str, Message] –

    Tuples of (filename, email_message) for each email found.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def extract_emails_from_archive(archive_path: Path) -> Iterator[tuple[str, EmailMessage]]:
    """Extract email files from a .tgz archive.

    Args:
        archive_path: Path to the .tgz archive file.

    Yields:
        Tuples of (filename, email_message) for each email found.
    """
    with tarfile.open(archive_path, "r:gz") as tar:
        for member in tar.getmembers():
            if member.isfile() and not member.name.startswith("."):
                # Extract to temporary location and read
                f = tar.extractfile(member)
                if f:
                    try:
                        content = f.read()
                        msg = email.message_from_bytes(content)
                        yield (member.name, msg)
                    except Exception:
                        _logger.debug("Could not parse %s from %s", member.name, archive_path, exc_info=True)

extract_quoted_text ¤

extract_quoted_text(body: str) -> tuple[str, str]

Separate new content from quoted text.

Parameters:

  • body ¤

    (str) –

    Email body.

Returns:

  • tuple[str, str] –

    Tuple of (new_content, quoted_content).

Source code in src/git_ew/_internal/email_parser.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def extract_quoted_text(body: str) -> tuple[str, str]:
    """Separate new content from quoted text.

    Args:
        body: Email body.

    Returns:
        Tuple of (new_content, quoted_content).
    """
    lines = body.split("\n")
    new_lines = []
    quoted_lines = []
    in_quote = False

    for line in lines:
        stripped = line.strip()
        # Common quote indicators
        if stripped.startswith((">", "|")):
            in_quote = True
            quoted_lines.append(line)
        elif stripped.startswith("On ") and " wrote:" in line:
            # "On [date] [person] wrote:" pattern
            in_quote = True
            quoted_lines.append(line)
        elif in_quote:
            quoted_lines.append(line)
        else:
            new_lines.append(line)

    return "\n".join(new_lines).strip(), "\n".join(quoted_lines).strip()

fetch_archive_list ¤

fetch_archive_list() -> dict[str, date | None]

Fetch the list of available archives from zsh.org with dates.

Returns:

  • dict[str, date | None] –

    Dict mapping archive filenames to their dates (or None if date couldn't be parsed)

Raises:

  • URLError –

    If the page cannot be fetched.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def fetch_archive_list() -> dict[str, date | None]:
    """Fetch the list of available archives from zsh.org with dates.

    Returns:
        Dict mapping archive filenames to their dates (or None if date couldn't be parsed)

    Raises:
        URLError: If the page cannot be fetched.
    """
    try:
        with urlopen(BASE_URL) as response:
            html = response.read().decode("utf-8")
    except URLError as e:
        raise URLError(f"Failed to fetch {BASE_URL}: {e}") from e

    parser = LinkExtractor()
    parser.feed(html)
    # Return sorted by filename
    return dict(sorted(parser.archives.items()))

find_email_by_xseq ¤

find_email_by_xseq(
    archive_dir: Path, xseq_number: int
) -> tuple[Message, int] | None

Find an email by its X-seq number by searching through archives.

Parameters:

  • archive_dir ¤

    (Path) –

    Directory containing .tgz archives.

  • xseq_number ¤

    (int) –

    The X-seq number to search for (e.g., 44316 for msg00353).

Returns:

  • tuple[Message, int] | None –

    Tuple of (email_message, xseq_number) if found, None otherwise.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def find_email_by_xseq(
    archive_dir: Path,
    xseq_number: int,
) -> tuple[EmailMessage, int] | None:
    """Find an email by its X-seq number by searching through archives.

    Args:
        archive_dir: Directory containing .tgz archives.
        xseq_number: The X-seq number to search for (e.g., 44316 for msg00353).

    Returns:
        Tuple of (email_message, xseq_number) if found, None otherwise.
    """
    for archive_path in sorted(archive_dir.glob("*.tgz")):
        try:
            for _filename, msg in extract_emails_from_archive(archive_path):
                xseq = get_email_xseq(msg)
                if xseq == xseq_number:
                    return (msg, xseq)
        except Exception:
            _logger.debug("Could not search archive %s", archive_path, exc_info=True)
            continue
    return None

get_email_in_reply_to ¤

get_email_in_reply_to(msg: Message) -> str | None

Extract In-Reply-To from email header.

Parameters:

Returns:

  • str | None –

    In-Reply-To ID or None if not found.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
148
149
150
151
152
153
154
155
156
157
158
159
160
def get_email_in_reply_to(msg: EmailMessage) -> str | None:
    """Extract In-Reply-To from email header.

    Args:
        msg: Email message object.

    Returns:
        In-Reply-To ID or None if not found.
    """
    in_reply_to = msg.get("In-Reply-To")
    if in_reply_to:
        return in_reply_to.strip("<>")
    return None

get_email_message_id ¤

get_email_message_id(msg: Message) -> str | None

Extract message ID from email header.

Parameters:

Returns:

  • str | None –

    Message ID or None if not found.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def get_email_message_id(msg: EmailMessage) -> str | None:
    """Extract message ID from email header.

    Args:
        msg: Email message object.

    Returns:
        Message ID or None if not found.
    """
    msg_id = msg.get("Message-ID")
    if msg_id:
        # Clean up message ID (remove angle brackets if present)
        return msg_id.strip("<>")
    return None

get_email_references ¤

get_email_references(msg: Message) -> list[str]

Extract References from email header.

Parameters:

Returns:

  • list[str] –

    List of reference message IDs.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def get_email_references(msg: EmailMessage) -> list[str]:
    """Extract References from email header.

    Args:
        msg: Email message object.

    Returns:
        List of reference message IDs.
    """
    refs = msg.get("References")
    if refs:
        # References are space-separated message IDs
        return [ref.strip("<>") for ref in refs.split() if ref.strip()]
    return []

get_email_xseq ¤

get_email_xseq(msg: Message) -> int | None

Extract X-seq number from email header.

The X-seq header contains the sequence number in the mailing list format: "zsh-workers 44316" means message #44316.

Parameters:

Returns:

  • int | None –

    Sequence number or None if not found.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def get_email_xseq(msg: EmailMessage) -> int | None:
    """Extract X-seq number from email header.

    The X-seq header contains the sequence number in the mailing list format:
    "zsh-workers 44316" means message #44316.

    Args:
        msg: Email message object.

    Returns:
        Sequence number or None if not found.
    """
    xseq = msg.get("X-seq")
    if xseq:
        # Format is typically "zsh-workers 44316"
        parts = xseq.strip().split()
        if len(parts) >= 2:  # noqa: PLR2004
            try:
                return int(parts[-1])  # Last part should be the number
            except ValueError:
                pass
    return None

get_engine ¤

get_engine(
    database_url: str = "sqlite+aiosqlite:///./git_ew.db",
) -> Engine

Create database engine.

Source code in src/git_ew/_internal/models.py
135
136
137
def get_engine(database_url: str = "sqlite+aiosqlite:///./git_ew.db") -> Engine:
    """Create database engine."""
    return create_engine(database_url, echo=False)

get_fetcher ¤

get_fetcher(
    source_type: str, config: dict[str, Any]
) -> EmailFetcher

Get an email fetcher based on source type.

Parameters:

  • source_type ¤

    (str) –

    Type of source (maildir, mbox, imap, public-inbox).

  • config ¤

    (dict[str, Any]) –

    Configuration for the fetcher.

Returns:

Raises:

Source code in src/git_ew/_internal/email_fetcher.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def get_fetcher(source_type: str, config: dict[str, Any]) -> EmailFetcher:
    """Get an email fetcher based on source type.

    Args:
        source_type: Type of source (maildir, mbox, imap, public-inbox).
        config: Configuration for the fetcher.

    Returns:
        EmailFetcher instance.

    Raises:
        ValueError: If source type is unknown.
    """
    if source_type == "maildir":
        return MaildirFetcher(config["path"])
    if source_type == "mbox":
        return MboxFetcher(config["path"])
    if source_type == "imap":
        return IMAPFetcher(
            host=config.get("host", "imap.fastmail.com"),
            port=int(config.get("port", 993)),
            username=config["username"],
            password=resolve_password(config),
            folders=config.get("folders"),
            mailing_list=config.get("mailing_list"),
        )
    if source_type == "public-inbox":
        return PublicInboxFetcher(config["url"])
    raise ValueError(f"Unknown source type: {source_type}")

get_missing_archives ¤

get_missing_archives(
    archive_dir: Path,
    available_archives: dict[str, date | None],
    since: date | None = None,
    until: date | None = None,
) -> list[str]

Determine which matching archives need to be downloaded.

Parameters:

  • archive_dir ¤

    (Path) –

    Directory where archives are stored.

  • available_archives ¤

    (dict[str, date | None]) –

    Dict of available archive filenames to dates.

  • since ¤

    (date | None, default: None ) –

    Only include archives from this date onwards.

  • until ¤

    (date | None, default: None ) –

    Only include archives up to this date.

Returns:

  • list[str] –

    Matching archive filenames that do not exist locally.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def get_missing_archives(
    archive_dir: Path,
    available_archives: dict[str, date | None],
    since: date | None = None,
    until: date | None = None,
) -> list[str]:
    """Determine which matching archives need to be downloaded.

    Args:
        archive_dir: Directory where archives are stored.
        available_archives: Dict of available archive filenames to dates.
        since: Only include archives from this date onwards.
        until: Only include archives up to this date.

    Returns:
        Matching archive filenames that do not exist locally.
    """
    existing = {file.name for file in archive_dir.glob("*.tgz")}

    return [
        filename for filename in _get_matching_archives(available_archives, since, until) if filename not in existing
    ]

get_parser ¤

get_parser() -> ArgumentParser

Return the CLI argument parser.

Returns:

Source code in src/git_ew/_internal/cli.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def get_parser() -> argparse.ArgumentParser:
    """Return the CLI argument parser.

    Returns:
        An argparse parser.
    """
    parser = argparse.ArgumentParser(
        prog="git-ew",
        description="Git Email Workflow - A web interface for email-based git workflows",
    )
    parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
    parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")

    # Subcommands
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Server command
    server_parser = subparsers.add_parser("serve", help="Start the web server")
    server_parser.add_argument(
        "--host",
        default="127.0.0.1",
        help="Host to bind to (default: 127.0.0.1)",
    )
    server_parser.add_argument(
        "--port",
        type=int,
        default=8000,
        help="Port to bind to (default: 8000)",
    )
    server_parser.add_argument(
        "--reload",
        action="store_true",
        help="Enable auto-reload for development",
    )

    # Init command
    subparsers.add_parser("init", help="Initialize the database")

    # Sync command
    subparsers.add_parser("sync", help="Sync emails from configured sources")

    # Configuration command
    subparsers.add_parser("config", help="Configure email accounts and sources")

    # Archive ingestion commands
    ingest_parser = subparsers.add_parser("ingest", help="Ingest mailing-list archives")
    ingest_subparsers = ingest_parser.add_subparsers(dest="ingest_source", required=True)
    zsh_workers_parser = ingest_subparsers.add_parser(
        "zsh-workers",
        help="Download and ingest zsh-workers archives",
    )
    zsh_workers_parser.add_argument(
        "--since",
        type=_parse_archive_date,
        help="Only include archives from this date (YYYY or YYYY-MM-DD)",
    )
    zsh_workers_parser.add_argument(
        "--until",
        type=lambda value: _parse_archive_date(value, end_of_year=True),
        help="Only include archives through this date (YYYY or YYYY-MM-DD)",
    )
    zsh_workers_parser.add_argument(
        "--archive-dir",
        type=Path,
        default=Path(".archives"),
        help="Directory for archives (default: .archives)",
    )
    zsh_workers_parser.add_argument(
        "--database",
        default="sqlite:///./git_ew.db",
        help="SQLAlchemy database URL (default: sqlite:///./git_ew.db)",
    )
    zsh_workers_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show archives to download without downloading or ingesting",
    )
    zsh_workers_parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="List archives selected for download",
    )

    return parser

get_session_maker ¤

get_session_maker(engine: Engine) -> sessionmaker

Get a session maker.

Source code in src/git_ew/_internal/models.py
147
148
149
def get_session_maker(engine: Engine) -> sessionmaker:
    """Get a session maker."""
    return sessionmaker(bind=engine)

get_thread_api async ¤

get_thread_api(thread_id: int) -> JSONResponse

Get thread details.

Parameters:

  • thread_id ¤

    (int) –

    Thread ID.

Returns:

  • JSONResponse –

    JSON response with thread and messages.

Source code in src/git_ew/_internal/app.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
@app.get("/api/thread/{thread_id}")
async def get_thread_api(thread_id: int) -> JSONResponse:
    """Get thread details.

    Args:
        thread_id: Thread ID.

    Returns:
        JSON response with thread and messages.
    """
    assert db is not None  # noqa: S101
    thread = await db.get_thread(thread_id)
    if not thread:
        raise HTTPException(status_code=404, detail="Thread not found")

    return JSONResponse(
        content={
            "id": thread.id,
            "subject": thread.subject,
            "created_at": thread.created_at.isoformat(),
            "updated_at": thread.updated_at.isoformat(),
            "status": thread.status,
            "is_patch": thread.is_patch,
            "messages": [
                {
                    "id": m.id,
                    "message_id": m.message_id,
                    "from_email": m.from_email,
                    "from_name": m.from_name,
                    "subject": m.subject,
                    "date": m.date.isoformat(),
                    "body": m.body,
                    "in_reply_to": m.in_reply_to,
                    "is_patch": m.is_patch,
                }
                for m in thread.messages
            ],
        },
    )

index async ¤

index(request: Request) -> HTMLResponse

Show all email threads.

Source code in src/git_ew/_internal/app.py
149
150
151
152
153
154
155
156
157
158
159
160
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
    """Show all email threads."""
    assert db is not None  # noqa: S101
    threads = await db.get_threads()
    return templates.TemplateResponse(
        request,
        "index.html",
        {
            "threads": threads,
        },
    )

ingest_archive ¤

ingest_archive(
    archive_path: Path,
    db: _SyncDatabaseProtocol,
    on_email_found: Callable[[str, Message, str], object]
    | None = None,
) -> tuple[int, int]

Ingest emails from an archive into the database.

Parameters:

  • archive_path ¤

    (Path) –

    Path to the .tgz archive.

  • db ¤

    (_SyncDatabaseProtocol) –

    Database instance.

  • on_email_found ¤

    (Callable[[str, Message, str], object] | None, default: None ) –

    Optional callback function called when an email is found. Called with (filename, msg, message_id).

Returns:

  • tuple[int, int] –

    Tuple of (inserted_count, skipped_count) for newly inserted vs existing emails.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def ingest_archive(
    archive_path: Path,
    db: _SyncDatabaseProtocol,
    on_email_found: Callable[[str, EmailMessage, str], object] | None = None,
) -> tuple[int, int]:
    """Ingest emails from an archive into the database.

    Args:
        archive_path: Path to the .tgz archive.
        db: Database instance.
        on_email_found: Optional callback function called when an email is found.
                       Called with (filename, msg, message_id).

    Returns:
        Tuple of (inserted_count, skipped_count) for newly inserted vs existing emails.
    """
    inserted = 0
    skipped = 0

    with db.session_maker() as session:
        for filename, msg in extract_emails_from_archive(archive_path):
            message_id = get_email_message_id(msg)
            if not message_id:
                continue

            # Callback for finding specific emails
            if on_email_found:
                on_email_found(filename, msg, message_id)

            # Extract patch attachments before deduplication so existing messages
            # can be enriched when the ingestion logic gains new MIME support.
            body, patch_content = extract_body_and_patch(msg)

            # Check if message already exists (deduplication)
            existing = session.query(Message).filter_by(message_id=message_id).first()
            if existing:
                if patch_content and not existing.patch_content:
                    existing.patch_content = patch_content
                    existing.is_patch = True
                skipped += 1
                continue

            # Parse email metadata
            from_header = msg.get("From", "unknown@example.com")
            from_name, from_email = parse_email_address(from_header)
            subject = decode_email_header(msg.get("Subject", "(no subject)"))
            date_str = msg.get("Date", "")
            in_reply_to = get_email_in_reply_to(msg)

            # Parse date
            try:
                date = parsedate_to_datetime(date_str)
            except (TypeError, ValueError):
                date = datetime.now(UTC)

            # Try to find or create thread
            # Use the first message in References (original message) as thread root
            # Fall back to in_reply_to, then to message_id if neither exist
            references = get_email_references(msg)
            intended_thread_root = None
            if references:
                # The first message in the References chain is the original message
                intended_thread_root = references[0]
                thread_root_id = references[0]
            elif in_reply_to:
                # If no References, use in_reply_to
                intended_thread_root = in_reply_to
                thread_root_id = in_reply_to
            else:
                # If neither References nor in_reply_to, this is the start of a new thread
                thread_root_id = message_id

            # Check if we already have a thread for this root
            thread = session.query(Thread).filter_by(first_message_id=thread_root_id).first()

            if not thread:
                # Check if the intended thread root message exists in our database
                if intended_thread_root and intended_thread_root != message_id:
                    root_message = session.query(Message).filter_by(message_id=intended_thread_root).first()
                    if root_message:
                        # Found the root message, use its thread instead
                        thread = session.query(Thread).filter_by(id=root_message.thread_id).first()
                    else:
                        # We don't have the actual first message, check if we have the in_reply_to message
                        # and if so, use its thread
                        if in_reply_to:
                            in_reply_message = session.query(Message).filter_by(message_id=in_reply_to).first()
                            if in_reply_message:
                                thread = session.query(Thread).filter_by(id=in_reply_message.thread_id).first()

                        if not thread:
                            # Still no thread, log a warning and create a new thread
                            _logger.warning(
                                "Thread first message is unavailable: %s. Using earliest available message %s (%s) as the thread root.",
                                intended_thread_root,
                                message_id,
                                date,
                            )
                            thread_root_id = message_id

                if not thread:
                    # Create new thread
                    thread = Thread(
                        subject=subject,
                        first_message_id=thread_root_id,
                        created_at=date,
                        updated_at=date,
                        is_patch="patch" in subject.lower(),
                        status="open",
                    )
                session.add(thread)
                session.flush()  # Ensure thread has an ID
            else:
                # Thread already exists, update its updated_at to the latest message date
                # Handle timezone-aware and naive datetimes safely
                # Aware and naive dates cannot be compared.
                with suppress(TypeError):
                    thread.updated_at = max(thread.updated_at, date)

            # Create message record
            message = Message(
                message_id=message_id,
                in_reply_to=in_reply_to,
                thread_id=thread.id,
                from_email=from_email,
                from_name=from_name,
                subject=subject,
                date=date,
                body=body,
                raw_email=str(msg),
                is_patch=bool(patch_content) or "patch" in subject.lower() or "---" in body[:500],
                patch_content=patch_content,
            )
            session.add(message)
            inserted += 1

        session.commit()

    return inserted, skipped

ingest_archives ¤

ingest_archives(
    archive_dir: Path = Path(".archives"),
    database_url: str = "sqlite:///./git_ew.db",
    *,
    filenames: Iterable[str] | None = None,
    verbose: bool = True,
) -> tuple[int, int]

Ingest all zsh-workers archives in a directory.

Parameters:

  • archive_dir ¤

    (Path, default: Path('.archives') ) –

    Directory containing .tgz archives.

  • database_url ¤

    (str, default: 'sqlite:///./git_ew.db' ) –

    SQLAlchemy URL for the target database.

  • filenames ¤

    (Iterable[str] | None, default: None ) –

    Archive filenames to ingest. Ingest all local archives if omitted.

  • verbose ¤

    (bool, default: True ) –

    Whether to log per-archive progress.

Returns:

  • tuple[int, int] –

    A tuple of newly inserted and duplicate message counts.

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
def ingest_archives(
    archive_dir: Path = Path(".archives"),
    database_url: str = "sqlite:///./git_ew.db",
    *,
    filenames: Iterable[str] | None = None,
    verbose: bool = True,
) -> tuple[int, int]:
    """Ingest all zsh-workers archives in a directory.

    Args:
        archive_dir: Directory containing ``.tgz`` archives.
        database_url: SQLAlchemy URL for the target database.
        filenames: Archive filenames to ingest. Ingest all local archives if omitted.
        verbose: Whether to log per-archive progress.

    Returns:
        A tuple of newly inserted and duplicate message counts.
    """
    engine = create_engine(database_url)
    Base.metadata.create_all(engine)
    db = _SyncDatabase(sessionmaker(bind=engine))
    if filenames is None:
        archives = sorted(archive_dir.glob("*.tgz"))
    else:
        archives = sorted(archive_dir / filename for filename in filenames)
        archives = [archive for archive in archives if archive.is_file()]

    if verbose:
        _logger.info("Found %d archives to ingest", len(archives))

    total_inserted = 0
    total_skipped = 0
    for archive_path in archives:
        if verbose:
            _logger.info("Ingesting %s", archive_path.name)
        try:
            inserted, skipped = ingest_archive(archive_path, db)
        except Exception:
            if verbose:
                _logger.exception("Failed to ingest %s", archive_path.name)
            raise

        total_inserted += inserted
        total_skipped += skipped
        if verbose:
            _logger.info("Ingested %s (%d new, %d skipped)", archive_path.name, inserted, skipped)

    if verbose:
        _logger.info("Total emails ingested: %d", total_inserted)
        _logger.info("Total emails skipped as duplicates: %d", total_skipped)

    return total_inserted, total_skipped

init_db ¤

init_db(
    database_url: str = "sqlite:///./git_ew.db",
) -> Engine

Initialize the database.

Source code in src/git_ew/_internal/models.py
140
141
142
143
144
def init_db(database_url: str = "sqlite:///./git_ew.db") -> Engine:
    """Initialize the database."""
    engine = create_engine(database_url, echo=False)
    Base.metadata.create_all(engine)
    return engine

lifespan async ¤

lifespan(app: FastAPI) -> AsyncIterator[None]

Application lifespan manager.

Source code in src/git_ew/_internal/app.py
64
65
66
67
68
69
70
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:  # noqa: ARG001
    """Application lifespan manager."""
    global db  # noqa: PLW0603
    db = Database()
    await db.init_db()
    yield

list_threads async ¤

list_threads(
    status: str | None = None,
    limit: int = 50,
    offset: int = 0,
) -> JSONResponse

Get list of threads.

Parameters:

  • status ¤

    (str | None, default: None ) –

    Filter by status.

  • limit ¤

    (int, default: 50 ) –

    Maximum number of threads.

  • offset ¤

    (int, default: 0 ) –

    Number of threads to skip.

Returns:

  • JSONResponse –

    JSON response with threads.

Source code in src/git_ew/_internal/app.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
@app.get("/api/threads")
async def list_threads(status: str | None = None, limit: int = 50, offset: int = 0) -> JSONResponse:
    """Get list of threads.

    Args:
        status: Filter by status.
        limit: Maximum number of threads.
        offset: Number of threads to skip.

    Returns:
        JSON response with threads.
    """
    assert db is not None  # noqa: S101
    threads = await db.get_threads(status=status, limit=limit, offset=offset)
    return JSONResponse(
        content={
            "threads": [
                {
                    "id": t.id,
                    "subject": t.subject,
                    "created_at": t.created_at.isoformat(),
                    "updated_at": t.updated_at.isoformat(),
                    "status": t.status,
                    "is_patch": t.is_patch,
                }
                for t in threads
            ],
        },
    )

main ¤

main(args: list[str] | None = None) -> int

Run the main program.

This function is executed when you type git-ew or python -m git_ew.

Parameters:

  • args ¤

    (list[str] | None, default: None ) –

    Arguments passed from the command line.

Returns:

  • int –

    An exit code.

Source code in src/git_ew/_internal/cli.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def main(args: list[str] | None = None) -> int:
    """Run the main program.

    This function is executed when you type `git-ew` or `python -m git_ew`.

    Parameters:
        args: Arguments passed from the command line.

    Returns:
        An exit code.
    """
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    parser = get_parser()
    opts = parser.parse_args(args=args)

    if opts.command == "serve":
        # Start the web server
        print(f"Starting git-ew server at http://{opts.host}:{opts.port}")
        print("Press Ctrl+C to stop the server")

        uvicorn.run(
            "git_ew._internal.app:app",
            host=opts.host,
            port=opts.port,
            reload=opts.reload,
        )
        return 0

    if opts.command == "init":
        # Initialize the database
        async def init_database() -> None:
            db = Database()
            await db.init_db()
            print("Database initialized successfully")

        asyncio.run(init_database())
        return 0

    if opts.command == "sync":
        # Sync emails from sources
        return asyncio.run(sync_command())

    if opts.command == "config":
        asyncio.run(config_command())
        return 0

    if opts.command == "ingest" and opts.ingest_source == "zsh-workers":
        opts.archive_dir.mkdir(parents=True, exist_ok=True)
        try:
            available = fetch_archive_list()
        except URLError as error:
            print(f"Error fetching zsh-workers archive list: {error}", file=sys.stderr)
            return 1

        matching = _get_matching_archives(available, opts.since, opts.until)
        missing = get_missing_archives(opts.archive_dir, available, opts.since, opts.until)
        print(f"Found {len(available)} archives available")
        print(f"Found {len(matching)} matching archives")
        print(f"Found {len(missing)} matching archives not downloaded")
        if opts.verbose:
            for filename in missing:
                print(f"  - {filename}")
        if opts.dry_run:
            print("Dry-run: no archives downloaded or ingested")
            return 0

        failed = 0
        for filename in missing:
            if not download_archive(filename, opts.archive_dir):
                failed += 1
        if failed:
            print(f"Error: {failed} archive download(s) failed", file=sys.stderr)
            return 1

        ingest_archives(opts.archive_dir, opts.database, filenames=matching)
        return 0

    # No command specified, show help
    parser.print_help()
    return 0

normalize_message_id ¤

normalize_message_id(message_id: str) -> str

Normalize a message ID by removing angle brackets.

Parameters:

  • message_id ¤

    (str) –

    Message ID.

Returns:

  • str –

    Normalized message ID.

Source code in src/git_ew/_internal/email_parser.py
265
266
267
268
269
270
271
272
273
274
def normalize_message_id(message_id: str) -> str:
    """Normalize a message ID by removing angle brackets.

    Args:
        message_id: Message ID.

    Returns:
        Normalized message ID.
    """
    return message_id.strip("<>")

parse_email ¤

parse_email(raw_email: str | bytes) -> ParsedEmail

Parse a raw email message.

Parameters:

  • raw_email ¤

    (str | bytes) –

    Raw email content as string or bytes.

Returns:

Source code in src/git_ew/_internal/email_parser.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def parse_email(raw_email: str | bytes) -> ParsedEmail:
    """Parse a raw email message.

    Args:
        raw_email: Raw email content as string or bytes.

    Returns:
        ParsedEmail object.
    """
    raw_email_bytes = raw_email.encode("utf-8") if isinstance(raw_email, str) else raw_email

    msg = email.message_from_bytes(raw_email_bytes, policy=policy.default)

    # Extract basic headers
    message_id = msg.get("Message-ID", "").strip("<>")
    subject = msg.get("Subject", "No Subject")

    # Parse from address
    from_header = msg.get("From", "")
    from_name, from_email = parseaddr(from_header)
    if not from_name:
        from_name = from_email

    # Parse date
    date_header = msg.get("Date")
    try:
        date = parsedate_to_datetime(date_header) if date_header else datetime.now(UTC)
    except Exception:  # noqa: BLE001
        date = datetime.now(UTC)

    # Get reply information
    in_reply_to = msg.get("In-Reply-To", "").strip("<>") or None
    references_header = msg.get("References", "")
    references = [ref.strip("<>") for ref in references_header.split() if ref.strip("<>")]

    body, patch_content = extract_body_and_patch(msg)
    is_patch = bool(patch_content)

    # Detect and extract patches
    # Look for git diff format or unified diff format
    patch_patterns = [
        r"diff --git",
        r"^---.*\n\+\+\+",
        r"^Index:",
    ]

    for pattern in patch_patterns:
        if re.search(pattern, body, re.MULTILINE):
            is_patch = True
            # Extract everything from the first diff marker
            match = re.search(r"(diff --git.*|^---.*\n\+\+\+.*|^Index:.*)", body, re.MULTILINE | re.DOTALL)
            if match:
                patch_content = body[match.start() :]
            break

    # Also check for [PATCH] in subject
    if "[PATCH]" in subject.upper() or "PATCH" in subject.upper():
        is_patch = True

    return ParsedEmail(
        message_id=message_id,
        from_email=from_email,
        from_name=from_name,
        subject=subject,
        date=date,
        body=body,
        in_reply_to=in_reply_to,
        references=references,
        is_patch=is_patch,
        patch_content=patch_content,
        raw=raw_email_bytes.decode("utf-8", errors="replace"),
    )

parse_email_address ¤

parse_email_address(from_header: str) -> tuple[str, str]

Parse From header to extract name and email address.

Parameters:

Returns:

Source code in src/git_ew/_internal/mailing_lists/zsh_workers/ingest.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def parse_email_address(from_header: str) -> tuple[str, str]:
    """Parse From header to extract name and email address.

    Args:
        from_header: Raw From header value (may be "Name <email@example.com>" or just "email@example.com").

    Returns:
        Tuple of (name, email).
    """
    # parseaddr handles both "Name <email>" and "email" formats
    name, email = parseaddr(from_header)

    # Decode the name if it contains encoded-words
    if name:
        name = decode_email_header(name)

    # If name is empty, use email as name
    if not name:
        name = email or from_header

    return name, email

post_comment async ¤

post_comment(
    thread_id: int, comment: CommentCreate
) -> JSONResponse

Post a comment to a thread (sends an email).

Parameters:

Returns:

  • JSONResponse –

    JSON response with created message.

Source code in src/git_ew/_internal/app.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
@app.post("/api/thread/{thread_id}/comment")
async def post_comment(thread_id: int, comment: CommentCreate) -> JSONResponse:
    """Post a comment to a thread (sends an email).

    Args:
        thread_id: Thread ID.
        comment: Comment data.

    Returns:
        JSON response with created message.
    """
    assert db is not None  # noqa: S101
    thread = await db.get_thread(thread_id)
    if not thread:
        raise HTTPException(status_code=404, detail="Thread not found")

    # Get email configuration
    email_config = await db.get_config("email_config")
    if not email_config:
        raise HTTPException(status_code=400, detail="Email not configured")

    # Create email sender
    sender = create_email_sender(email_config)

    own_addresses = {
        address.lower() for address in (email_config.get("from_email"), email_config.get("username")) if address
    }

    # Determine Reply All recipients and references.
    if comment.in_reply_to:
        # Get the message we're replying to
        reply_to_msg = await db.get_message_by_id(comment.in_reply_to)
        if not reply_to_msg:
            raise HTTPException(status_code=404, detail="Reply-to message not found")

        source_message = reply_to_msg
        subject = reply_to_msg.subject
    else:
        # Reply to the first message in the thread
        first_msg = next((m for m in thread.messages if m.message_id == thread.first_message_id), None)
        if not first_msg:
            raise HTTPException(status_code=404, detail="First message not found")

        source_message = first_msg
        subject = thread.subject

    try:
        to_email, cc, references = _reply_all_metadata(source_message, thread.first_message_id, own_addresses)
    except ValueError as error:
        raise HTTPException(status_code=400, detail=str(error)) from error

    # Build and send the email.
    outgoing = sender.build_reply(
        to_email=to_email,
        subject=subject,
        body=comment.body,
        in_reply_to=comment.in_reply_to or thread.first_message_id,
        references=references,
        cc=cc,
    )
    try:
        sender.send_message(outgoing)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to send email: {e}") from e

    # Store in database
    message_id = outgoing["Message-ID"].strip("<>")
    message = await db.create_message(
        message_id=message_id,
        thread_id=thread_id,
        from_email=email_config["from_email"],
        from_name=email_config.get("from_name", email_config["from_email"]),
        subject=outgoing["Subject"],
        date=datetime.now(UTC),
        body=comment.body,
        in_reply_to=comment.in_reply_to or thread.first_message_id,
        raw_email=outgoing.as_string(),
    )

    sent_folder_saved = False
    sent_folder_error = "No IMAP source configured"
    for source in await db.get_email_sources():
        if not source.enabled or source.source_type != "imap":
            continue
        imap_config = json.loads(source.config)
        folders = imap_config.get("folders", [])
        sent_folder = next((folder for folder in folders if folder.lower() == "sent"), "Sent")
        try:
            await asyncio.to_thread(append_sent_message, outgoing, imap_config, sent_folder)
            sent_folder_saved = True
            sent_folder_error = ""
        except (imaplib.IMAP4.error, OSError, RuntimeError) as error:
            sent_folder_error = str(error)
        break

    return JSONResponse(
        content={
            "message_id": message.message_id,
            "success": True,
            "sent_folder_saved": sent_folder_saved,
            "sent_folder_error": sent_folder_error,
        },
    )

render_markdown ¤

render_markdown(text: str) -> str

Render plain text as markdown HTML.

Parameters:

  • text ¤

    (str) –

    Plain text to render.

Returns:

  • str –

    HTML string.

Source code in src/git_ew/_internal/thread_utils.py
71
72
73
74
75
76
77
78
79
80
81
def render_markdown(text: str) -> str:
    """Render plain text as markdown HTML.

    Args:
        text: Plain text to render.

    Returns:
        HTML string.
    """
    _md.reset()
    return _md.convert(text)

resolve_password ¤

resolve_password(
    config: dict[str, Any],
    *,
    required: Literal[True] = True,
) -> str
resolve_password(
    config: dict[str, Any], *, required: Literal[False]
) -> str | None
resolve_password(
    config: dict[str, Any], *, required: bool = True
) -> str | None

Resolve a configured password.

Return None when no password is configured and required is false.

Source code in src/git_ew/_internal/secrets.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def resolve_password(config: dict[str, Any], *, required: bool = True) -> str | None:
    """Resolve a configured password.

    Return `None` when no password is configured and `required` is false.
    """
    command = config.get("password_command")
    if command:
        result = subprocess.run(  # noqa: S603
            shlex.split(command),
            check=True,
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=10,
        )
        password = result.stdout.strip()
        if not password:
            raise RuntimeError("password command returned no password")
        return password

    password = config.get("password")
    if password:
        return password
    if not required:
        return None
    raise RuntimeError("neither password nor password_command is configured")

sync_all_sources async ¤

sync_all_sources(db: Database | None = None) -> _SyncStats

Sync emails from all configured sources.

Parameters:

  • db ¤

    (Database | None, default: None ) –

    Database instance. If None, creates a new one.

Returns:

  • _SyncStats –

    Dictionary with sync statistics.

Source code in src/git_ew/_internal/sync.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
async def sync_all_sources(db: Database | None = None) -> _SyncStats:
    """Sync emails from all configured sources.

    Args:
        db: Database instance. If None, creates a new one.

    Returns:
        Dictionary with sync statistics.
    """
    if db is None:
        db = Database()
        await db.init_db()

    sources = await db.get_email_sources()
    stats: _SyncStats = {
        "total_sources": len(sources),
        "processed_sources": 0,
        "total_messages": 0,
        "new_messages": 0,
        "new_threads": 0,
        "errors": [],
    }

    for source in sources:
        if not source.enabled:
            continue

        try:
            config = json.loads(source.config)
            fetcher = get_fetcher(source.source_type, config)

            source_messages = 0
            async for parsed_email in fetcher.fetch_emails():
                # Check if message already exists
                existing = await db.get_message_by_id(parsed_email.message_id)
                if existing:
                    if parsed_email.patch_content and not existing.patch_content:
                        await db.update_message_patch(parsed_email.message_id, parsed_email.patch_content)
                    continue

                # Find or create thread
                thread_id_str = parsed_email.get_thread_id()
                thread = await db.get_thread_by_message_id(thread_id_str)

                if not thread:
                    # Create new thread
                    thread = await db.create_thread(
                        subject=parsed_email.clean_subject,
                        first_message_id=thread_id_str,
                        is_patch=parsed_email.is_patch,
                    )
                    stats["new_threads"] += 1

                # Create message
                await db.create_message(
                    message_id=parsed_email.message_id,
                    thread_id=thread.id,
                    from_email=parsed_email.from_email,
                    from_name=parsed_email.from_name,
                    subject=parsed_email.subject,
                    date=parsed_email.date,
                    body=parsed_email.body,
                    in_reply_to=parsed_email.in_reply_to,
                    is_patch=parsed_email.is_patch,
                    patch_content=parsed_email.patch_content,
                    raw_email=parsed_email.raw,
                )

                source_messages += 1
                stats["new_messages"] += 1

            stats["processed_sources"] += 1
            stats["total_messages"] += source_messages

            _logger.info(f"✓ Synced {source_messages} messages from '{source.name}'")

        except Exception as e:
            error_msg = f"Error syncing source '{source.name}': {e}"
            stats["errors"].append(error_msg)
            _logger.exception("✗ %s", error_msg)

    return stats

sync_command async ¤

sync_command() -> int

Run email sync as a standalone command.

Source code in src/git_ew/_internal/sync.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
async def sync_command() -> int:
    """Run email sync as a standalone command."""
    logging.basicConfig(level=logging.INFO, format="%(message)s")

    _logger.info("Starting email sync...")
    _logger.info("-" * 50)

    db = Database()
    await db.init_db()

    stats = await sync_all_sources(db)

    _logger.info("-" * 50)
    _logger.info("\nSync Summary:")
    _logger.info(f"  Sources processed: {stats['processed_sources']}/{stats['total_sources']}")
    _logger.info(f"  New threads: {stats['new_threads']}")
    _logger.info(f"  New messages: {stats['new_messages']}")

    if stats["errors"]:
        _logger.info(f"\n  Errors: {len(stats['errors'])}")
        for error in stats["errors"]:
            _logger.info(f"    - {error}")

    return 0 if not stats["errors"] else 1

sync_emails async ¤

sync_emails() -> JSONResponse

Sync emails from configured sources.

Returns:

  • JSONResponse –

    JSON response with sync results.

Source code in src/git_ew/_internal/app.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
@app.post("/api/sync")
async def sync_emails() -> JSONResponse:
    """Sync emails from configured sources.

    Returns:
        JSON response with sync results.
    """
    assert db is not None  # noqa: S101
    stats = await sync_all_sources(db)

    return JSONResponse(
        content={
            "success": not stats["errors"],
            "synced": stats["new_messages"],
            "errors": stats["errors"],
        },
    )

thread_to_nested_structure ¤

thread_to_nested_structure(
    roots: list[ThreadNode], *, flatten: bool = True
) -> list[_RenderedMessage]

Convert thread tree to nested structure, with single-children popped out to sibling level.

Also detects and marks quoted sections in message bodies.

Parameters:

  • roots ¤

    (list[ThreadNode]) –

    List of root ThreadNodes.

  • flatten ¤

    (bool, default: True ) –

    Place a sole child beside its parent when true.

Returns:

  • list[_RenderedMessage] –

    Nested list of messages.

Source code in src/git_ew/_internal/thread_utils.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def thread_to_nested_structure(roots: list[ThreadNode], *, flatten: bool = True) -> list[_RenderedMessage]:
    """Convert thread tree to nested structure, with single-children popped out to sibling level.

    Also detects and marks quoted sections in message bodies.

    Args:
        roots: List of root ThreadNodes.
        flatten: Place a sole child beside its parent when true.

    Returns:
        Nested list of messages.
    """
    result: list[_RenderedMessage] = []
    for root in roots:
        body, quoted_body = _split_quoted_reply(root.message.body)
        rendered = _RenderedMessage(
            message=root.message,
            body=body,
            quoted_body=quoted_body,
            children=[],
        )
        if flatten and len(root.children) == 1:
            result.append(rendered)
            result.extend(thread_to_nested_structure(root.children, flatten=flatten))
        elif root.children:
            rendered.children = thread_to_nested_structure(root.children, flatten=flatten)
            result.append(rendered)
        else:
            result.append(rendered)
    return result

update_thread async ¤

update_thread(
    thread_id: int, update: ThreadUpdate
) -> JSONResponse

Update a thread.

Parameters:

Returns:

  • JSONResponse –

    JSON response.

Source code in src/git_ew/_internal/app.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@app.post("/api/thread/{thread_id}")
async def update_thread(thread_id: int, update: ThreadUpdate) -> JSONResponse:
    """Update a thread.

    Args:
        thread_id: Thread ID.
        update: Update data.

    Returns:
        JSON response.
    """
    assert db is not None  # noqa: S101
    thread = await db.get_thread(thread_id)
    if not thread:
        raise HTTPException(status_code=404, detail="Thread not found")

    if update.status:
        await db.update_thread_status(thread_id, update.status)

    return JSONResponse(content={"success": True})

view_thread async ¤

view_thread(
    request: Request,
    thread_id: int,
    *,
    flatten: bool = True,
) -> HTMLResponse

View a specific thread with all messages.

Parameters:

  • request ¤

    (Request) –

    FastAPI request.

  • thread_id ¤

    (int) –

    Thread ID.

  • flatten ¤

    (bool, default: True ) –

    Whether to flatten linear chains.

Returns:

  • HTMLResponse –

    HTML response.

Source code in src/git_ew/_internal/app.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
@app.get("/thread/{thread_id}", response_class=HTMLResponse)
async def view_thread(request: Request, thread_id: int, *, flatten: bool = True) -> HTMLResponse:
    """View a specific thread with all messages.

    Args:
        request: FastAPI request.
        thread_id: Thread ID.
        flatten: Whether to flatten linear chains.

    Returns:
        HTML response.
    """
    assert db is not None  # noqa: S101
    thread = await db.get_thread(thread_id)
    if not thread:
        raise HTTPException(status_code=404, detail="Thread not found")

    # Build thread tree
    tree = build_thread_tree(thread.messages)
    nested_messages = thread_to_nested_structure(tree, flatten=flatten)

    return templates.TemplateResponse(
        request,
        "thread.html",
        {
            "thread": thread,
            "messages": nested_messages,
        },
    )