From 0df175c31c853cdfbea3e5e601848b12d6cfc994 Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Wed, 29 Jul 2026 21:39:20 +0100 Subject: [PATCH 1/6] feat(warehouse): add Shipmail source --- frontend/public/services/shipmail.png | Bin 0 -> 7803 bytes frontend/src/queries/schema.json | 1 + frontend/src/queries/schema/schema-general.ts | 1 + posthog/schema_enums.py | 1 + .../migrations/0112_shipmail_source.py | 2546 +++++++++++++++++ .../temporal/data_imports/sources/SOURCES.md | 1 + .../data_imports/sources/_load_all.py | 1 + .../sources/generated_configs/shipmail.py | 9 + .../data_imports/sources/shipmail/__init__.py | 1 + .../shipmail/canonical_descriptions.py | 79 + .../data_imports/sources/shipmail/settings.py | 53 + .../data_imports/sources/shipmail/shipmail.py | 129 + .../data_imports/sources/shipmail/source.py | 168 ++ .../sources/shipmail/tests/__init__.py | 1 + .../sources/shipmail/tests/test_shipmail.py | 205 ++ .../shipmail/tests/test_shipmail_source.py | 72 + products/warehouse_sources/backend/types.py | 1 + 17 files changed, 3269 insertions(+) create mode 100644 frontend/public/services/shipmail.png create mode 100644 products/warehouse_sources/backend/migrations/0112_shipmail_source.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/__init__.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py create mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py diff --git a/frontend/public/services/shipmail.png b/frontend/public/services/shipmail.png new file mode 100644 index 0000000000000000000000000000000000000000..8a53e4a422534a2de881fbaf3c54117db5a26a8e GIT binary patch literal 7803 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 str: + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def _initial_paginator_state( + resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], +) -> dict[str, str] | None: + if not resumable_source_manager.can_resume(): + return None + resume = resumable_source_manager.load_state() + if resume is None or resume.cursor is None: + return None + return {"cursor": resume.cursor} + + +def shipmail_source( + api_key: str, + endpoint: str, + team_id: int, + job_id: str, + resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], + should_use_incremental_field: bool = False, + db_incremental_field_last_value: Optional[Any] = None, + incremental_field: Optional[str] = None, +) -> SourceResponse: + endpoint_config = SHIPMAIL_ENDPOINTS[endpoint] + params: dict[str, Any] = {"limit": 100} + + if ( + endpoint == "messages" + and should_use_incremental_field + and incremental_field == "updated_at" + and db_incremental_field_last_value is not None + ): + params["updated_after"] = _incremental_value(db_incremental_field_last_value) + + rest_config: RESTAPIConfig = { + "client": { + "base_url": SHIPMAIL_BASE_URL, + "headers": {"Accept": "application/json"}, + "auth": {"type": "bearer", "token": api_key}, + "paginator": JSONResponseCursorPaginator( + cursor_path="pagination.next_cursor", + cursor_param="cursor", + ), + }, + "resource_defaults": {}, + "resources": [ + { + "name": endpoint, + "endpoint": { + "path": endpoint_config.path, + "params": params, + "data_selector": "data", + "data_selector_required": True, + }, + } + ], + } + + def save_checkpoint(state: dict[str, Any] | None) -> None: + if state and state.get("cursor"): + resumable_source_manager.save_state(ShipMailResumeConfig(cursor=str(state["cursor"]))) + + resource = rest_api_resource( + rest_config, + team_id, + job_id, + db_incremental_field_last_value, + resume_hook=save_checkpoint, + initial_paginator_state=_initial_paginator_state(resumable_source_manager), + ) + + return SourceResponse( + name=endpoint, + items=lambda: resource, + primary_keys=endpoint_config.primary_keys, + partition_count=1, + partition_size=1, + partition_mode="datetime", + partition_format="month", + partition_keys=[endpoint_config.partition_key], + sort_mode="asc" if endpoint == "messages" else "desc", + ) + + +def get_capabilities(api_key: str) -> tuple[int | None, set[str]]: + try: + session = make_tracked_session(redact_values=(api_key,)) + response = session.get( + f"{SHIPMAIL_BASE_URL}/capabilities", + headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"}, + timeout=10, + ) + if response.status_code != 200: + return response.status_code, set() + body = response.json() + scopes = body.get("scopes") + if not isinstance(scopes, list) or not all(isinstance(scope, str) for scope in scopes): + return response.status_code, set() + return response.status_code, {scope for scope in scopes if isinstance(scope, str)} + except Exception: # noqa: BLE001 - credential probes must return a validation result, never raise + return None, set() diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py new file mode 100644 index 000000000000..e81f2b17b681 --- /dev/null +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py @@ -0,0 +1,168 @@ +from typing import Optional, cast + +from posthog.schema import ( + DataWarehouseSourceCategory, + ExternalDataSourceType as SchemaExternalDataSourceType, + ReleaseStatus, + SourceConfig, + SourceFieldInputConfig, + SourceFieldInputConfigType, +) + +from products.warehouse_sources.backend.temporal.data_imports.sources.common.base import FieldType, ResumableSource +from products.warehouse_sources.backend.temporal.data_imports.sources.common.canonical_descriptions import ( + CanonicalDescriptions, +) +from products.warehouse_sources.backend.temporal.data_imports.sources.common.registry import SourceRegistry +from products.warehouse_sources.backend.temporal.data_imports.sources.common.resumable import ResumableSourceManager +from products.warehouse_sources.backend.temporal.data_imports.sources.common.schema import SourceSchema +from products.warehouse_sources.backend.temporal.data_imports.sources.common.typings import SourceInputs, SourceResponse +from products.warehouse_sources.backend.temporal.data_imports.sources.generated_configs.shipmail import ( + ShipMailSourceConfig, +) +from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.settings import SHIPMAIL_ENDPOINTS +from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail import ( + ShipMailResumeConfig, + get_capabilities, + shipmail_source, +) +from products.warehouse_sources.backend.types import ExternalDataSourceType + + +@SourceRegistry.register +class ShipMailSource(ResumableSource[ShipMailSourceConfig, ShipMailResumeConfig]): + lists_tables_without_credentials = True + api_docs_url = "https://shipmail.to/docs/api" + + @property + def source_type(self) -> ExternalDataSourceType: + return ExternalDataSourceType.SHIPMAIL + + @property + def get_source_config(self) -> SourceConfig: + return SourceConfig( + name=SchemaExternalDataSourceType.SHIP_MAIL, + category=DataWarehouseSourceCategory.MARKETING___EMAIL, + label="ShipMail", + releaseStatus=ReleaseStatus.ALPHA, + caption="""Enter your ShipMail API key to sync message analytics, mailboxes, domains, and suppressions into the PostHog Data warehouse. + +Grant the key the read scopes for the tables you want to sync: `messages:read`, `mailboxes:read`, `domains:read`, and `suppressions:read`.""", + iconPath="/static/services/shipmail.png", + docsUrl="https://posthog.com/docs/cdp/sources/shipmail", + fields=cast( + list[FieldType], + [ + SourceFieldInputConfig( + name="api_key", + label="API key", + type=SourceFieldInputConfigType.PASSWORD, + required=True, + placeholder="ShipMail API key", + caption="Create a key in your [ShipMail API settings](https://shipmail.to/settings/api-keys).", + secret=True, + ), + ], + ), + ) + + def get_canonical_descriptions(self) -> CanonicalDescriptions: + from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.canonical_descriptions import ( + CANONICAL_DESCRIPTIONS, + ) + + return CANONICAL_DESCRIPTIONS + + def get_non_retryable_errors(self) -> dict[str, str | None]: + return { + "401 Client Error: Unauthorized for url: https://shipmail.to": "Your ShipMail API key is invalid or has been revoked. Create a new key and reconnect.", + "403 Client Error: Forbidden for url: https://shipmail.to": "Your ShipMail API key is missing the read scope required for this table.", + } + + def get_schemas( + self, + config: ShipMailSourceConfig, + team_id: int, + with_counts: bool = False, + names: list[str] | None = None, + force_refresh: bool = False, + api_version: str | None = None, + ) -> list[SourceSchema]: + schemas = [ + SourceSchema( + name=name, + supports_incremental=bool(endpoint.incremental_fields), + supports_append=bool(endpoint.incremental_fields), + incremental_fields=endpoint.incremental_fields, + detected_primary_keys=endpoint.primary_keys, + ) + for name, endpoint in SHIPMAIL_ENDPOINTS.items() + ] + if names is not None: + names_set = set(names) + schemas = [schema for schema in schemas if schema.name in names_set] + return schemas + + def validate_credentials( + self, + config: ShipMailSourceConfig, + team_id: int, + schema_name: Optional[str] = None, + api_version: str | None = None, + ) -> tuple[bool, str | None]: + status, scopes = get_capabilities(config.api_key) + if status == 200: + if schema_name in SHIPMAIL_ENDPOINTS: + required_scope = SHIPMAIL_ENDPOINTS[schema_name].required_scope + if "*" not in scopes and required_scope not in scopes: + return False, f"Your ShipMail API key is missing the `{required_scope}` scope" + return True, None + if status == 401: + return False, "Invalid ShipMail API key" + if status == 403: + return False, "Your ShipMail API key cannot access the capabilities endpoint" + return False, "Could not validate ShipMail API key" + + def get_endpoint_permissions( + self, + config: ShipMailSourceConfig, + team_id: int, + endpoints: list[str], + api_version: str | None = None, + ) -> dict[str, str | None]: + status, scopes = get_capabilities(config.api_key) + if status == 401: + return dict.fromkeys(endpoints, "API key is invalid") + if status != 200: + return dict.fromkeys(endpoints) + + permissions: dict[str, str | None] = {} + for endpoint in endpoints: + endpoint_config = SHIPMAIL_ENDPOINTS.get(endpoint) + if endpoint_config is None or "*" in scopes or endpoint_config.required_scope in scopes: + permissions[endpoint] = None + else: + permissions[endpoint] = f"API key is missing the `{endpoint_config.required_scope}` scope" + return permissions + + def get_resumable_source_manager(self, inputs: SourceInputs) -> ResumableSourceManager[ShipMailResumeConfig]: + return ResumableSourceManager[ShipMailResumeConfig](inputs, ShipMailResumeConfig) + + def source_for_pipeline( + self, + config: ShipMailSourceConfig, + resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], + inputs: SourceInputs, + ) -> SourceResponse: + return shipmail_source( + api_key=config.api_key, + endpoint=inputs.schema_name, + team_id=inputs.team_id, + job_id=inputs.job_id, + resumable_source_manager=resumable_source_manager, + should_use_incremental_field=inputs.should_use_incremental_field, + db_incremental_field_last_value=inputs.db_incremental_field_last_value + if inputs.should_use_incremental_field + else None, + incremental_field=inputs.incremental_field, + ) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py new file mode 100644 index 000000000000..28dbf3203112 --- /dev/null +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py @@ -0,0 +1,205 @@ +import json +from datetime import UTC, datetime +from typing import Any + +import pytest +from unittest import mock + +from requests import Response + +from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail import ( + SHIPMAIL_BASE_URL, + ShipMailResumeConfig, + _incremental_value, + get_capabilities, + shipmail_source, +) + +CLIENT_SESSION_PATCH = "products.warehouse_sources.backend.temporal.data_imports.sources.common.rest_source.rest_client.make_tracked_session" +CAPABILITIES_SESSION_PATCH = ( + "products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail.make_tracked_session" +) + + +def _response(body: Any, status_code: int = 200) -> Response: + response = Response() + response.status_code = status_code + response._content = json.dumps(body).encode() + return response + + +def _manager(resume_state: ShipMailResumeConfig | None = None) -> mock.MagicMock: + manager = mock.MagicMock() + manager.can_resume.return_value = resume_state is not None + manager.load_state.return_value = resume_state + return manager + + +def _wire(session: mock.MagicMock, responses: list[Response]) -> list[dict[str, Any]]: + session.headers = {} + params: list[dict[str, Any]] = [] + + def prepare(request: Any) -> mock.MagicMock: + params.append(dict(request.params or {})) + return mock.MagicMock() + + session.prepare_request.side_effect = prepare + session.send.side_effect = responses + return params + + +def _source(endpoint: str, manager: mock.MagicMock, **overrides: Any): + return shipmail_source( + "test-key", + endpoint, + team_id=1, + job_id="job", + resumable_source_manager=manager, + **overrides, + ) + + +def _rows(source_response) -> list[dict[str, Any]]: + return [row for page in source_response.items() for row in page] + + +def test_incremental_value_preserves_iso_timestamp() -> None: + value = datetime(2026, 7, 29, 12, 30, tzinfo=UTC) + assert _incremental_value(value) == "2026-07-29T12:30:00+00:00" + assert _incremental_value("2026-07-29T12:30:00Z") == "2026-07-29T12:30:00Z" + + +@mock.patch(CLIENT_SESSION_PATCH) +def test_messages_paginates_checkpoints_and_sends_incremental_watermark(MockSession) -> None: + session = MockSession.return_value + params = _wire( + session, + [ + _response( + { + "data": [{"id": "msg_1"}], + "pagination": {"next_cursor": "cursor_2", "has_more": True, "limit": 100}, + } + ), + _response( + { + "data": [{"id": "msg_2"}], + "pagination": {"next_cursor": None, "has_more": False, "limit": 100}, + } + ), + ], + ) + manager = _manager() + + rows = _rows( + _source( + "messages", + manager, + should_use_incremental_field=True, + db_incremental_field_last_value="2026-07-01T00:00:00Z", + incremental_field="updated_at", + ) + ) + + assert rows == [{"id": "msg_1"}, {"id": "msg_2"}] + assert params[0] == {"limit": 100, "updated_after": "2026-07-01T00:00:00Z"} + assert params[1] == { + "limit": 100, + "updated_after": "2026-07-01T00:00:00Z", + "cursor": "cursor_2", + } + manager.save_state.assert_called_once_with(ShipMailResumeConfig(cursor="cursor_2")) + + +@mock.patch(CLIENT_SESSION_PATCH) +def test_resumes_from_saved_cursor(MockSession) -> None: + session = MockSession.return_value + params = _wire( + session, + [ + _response( + { + "data": [{"id": "mbx_2"}], + "pagination": {"next_cursor": None, "has_more": False, "limit": 100}, + } + ) + ], + ) + + rows = _rows(_source("mailboxes", _manager(ShipMailResumeConfig(cursor="cursor_2")))) + + assert rows == [{"id": "mbx_2"}] + assert params[0] == {"limit": 100, "cursor": "cursor_2"} + + +@mock.patch(CLIENT_SESSION_PATCH) +def test_full_refresh_endpoint_does_not_send_incremental_watermark(MockSession) -> None: + session = MockSession.return_value + params = _wire( + session, + [ + _response( + { + "data": [{"id": "domain_1"}], + "pagination": {"next_cursor": None, "has_more": False, "limit": 100}, + } + ) + ], + ) + + _rows( + _source( + "domains", + _manager(), + should_use_incremental_field=True, + db_incremental_field_last_value="2026-07-01T00:00:00Z", + incremental_field="updated_at", + ) + ) + + assert params[0] == {"limit": 100} + + +@mock.patch(CLIENT_SESSION_PATCH) +def test_missing_data_key_raises(MockSession) -> None: + session = MockSession.return_value + _wire(session, [_response({"pagination": {"next_cursor": None}})]) + + with pytest.raises(ValueError, match="matched nothing"): + _rows(_source("messages", _manager())) + + +@pytest.mark.parametrize( + ("endpoint", "primary_keys", "sort_mode"), + [ + ("messages", ["id"], "asc"), + ("mailboxes", ["id"], "desc"), + ("domains", ["id"], "desc"), + ("suppressions", ["email_address"], "desc"), + ], +) +def test_source_response_metadata(endpoint: str, primary_keys: list[str], sort_mode: str) -> None: + response = _source(endpoint, _manager()) + assert response.primary_keys == primary_keys + assert response.partition_keys == ["created_at"] + assert response.partition_format == "month" + assert response.sort_mode == sort_mode + + +@mock.patch(CAPABILITIES_SESSION_PATCH) +def test_get_capabilities_returns_status_and_scopes(make_session: mock.MagicMock) -> None: + session = make_session.return_value + session.get.return_value = _response({"scopes": ["messages:read", "domains:read"]}) + + assert get_capabilities("secret-key") == (200, {"messages:read", "domains:read"}) + session.get.assert_called_once_with( + f"{SHIPMAIL_BASE_URL}/capabilities", + headers={"Authorization": "Bearer secret-key", "Accept": "application/json"}, + timeout=10, + ) + + +@mock.patch(CAPABILITIES_SESSION_PATCH) +def test_get_capabilities_handles_invalid_credentials(make_session: mock.MagicMock) -> None: + make_session.return_value.get.return_value = _response({"error": "unauthorized"}, status_code=401) + assert get_capabilities("bad-key") == (401, set()) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py new file mode 100644 index 000000000000..7b77f38f259e --- /dev/null +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py @@ -0,0 +1,72 @@ +from unittest import mock + +from posthog.schema import ReleaseStatus + +from products.warehouse_sources.backend.temporal.data_imports.sources.generated_configs.shipmail import ( + ShipMailSourceConfig, +) +from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.source import ShipMailSource +from products.warehouse_sources.backend.types import ExternalDataSourceType + +CAPABILITIES_PATCH = "products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.source.get_capabilities" + + +class TestShipMailSource: + def setup_method(self) -> None: + self.source = ShipMailSource() + self.config = ShipMailSourceConfig(api_key="test-key") + + def test_source_metadata(self) -> None: + assert self.source.source_type == ExternalDataSourceType.SHIPMAIL + assert self.source.lists_tables_without_credentials is True + assert self.source.get_source_config.releaseStatus == ReleaseStatus.ALPHA + + def test_static_schemas_include_keys_and_incremental_support(self) -> None: + schemas = {schema.name: schema for schema in self.source.get_schemas(self.config, team_id=1)} + + assert set(schemas) == {"messages", "mailboxes", "domains", "suppressions"} + assert schemas["messages"].supports_incremental is True + assert schemas["messages"].detected_primary_keys == ["id"] + assert schemas["mailboxes"].supports_incremental is False + assert schemas["suppressions"].detected_primary_keys == ["email_address"] + + def test_schema_filter(self) -> None: + schemas = self.source.get_schemas(self.config, team_id=1, names=["domains"]) + assert [schema.name for schema in schemas] == ["domains"] + + def test_documented_tables_are_available_without_credentials(self) -> None: + tables = {table["name"]: table for table in self.source.get_documented_tables()} + + assert set(tables) == {"messages", "mailboxes", "domains", "suppressions"} + assert tables["messages"]["sync_methods"] == ["Incremental", "Full refresh"] + assert tables["messages"]["incremental_fields"] == ["updated_at"] + assert tables["messages"]["primary_keys"] == ["id"] + assert tables["suppressions"]["primary_keys"] == ["email_address"] + + @mock.patch(CAPABILITIES_PATCH, return_value=(200, {"messages:read"})) + def test_validates_token_without_requiring_every_table_scope(self, get_capabilities: mock.MagicMock) -> None: + assert self.source.validate_credentials(self.config, team_id=1) == (True, None) + assert self.source.validate_credentials(self.config, team_id=1, schema_name="messages") == (True, None) + assert self.source.validate_credentials(self.config, team_id=1, schema_name="domains") == ( + False, + "Your ShipMail API key is missing the `domains:read` scope", + ) + + @mock.patch(CAPABILITIES_PATCH, return_value=(200, {"messages:read", "suppressions:read"})) + def test_endpoint_permissions_use_one_capabilities_response(self, get_capabilities: mock.MagicMock) -> None: + permissions = self.source.get_endpoint_permissions( + self.config, + team_id=1, + endpoints=["messages", "mailboxes", "suppressions"], + ) + + assert permissions == { + "messages": None, + "mailboxes": "API key is missing the `mailboxes:read` scope", + "suppressions": None, + } + get_capabilities.assert_called_once_with("test-key") + + @mock.patch(CAPABILITIES_PATCH, return_value=(401, set())) + def test_rejects_invalid_token(self, get_capabilities: mock.MagicMock) -> None: + assert self.source.validate_credentials(self.config, team_id=1) == (False, "Invalid ShipMail API key") diff --git a/products/warehouse_sources/backend/types.py b/products/warehouse_sources/backend/types.py index 3ba62f5a64fd..bf0bff97c8dd 100644 --- a/products/warehouse_sources/backend/types.py +++ b/products/warehouse_sources/backend/types.py @@ -217,6 +217,7 @@ class ExternalDataSourceType(models.TextChoices): EBAY = "Ebay", "Ebay" COMMERCETOOLS = "Commercetools", "Commercetools" LIGHTSPEEDRETAIL = "LightspeedRetail", "LightspeedRetail" + SHIPMAIL = "ShipMail", "ShipMail" SHIPSTATION = "ShipStation", "ShipStation" CONSTANTCONTACT = "ConstantContact", "ConstantContact" MAILGUN = "Mailgun", "Mailgun" From 297698b353bd5c220df62ad7b11df7b73a20c037 Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Wed, 29 Jul 2026 22:08:25 +0100 Subject: [PATCH 2/6] fix(warehouse): disable Shipmail response sampling --- .../data_imports/sources/shipmail/shipmail.py | 3 +- .../sources/shipmail/tests/test_shipmail.py | 33 ++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py index 401f17aa69aa..dfe074de942b 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py @@ -69,6 +69,7 @@ def shipmail_source( cursor_path="pagination.next_cursor", cursor_param="cursor", ), + "session": make_tracked_session(redact_values=(api_key,), capture=False), }, "resource_defaults": {}, "resources": [ @@ -112,7 +113,7 @@ def save_checkpoint(state: dict[str, Any] | None) -> None: def get_capabilities(api_key: str) -> tuple[int | None, set[str]]: try: - session = make_tracked_session(redact_values=(api_key,)) + session = make_tracked_session(redact_values=(api_key,), capture=False) response = session.get( f"{SHIPMAIL_BASE_URL}/capabilities", headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"}, diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py index 28dbf3203112..d6ed368e0e2c 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py @@ -15,8 +15,7 @@ shipmail_source, ) -CLIENT_SESSION_PATCH = "products.warehouse_sources.backend.temporal.data_imports.sources.common.rest_source.rest_client.make_tracked_session" -CAPABILITIES_SESSION_PATCH = ( +TRACKED_SESSION_PATCH = ( "products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail.make_tracked_session" ) @@ -69,9 +68,9 @@ def test_incremental_value_preserves_iso_timestamp() -> None: assert _incremental_value("2026-07-29T12:30:00Z") == "2026-07-29T12:30:00Z" -@mock.patch(CLIENT_SESSION_PATCH) -def test_messages_paginates_checkpoints_and_sends_incremental_watermark(MockSession) -> None: - session = MockSession.return_value +@mock.patch(TRACKED_SESSION_PATCH) +def test_messages_paginates_checkpoints_and_sends_incremental_watermark(make_session: mock.MagicMock) -> None: + session = make_session.return_value params = _wire( session, [ @@ -109,11 +108,12 @@ def test_messages_paginates_checkpoints_and_sends_incremental_watermark(MockSess "cursor": "cursor_2", } manager.save_state.assert_called_once_with(ShipMailResumeConfig(cursor="cursor_2")) + make_session.assert_called_once_with(redact_values=("test-key",), capture=False) -@mock.patch(CLIENT_SESSION_PATCH) -def test_resumes_from_saved_cursor(MockSession) -> None: - session = MockSession.return_value +@mock.patch(TRACKED_SESSION_PATCH) +def test_resumes_from_saved_cursor(make_session: mock.MagicMock) -> None: + session = make_session.return_value params = _wire( session, [ @@ -132,9 +132,9 @@ def test_resumes_from_saved_cursor(MockSession) -> None: assert params[0] == {"limit": 100, "cursor": "cursor_2"} -@mock.patch(CLIENT_SESSION_PATCH) -def test_full_refresh_endpoint_does_not_send_incremental_watermark(MockSession) -> None: - session = MockSession.return_value +@mock.patch(TRACKED_SESSION_PATCH) +def test_full_refresh_endpoint_does_not_send_incremental_watermark(make_session: mock.MagicMock) -> None: + session = make_session.return_value params = _wire( session, [ @@ -160,9 +160,9 @@ def test_full_refresh_endpoint_does_not_send_incremental_watermark(MockSession) assert params[0] == {"limit": 100} -@mock.patch(CLIENT_SESSION_PATCH) -def test_missing_data_key_raises(MockSession) -> None: - session = MockSession.return_value +@mock.patch(TRACKED_SESSION_PATCH) +def test_missing_data_key_raises(make_session: mock.MagicMock) -> None: + session = make_session.return_value _wire(session, [_response({"pagination": {"next_cursor": None}})]) with pytest.raises(ValueError, match="matched nothing"): @@ -186,7 +186,7 @@ def test_source_response_metadata(endpoint: str, primary_keys: list[str], sort_m assert response.sort_mode == sort_mode -@mock.patch(CAPABILITIES_SESSION_PATCH) +@mock.patch(TRACKED_SESSION_PATCH) def test_get_capabilities_returns_status_and_scopes(make_session: mock.MagicMock) -> None: session = make_session.return_value session.get.return_value = _response({"scopes": ["messages:read", "domains:read"]}) @@ -197,9 +197,10 @@ def test_get_capabilities_returns_status_and_scopes(make_session: mock.MagicMock headers={"Authorization": "Bearer secret-key", "Accept": "application/json"}, timeout=10, ) + make_session.assert_called_once_with(redact_values=("secret-key",), capture=False) -@mock.patch(CAPABILITIES_SESSION_PATCH) +@mock.patch(TRACKED_SESSION_PATCH) def test_get_capabilities_handles_invalid_credentials(make_session: mock.MagicMock) -> None: make_session.return_value.get.return_value = _response({"error": "unauthorized"}, status_code=401) assert get_capabilities("bad-key") == (401, set()) From e015980d008c00f3e779f9f0c43e54aea74daec9 Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Thu, 30 Jul 2026 17:19:04 +0100 Subject: [PATCH 3/6] fix(warehouse): finalize Shipmail source --- frontend/src/queries/schema.json | 2 +- frontend/src/queries/schema/schema-general.ts | 2 +- posthog/schema_enums.py | 2 +- ...mail_source.py => 0113_shipmail_source.py} | 24 +++++++++-- .../data_imports/sources/_load_all.py | 2 +- .../sources/generated_configs/shipmail.py | 2 +- .../data_imports/sources/shipmail/__init__.py | 1 - .../shipmail/canonical_descriptions.py | 12 +++--- .../data_imports/sources/shipmail/settings.py | 12 +++--- .../data_imports/sources/shipmail/shipmail.py | 8 ++-- .../data_imports/sources/shipmail/source.py | 42 +++++++++---------- .../sources/shipmail/tests/__init__.py | 1 - .../sources/shipmail/tests/test_shipmail.py | 8 ++-- .../shipmail/tests/test_shipmail_source.py | 14 +++---- products/warehouse_sources/backend/types.py | 2 +- 15 files changed, 74 insertions(+), 60 deletions(-) rename products/warehouse_sources/backend/migrations/{0112_shipmail_source.py => 0113_shipmail_source.py} (99%) delete mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/__init__.py delete mode 100644 products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index ec3ac1b6ab93..41760b266e8b 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -23317,7 +23317,7 @@ "Ebay", "Commercetools", "LightspeedRetail", - "ShipMail", + "Shipmail", "ShipStation", "ConstantContact", "Mailgun", diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts index fb43d9921e23..52f2343977c6 100644 --- a/frontend/src/queries/schema/schema-general.ts +++ b/frontend/src/queries/schema/schema-general.ts @@ -7141,7 +7141,7 @@ export const externalDataSources = [ 'Ebay', 'Commercetools', 'LightspeedRetail', - 'ShipMail', + 'Shipmail', 'ShipStation', 'ConstantContact', 'Mailgun', diff --git a/posthog/schema_enums.py b/posthog/schema_enums.py index 6268373f9be2..4ad788206301 100644 --- a/posthog/schema_enums.py +++ b/posthog/schema_enums.py @@ -1363,7 +1363,7 @@ class ExternalDataSourceType(StrEnum): EBAY = "Ebay" COMMERCETOOLS = "Commercetools" LIGHTSPEED_RETAIL = "LightspeedRetail" - SHIP_MAIL = "ShipMail" + SHIPMAIL = "Shipmail" SHIP_STATION = "ShipStation" CONSTANT_CONTACT = "ConstantContact" MAILGUN = "Mailgun" diff --git a/products/warehouse_sources/backend/migrations/0112_shipmail_source.py b/products/warehouse_sources/backend/migrations/0113_shipmail_source.py similarity index 99% rename from products/warehouse_sources/backend/migrations/0112_shipmail_source.py rename to products/warehouse_sources/backend/migrations/0113_shipmail_source.py index aa84eff9bf1d..a4ed411cbd85 100644 --- a/products/warehouse_sources/backend/migrations/0112_shipmail_source.py +++ b/products/warehouse_sources/backend/migrations/0113_shipmail_source.py @@ -1,11 +1,11 @@ -# Generated by Django 5.2.14 on 2026-07-29 20:29 +# Generated by Django 5.2.14 on 2026-07-30 16:13 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("warehouse_sources", "0111_repin_zonka_feedback_api_version"), + ("warehouse_sources", "0112_scaffold_eight_requested_sources"), ] operations = [ @@ -189,7 +189,7 @@ class Migration(migrations.Migration): ("Ebay", "Ebay"), ("Commercetools", "Commercetools"), ("LightspeedRetail", "LightspeedRetail"), - ("ShipMail", "ShipMail"), + ("Shipmail", "Shipmail"), ("ShipStation", "ShipStation"), ("ConstantContact", "ConstantContact"), ("Mailgun", "Mailgun"), @@ -1272,6 +1272,14 @@ class Migration(migrations.Migration): ("DuckLake", "DuckLake"), ("Starburst", "Starburst"), ("Easybill", "Easybill"), + ("Bexio", "Bexio"), + ("Umami", "Umami"), + ("Manychat", "Manychat"), + ("Kickstarter", "Kickstarter"), + ("Typesense", "Typesense"), + ("FirstPromoter", "FirstPromoter"), + ("Zero", "Zero"), + ("Inth", "Inth"), ], max_length=128, ), @@ -1456,7 +1464,7 @@ class Migration(migrations.Migration): ("Ebay", "Ebay"), ("Commercetools", "Commercetools"), ("LightspeedRetail", "LightspeedRetail"), - ("ShipMail", "ShipMail"), + ("Shipmail", "Shipmail"), ("ShipStation", "ShipStation"), ("ConstantContact", "ConstantContact"), ("Mailgun", "Mailgun"), @@ -2539,6 +2547,14 @@ class Migration(migrations.Migration): ("DuckLake", "DuckLake"), ("Starburst", "Starburst"), ("Easybill", "Easybill"), + ("Bexio", "Bexio"), + ("Umami", "Umami"), + ("Manychat", "Manychat"), + ("Kickstarter", "Kickstarter"), + ("Typesense", "Typesense"), + ("FirstPromoter", "FirstPromoter"), + ("Zero", "Zero"), + ("Inth", "Inth"), ], max_length=128, ), diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/_load_all.py b/products/warehouse_sources/backend/temporal/data_imports/sources/_load_all.py index 53a756f9ae8e..473f587b34f3 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/_load_all.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/_load_all.py @@ -1022,7 +1022,7 @@ from .sftp.source import SFTPSource from .sharepoint.source import SharePointSource from .sharetribe.source import SharetribeSource -from .shipmail.source import ShipMailSource +from .shipmail.source import ShipmailSource from .shippo.source import ShippoSource from .shipstation.source import ShipStationSource from .shopify.source import ShopifySource diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py index 60d4f72f9014..b0379a236fee 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py @@ -5,5 +5,5 @@ @config.config -class ShipMailSourceConfig(config.Config): +class ShipmailSourceConfig(config.Config): api_key: str diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/__init__.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/__init__.py deleted file mode 100644 index 8b137891791f..000000000000 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py index cd9caecc999f..ebf6b3543d78 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py @@ -1,4 +1,4 @@ -"""Canonical descriptions sourced from the ShipMail API documentation.""" +"""Canonical descriptions sourced from the Shipmail API documentation.""" from products.warehouse_sources.backend.temporal.data_imports.sources.common.canonical_descriptions import ( CanonicalDescriptions, @@ -27,7 +27,7 @@ }, }, "mailboxes": { - "description": "Mailboxes belonging to the authenticated ShipMail organization.", + "description": "Mailboxes belonging to the authenticated Shipmail organization.", "docs_url": "https://shipmail.to/docs/api/mailboxes", "columns": { "object": "The API object type. Always `mailbox`.", @@ -44,26 +44,26 @@ }, }, "domains": { - "description": "Sending and receiving domains configured in the authenticated ShipMail organization.", + "description": "Sending and receiving domains configured in the authenticated Shipmail organization.", "docs_url": "https://shipmail.to/docs/api/domains", "columns": { "object": "The API object type. Always `domain`.", "id": "Unique identifier for the domain.", "name": "Domain name.", "status": "Current domain status.", - "managed_by": "Whether DNS is managed externally or by ShipMail.", + "managed_by": "Whether DNS is managed externally or by Shipmail.", "dns_provider": "Detected DNS provider, when available.", "mx_verified": "Whether the MX record is verified.", "spf_verified": "Whether the SPF record is verified.", "dkim_verified": "Whether the DKIM record is verified.", "dmarc_verified": "Whether the DMARC record is verified.", - "dmarc_managed_externally": "Whether DMARC is managed outside ShipMail.", + "dmarc_managed_externally": "Whether DMARC is managed outside Shipmail.", "outbound_verified": "Whether outbound sending is verified.", "catch_all_mailbox_id": "Identifier of the catch-all mailbox, when configured.", "verified_at": "Time at which the domain completed verification.", "created_at": "Time at which the domain was created.", "updated_at": "Time at which the domain was last updated.", - "registration": "Registration details for domains managed by ShipMail, when available.", + "registration": "Registration details for domains managed by Shipmail, when available.", }, }, "suppressions": { diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py index de63094ae4d9..4e031115c901 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py @@ -5,7 +5,7 @@ @dataclass(frozen=True) -class ShipMailEndpointConfig: +class ShipmailEndpointConfig: name: str path: str primary_keys: list[str] @@ -14,8 +14,8 @@ class ShipMailEndpointConfig: incremental_fields: list[IncrementalField] = field(default_factory=list) -SHIPMAIL_ENDPOINTS: dict[str, ShipMailEndpointConfig] = { - "messages": ShipMailEndpointConfig( +SHIPMAIL_ENDPOINTS: dict[str, ShipmailEndpointConfig] = { + "messages": ShipmailEndpointConfig( name="messages", path="/messages/analytics", primary_keys=["id"], @@ -23,21 +23,21 @@ class ShipMailEndpointConfig: partition_key="created_at", incremental_fields=[incremental_field("updated_at")], ), - "mailboxes": ShipMailEndpointConfig( + "mailboxes": ShipmailEndpointConfig( name="mailboxes", path="/mailboxes", primary_keys=["id"], required_scope="mailboxes:read", partition_key="created_at", ), - "domains": ShipMailEndpointConfig( + "domains": ShipmailEndpointConfig( name="domains", path="/domains", primary_keys=["id"], required_scope="domains:read", partition_key="created_at", ), - "suppressions": ShipMailEndpointConfig( + "suppressions": ShipmailEndpointConfig( name="suppressions", path="/suppressions", primary_keys=["email_address"], diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py index dfe074de942b..23613531520d 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py @@ -18,7 +18,7 @@ @dataclasses.dataclass -class ShipMailResumeConfig: +class ShipmailResumeConfig: cursor: str | None = None @@ -29,7 +29,7 @@ def _incremental_value(value: Any) -> str: def _initial_paginator_state( - resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], + resumable_source_manager: ResumableSourceManager[ShipmailResumeConfig], ) -> dict[str, str] | None: if not resumable_source_manager.can_resume(): return None @@ -44,7 +44,7 @@ def shipmail_source( endpoint: str, team_id: int, job_id: str, - resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], + resumable_source_manager: ResumableSourceManager[ShipmailResumeConfig], should_use_incremental_field: bool = False, db_incremental_field_last_value: Optional[Any] = None, incremental_field: Optional[str] = None, @@ -87,7 +87,7 @@ def shipmail_source( def save_checkpoint(state: dict[str, Any] | None) -> None: if state and state.get("cursor"): - resumable_source_manager.save_state(ShipMailResumeConfig(cursor=str(state["cursor"]))) + resumable_source_manager.save_state(ShipmailResumeConfig(cursor=str(state["cursor"]))) resource = rest_api_resource( rest_config, diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py index e81f2b17b681..b595281cfb4c 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/source.py @@ -18,11 +18,11 @@ from products.warehouse_sources.backend.temporal.data_imports.sources.common.schema import SourceSchema from products.warehouse_sources.backend.temporal.data_imports.sources.common.typings import SourceInputs, SourceResponse from products.warehouse_sources.backend.temporal.data_imports.sources.generated_configs.shipmail import ( - ShipMailSourceConfig, + ShipmailSourceConfig, ) from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.settings import SHIPMAIL_ENDPOINTS from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail import ( - ShipMailResumeConfig, + ShipmailResumeConfig, get_capabilities, shipmail_source, ) @@ -30,7 +30,7 @@ @SourceRegistry.register -class ShipMailSource(ResumableSource[ShipMailSourceConfig, ShipMailResumeConfig]): +class ShipmailSource(ResumableSource[ShipmailSourceConfig, ShipmailResumeConfig]): lists_tables_without_credentials = True api_docs_url = "https://shipmail.to/docs/api" @@ -41,11 +41,11 @@ def source_type(self) -> ExternalDataSourceType: @property def get_source_config(self) -> SourceConfig: return SourceConfig( - name=SchemaExternalDataSourceType.SHIP_MAIL, + name=SchemaExternalDataSourceType.SHIPMAIL, category=DataWarehouseSourceCategory.MARKETING___EMAIL, - label="ShipMail", + label="Shipmail", releaseStatus=ReleaseStatus.ALPHA, - caption="""Enter your ShipMail API key to sync message analytics, mailboxes, domains, and suppressions into the PostHog Data warehouse. + caption="""Enter your Shipmail API key to sync message analytics, mailboxes, domains, and suppressions into the PostHog Data warehouse. Grant the key the read scopes for the tables you want to sync: `messages:read`, `mailboxes:read`, `domains:read`, and `suppressions:read`.""", iconPath="/static/services/shipmail.png", @@ -58,8 +58,8 @@ def get_source_config(self) -> SourceConfig: label="API key", type=SourceFieldInputConfigType.PASSWORD, required=True, - placeholder="ShipMail API key", - caption="Create a key in your [ShipMail API settings](https://shipmail.to/settings/api-keys).", + placeholder="Shipmail API key", + caption="Create a key in your [Shipmail API settings](https://shipmail.to/settings/api-keys).", secret=True, ), ], @@ -75,13 +75,13 @@ def get_canonical_descriptions(self) -> CanonicalDescriptions: def get_non_retryable_errors(self) -> dict[str, str | None]: return { - "401 Client Error: Unauthorized for url: https://shipmail.to": "Your ShipMail API key is invalid or has been revoked. Create a new key and reconnect.", - "403 Client Error: Forbidden for url: https://shipmail.to": "Your ShipMail API key is missing the read scope required for this table.", + "401 Client Error: Unauthorized for url: https://shipmail.to": "Your Shipmail API key is invalid or has been revoked. Create a new key and reconnect.", + "403 Client Error: Forbidden for url: https://shipmail.to": "Your Shipmail API key is missing the read scope required for this table.", } def get_schemas( self, - config: ShipMailSourceConfig, + config: ShipmailSourceConfig, team_id: int, with_counts: bool = False, names: list[str] | None = None, @@ -105,7 +105,7 @@ def get_schemas( def validate_credentials( self, - config: ShipMailSourceConfig, + config: ShipmailSourceConfig, team_id: int, schema_name: Optional[str] = None, api_version: str | None = None, @@ -115,17 +115,17 @@ def validate_credentials( if schema_name in SHIPMAIL_ENDPOINTS: required_scope = SHIPMAIL_ENDPOINTS[schema_name].required_scope if "*" not in scopes and required_scope not in scopes: - return False, f"Your ShipMail API key is missing the `{required_scope}` scope" + return False, f"Your Shipmail API key is missing the `{required_scope}` scope" return True, None if status == 401: - return False, "Invalid ShipMail API key" + return False, "Invalid Shipmail API key" if status == 403: - return False, "Your ShipMail API key cannot access the capabilities endpoint" - return False, "Could not validate ShipMail API key" + return False, "Your Shipmail API key cannot access the capabilities endpoint" + return False, "Could not validate Shipmail API key" def get_endpoint_permissions( self, - config: ShipMailSourceConfig, + config: ShipmailSourceConfig, team_id: int, endpoints: list[str], api_version: str | None = None, @@ -145,13 +145,13 @@ def get_endpoint_permissions( permissions[endpoint] = f"API key is missing the `{endpoint_config.required_scope}` scope" return permissions - def get_resumable_source_manager(self, inputs: SourceInputs) -> ResumableSourceManager[ShipMailResumeConfig]: - return ResumableSourceManager[ShipMailResumeConfig](inputs, ShipMailResumeConfig) + def get_resumable_source_manager(self, inputs: SourceInputs) -> ResumableSourceManager[ShipmailResumeConfig]: + return ResumableSourceManager[ShipmailResumeConfig](inputs, ShipmailResumeConfig) def source_for_pipeline( self, - config: ShipMailSourceConfig, - resumable_source_manager: ResumableSourceManager[ShipMailResumeConfig], + config: ShipmailSourceConfig, + resumable_source_manager: ResumableSourceManager[ShipmailResumeConfig], inputs: SourceInputs, ) -> SourceResponse: return shipmail_source( diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py deleted file mode 100644 index 8b137891791f..000000000000 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py index d6ed368e0e2c..88a74782c433 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail.py @@ -9,7 +9,7 @@ from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.shipmail import ( SHIPMAIL_BASE_URL, - ShipMailResumeConfig, + ShipmailResumeConfig, _incremental_value, get_capabilities, shipmail_source, @@ -27,7 +27,7 @@ def _response(body: Any, status_code: int = 200) -> Response: return response -def _manager(resume_state: ShipMailResumeConfig | None = None) -> mock.MagicMock: +def _manager(resume_state: ShipmailResumeConfig | None = None) -> mock.MagicMock: manager = mock.MagicMock() manager.can_resume.return_value = resume_state is not None manager.load_state.return_value = resume_state @@ -107,7 +107,7 @@ def test_messages_paginates_checkpoints_and_sends_incremental_watermark(make_ses "updated_after": "2026-07-01T00:00:00Z", "cursor": "cursor_2", } - manager.save_state.assert_called_once_with(ShipMailResumeConfig(cursor="cursor_2")) + manager.save_state.assert_called_once_with(ShipmailResumeConfig(cursor="cursor_2")) make_session.assert_called_once_with(redact_values=("test-key",), capture=False) @@ -126,7 +126,7 @@ def test_resumes_from_saved_cursor(make_session: mock.MagicMock) -> None: ], ) - rows = _rows(_source("mailboxes", _manager(ShipMailResumeConfig(cursor="cursor_2")))) + rows = _rows(_source("mailboxes", _manager(ShipmailResumeConfig(cursor="cursor_2")))) assert rows == [{"id": "mbx_2"}] assert params[0] == {"limit": 100, "cursor": "cursor_2"} diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py index 7b77f38f259e..2da1985a4c09 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/tests/test_shipmail_source.py @@ -3,18 +3,18 @@ from posthog.schema import ReleaseStatus from products.warehouse_sources.backend.temporal.data_imports.sources.generated_configs.shipmail import ( - ShipMailSourceConfig, + ShipmailSourceConfig, ) -from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.source import ShipMailSource +from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.source import ShipmailSource from products.warehouse_sources.backend.types import ExternalDataSourceType CAPABILITIES_PATCH = "products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.source.get_capabilities" -class TestShipMailSource: +class TestShipmailSource: def setup_method(self) -> None: - self.source = ShipMailSource() - self.config = ShipMailSourceConfig(api_key="test-key") + self.source = ShipmailSource() + self.config = ShipmailSourceConfig(api_key="test-key") def test_source_metadata(self) -> None: assert self.source.source_type == ExternalDataSourceType.SHIPMAIL @@ -49,7 +49,7 @@ def test_validates_token_without_requiring_every_table_scope(self, get_capabilit assert self.source.validate_credentials(self.config, team_id=1, schema_name="messages") == (True, None) assert self.source.validate_credentials(self.config, team_id=1, schema_name="domains") == ( False, - "Your ShipMail API key is missing the `domains:read` scope", + "Your Shipmail API key is missing the `domains:read` scope", ) @mock.patch(CAPABILITIES_PATCH, return_value=(200, {"messages:read", "suppressions:read"})) @@ -69,4 +69,4 @@ def test_endpoint_permissions_use_one_capabilities_response(self, get_capabiliti @mock.patch(CAPABILITIES_PATCH, return_value=(401, set())) def test_rejects_invalid_token(self, get_capabilities: mock.MagicMock) -> None: - assert self.source.validate_credentials(self.config, team_id=1) == (False, "Invalid ShipMail API key") + assert self.source.validate_credentials(self.config, team_id=1) == (False, "Invalid Shipmail API key") diff --git a/products/warehouse_sources/backend/types.py b/products/warehouse_sources/backend/types.py index bf0bff97c8dd..ac9f06077eeb 100644 --- a/products/warehouse_sources/backend/types.py +++ b/products/warehouse_sources/backend/types.py @@ -217,7 +217,7 @@ class ExternalDataSourceType(models.TextChoices): EBAY = "Ebay", "Ebay" COMMERCETOOLS = "Commercetools", "Commercetools" LIGHTSPEEDRETAIL = "LightspeedRetail", "LightspeedRetail" - SHIPMAIL = "ShipMail", "ShipMail" + SHIPMAIL = "Shipmail", "Shipmail" SHIPSTATION = "ShipStation", "ShipStation" CONSTANTCONTACT = "ConstantContact", "ConstantContact" MAILGUN = "Mailgun", "Mailgun" From 12ca73ae83aca6f71f5724ec1f968e9609b7c9d3 Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Thu, 30 Jul 2026 19:05:00 +0100 Subject: [PATCH 4/6] fix(warehouse): rebase Shipmail migration --- ...0113_shipmail_source.py => 0114_shipmail_source.py} | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) rename products/warehouse_sources/backend/migrations/{0113_shipmail_source.py => 0114_shipmail_source.py} (99%) diff --git a/products/warehouse_sources/backend/migrations/0113_shipmail_source.py b/products/warehouse_sources/backend/migrations/0114_shipmail_source.py similarity index 99% rename from products/warehouse_sources/backend/migrations/0113_shipmail_source.py rename to products/warehouse_sources/backend/migrations/0114_shipmail_source.py index a4ed411cbd85..135ca3869814 100644 --- a/products/warehouse_sources/backend/migrations/0113_shipmail_source.py +++ b/products/warehouse_sources/backend/migrations/0114_shipmail_source.py @@ -1,11 +1,11 @@ -# Generated by Django 5.2.14 on 2026-07-30 16:13 +# Generated by Django 5.2.14 on 2026-07-30 18:02 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("warehouse_sources", "0112_scaffold_eight_requested_sources"), + ("warehouse_sources", "0113_scaffold_three_requested_sources"), ] operations = [ @@ -1280,6 +1280,9 @@ class Migration(migrations.Migration): ("FirstPromoter", "FirstPromoter"), ("Zero", "Zero"), ("Inth", "Inth"), + ("BCMS", "BCMS"), + ("Convonite", "Convonite"), + ("Hookdeck", "Hookdeck"), ], max_length=128, ), @@ -2555,6 +2558,9 @@ class Migration(migrations.Migration): ("FirstPromoter", "FirstPromoter"), ("Zero", "Zero"), ("Inth", "Inth"), + ("BCMS", "BCMS"), + ("Convonite", "Convonite"), + ("Hookdeck", "Hookdeck"), ], max_length=128, ), From 6bb53ab8131138283322249ee444c3298bf3224a Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Thu, 30 Jul 2026 23:37:28 +0100 Subject: [PATCH 5/6] fix(warehouse): advance Shipmail migration --- ...mail_source.py => 0116_shipmail_source.py} | 26 +++++++++++++++++-- .../backend/migrations/max_migration.txt | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) rename products/warehouse_sources/backend/migrations/{0114_shipmail_source.py => 0116_shipmail_source.py} (99%) diff --git a/products/warehouse_sources/backend/migrations/0114_shipmail_source.py b/products/warehouse_sources/backend/migrations/0116_shipmail_source.py similarity index 99% rename from products/warehouse_sources/backend/migrations/0114_shipmail_source.py rename to products/warehouse_sources/backend/migrations/0116_shipmail_source.py index 135ca3869814..410ebe618854 100644 --- a/products/warehouse_sources/backend/migrations/0114_shipmail_source.py +++ b/products/warehouse_sources/backend/migrations/0116_shipmail_source.py @@ -1,11 +1,11 @@ -# Generated by Django 5.2.14 on 2026-07-30 18:02 +# Generated by Django 5.2.14 on 2026-07-31 10:37 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("warehouse_sources", "0113_scaffold_three_requested_sources"), + ("warehouse_sources", "0115_scaffold_four_requested_sources"), ] operations = [ @@ -1283,6 +1283,17 @@ class Migration(migrations.Migration): ("BCMS", "BCMS"), ("Convonite", "Convonite"), ("Hookdeck", "Hookdeck"), + ("Billit", "Billit"), + ("Moxie", "Moxie"), + ("TripleWhale", "TripleWhale"), + ("Directus", "Directus"), + ("Clay", "Clay"), + ("TradableBits", "TradableBits"), + ("Swan", "Swan"), + ("Hyros", "Hyros"), + ("Odoo", "Odoo"), + ("Airbridge", "Airbridge"), + ("Snovio", "Snovio"), ], max_length=128, ), @@ -2561,6 +2572,17 @@ class Migration(migrations.Migration): ("BCMS", "BCMS"), ("Convonite", "Convonite"), ("Hookdeck", "Hookdeck"), + ("Billit", "Billit"), + ("Moxie", "Moxie"), + ("TripleWhale", "TripleWhale"), + ("Directus", "Directus"), + ("Clay", "Clay"), + ("TradableBits", "TradableBits"), + ("Swan", "Swan"), + ("Hyros", "Hyros"), + ("Odoo", "Odoo"), + ("Airbridge", "Airbridge"), + ("Snovio", "Snovio"), ], max_length=128, ), diff --git a/products/warehouse_sources/backend/migrations/max_migration.txt b/products/warehouse_sources/backend/migrations/max_migration.txt index 67632785cded..0fb50434b738 100644 --- a/products/warehouse_sources/backend/migrations/max_migration.txt +++ b/products/warehouse_sources/backend/migrations/max_migration.txt @@ -1 +1 @@ -0115_scaffold_four_requested_sources +0116_shipmail_source From 0a4f9fbd6a24e56aefda13a6ad187bce97f82848 Mon Sep 17 00:00:00 2001 From: Julien Coulaud Date: Fri, 31 Jul 2026 15:39:17 +0100 Subject: [PATCH 6/6] fix(warehouse): regenerate Shipmail API types --- .../data_warehouse/frontend/generated/api.schemas.ts | 2 ++ .../warehouse_sources/frontend/generated/api.schemas.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/products/data_warehouse/frontend/generated/api.schemas.ts b/products/data_warehouse/frontend/generated/api.schemas.ts index ea5989b161ac..f617b66c2dd5 100644 --- a/products/data_warehouse/frontend/generated/api.schemas.ts +++ b/products/data_warehouse/frontend/generated/api.schemas.ts @@ -1457,6 +1457,7 @@ export interface CredentialApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -2738,6 +2739,7 @@ export const ExternalDataSourceTypeEnumApi = { Ebay: 'Ebay', Commercetools: 'Commercetools', LightspeedRetail: 'LightspeedRetail', + Shipmail: 'Shipmail', ShipStation: 'ShipStation', ConstantContact: 'ConstantContact', Mailgun: 'Mailgun', diff --git a/products/warehouse_sources/frontend/generated/api.schemas.ts b/products/warehouse_sources/frontend/generated/api.schemas.ts index daa487a72ac4..1072d7249db2 100644 --- a/products/warehouse_sources/frontend/generated/api.schemas.ts +++ b/products/warehouse_sources/frontend/generated/api.schemas.ts @@ -605,6 +605,7 @@ export const ExternalDataSourceSerializersCreatedViaEnumApi = { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -1886,6 +1887,7 @@ export const ExternalDataSourceTypeEnumApi = { Ebay: 'Ebay', Commercetools: 'Commercetools', LightspeedRetail: 'LightspeedRetail', + Shipmail: 'Shipmail', ShipStation: 'ShipStation', ConstantContact: 'ConstantContact', Mailgun: 'Mailgun', @@ -3307,6 +3309,7 @@ export interface ExternalDataSourceCreateApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -4802,6 +4805,7 @@ export interface ExternalDataSourceConnectionOptionApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -6110,6 +6114,7 @@ export interface DatabaseSchemaRequestApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -7395,6 +7400,7 @@ export interface DirectConnectionSourceOptionApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -8765,6 +8771,7 @@ export interface SourcePreviewRequestApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -10085,6 +10092,7 @@ export interface SourceSetupApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun @@ -11412,6 +11420,7 @@ export interface SourceCredentialCreateApi { * * `Ebay` - Ebay * * `Commercetools` - Commercetools * * `LightspeedRetail` - LightspeedRetail + * * `Shipmail` - Shipmail * * `ShipStation` - ShipStation * * `ConstantContact` - ConstantContact * * `Mailgun` - Mailgun