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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 77 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# cloudrift

Cloud-agnostic abstraction for **storage**, **messaging**, **document databases**, and **cache** — built for Lyzr microservices.
Cloud-agnostic abstraction for **storage**, **messaging**, **document databases**, **cache**, and **email** — built for Lyzr microservices.

- **Async-first.** Every public method is `async def`. All four categories use native-async SDK clients (`aioboto3`, `azure.*.aio`, `motor`, `redis.asyncio`) — no thread-pool wrapping.
- **Drop-in providers.** Same interface across AWS, Azure, and self-hosted backends. Swap `s3` ↔ `azure_blob` (or `sqs` ↔ `azure_bus`, `documentdb` ↔ `cosmos`, `redis` ↔ `elasticache` ↔ `azure_redis`) by changing one string.
- **Async-first.** Every public method is `async def`. All five categories use native-async SDK clients (`aioboto3`, `azure.*.aio`, `motor`, `redis.asyncio`, `aiosmtplib`) — no thread-pool wrapping.
- **Drop-in providers.** Same interface across AWS, Azure, and self-hosted backends. Swap `s3` ↔ `azure_blob` (or `sqs` ↔ `azure_bus`, `documentdb` ↔ `cosmos`, `redis` ↔ `elasticache` ↔ `azure_redis`, `ses` ↔ `azure_acs` ↔ `smtp`) by changing one string.
- **Multiple auth methods per provider.** Static keys, IAM roles, profiles, managed identity, service principals, SAS tokens, mTLS, IAM auth — pick what your microservice already has.

| Category | AWS | Azure | Self-hosted |
Expand All @@ -12,6 +12,7 @@ Cloud-agnostic abstraction for **storage**, **messaging**, **document databases*
| Messaging | SQS | Service Bus | — |
| Document DB | DocumentDB | Cosmos DB (MongoDB API) | — |
| Cache | ElastiCache | Azure Cache for Redis | Redis |
| Email | SES | Communication Services | SMTP |

---

Expand All @@ -20,9 +21,10 @@ Cloud-agnostic abstraction for **storage**, **messaging**, **document databases*
Pick the extras your service needs:

```bash
pip install "cloudrift[aws]" # S3 + SQS + DocumentDB + Redis client
pip install "cloudrift[azure]" # Blob + Service Bus + Cosmos + Redis client
pip install "cloudrift[aws]" # S3 + SQS + DocumentDB + SES + Redis client
pip install "cloudrift[azure]" # Blob + Service Bus + Cosmos + ACS Email + Redis client
pip install "cloudrift[cache]" # Just Redis (any flavour)
pip install "cloudrift[email]" # Just raw SMTP (aiosmtplib)
pip install "cloudrift[all]" # Everything
```

Expand Down Expand Up @@ -254,6 +256,74 @@ await cache.close()

---

## Email

```python
from cloudrift.email import get_email

# AWS SES (SESv2)
ses = get_email("ses", region="us-east-1", default_from="noreply@example.com") # IAM / env
ses = get_email("ses", aws_access_key_id="AKIA...",
aws_secret_access_key="...", region="us-east-1",
default_from="noreply@example.com") # static keys
ses = get_email("ses", profile_name="dev", region="us-east-1",
default_from="noreply@example.com") # ~/.aws profile

# Azure Communication Services
acs = get_email("azure_acs",
connection_string="endpoint=https://...;accesskey=...",
default_from="DoNotReply@example.com") # connection string
acs = get_email("azure_acs", endpoint="https://x.communication.azure.com",
default_from="DoNotReply@example.com") # managed identity
acs = get_email("azure_acs", endpoint="https://x.communication.azure.com",
tenant_id="...", client_id="...", client_secret="...",
default_from="DoNotReply@example.com") # service principal

# Raw SMTP (SendGrid, Mailgun, Postmark, Office365, MailHog, ...)
smtp = get_email("smtp", host="smtp.sendgrid.net",
username="apikey", password="...",
default_from="noreply@example.com") # STARTTLS, port 587 (default)
smtp = get_email("smtp", mode="tls", host="smtp.example.com", port=465,
username="user", password="pw",
default_from="noreply@example.com") # implicit TLS
smtp = get_email("smtp", mode="plaintext", host="localhost", port=1025,
default_from="noreply@example.test") # MailHog / Mailpit (dev)
```

**Operations** — same on every backend:

```python
from cloudrift.email import Attachment, EmailMessage

# Single send (text, HTML, or multipart/alternative)
msg_id: str = await email.send(
"alice@example.com",
"Welcome",
body_text="Plain text body",
body_html="<p>HTML body</p>",
cc=["bob@example.com"], bcc=["audit@example.com"],
reply_to=["support@example.com"],
attachments=[Attachment(filename="welcome.pdf",
content=pdf_bytes,
content_type="application/pdf")],
headers={"X-Campaign": "welcome-v2"},
)

# Batch send (loops `send()` by default; subclasses override when the
# provider exposes a true bulk API)
ids: list[str] = await email.send_batch([
EmailMessage(to=["alice@example.com"], subject="hi", body_text="hi"),
EmailMessage(to=["bob@example.com"], subject="hi2", body_html="<b>hi2</b>"),
])

ok: bool = await email.health_check()
await email.close()
```

> **Default sender.** Each backend accepts a `default_from` at construction time; calls that omit `from_` fall back to it. SES requires the sender (address or domain) to be verified; ACS requires the sending domain to be linked to the resource.

---

## Connection pooling & lifecycle

Every backend holds **one long-lived async client** that is reused across all operations. This is the single biggest perf knob:
Expand Down Expand Up @@ -285,6 +355,8 @@ from cloudrift.core.exceptions import (
QueueNotFoundError, MessageSendError, MessagingError,
DocumentConnectionError,
CacheKeyNotFoundError, CacheConnectionError, CacheError,
EmailError, EmailSendError,
RecipientRejectedError, SenderUnverifiedError, EmailThrottledError,
)

try:
Expand Down
2 changes: 2 additions & 0 deletions cloudrift/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from cloudrift.cache import get_cache
from cloudrift.secrets import get_secrets
from cloudrift.pubsub import get_pubsub
from cloudrift.email import get_email

__version__ = "0.2.0"
__all__ = [
Expand All @@ -13,4 +14,5 @@
"get_cache",
"get_secrets",
"get_pubsub",
"get_email",
]
21 changes: 21 additions & 0 deletions cloudrift/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,24 @@ class TopicNotFoundError(PubSubError):

class PublishError(PubSubError):
"""Raised when a message fails to publish."""


# Email exceptions
class EmailError(CloudRiftError):
"""Base exception for email operations."""


class EmailSendError(EmailError):
"""Raised when an email fails to send."""


class RecipientRejectedError(EmailError):
"""Raised when one or more recipients are rejected by the provider."""


class SenderUnverifiedError(EmailError):
"""Raised when the From address or domain is not verified with the provider."""


class EmailThrottledError(EmailError):
"""Raised when the provider rate-limits the send."""
67 changes: 67 additions & 0 deletions cloudrift/email/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from cloudrift.email.base import Attachment, EmailBackend, EmailMessage


def get_email(provider: str, **kwargs) -> EmailBackend:

Check failure on line 4 in cloudrift/email/__init__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LYZR-OSS_cloudrift&issues=AZ5s4NE5E1924JjNY8AG&open=AZ5s4NE5E1924JjNY8AG&pullRequest=4
"""Factory to instantiate an email backend.

Args:
provider: ``"ses"``, ``"azure_acs"``, or ``"smtp"``.
**kwargs: Provider-specific config. The factory routes to the
appropriate ``from_*`` classmethod based on which credential keys
are present.

Returns:
An :class:`EmailBackend` instance.

Examples:
get_email("ses", region="us-east-1", default_from="noreply@example.com")
get_email("ses", aws_access_key_id="AKIA...", aws_secret_access_key="...",
default_from="noreply@example.com")
get_email("azure_acs", connection_string="endpoint=https://...;accesskey=...",
default_from="DoNotReply@example.com")
get_email("azure_acs", endpoint="https://...communication.azure.com",
default_from="DoNotReply@example.com")
get_email("smtp", host="smtp.sendgrid.net", username="apikey", password="...",
default_from="noreply@example.com")
get_email("smtp", mode="tls", host="smtp.example.com", port=465,
username="user", password="pw", default_from="...")
"""
if provider == "ses":
from cloudrift.email.ses import AWSSESBackend

if "aws_access_key_id" in kwargs:
return AWSSESBackend.from_access_key(**kwargs)
if "profile_name" in kwargs:
return AWSSESBackend.from_profile(**kwargs)
return AWSSESBackend.from_iam_role(**kwargs)

if provider == "azure_acs":
from cloudrift.email.azure_acs import AzureACSEmailBackend

if "connection_string" in kwargs:
return AzureACSEmailBackend.from_connection_string(**kwargs)
if "client_secret" in kwargs:
return AzureACSEmailBackend.from_service_principal(**kwargs)
return AzureACSEmailBackend.from_managed_identity(**kwargs)

if provider == "smtp":
from cloudrift.email.smtp import SMTPEmailBackend

mode = kwargs.pop("mode", "starttls")
if mode == "tls":
return SMTPEmailBackend.from_tls(**kwargs)
if mode == "plaintext":
return SMTPEmailBackend.from_plaintext(**kwargs)
if mode == "starttls":
return SMTPEmailBackend.from_starttls(**kwargs)
raise ValueError(
f"Unknown SMTP mode: {mode!r}. Choose 'plaintext', 'starttls', or 'tls'."
)

raise ValueError(
f"Unknown email provider: {provider!r}. "
"Choose 'ses', 'azure_acs', or 'smtp'."
)


__all__ = ["Attachment", "EmailBackend", "EmailMessage", "get_email"]
Loading
Loading