Skip to content
Merged
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
6 changes: 3 additions & 3 deletions email_profile/clients/imap/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ def append(
from email_profile.serializers.email import Message

if isinstance(message, Message):
raw = message.file.encode("utf-8")
raw = message.file
if date is None:
date = message.date
elif isinstance(message, str):
raw = message.encode("utf-8")
elif isinstance(message, bytes):
raw = message
elif isinstance(message, (bytes, bytearray)):
raw = bytes(message)
else:
raise TypeError(
"append expects Message, bytes or str — "
Expand Down
2 changes: 1 addition & 1 deletion email_profile/clients/imap/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def sync(
uid=entry.uid,
mailbox=mailbox.name,
flags=entry.flags,
file=entry.text(),
file=entry.raw(),
)

try:
Expand Down
6 changes: 3 additions & 3 deletions email_profile/models/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

from __future__ import annotations

from sqlalchemy import Column, String, Text, UniqueConstraint
from sqlalchemy import Column, LargeBinary, String, UniqueConstraint

from email_profile.storage.db import Base


class RawModel(Base):
"""Complete RFC822 source (with attachments in base64)."""
"""Complete RFC822 source stored verbatim as bytes."""

__tablename__ = "raw"
__table_args__ = (
Expand All @@ -19,4 +19,4 @@ class RawModel(Base):
mailbox = Column(String, primary_key=True)
message_id = Column(String, nullable=False, index=True)
flags = Column(String, nullable=False, default="")
file = Column(Text)
file = Column(LargeBinary)
4 changes: 2 additions & 2 deletions email_profile/serializers/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class Message(BaseModel):
reply_to: Optional[str] = None

subject: Optional[str] = None
file: str
file: bytes
body_text_plain: str = ""
body_text_html: str = ""
content_type: Optional[str] = None
Expand Down Expand Up @@ -66,7 +66,7 @@ def from_raw(
bcc=parsed.bcc,
reply_to=parsed.reply_to,
subject=parsed.subject,
file=raw.decode("utf-8", errors="replace"),
file=raw,
body_text_plain=parsed.body_text_plain,
body_text_html=parsed.body_text_html,
content_type=parsed.content_type,
Expand Down
28 changes: 25 additions & 3 deletions email_profile/serializers/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,36 @@

from __future__ import annotations

from pydantic import BaseModel
from typing import Union

from pydantic import BaseModel, ConfigDict, field_validator


class RawSerializer(BaseModel):
"""Minimum data contract for any storage backend."""
"""Minimum data contract for any storage backend.

``file`` stores the raw RFC822 bytes verbatim so binary attachments
and non-UTF8 bodies round-trip without loss. ``str`` input is accepted
for backward compatibility and encoded as latin-1 (byte-preserving).
"""

model_config = ConfigDict(arbitrary_types_allowed=True)

message_id: str
uid: str
mailbox: str
flags: str = ""
file: str
file: bytes

@field_validator("file", mode="before")
@classmethod
def _coerce_file(
cls, value: Union[bytes, bytearray, memoryview, str]
) -> bytes:
if isinstance(value, (bytes, bytearray, memoryview)):
return bytes(value)
if isinstance(value, str):
return value.encode("latin-1", errors="replace")
raise TypeError(
f"file must be bytes or str, got {type(value).__name__}"
)
17 changes: 15 additions & 2 deletions tests/serializers/test_eml.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,23 @@ def test_attaches_parsed_body(self):
class TestRawSerializer(TestCase):
def test_creates_from_fields(self):
raw = RawSerializer(
message_id="<abc@x>", uid="1", mailbox="INBOX", file="raw content"
message_id="<abc@x>", uid="1", mailbox="INBOX", file=b"raw content"
)
self.assertEqual(raw.message_id, "<abc@x>")
self.assertEqual(raw.file, "raw content")
self.assertEqual(raw.file, b"raw content")

def test_str_input_coerced_to_bytes_latin1(self):
raw = RawSerializer(
message_id="<x>", uid="1", mailbox="INBOX", file="hello"
)
self.assertEqual(raw.file, b"hello")

def test_bytes_preserved_verbatim(self):
payload = bytes(range(256))
raw = RawSerializer(
message_id="<x>", uid="1", mailbox="INBOX", file=payload
)
self.assertEqual(raw.file, payload)


class TestRawModel(TestCase):
Expand Down
16 changes: 11 additions & 5 deletions tests/storage/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def _raw(
message_id: str = "<test@x>",
uid: str = "1",
mailbox: str = "INBOX",
file_content: str = "raw content",
file_content: bytes = b"raw content",
) -> RawSerializer:
return RawSerializer(
message_id=message_id, uid=uid, mailbox=mailbox, file=file_content
Expand Down Expand Up @@ -47,17 +47,23 @@ def test_save_and_get(self):
result = self.storage.get("<test@x>")
self.assertIsNotNone(result)
self.assertEqual(result.message_id, "<test@x>")
self.assertEqual(result.file, "raw content")
self.assertEqual(result.file, b"raw content")

def test_binary_payload_round_trips_verbatim(self):
payload = bytes(range(256))
self.storage.save(_raw("<bin@x>", file_content=payload))
result = self.storage.get("<bin@x>")
self.assertEqual(result.file, payload)

def test_get_returns_none_for_missing(self):
result = self.storage.get("nonexistent")
self.assertIsNone(result)

def test_save_upserts(self):
self.storage.save(_raw("<x@x>", file_content="v1"))
self.storage.save(_raw("<x@x>", file_content="v2"))
self.storage.save(_raw("<x@x>", file_content=b"v1"))
self.storage.save(_raw("<x@x>", file_content=b"v2"))
result = self.storage.get("<x@x>")
self.assertEqual(result.file, "v2")
self.assertEqual(result.file, b"v2")

def test_ids(self):
self.storage.save(_raw("<a@x>"))
Expand Down
Loading