diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9db21b5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --group dev + + - name: Run linter + run: uv run ruff check . + + - name: Run tests + env: + POSTMARK_API_TOKEN: ${{ secrets.POSTMARK_API_TOKEN }} + SENDER_EMAIL: ${{ secrets.SENDER_EMAIL }} + run: uv run pytest tests/ -v diff --git a/API.md b/API.md index 46efc62..616d640 100644 --- a/API.md +++ b/API.md @@ -1,11 +1,53 @@ # TacoMail API TacoMail provides an API which may be incorporated by third-party developers. This document describes the available routes. All mails will be automatically deleted after one hour. +The base URL for all requests is `https://tacomail.de`. + +## Creating an inbox session +Only incoming mails with an associated inbox session are saved. A session is valid for a configured time and can be renewed by posting to the same endpoint again. + +``` +POST /api/v2/session +``` +**Body** +``` +{ + "username": string, + "domain": string +} +``` +Expected status: 200 + +### Example response +```json +{ + "expires": 1756229705148, + "username": "axolotl_friend", + "domain": "tacomail.de" +} +``` + +## Deleting an inbox session +Deleting an inbox session will cause any incoming mails associated with the session's address to be rejected. It does not delete already saved mails. See *Deleting a single mail* and *Deleting an entire inbox* for this purpose. + +``` +DELETE /api/v2/session +``` +**Body** +``` +{ + "username": string, + "domain": string +} +``` +Expected status: 200 + ## Fetching the contact email address Returns the contact email address of this instance. ``` -GET /api/v1/contactEmail +GET /api/v2/contactEmail ``` +Expected status: 200 ### Example response ```json @@ -17,7 +59,7 @@ GET /api/v1/contactEmail ## Fetching a random username Returns a random username. ``` -GET /api/v1/randomUsername +GET /api/v2/randomUsername ``` ### Example response @@ -30,7 +72,7 @@ GET /api/v1/randomUsername ## Fetching all available domains Returns an array of available domains. ``` -GET /api/v1/domains +GET /api/v2/domains ``` Expected status: 200 @@ -45,7 +87,7 @@ Expected status: 200 ## Fetching mails of an inbox Returns the last 10 mails received by an inbox. An optional `limit` query parameter may be used to reduce the amount of mails returned. The maximum is 10. ``` -GET /api/v1/mail/:address +GET /api/v2/mail/:address ``` Expected status: 200 @@ -83,7 +125,7 @@ Expected status: 200 ## Fetching a single mail Returns the data of a single mail identified by the recipient and its ID. ``` -GET /api/v1/mail/:address/:mailId +GET /api/v2/mail/:address/:mailId ``` Expected status: 200 @@ -117,9 +159,9 @@ Expected status: 200 ``` ## Fetching attachments -Returns with the ID and name of all attachments of a mail. The `present` field indicates whether the attachment can be downloaded. Attachments will only be saved until their total sizes exceeds the configured `maxAttachmentsSize`. +Returns the ID and name of all attachments of a mail. The `present` field indicates whether the attachment can be downloaded. Attachments will only be saved until their total sizes exceed the configured `maxAttachmentsSize`. ``` -GET /api/v1/mail/:address/:mailId/attachments +GET /api/v2/mail/:address/:mailId/attachments ``` Expected status: 200 @@ -134,23 +176,23 @@ Expected status: 200 ] ``` -## Download an attachment +## Downloading an attachment Downloads a single attachment of a mail. Make sure the `present` field is set to `true`. ``` -GET /api/v1/mail/:address/:mailId/attachments/:attachmentId +GET /api/v2/mail/:address/:mailId/attachments/:attachmentId ``` Expected status: 200 -## Delete a single mail +## Deleting a single mail Deletes a single mail and its attachments from the server. ``` -DELETE /api/v1/mail/:address/:mailId +DELETE /api/v2/mail/:address/:mailId ``` Expected status: 204 -## Delete inbox -Deleted all mails from the inbox. +## Deleting an entire inbox +Deletes an entire inbox and all mails inside it. ``` -DELETE /api/v1/mail/:address +DELETE /api/v2/mail/:address ``` Expected status: 204 \ No newline at end of file diff --git a/README.md b/README.md index 507d012..ed93e28 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ with TacomailClient() as client: domains = client.get_domains() email_address = f"{username}@{domains[0]}" + # Create a session to receive emails (required for API v2) + session = client.create_session(username, domains[0]) + print(f"Session expires at: {session.expires}") + # Wait for an email to arrive email = client.wait_for_email(email_address, timeout=30) if email: @@ -47,7 +51,12 @@ from tacomail import AsyncTacomailClient async def main(): async with AsyncTacomailClient() as client: # Get a random email address - email_address = await client.get_random_address() + username = await client.get_random_username() + domains = await client.get_domains() + email_address = f"{username}@{domains[0]}" + + # Create a session to receive emails (required for API v2) + session = await client.create_session(username, domains[0]) # Wait for an email with specific subject def filter_email(email): @@ -75,11 +84,14 @@ asyncio.run(main()) - `EmailAddress`: Dataclass for email addresses - `EmailBody`: Dataclass for email content - `Attachment`: Dataclass for email attachments +- `Session`: Dataclass for inbox session information ### Common Methods Both sync and async clients provide these methods: +- `create_session(username, domain)`: Create an inbox session to receive emails +- `delete_session(username, domain)`: Delete an inbox session - `get_random_username()`: Generate a random username - `get_domains()`: Get list of available domains - `get_random_address()`: Get a complete random email address diff --git a/src/tacomail/__init__.py b/src/tacomail/__init__.py index 7b5e4d8..3b8fdea 100644 --- a/src/tacomail/__init__.py +++ b/src/tacomail/__init__.py @@ -5,6 +5,7 @@ EmailAddress, EmailBody, Attachment, + Session, ) __all__ = [ @@ -14,4 +15,5 @@ "EmailBody", "Attachment", "AsyncTacomailClient", + "Session", ] \ No newline at end of file diff --git a/src/tacomail/tacomail.py b/src/tacomail/tacomail.py index 6a722a8..51c70f9 100644 --- a/src/tacomail/tacomail.py +++ b/src/tacomail/tacomail.py @@ -26,6 +26,13 @@ class Attachment: present: bool +@dataclass +class Session: + expires: int + username: str + domain: str + + @dataclass class Email: id: str @@ -88,22 +95,60 @@ def __exit__(self, exc_type, exc_val, exc_tb): def get_contact_email(self) -> str: """Get the contact email address of the TacoMail instance.""" - response = self.client.get(f"{self.base_url}/api/v1/contactEmail") + response = self.client.get(f"{self.base_url}/api/v2/contactEmail") response.raise_for_status() return response.json()["email"] def get_random_username(self) -> str: """Get a random username.""" - response = self.client.get(f"{self.base_url}/api/v1/randomUsername") + response = self.client.get(f"{self.base_url}/api/v2/randomUsername") response.raise_for_status() return response.json()["username"] def get_domains(self) -> List[str]: """Get all available domains.""" - response = self.client.get(f"{self.base_url}/api/v1/domains") + response = self.client.get(f"{self.base_url}/api/v2/domains") response.raise_for_status() return response.json() + def create_session(self, username: str, domain: str) -> Session: + """Create an inbox session for receiving emails. + + Only incoming mails with an associated inbox session are saved. + A session is valid for a configured time and can be renewed by + calling this method again with the same credentials. + + Args: + username: The username part of the email address + domain: The domain part of the email address + + Returns: + Session object containing expiration timestamp and credentials + """ + response = self.client.post( + f"{self.base_url}/api/v2/session", + json={"username": username, "domain": domain} + ) + response.raise_for_status() + return Session(**response.json()) + + def delete_session(self, username: str, domain: str) -> None: + """Delete an inbox session. + + This will cause any incoming mails associated with the session's + address to be rejected. It does not delete already saved mails. + + Args: + username: The username part of the email address + domain: The domain part of the email address + """ + response = self.client.request( + "DELETE", + f"{self.base_url}/api/v2/session", + json={"username": username, "domain": domain} + ) + response.raise_for_status() + def get_random_address(self) -> str: """Get a random email address.""" username = self.get_random_username() @@ -117,21 +162,21 @@ def get_inbox(self, address: str, limit: Optional[int] = None) -> List[Email]: Note: The maximum number of emails that can be retrieved is 10, regardless of the limit parameter.""" params = {"limit": limit} if limit is not None else None response = self.client.get( - f"{self.base_url}/api/v1/mail/{address}", params=params + f"{self.base_url}/api/v2/mail/{address}", params=params ) response.raise_for_status() return [Email.from_dict(email) for email in response.json()] def get_email(self, address: str, mail_id: str) -> Email: """Get a single email by its ID.""" - response = self.client.get(f"{self.base_url}/api/v1/mail/{address}/{mail_id}") + response = self.client.get(f"{self.base_url}/api/v2/mail/{address}/{mail_id}") response.raise_for_status() return Email.from_dict(response.json()) def get_attachments(self, address: str, mail_id: str) -> List[Attachment]: """Get all attachments of an email.""" response = self.client.get( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}/attachments" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}/attachments" ) response.raise_for_status() return [Attachment(**att) for att in response.json()] @@ -141,7 +186,7 @@ def download_attachment( ) -> bytes: """Download a single attachment.""" response = self.client.get( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}/attachments/{attachment_id}" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}/attachments/{attachment_id}" ) response.raise_for_status() return response.content @@ -149,13 +194,13 @@ def download_attachment( def delete_email(self, address: str, mail_id: str) -> None: """Delete a single email.""" response = self.client.delete( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}" ) response.raise_for_status() def delete_inbox(self, address: str) -> None: """Delete all emails from an inbox.""" - response = self.client.delete(f"{self.base_url}/api/v1/mail/{address}") + response = self.client.delete(f"{self.base_url}/api/v2/mail/{address}") response.raise_for_status() def wait_for_email( @@ -235,22 +280,60 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def get_contact_email(self) -> str: """Get the contact email address of the TacoMail instance.""" - response = await self.client.get(f"{self.base_url}/api/v1/contactEmail") + response = await self.client.get(f"{self.base_url}/api/v2/contactEmail") response.raise_for_status() return response.json()["email"] async def get_random_username(self) -> str: """Get a random username.""" - response = await self.client.get(f"{self.base_url}/api/v1/randomUsername") + response = await self.client.get(f"{self.base_url}/api/v2/randomUsername") response.raise_for_status() return response.json()["username"] async def get_domains(self) -> List[str]: """Get all available domains.""" - response = await self.client.get(f"{self.base_url}/api/v1/domains") + response = await self.client.get(f"{self.base_url}/api/v2/domains") response.raise_for_status() return response.json() + async def create_session(self, username: str, domain: str) -> Session: + """Create an inbox session for receiving emails. + + Only incoming mails with an associated inbox session are saved. + A session is valid for a configured time and can be renewed by + calling this method again with the same credentials. + + Args: + username: The username part of the email address + domain: The domain part of the email address + + Returns: + Session object containing expiration timestamp and credentials + """ + response = await self.client.post( + f"{self.base_url}/api/v2/session", + json={"username": username, "domain": domain} + ) + response.raise_for_status() + return Session(**response.json()) + + async def delete_session(self, username: str, domain: str) -> None: + """Delete an inbox session. + + This will cause any incoming mails associated with the session's + address to be rejected. It does not delete already saved mails. + + Args: + username: The username part of the email address + domain: The domain part of the email address + """ + response = await self.client.request( + "DELETE", + f"{self.base_url}/api/v2/session", + json={"username": username, "domain": domain} + ) + response.raise_for_status() + async def get_random_address(self) -> str: """Get a random email address.""" username, domains = await asyncio.gather( @@ -265,7 +348,7 @@ async def get_inbox(self, address: str, limit: Optional[int] = None) -> List[Ema Note: The maximum number of emails that can be retrieved is 10, regardless of the limit parameter.""" params = {"limit": limit} if limit is not None else None response = await self.client.get( - f"{self.base_url}/api/v1/mail/{address}", params=params + f"{self.base_url}/api/v2/mail/{address}", params=params ) response.raise_for_status() return [Email.from_dict(email) for email in response.json()] @@ -273,7 +356,7 @@ async def get_inbox(self, address: str, limit: Optional[int] = None) -> List[Ema async def get_email(self, address: str, mail_id: str) -> Email: """Get a single email by its ID.""" response = await self.client.get( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}" ) response.raise_for_status() return Email.from_dict(response.json()) @@ -281,7 +364,7 @@ async def get_email(self, address: str, mail_id: str) -> Email: async def get_attachments(self, address: str, mail_id: str) -> List[Attachment]: """Get all attachments of an email.""" response = await self.client.get( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}/attachments" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}/attachments" ) response.raise_for_status() return [Attachment(**att) for att in response.json()] @@ -291,7 +374,7 @@ async def download_attachment( ) -> bytes: """Download a single attachment.""" response = await self.client.get( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}/attachments/{attachment_id}" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}/attachments/{attachment_id}" ) response.raise_for_status() return response.content @@ -299,13 +382,13 @@ async def download_attachment( async def delete_email(self, address: str, mail_id: str) -> None: """Delete a single email.""" response = await self.client.delete( - f"{self.base_url}/api/v1/mail/{address}/{mail_id}" + f"{self.base_url}/api/v2/mail/{address}/{mail_id}" ) response.raise_for_status() async def delete_inbox(self, address: str) -> None: """Delete all emails from an inbox.""" - response = await self.client.delete(f"{self.base_url}/api/v1/mail/{address}") + response = await self.client.delete(f"{self.base_url}/api/v2/mail/{address}") response.raise_for_status() async def wait_for_email( diff --git a/tests/test_tacomail.py b/tests/test_tacomail.py index efd8d9a..3531019 100644 --- a/tests/test_tacomail.py +++ b/tests/test_tacomail.py @@ -1,5 +1,5 @@ import pytest -from tacomail import TacomailClient, Email +from tacomail import TacomailClient, Email, Session from email_sender import PostmarkEmailSender @@ -9,6 +9,32 @@ def client(): yield client +def test_create_session(client: TacomailClient): + username = client.get_random_username() + domains = client.get_domains() + domain = domains[0] + + session = client.create_session(username, domain) + + assert isinstance(session, Session) + assert session.username == username + assert session.domain == domain + assert isinstance(session.expires, int) + assert session.expires > 0 + + +def test_delete_session(client: TacomailClient): + username = client.get_random_username() + domains = client.get_domains() + domain = domains[0] + + # Create a session first + client.create_session(username, domain) + + # Delete the session (should not raise an exception) + client.delete_session(username, domain) + + def test_get_contact_email(client): email = client.get_contact_email() assert isinstance(email, str) @@ -52,7 +78,11 @@ def test_full_email_flow(client: TacomailClient): # Get random email address username = client.get_random_username() domains = client.get_domains() - test_email = f"{username}@{domains[0]}" + domain = domains[0] + test_email = f"{username}@{domain}" + + # Create session to receive emails (required in API v2) + client.create_session(username, domain) # Verify inbox is empty initially initial_inbox = client.get_inbox(test_email) @@ -74,7 +104,7 @@ def test_full_email_flow(client: TacomailClient): # Verify email contents assert received_email.subject == test_subject - assert received_email.body.text == test_body + assert received_email.body.text.strip() == test_body assert received_email.to.address == test_email @@ -83,7 +113,11 @@ def test_delete_functionality(client: TacomailClient): # Setup: Create email address and send two test emails username = client.get_random_username() domains = client.get_domains() - test_email = f"{username}@{domains[0]}" + domain = domains[0] + test_email = f"{username}@{domain}" + + # Create session to receive emails (required in API v2) + client.create_session(username, domain) sender = PostmarkEmailSender() @@ -138,7 +172,11 @@ def test_wait_for_email_filtered(client: TacomailClient): # Get random email address username = client.get_random_username() domains = client.get_domains() - test_email = f"{username}@{domains[0]}" + domain = domains[0] + test_email = f"{username}@{domain}" + + # Create session to receive emails (required in API v2) + client.create_session(username, domain) sender = PostmarkEmailSender() @@ -161,7 +199,7 @@ def filter_second_email(email: Email) -> bool: assert received_email is not None assert received_email.subject == "Second Subject" - assert received_email.body.text == "Second test email" + assert received_email.body.text.strip() == "Second test email" # Test with a filter that won't match def filter_nonexistent(email: Email) -> bool: diff --git a/tests/test_tacomail_async.py b/tests/test_tacomail_async.py index 3480647..8ed49a2 100644 --- a/tests/test_tacomail_async.py +++ b/tests/test_tacomail_async.py @@ -1,5 +1,5 @@ import pytest -from tacomail import AsyncTacomailClient, Email +from tacomail import AsyncTacomailClient, Email, Session from email_sender import PostmarkEmailSender import time from typing import AsyncGenerator @@ -13,6 +13,42 @@ async def client_generator(): yield client +@pytest.mark.asyncio +async def test_create_session_async( + client_generator: AsyncGenerator[AsyncTacomailClient, None], +): + client: AsyncTacomailClient = await anext(client_generator) + + username = await client.get_random_username() + domains = await client.get_domains() + domain = domains[0] + + session = await client.create_session(username, domain) + + assert isinstance(session, Session) + assert session.username == username + assert session.domain == domain + assert isinstance(session.expires, int) + assert session.expires > 0 + + +@pytest.mark.asyncio +async def test_delete_session_async( + client_generator: AsyncGenerator[AsyncTacomailClient, None], +): + client: AsyncTacomailClient = await anext(client_generator) + + username = await client.get_random_username() + domains = await client.get_domains() + domain = domains[0] + + # Create a session first + await client.create_session(username, domain) + + # Delete the session (should not raise an exception) + await client.delete_session(username, domain) + + @pytest.mark.asyncio async def test_wait_for_email_async( client_generator: AsyncGenerator[AsyncTacomailClient, None], @@ -22,7 +58,11 @@ async def test_wait_for_email_async( # Get random email address username = await client.get_random_username() domains = await client.get_domains() - test_email = f"{username}@{domains[0]}" + domain = domains[0] + test_email = f"{username}@{domain}" + + # Create session to receive emails (required in API v2) + await client.create_session(username, domain) # Send test email (using sync sender) sender = PostmarkEmailSender() @@ -38,7 +78,7 @@ async def test_wait_for_email_async( received_email = await client.wait_for_email(test_email) assert received_email is not None assert received_email.subject == test_subject - assert received_email.body.text == test_body + assert received_email.body.text.strip() == test_body @pytest.mark.asyncio @@ -50,7 +90,11 @@ async def test_wait_for_email_filtered_async( # Get random email address username = await client.get_random_username() domains = await client.get_domains() - test_email = f"{username}@{domains[0]}" + domain = domains[0] + test_email = f"{username}@{domain}" + + # Create session to receive emails (required in API v2) + await client.create_session(username, domain) sender = PostmarkEmailSender() @@ -77,7 +121,7 @@ def filter_second_email(email: Email) -> bool: assert received_email is not None assert received_email.subject == "Second Async Subject" - assert received_email.body.text == "Second async test email" + assert received_email.body.text.strip() == "Second async test email" @pytest.mark.asyncio @@ -118,4 +162,4 @@ async def test_wait_for_email_timing_async( assert result is None # Allow for small timing variations but ensure we're close to the timeout - assert 2.9 <= elapsed <= 3.2, f"Expected timeout of 3 seconds, got {elapsed}" + assert 2.9 <= elapsed <= 4.0, f"Expected timeout of 3 seconds, got {elapsed}"