-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(warehouse): add Shipmail source #75069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jcoulaud
wants to merge
6
commits into
PostHog:master
Choose a base branch
from
jcoulaud:codex/shipmail-warehouse-source
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,325
−1
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0df175c
feat(warehouse): add Shipmail source
jcoulaud 297698b
fix(warehouse): disable Shipmail response sampling
jcoulaud e015980
fix(warehouse): finalize Shipmail source
jcoulaud 12ca73a
fix(warehouse): rebase Shipmail migration
jcoulaud 6bb53ab
fix(warehouse): advance Shipmail migration
jcoulaud 0a4f9fb
fix(warehouse): regenerate Shipmail API types
jcoulaud File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
2,590 changes: 2,590 additions & 0 deletions
2,590
products/warehouse_sources/backend/migrations/0116_shipmail_source.py
Large diffs are not rendered by default.
Oops, something went wrong.
2 changes: 1 addition & 1 deletion
2
products/warehouse_sources/backend/migrations/max_migration.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 0115_scaffold_four_requested_sources | ||
| 0116_shipmail_source |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
...cts/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/shipmail.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # This file is automatically generated from `SourceRegistry.get_all_sources()` | ||
| # Do not edit manually - run `pnpm generate:source-configs` to regenerate. | ||
|
|
||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common import config | ||
|
|
||
|
|
||
| @config.config | ||
| class ShipmailSourceConfig(config.Config): | ||
| api_key: str |
79 changes: 79 additions & 0 deletions
79
...arehouse_sources/backend/temporal/data_imports/sources/shipmail/canonical_descriptions.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Canonical descriptions sourced from the Shipmail API documentation.""" | ||
|
|
||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.canonical_descriptions import ( | ||
| CanonicalDescriptions, | ||
| ) | ||
|
|
||
| CANONICAL_DESCRIPTIONS: CanonicalDescriptions = { | ||
| "messages": { | ||
| "description": "Analytics-safe message records in stable update order, without message content or sensitive headers.", | ||
| "docs_url": "https://shipmail.to/docs/api/messages", | ||
| "columns": { | ||
| "object": "The API object type. Always `message_analytics`.", | ||
| "id": "Unique identifier for the message.", | ||
| "mailbox_id": "Identifier of the mailbox associated with the message.", | ||
| "thread_id": "Identifier of the conversation thread, when available.", | ||
| "client_reference": "Caller-provided reference used to associate the message with an external record.", | ||
| "direction": "Whether the message was inbound or outbound.", | ||
| "contact_addresses": "Email addresses of contacts involved in the message.", | ||
| "recipient_count": "Number of recipients on the message.", | ||
| "attachment_count": "Number of attachments on the message.", | ||
| "source": "System or integration that created the message.", | ||
| "mode": "Whether the message belongs to live or sandbox data.", | ||
| "status": "Current message status.", | ||
| "scheduled_at": "Time at which the message was scheduled to be sent, when applicable.", | ||
| "created_at": "Time at which the message was created.", | ||
| "updated_at": "Time at which the message was last updated.", | ||
| }, | ||
| }, | ||
| "mailboxes": { | ||
| "description": "Mailboxes belonging to the authenticated Shipmail organization.", | ||
| "docs_url": "https://shipmail.to/docs/api/mailboxes", | ||
| "columns": { | ||
| "object": "The API object type. Always `mailbox`.", | ||
| "id": "Unique identifier for the mailbox.", | ||
| "domain_id": "Identifier of the domain that hosts the mailbox.", | ||
| "address": "Email address of the mailbox.", | ||
| "display_name": "Display name used for the mailbox.", | ||
| "suspended_at": "Time at which the mailbox was suspended, when applicable.", | ||
| "suspension_reasons": "Reasons the mailbox is suspended.", | ||
| "spam_filter_threshold": "Spam score threshold applied to inbound messages.", | ||
| "auto_reply": "Automatic reply configuration for the mailbox.", | ||
| "created_at": "Time at which the mailbox was created.", | ||
| "updated_at": "Time at which the mailbox was last updated.", | ||
| }, | ||
| }, | ||
| "domains": { | ||
| "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.", | ||
| "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.", | ||
| "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.", | ||
| }, | ||
| }, | ||
| "suppressions": { | ||
| "description": "Email addresses suppressed from outbound delivery.", | ||
| "docs_url": "https://shipmail.to/docs/api/suppressions", | ||
| "columns": { | ||
| "object": "The API object type. Always `suppression`.", | ||
| "email_address": "Suppressed email address.", | ||
| "reason": "Reason the address was suppressed.", | ||
| "created_at": "Time at which the suppression was created.", | ||
| }, | ||
| }, | ||
| } |
53 changes: 53 additions & 0 deletions
53
products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/settings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| from dataclasses import dataclass, field | ||
|
|
||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.schema import incremental_field | ||
| from products.warehouse_sources.backend.types import IncrementalField | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ShipmailEndpointConfig: | ||
| name: str | ||
| path: str | ||
| primary_keys: list[str] | ||
| required_scope: str | ||
| partition_key: str | ||
| incremental_fields: list[IncrementalField] = field(default_factory=list) | ||
|
|
||
|
|
||
| SHIPMAIL_ENDPOINTS: dict[str, ShipmailEndpointConfig] = { | ||
| "messages": ShipmailEndpointConfig( | ||
| name="messages", | ||
| path="/messages/analytics", | ||
| primary_keys=["id"], | ||
| required_scope="messages:read", | ||
| partition_key="created_at", | ||
| incremental_fields=[incremental_field("updated_at")], | ||
| ), | ||
| "mailboxes": ShipmailEndpointConfig( | ||
| name="mailboxes", | ||
| path="/mailboxes", | ||
| primary_keys=["id"], | ||
| required_scope="mailboxes:read", | ||
| partition_key="created_at", | ||
| ), | ||
| "domains": ShipmailEndpointConfig( | ||
| name="domains", | ||
| path="/domains", | ||
| primary_keys=["id"], | ||
| required_scope="domains:read", | ||
| partition_key="created_at", | ||
| ), | ||
| "suppressions": ShipmailEndpointConfig( | ||
| name="suppressions", | ||
| path="/suppressions", | ||
| primary_keys=["email_address"], | ||
| required_scope="suppressions:read", | ||
| partition_key="created_at", | ||
| ), | ||
| } | ||
|
|
||
| ENDPOINTS = tuple(SHIPMAIL_ENDPOINTS.keys()) | ||
|
|
||
| INCREMENTAL_FIELDS: dict[str, list[IncrementalField]] = { | ||
| name: config.incremental_fields for name, config in SHIPMAIL_ENDPOINTS.items() if config.incremental_fields | ||
| } |
130 changes: 130 additions & 0 deletions
130
products/warehouse_sources/backend/temporal/data_imports/sources/shipmail/shipmail.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import dataclasses | ||
| from datetime import datetime | ||
| from typing import Any, Optional | ||
|
|
||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.http import make_tracked_session | ||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.rest_source import ( | ||
| RESTAPIConfig, | ||
| rest_api_resource, | ||
| ) | ||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.rest_source.paginators import ( | ||
| JSONResponseCursorPaginator, | ||
| ) | ||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.resumable import ResumableSourceManager | ||
| from products.warehouse_sources.backend.temporal.data_imports.sources.common.typings import SourceResponse | ||
| from products.warehouse_sources.backend.temporal.data_imports.sources.shipmail.settings import SHIPMAIL_ENDPOINTS | ||
|
|
||
| SHIPMAIL_BASE_URL = "https://shipmail.to/api/v1" | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class ShipmailResumeConfig: | ||
| cursor: str | None = None | ||
|
|
||
|
|
||
| def _incremental_value(value: Any) -> 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", | ||
| ), | ||
| "session": make_tracked_session(redact_values=(api_key,), capture=False), | ||
| }, | ||
| "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,), capture=False) | ||
| 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() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.