Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# LETTERFEED_MARK_AS_READ=true # Mark processed emails as read
# LETTERFEED_EMAIL_CHECK_INTERVAL=15 # Interval between checks for new emails
# LETTERFEED_AUTO_ADD_NEW_SENDERS=false # Automatically set up new emails for unknown senders
# LETTERFEED_MAX_ENTRIES_PER_FEED=0 # Max entries per feed, including the master feed (0 = unlimited)

# Authentication
# To generate a new secret key, run:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
id: meta_backend
uses: docker/metadata-action@v5
with:
images: ghcr.io/leonmuscoden/letterfeed-backend
images: ghcr.io/${{ github.repository_owner }}/letterfeed-backend
tags: |
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
Expand Down Expand Up @@ -119,7 +119,7 @@ jobs:
id: meta_frontend
uses: docker/metadata-action@v5
with:
images: ghcr.io/leonmuscoden/letterfeed-frontend
images: ghcr.io/${{ github.repository_owner }}/letterfeed-frontend
tags: |
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add max_entries_per_feed to settings

Revision ID: a1b2c3d4e5f6
Revises: 75ed3dbf1e16
Create Date: 2026-04-03 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '75ed3dbf1e16'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.add_column('settings', sa.Column('max_entries_per_feed', sa.Integer(), nullable=True, server_default='0'))


def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('settings', 'max_entries_per_feed')
1 change: 1 addition & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Settings(BaseSettings):
mark_as_read: bool = False
email_check_interval: int = 15
auto_add_new_senders: bool = False
max_entries_per_feed: int = 0
auth_username: str | None = None
auth_password: str | None = None
secret_key: str | None = Field(
Expand Down
1 change: 1 addition & 0 deletions backend/app/crud/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
"mark_as_read": db_settings.mark_as_read,
"email_check_interval": db_settings.email_check_interval,
"auto_add_new_senders": db_settings.auto_add_new_senders,
"max_entries_per_feed": db_settings.max_entries_per_feed,
"auth_username": db_settings.auth_username,
}

Expand Down
1 change: 1 addition & 0 deletions backend/app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ class Settings(Base):
mark_as_read = Column(Boolean, default=False)
email_check_interval = Column(Integer, default=15) # Interval in minutes
auto_add_new_senders = Column(Boolean, default=False)
max_entries_per_feed = Column(Integer, default=0)
auth_username = Column(String, nullable=True)
auth_password_hash = Column(String, nullable=True)
1 change: 1 addition & 0 deletions backend/app/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class SettingsBase(BaseModel):
mark_as_read: bool = False
email_check_interval: int = 15
auto_add_new_senders: bool = False
max_entries_per_feed: int = 0
auth_username: str | None = None


Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/email_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def _process_single_email(
move_folder = newsletter.move_to_folder or settings.move_to_folder
if move_folder:
logger.debug(f"Moving email with id={num} to {move_folder}")
mail.copy(num, move_folder)
mail.copy(num, f'"{move_folder}"')
mail.store(num, "+FLAGS", "\\Deleted")


Expand Down
34 changes: 31 additions & 3 deletions backend/app/services/feed_generator.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import re
from typing import List

from bs4 import BeautifulSoup
from dateutil import tz
from feedgen.feed import FeedGenerator
from sqlalchemy.orm import Session

from app.core.config import settings
from app.crud.entries import get_all_entries, get_entries_by_newsletter
from app.crud.newsletters import get_newsletter_by_identifier
from app.crud.settings import get_settings
from app.models.entries import Entry


def _make_responsive(html: str) -> str:
"""Strip hardcoded widths and add responsive inline styles to HTML content."""
soup = BeautifulSoup(html, "html.parser")

for tag in soup.find_all(["img", "table", "td", "div"]):
# Remove hardcoded width/height attributes
tag.attrs.pop("width", None)
tag.attrs.pop("height", None)

# Override width/height in existing inline styles
existing_style = tag.get("style", "")
existing_style = re.sub(r"width\s*:\s*[^;]+;?", "", existing_style)
existing_style = re.sub(r"height\s*:\s*[^;]+;?", "", existing_style)

responsive = "max-width:100%;height:auto;"
new_style = f"{responsive}{existing_style}".strip()
tag["style"] = new_style

return str(soup)


def _create_feed_generator(
feed_id: str, title: str, feed_url: str, description: str
) -> FeedGenerator:
Expand Down Expand Up @@ -40,7 +64,7 @@ def _add_entries_to_feed(
if is_master_feed
else entry.subject
)
fe.content(entry.body, type="html")
fe.content(_make_responsive(entry.body), type="html")

if entry.received_at.tzinfo is None:
timezone_aware_received_at = entry.received_at.replace(tzinfo=tz.tzutc())
Expand All @@ -57,7 +81,9 @@ def generate_feed(db: Session, feed_identifier: str):
if not newsletter:
return None

entries = get_entries_by_newsletter(db, newsletter.id)
app_settings = get_settings(db)
limit = app_settings.max_entries_per_feed or None
entries = get_entries_by_newsletter(db, newsletter.id, limit=limit)

feed_url = f"{settings.app_base_url}/feeds/{newsletter.slug or newsletter.id}"
sender_emails = ", ".join([s.email for s in newsletter.senders])
Expand All @@ -77,7 +103,9 @@ def generate_feed(db: Session, feed_identifier: str):

def generate_master_feed(db: Session):
"""Generate a master Atom feed for all newsletters."""
entries = get_all_entries(db)
app_settings = get_settings(db)
limit = app_settings.max_entries_per_feed or None
entries = get_all_entries(db, limit=limit)

feed_url = f"{settings.app_base_url}/feeds/all"

Expand Down
2 changes: 1 addition & 1 deletion backend/app/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def test_process_emails(mock_imap, db_session: Session):
mock_mail.search.assert_called_once_with(None, "(UNSEEN)")
mock_mail.fetch.assert_called_once_with(b"1", "(BODY.PEEK[])")
mock_mail.store.assert_any_call(b"1", "+FLAGS", "\\Seen")
mock_mail.copy.assert_called_once_with(b"1", "Processed")
mock_mail.copy.assert_called_once_with(b"1", '"Processed"')
mock_mail.store.assert_any_call(b"1", "+FLAGS", "\\Deleted")
mock_mail.expunge.assert_called_once()
mock_mail.logout.assert_called_once()
Expand Down
4 changes: 2 additions & 2 deletions backend/app/tests/test_email_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def test_process_single_email_with_newsletter_move_folder(db_session: Session):
_process_single_email("1", mock_mail, db_session, sender_map, settings)

# 3. ASSERT
mock_mail.copy.assert_called_once_with("1", "NewsletterArchive")
mock_mail.copy.assert_called_once_with("1", '"NewsletterArchive"')
mock_mail.store.assert_any_call("1", "+FLAGS", "\\Deleted")


Expand All @@ -80,7 +80,7 @@ def test_process_single_email_with_global_move_folder(db_session: Session):
_process_single_email("1", mock_mail, db_session, sender_map, settings)

# 3. ASSERT
mock_mail.copy.assert_called_once_with("1", "GlobalArchive")
mock_mail.copy.assert_called_once_with("1", '"GlobalArchive"')
mock_mail.store.assert_any_call("1", "+FLAGS", "\\Deleted")


Expand Down
21 changes: 16 additions & 5 deletions backend/app/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@

from app.crud.entries import create_entry
from app.crud.newsletters import create_newsletter
from app.crud.settings import create_or_update_settings
from app.schemas.entries import EntryCreate
from app.schemas.newsletters import NewsletterCreate
from app.schemas.settings import SettingsCreate
from app.services.feed_generator import generate_feed, generate_master_feed


def test_generate_master_feed(db_session: Session):
"""Test the master feed generation for all newsletters."""
create_or_update_settings(
db_session,
SettingsCreate(imap_server="test.com", imap_username="test", imap_password="pw"),
)

# Create newsletters and entries
nl1 = create_newsletter(
db_session,
Expand Down Expand Up @@ -56,6 +63,12 @@ def test_generate_master_feed(db_session: Session):

def test_generate_feed(db_session: Session):
"""Test the feed generation for a newsletter with entries."""
# Create settings (required by generate_feed)
create_or_update_settings(
db_session,
SettingsCreate(imap_server="test.com", imap_username="test", imap_password="pw"),
)

# Create a newsletter
newsletter_data = NewsletterCreate(
name="Feed Test Newsletter", sender_emails=["feed@example.com"]
Expand Down Expand Up @@ -102,12 +115,10 @@ def test_generate_feed(db_session: Session):
assert "First Entry" in entry_titles
assert "Second Entry" in entry_titles

# Check content of one entry
# Check content of one entry (wrapped in responsive container)
first_entry_element = root.find(".//atom:title[.='First Entry']/..", ns)
assert (
first_entry_element.find("atom:content", ns).text
== "<p>This is the first entry.</p>"
)
content_text = first_entry_element.find("atom:content", ns).text
assert "<p>This is the first entry.</p>" in content_text


def test_generate_feed_nonexistent_newsletter(db_session: Session):
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/letterfeed/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,26 @@ export function SettingsDialog({
disabled={settings.locked_fields.includes("email_check_interval")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-entries">Max Entries Per Feed</Label>
<Input
id="max-entries"
type="number"
min="0"
value={currentSettings.max_entries_per_feed}
onChange={(e) =>
handleSettingsChange(
"max_entries_per_feed",
Number.parseInt(e.target.value) || 0
)
}
placeholder="0 (unlimited)"
disabled={settings.locked_fields.includes("max_entries_per_feed")}
/>
<p className="text-xs text-muted-foreground">
0 = unlimited.
</p>
</div>
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id="mark-read"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface Settings {
mark_as_read: boolean;
email_check_interval: number;
auto_add_new_senders: boolean;
max_entries_per_feed: number;
locked_fields: string[];
}

Expand All @@ -57,6 +58,7 @@ export interface SettingsCreate {
mark_as_read: boolean;
email_check_interval: number;
auto_add_new_senders: boolean;
max_entries_per_feed: number;
}


Expand Down