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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.3.0] - Unreleased
## [4.4.0] - Unreleased

### Added

- **`createdAt` in message state** — message state results now expose the moment the message was created, parsed from the API's ISO 8601 timestamp into a `datetime` (timezone-aware when the value carries a `Z` suffix) instead of leaving the creation time unavailable
```python
from android_sms_gateway import client, domain

with client.APIClient(login, password) as c:
state = c.get_state("message-id")

print(state.created_at) # datetime.datetime(..., tzinfo=datetime.timezone.utc)
```

Responses that omit the field, send a null value, or send a malformed timestamp leave `state.created_at` as `None` rather than raising an error.

## [4.3.0] - 2026-09-23

### Added

Expand Down
85 changes: 66 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ A modern Python client for the [SMSGate](https://sms-gate.app) API: send SMS mes
- [📚 Table of Contents](#-table-of-contents)
- [⭐ Features](#-features)
- [📦 Installation](#-installation)
- [🚀 Getting Started](#-getting-started)
- [Prerequisites](#prerequisites)
- [Quick Setup](#quick-setup)
- [🔑 Authentication](#-authentication)
- [Basic Authentication](#basic-authentication)
- [JWT Authentication](#jwt-authentication)
- [🚀 Quickstart](#-quickstart)
- [💻 Usage](#-usage)
- [📖 API Reference](#-api-reference)
- [🗺️ Roadmap](#️-roadmap)
- [🤝 Contributing](#-contributing)
- [📞 Contact](#-contact)
- [📄 License](#-license)
- [🙏 Acknowledgments](#-acknowledgments)

## ⭐ Features

Expand All @@ -54,6 +59,42 @@ pip install android-sms-gateway[httpx] # httpx backend (sync + async)
pip install android-sms-gateway[encryption] # end-to-end encryption (pycryptodome)
```

## 🚀 Getting Started

### Prerequisites

- Python 3.9 or higher
- An [SMSGate](https://sms-gate.app) account with API credentials
- An Android device running the SMSGate app

### Quick Setup

1. Install the library with your preferred HTTP backend:
```bash
pip install android-sms-gateway[requests]
```

2. Set environment variables:
```bash
export SMSGATE_LOGIN="your-login"
export SMSGATE_PASSWORD="your-password"
```

3. Send your first message:
```python
import os
from android_sms_gateway import client, domain

message = domain.Message(
phone_numbers=["+12025550100"],
text_message=domain.TextMessage(text="Hello from Python"),
)

with client.APIClient(os.getenv("SMSGATE_LOGIN"), os.getenv("SMSGATE_PASSWORD")) as c:
state = c.send(message)
print(f"Message ID: {state.id}")
```

## 🔑 Authentication

Two methods are supported: Basic authentication with account credentials, and JWT bearer tokens with scoped permissions. Pass `login=None` with a token to switch to JWT.
Expand Down Expand Up @@ -89,24 +130,6 @@ with client.APIClient(login, password) as c:
jwt_client = client.APIClient(None, token.access_token)
```

## 🚀 Quickstart

```python
import os

from android_sms_gateway import client, domain

message = domain.Message(
phone_numbers=["+12025550100"],
text_message=domain.TextMessage(text="Hello from Python"),
with_delivery_report=True,
)

with client.APIClient(os.getenv("SMSGATE_LOGIN"), os.getenv("SMSGATE_PASSWORD")) as c:
state = c.send(message)
print(f"Message ID: {state.id}")
```

## 💻 Usage

Beyond sending, the client covers message listing and cancellation, inbox listing and refresh, device management, health checks, logs, settings (get, update, patch), webhooks, and the token lifecycle. See [android_sms_gateway/client.py](https://github.com/android-sms-gateway/client-py/blob/master/android_sms_gateway/client.py) for the complete method list with signatures and [android_sms_gateway/domain.py](https://github.com/android-sms-gateway/client-py/blob/master/android_sms_gateway/domain.py) for the domain models.
Expand All @@ -118,14 +141,38 @@ Beyond sending, the client covers message listing and cancellation, inbox listin
- [Client libraries overview](https://docs.sms-gate.app/integration/client-libraries/)
- [Client source](https://github.com/android-sms-gateway/client-py/blob/master/android_sms_gateway/client.py) - full method reference and examples

## 🗺️ Roadmap

- [ ] Add support for batch message sending
- [ ] Implement message scheduling
- [ ] Add webhook signature verification
- [ ] Support for custom HTTP headers
- [ ] Async context manager improvements

See the [open issues](https://github.com/android-sms-gateway/client-py/issues) for a full list of proposed features and known issues.

## 🤝 Contributing

Contributions are welcome. Open an issue to discuss major changes before submitting a pull request; PRs target the `master` branch.

## 📞 Contact

**Aleksandr Soloshenko** - admin@sms-gate.app

Project Link: [https://github.com/android-sms-gateway/client-py](https://github.com/android-sms-gateway/client-py)

## 📄 License

Distributed under the Apache License 2.0. See [LICENSE](https://github.com/android-sms-gateway/client-py/blob/master/LICENSE).

## 🙏 Acknowledgments

- [requests](https://docs.python-requests.org/) - HTTP library for sync requests
- [aiohttp](https://docs.aiohttp.org/) - Async HTTP client/server
- [httpx](https://www.python-httpx.org/) - Modern HTTP client for sync and async
- [pycryptodome](https://pycryptodome.readthedocs.io/) - Cryptographic library for encryption
- [Best README Template](https://github.com/othneildrew/Best-README-Template) - README structure inspiration

<!-- Badge references: Shields.io style=for-the-badge is mandatory -->
[contributors-shield]: https://img.shields.io/github/contributors/android-sms-gateway/client-py?style=for-the-badge
[contributors-url]: https://github.com/android-sms-gateway/client-py/graphs/contributors
Expand Down
10 changes: 10 additions & 0 deletions android_sms_gateway/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,18 @@ class MessageState:
is_encrypted: bool
device_id: t.Optional[str] = None
states: t.Optional[t.Dict[str, str]] = None
created_at: t.Optional[datetime.datetime] = None
"""The timestamp when the message was created."""

@classmethod
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "MessageState":
created_at = None
raw_created_at = payload.get("createdAt")
if raw_created_at is not None:
try:
created_at = _parse_iso(raw_created_at)
except (AttributeError, TypeError, ValueError):
created_at = None
return cls(
id=payload["id"],
device_id=payload.get("deviceId"),
Expand All @@ -268,6 +277,7 @@ def from_dict(cls, payload: t.Dict[str, t.Any]) -> "MessageState":
is_hashed=payload.get("isHashed", False),
is_encrypted=payload.get("isEncrypted", False),
states=payload.get("states"),
created_at=created_at,
)


Expand Down
56 changes: 56 additions & 0 deletions tests/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,62 @@ def test_message_state_from_dict_incorrect_types():
MessageState.from_dict(incorrect_payload)


def test_message_state_from_dict_with_created_at():
payload = {
"id": "123",
"state": "Pending",
"recipients": [{"phoneNumber": "123", "state": "Pending"}],
"isHashed": True,
"isEncrypted": False,
"createdAt": "2026-08-23T10:30:15Z",
}

message_state = MessageState.from_dict(payload)

assert isinstance(message_state.created_at, datetime.datetime)
assert message_state.created_at == datetime.datetime.fromisoformat(
"2026-08-23T10:30:15+00:00"
)


def test_message_state_created_at_defaults_to_none_when_absent():
payload = {
"id": "123",
"state": "Pending",
"recipients": [{"phoneNumber": "123", "state": "Pending"}],
}

message_state = MessageState.from_dict(payload)

assert message_state.created_at is None


def test_message_state_created_at_none_value():
payload = {
"id": "123",
"state": "Pending",
"recipients": [{"phoneNumber": "123", "state": "Pending"}],
"createdAt": None,
}

message_state = MessageState.from_dict(payload)

assert message_state.created_at is None


def test_message_state_created_at_malformed_does_not_crash():
payload = {
"id": "123",
"state": "Pending",
"recipients": [{"phoneNumber": "123", "state": "Pending"}],
"createdAt": "not-a-valid-timestamp",
}

message_state = MessageState.from_dict(payload)

assert message_state.created_at is None


def test_webhook_from_dict():
"""
Tests that a Webhook instance can be successfully instantiated from a dictionary
Expand Down
Loading