Skip to content

Commit 8fa4747

Browse files
committed
feat: define search folder per newsletter #3
1 parent eefd5b4 commit 8fa4747

10 files changed

Lines changed: 224 additions & 26 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""add search_folder to newsletter
2+
3+
Revision ID: 75ed3dbf1e16
4+
Revises: 1ed7baeaf282
5+
Create Date: 2025-08-12 21:13:07.535625
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '75ed3dbf1e16'
16+
down_revision: Union[str, Sequence[str], None] = '1ed7baeaf282'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.add_column('newsletters', sa.Column('search_folder', sa.String(), nullable=True))
25+
# ### end Alembic commands ###
26+
27+
28+
def downgrade() -> None:
29+
"""Downgrade schema."""
30+
# ### commands auto generated by Alembic - please adjust! ###
31+
op.drop_column('newsletters', 'search_folder')
32+
# ### end Alembic commands ###

backend/app/crud/newsletters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def create_newsletter(db: Session, newsletter: NewsletterCreate):
6464
id=generate(size=10),
6565
name=newsletter.name,
6666
slug=newsletter.slug,
67+
search_folder=newsletter.search_folder,
6768
extract_content=newsletter.extract_content,
6869
move_to_folder=newsletter.move_to_folder,
6970
)

backend/app/models/newsletters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Newsletter(Base):
1212
id = Column(String, primary_key=True, index=True)
1313
slug = Column(String, unique=True, index=True, nullable=True)
1414
name = Column(String)
15+
search_folder = Column(String, nullable=True)
1516
move_to_folder = Column(String, nullable=True)
1617
is_active = Column(Boolean, default=True)
1718
extract_content = Column(Boolean, default=False)

backend/app/schemas/newsletters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class NewsletterBase(BaseModel):
3131

3232
name: str
3333
slug: str | None = None
34+
search_folder: str | None = None
3435
move_to_folder: str | None = None
3536
extract_content: bool = False
3637

backend/app/services/email_processor.py

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,22 @@ def _is_configured(settings: Settings | None) -> bool:
3434
return True
3535

3636

37-
def _connect_to_imap(settings: Settings) -> imaplib.IMAP4_SSL | None:
37+
def _connect_to_imap(
38+
settings: Settings, search_folder: str
39+
) -> imaplib.IMAP4_SSL | None:
3840
"""Connect to the IMAP server and select the mailbox."""
3941
try:
4042
logger.info(f"Connecting to IMAP server: {settings.imap_server}")
4143
mail = imaplib.IMAP4_SSL(settings.imap_server)
4244
mail.login(settings.imap_username, settings.imap_password)
43-
status, messages = mail.select(settings.search_folder)
45+
status, messages = mail.select(search_folder)
4446
if status != "OK":
4547
logger.error(
46-
f"Failed to select mailbox: {settings.search_folder}, status: {status}, messages: {messages}"
48+
f"Failed to select mailbox: {search_folder}, status: {status}, messages: {messages}"
4749
)
4850
mail.logout()
4951
return None
50-
logger.info(f"Selected mailbox: {settings.search_folder}")
52+
logger.info(f"Selected mailbox: {search_folder}")
5153
return mail
5254
except Exception as e:
5355
logger.error(f"Failed to connect to IMAP server: {e}", exc_info=True)
@@ -235,26 +237,61 @@ def process_emails(db: Session) -> None:
235237
if not _is_configured(settings):
236238
return
237239

238-
newsletters = get_newsletters(db)
239-
sender_map = {sender.email: nl for nl in newsletters for sender in nl.senders}
240-
logger.info(f"Processing emails for {len(newsletters)} newsletters.")
240+
all_newsletters = get_newsletters(db)
241+
logger.info(f"Processing emails for {len(all_newsletters)} newsletters.")
241242

242-
mail = _connect_to_imap(settings)
243-
if not mail:
244-
return
243+
# Group newsletters by search folder
244+
folder_groups: dict[str, list[Newsletter]] = {}
245+
for nl in all_newsletters:
246+
folder = nl.search_folder or settings.search_folder
247+
if folder not in folder_groups:
248+
folder_groups[folder] = []
249+
folder_groups[folder].append(nl)
245250

246-
try:
247-
email_ids = _fetch_unread_email_ids(mail)
248-
logger.info(f"Found {len(email_ids)} unseen emails.")
249-
for num in email_ids:
250-
_process_single_email(num, mail, db, sender_map, settings)
251+
# If auto-adding is enabled, ensure the default search folder is always checked.
252+
if settings.auto_add_new_senders and settings.search_folder not in folder_groups:
253+
folder_groups[settings.search_folder] = []
251254

252-
if settings.move_to_folder:
253-
logger.info("Expunging deleted emails")
254-
mail.expunge()
255+
for search_folder, newsletters_in_folder in folder_groups.items():
256+
logger.info(
257+
f"Processing folder '{search_folder}' for {len(newsletters_in_folder)} newsletters."
258+
)
259+
sender_map = {
260+
sender.email: nl for nl in newsletters_in_folder for sender in nl.senders
261+
}
262+
263+
mail = _connect_to_imap(settings, search_folder)
264+
if not mail:
265+
logger.warning(
266+
f"Skipping folder '{search_folder}' due to connection issue."
267+
)
268+
continue
255269

256-
except Exception as e:
257-
logger.error(f"Error processing emails: {e}", exc_info=True)
258-
finally:
259-
mail.logout()
260-
logger.info("Email processing finished successfully.")
270+
try:
271+
email_ids = _fetch_unread_email_ids(mail)
272+
logger.info(
273+
f"Found {len(email_ids)} unseen emails in folder '{search_folder}'."
274+
)
275+
for num in email_ids:
276+
_process_single_email(num, mail, db, sender_map, settings)
277+
278+
# Expunge logic needs to be carefully considered.
279+
# If any newsletter in this folder group has a move_to_folder, we expunge.
280+
# This is an approximation. A more robust solution might require per-email expunge.
281+
should_expunge = any(
282+
nl.move_to_folder or settings.move_to_folder
283+
for nl in newsletters_in_folder
284+
)
285+
if should_expunge:
286+
logger.info(f"Expunging deleted emails from '{search_folder}'")
287+
mail.expunge()
288+
289+
except Exception as e:
290+
logger.error(
291+
f"Error processing emails in folder '{search_folder}': {e}",
292+
exc_info=True,
293+
)
294+
finally:
295+
mail.logout()
296+
297+
logger.info("Email processing finished successfully.")

backend/app/tests/test_crud.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,33 @@ def test_create_newsletter_with_move_to_folder(db_session: Session):
152152
assert retrieved_newsletter.extract_content is True
153153

154154

155+
def test_create_newsletter_with_search_folder(db_session: Session):
156+
"""Test creating and updating a newsletter with the search_folder attribute."""
157+
unique_email = f"sender_{uuid.uuid4()}@test.com"
158+
newsletter_data = NewsletterCreate(
159+
name="Test Newsletter with Search Folder",
160+
sender_emails=[unique_email],
161+
search_folder="CustomInbox",
162+
)
163+
newsletter = create_newsletter(db_session, newsletter_data)
164+
retrieved_newsletter = get_newsletter_by_identifier(db_session, newsletter.id)
165+
166+
assert retrieved_newsletter.name == "Test Newsletter with Search Folder"
167+
assert retrieved_newsletter.search_folder == "CustomInbox"
168+
169+
# Test updating the search_folder
170+
from app.crud.newsletters import update_newsletter
171+
from app.schemas.newsletters import NewsletterUpdate
172+
173+
update_data = NewsletterUpdate(
174+
name=newsletter.name,
175+
sender_emails=[unique_email],
176+
search_folder="UpdatedCustomInbox",
177+
)
178+
updated_newsletter = update_newsletter(db_session, newsletter.id, update_data)
179+
assert updated_newsletter.search_folder == "UpdatedCustomInbox"
180+
181+
155182
def test_get_newsletter_by_identifier(db_session: Session):
156183
"""Test getting a single newsletter."""
157184
unique_email = f"sender_{uuid.uuid4()}@test.com"

backend/app/tests/test_email_processor.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
from app.crud.settings import create_or_update_settings
99
from app.models.newsletters import Newsletter
1010
from app.schemas.newsletters import NewsletterCreate
11-
from app.schemas.settings import SettingsCreate
12-
from app.services.email_processor import _process_single_email
11+
from app.schemas.settings import Settings, SettingsCreate
12+
from app.services.email_processor import _process_single_email, process_emails
1313

1414

1515
def _setup_test_email_processing(
1616
db_session: Session,
1717
newsletter_create_data: NewsletterCreate,
1818
settings_create_data: SettingsCreate,
19-
) -> tuple[MagicMock, Newsletter, SettingsCreate]:
19+
) -> tuple[MagicMock, Newsletter, Settings]:
2020
"""Help to set up mocks and data for email processing tests."""
2121
settings = create_or_update_settings(db_session, settings_create_data)
2222
newsletter = create_newsletter(db_session, newsletter_create_data)
@@ -84,6 +84,74 @@ def test_process_single_email_with_global_move_folder(db_session: Session):
8484
mock_mail.store.assert_any_call("1", "+FLAGS", "\\Deleted")
8585

8686

87+
@patch("app.services.email_processor._connect_to_imap")
88+
def test_process_emails_uses_newsletter_search_folder(
89+
mock_connect_to_imap,
90+
db_session: Session,
91+
):
92+
"""Test that the per-newsletter search_folder is used, overriding the global setting."""
93+
# 1. ARRANGE
94+
settings_data = SettingsCreate(
95+
imap_server="test.com",
96+
imap_username="test",
97+
imap_password="password",
98+
search_folder="GlobalInbox",
99+
)
100+
create_or_update_settings(db_session, settings_data)
101+
102+
newsletter_data = NewsletterCreate(
103+
name="Test Newsletter",
104+
sender_emails=["test@example.com"],
105+
search_folder="NewsletterInbox",
106+
)
107+
create_newsletter(db_session, newsletter_data)
108+
109+
# Mock the return of _connect_to_imap to avoid a real IMAP connection
110+
mock_connect_to_imap.return_value = None
111+
112+
# 2. ACT
113+
process_emails(db_session)
114+
115+
# 3. ASSERT
116+
# Check that _connect_to_imap was called with the newsletter's specific folder
117+
mock_connect_to_imap.assert_called_once()
118+
call_args = mock_connect_to_imap.call_args[0]
119+
assert call_args[1] == "NewsletterInbox"
120+
121+
122+
@patch("app.services.email_processor._connect_to_imap")
123+
def test_process_emails_uses_global_search_folder(
124+
mock_connect_to_imap,
125+
db_session: Session,
126+
):
127+
"""Test that the global search_folder is used when the per-newsletter one is not set."""
128+
# 1. ARRANGE
129+
settings_data = SettingsCreate(
130+
imap_server="test.com",
131+
imap_username="test",
132+
imap_password="password",
133+
search_folder="GlobalInbox",
134+
)
135+
create_or_update_settings(db_session, settings_data)
136+
137+
newsletter_data = NewsletterCreate(
138+
name="Test Newsletter",
139+
sender_emails=["test@example.com"],
140+
search_folder=None, # Explicitly not set
141+
)
142+
create_newsletter(db_session, newsletter_data)
143+
144+
mock_connect_to_imap.return_value = None
145+
146+
# 2. ACT
147+
process_emails(db_session)
148+
149+
# 3. ASSERT
150+
mock_connect_to_imap.assert_called_once()
151+
call_args = mock_connect_to_imap.call_args[0]
152+
assert call_args[1] == "GlobalInbox"
153+
154+
87155
@patch("app.services.email_processor._extract_and_clean_html")
88156
def test_process_single_email_with_content_extraction(
89157
mock_extract_clean,

frontend/src/components/letterfeed/NewsletterDialog.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const getInitialState = (newsletter: Newsletter | null | undefined) => {
3636
name: newsletter.name,
3737
slug: newsletter.slug || "",
3838
emails: newsletter.senders.map((s) => s.email),
39+
search_folder: newsletter.search_folder || "",
3940
move_to_folder: newsletter.move_to_folder || "",
4041
extract_content: newsletter.extract_content,
4142
}
@@ -44,6 +45,7 @@ const getInitialState = (newsletter: Newsletter | null | undefined) => {
4445
name: "",
4546
slug: "",
4647
emails: [""],
48+
search_folder: "",
4749
move_to_folder: "",
4850
extract_content: false,
4951
}
@@ -89,6 +91,7 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan
8991
name: formData.name,
9092
slug: formData.slug,
9193
sender_emails: formData.emails.filter((email) => email.trim()),
94+
search_folder: formData.search_folder,
9295
move_to_folder: formData.move_to_folder,
9396
extract_content: formData.extract_content,
9497
}
@@ -148,6 +151,28 @@ export function NewsletterDialog({ newsletter, isOpen, folderOptions, onOpenChan
148151
/>
149152
</div>
150153

154+
<div className="space-y-2">
155+
<Label htmlFor="search_folder">Folder to Search</Label>
156+
<Select
157+
value={formData.search_folder || "None"}
158+
onValueChange={(value) =>
159+
setFormData((prev) => ({ ...prev, search_folder: value === "None" ? "" : value }))
160+
}
161+
>
162+
<SelectTrigger>
163+
<SelectValue placeholder="Select folder or leave empty" />
164+
</SelectTrigger>
165+
<SelectContent>
166+
<SelectItem value="None">Default (use global setting)</SelectItem>
167+
{folderOptions.map((folder) => (
168+
<SelectItem key={folder} value={folder}>
169+
{folder}
170+
</SelectItem>
171+
))}
172+
</SelectContent>
173+
</Select>
174+
</div>
175+
151176
<div className="space-y-2">
152177
<Label htmlFor="move_to_folder">Move To Folder</Label>
153178
<Select

frontend/src/components/letterfeed/__tests__/NewsletterDialog.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const mockNewsletter: Newsletter = {
2323
extract_content: false,
2424
senders: [{ id: "1", email: "current@example.com" }],
2525
entries_count: 5,
26+
search_folder: "",
2627
move_to_folder: "",
2728
}
2829

@@ -62,6 +63,7 @@ describe("NewsletterDialog", () => {
6263
name: "My New Newsletter",
6364
slug: "my-new-newsletter",
6465
sender_emails: ["test@example.com"],
66+
search_folder: "",
6567
move_to_folder: "",
6668
extract_content: false,
6769
})
@@ -109,6 +111,7 @@ describe("NewsletterDialog", () => {
109111
name: "Updated Name",
110112
slug: "existing-newsletter",
111113
sender_emails: ["current@example.com"],
114+
search_folder: "",
112115
move_to_folder: "",
113116
extract_content: false,
114117
})

frontend/src/lib/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface Newsletter {
1111
name: string
1212
slug: string | null
1313
is_active: boolean
14+
search_folder?: string | null
1415
move_to_folder?: string | null
1516
extract_content: boolean
1617
senders: { id: string; email: string }[]
@@ -21,6 +22,7 @@ export interface NewsletterCreate {
2122
name: string;
2223
slug?: string | null;
2324
sender_emails: string[];
25+
search_folder?: string | null;
2426
move_to_folder?: string | null;
2527
extract_content: boolean;
2628
}
@@ -29,6 +31,7 @@ export interface NewsletterUpdate {
2931
name: string;
3032
slug?: string | null;
3133
sender_emails: string[];
34+
search_folder?: string | null;
3235
move_to_folder?: string | null;
3336
extract_content: boolean;
3437
}

0 commit comments

Comments
 (0)