sf-queue-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- queue_sdk/__init__.py +25 -0
- queue_sdk/client.py +58 -0
- queue_sdk/generated/__init__.py +0 -0
- queue_sdk/generated/queue/email/v1/email_pb2.py +45 -0
- queue_sdk/generated/queue/email/v1/email_pb2.pyi +73 -0
- queue_sdk/queues/__init__.py +0 -0
- queue_sdk/queues/base_queue.py +71 -0
- queue_sdk/queues/email_queue.py +161 -0
- queue_sdk/redis_client.py +51 -0
- queue_sdk/sanitize.py +15 -0
- queue_sdk/types.py +87 -0
- sf_queue_sdk-0.1.0.dist-info/METADATA +6 -0
- sf_queue_sdk-0.1.0.dist-info/RECORD +14 -0
- sf_queue_sdk-0.1.0.dist-info/WHEEL +4 -0
queue_sdk/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""sf-queue Python SDK - Redis-based queue system."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from queue_sdk.client import QueueClient
|
|
6
|
+
from queue_sdk.types import (
|
|
7
|
+
BatchEmailResponse,
|
|
8
|
+
EmailButton,
|
|
9
|
+
EmailData,
|
|
10
|
+
EmailImage,
|
|
11
|
+
EmailResponse,
|
|
12
|
+
QueueClientConfig,
|
|
13
|
+
SendResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"QueueClient",
|
|
18
|
+
"QueueClientConfig",
|
|
19
|
+
"EmailData",
|
|
20
|
+
"EmailButton",
|
|
21
|
+
"EmailImage",
|
|
22
|
+
"EmailResponse",
|
|
23
|
+
"BatchEmailResponse",
|
|
24
|
+
"SendResult",
|
|
25
|
+
]
|
queue_sdk/client.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""QueueClient - main entry point for the Python SDK."""
|
|
2
|
+
|
|
3
|
+
from queue_sdk.queues.email_queue import EmailQueue
|
|
4
|
+
from queue_sdk.redis_client import create_redis_client
|
|
5
|
+
from queue_sdk.types import QueueClientConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueueClient:
|
|
9
|
+
"""Main entry point for the sf-queue Python SDK.
|
|
10
|
+
|
|
11
|
+
Initialize with Redis connection details and environment,
|
|
12
|
+
then use typed queue properties to send messages.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
client = QueueClient(
|
|
16
|
+
redis_url="redis://localhost:6379",
|
|
17
|
+
redis_password="your-password",
|
|
18
|
+
environment="staging",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Fire and forget
|
|
22
|
+
result = client.email.send(
|
|
23
|
+
to="user@example.com",
|
|
24
|
+
preview="Welcome!",
|
|
25
|
+
subject="Welcome!",
|
|
26
|
+
paragraphs=["Hello!", "Welcome to our platform."],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Send and wait for confirmation
|
|
30
|
+
response = client.email.send_and_wait(
|
|
31
|
+
to="user@example.com",
|
|
32
|
+
preview="Welcome!",
|
|
33
|
+
subject="Welcome!",
|
|
34
|
+
paragraphs=["Hello!"],
|
|
35
|
+
timeout=30,
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
redis_url: str,
|
|
42
|
+
redis_password: str,
|
|
43
|
+
environment: str,
|
|
44
|
+
):
|
|
45
|
+
config = QueueClientConfig(
|
|
46
|
+
redis_url=redis_url,
|
|
47
|
+
redis_password=redis_password,
|
|
48
|
+
environment=environment,
|
|
49
|
+
)
|
|
50
|
+
self._redis = create_redis_client(config)
|
|
51
|
+
self._environment = environment
|
|
52
|
+
|
|
53
|
+
# Initialize queue instances
|
|
54
|
+
self.email = EmailQueue(self._redis, self._environment)
|
|
55
|
+
|
|
56
|
+
def disconnect(self) -> None:
|
|
57
|
+
"""Close the Redis connection."""
|
|
58
|
+
self._redis.close()
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: queue/email/v1/email.proto
|
|
5
|
+
# Protobuf Python Version: 6.33.5
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
6,
|
|
15
|
+
33,
|
|
16
|
+
5,
|
|
17
|
+
'',
|
|
18
|
+
'queue/email/v1/email.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1aqueue/email/v1/email.proto\x12\x0equeue.email.v1\"5\n\x0b\x45mailButton\x12\x12\n\x04text\x18\x01 \x01(\tR\x04text\x12\x12\n\x04href\x18\x02 \x01(\tR\x04href\"^\n\nEmailImage\x12\x10\n\x03src\x18\x01 \x01(\tR\x03src\x12\x10\n\x03\x61lt\x18\x02 \x01(\tR\x03\x61lt\x12\x14\n\x05width\x18\x03 \x01(\x05R\x05width\x12\x16\n\x06height\x18\x04 \x01(\x05R\x06height\"\xf4\x01\n\x0c\x45mailRequest\x12\x0e\n\x02to\x18\x01 \x01(\tR\x02to\x12\x18\n\x07preview\x18\x02 \x01(\tR\x07preview\x12\x18\n\x07subject\x18\x03 \x01(\tR\x07subject\x12\x1e\n\nparagraphs\x18\x04 \x03(\tR\nparagraphs\x12\x33\n\x06\x62utton\x18\x05 \x01(\x0b\x32\x1b.queue.email.v1.EmailButtonR\x06\x62utton\x12\x19\n\x08reply_to\x18\x06 \x01(\tR\x07replyTo\x12\x30\n\x05image\x18\x07 \x01(\x0b\x32\x1a.queue.email.v1.EmailImageR\x05image\"\xf9\x01\n\x11\x42\x61tchEmailRequest\x12\x0e\n\x02to\x18\x01 \x03(\tR\x02to\x12\x18\n\x07preview\x18\x02 \x01(\tR\x07preview\x12\x18\n\x07subject\x18\x03 \x01(\tR\x07subject\x12\x1e\n\nparagraphs\x18\x04 \x03(\tR\nparagraphs\x12\x33\n\x06\x62utton\x18\x05 \x01(\x0b\x32\x1b.queue.email.v1.EmailButtonR\x06\x62utton\x12\x19\n\x08reply_to\x18\x06 \x01(\tR\x07replyTo\x12\x30\n\x05image\x18\x07 \x01(\x0b\x32\x1a.queue.email.v1.EmailImageR\x05image\"^\n\rEmailResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12\x1d\n\nmessage_id\x18\x02 \x01(\tR\tmessageId\x12\x14\n\x05\x65rror\x18\x03 \x01(\tR\x05\x65rrorBLZJgithub.com/StudyFetch/sf-queue/packages/go/proto-go/queue/email/v1;emailv1b\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'queue.email.v1.email_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
_globals['DESCRIPTOR']._loaded_options = None
|
|
34
|
+
_globals['DESCRIPTOR']._serialized_options = b'ZJgithub.com/StudyFetch/sf-queue/packages/go/proto-go/queue/email/v1;emailv1'
|
|
35
|
+
_globals['_EMAILBUTTON']._serialized_start=46
|
|
36
|
+
_globals['_EMAILBUTTON']._serialized_end=99
|
|
37
|
+
_globals['_EMAILIMAGE']._serialized_start=101
|
|
38
|
+
_globals['_EMAILIMAGE']._serialized_end=195
|
|
39
|
+
_globals['_EMAILREQUEST']._serialized_start=198
|
|
40
|
+
_globals['_EMAILREQUEST']._serialized_end=442
|
|
41
|
+
_globals['_BATCHEMAILREQUEST']._serialized_start=445
|
|
42
|
+
_globals['_BATCHEMAILREQUEST']._serialized_end=694
|
|
43
|
+
_globals['_EMAILRESPONSE']._serialized_start=696
|
|
44
|
+
_globals['_EMAILRESPONSE']._serialized_end=790
|
|
45
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from google.protobuf.internal import containers as _containers
|
|
2
|
+
from google.protobuf import descriptor as _descriptor
|
|
3
|
+
from google.protobuf import message as _message
|
|
4
|
+
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
|
5
|
+
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
|
6
|
+
|
|
7
|
+
DESCRIPTOR: _descriptor.FileDescriptor
|
|
8
|
+
|
|
9
|
+
class EmailButton(_message.Message):
|
|
10
|
+
__slots__ = ("text", "href")
|
|
11
|
+
TEXT_FIELD_NUMBER: _ClassVar[int]
|
|
12
|
+
HREF_FIELD_NUMBER: _ClassVar[int]
|
|
13
|
+
text: str
|
|
14
|
+
href: str
|
|
15
|
+
def __init__(self, text: _Optional[str] = ..., href: _Optional[str] = ...) -> None: ...
|
|
16
|
+
|
|
17
|
+
class EmailImage(_message.Message):
|
|
18
|
+
__slots__ = ("src", "alt", "width", "height")
|
|
19
|
+
SRC_FIELD_NUMBER: _ClassVar[int]
|
|
20
|
+
ALT_FIELD_NUMBER: _ClassVar[int]
|
|
21
|
+
WIDTH_FIELD_NUMBER: _ClassVar[int]
|
|
22
|
+
HEIGHT_FIELD_NUMBER: _ClassVar[int]
|
|
23
|
+
src: str
|
|
24
|
+
alt: str
|
|
25
|
+
width: int
|
|
26
|
+
height: int
|
|
27
|
+
def __init__(self, src: _Optional[str] = ..., alt: _Optional[str] = ..., width: _Optional[int] = ..., height: _Optional[int] = ...) -> None: ...
|
|
28
|
+
|
|
29
|
+
class EmailRequest(_message.Message):
|
|
30
|
+
__slots__ = ("to", "preview", "subject", "paragraphs", "button", "reply_to", "image")
|
|
31
|
+
TO_FIELD_NUMBER: _ClassVar[int]
|
|
32
|
+
PREVIEW_FIELD_NUMBER: _ClassVar[int]
|
|
33
|
+
SUBJECT_FIELD_NUMBER: _ClassVar[int]
|
|
34
|
+
PARAGRAPHS_FIELD_NUMBER: _ClassVar[int]
|
|
35
|
+
BUTTON_FIELD_NUMBER: _ClassVar[int]
|
|
36
|
+
REPLY_TO_FIELD_NUMBER: _ClassVar[int]
|
|
37
|
+
IMAGE_FIELD_NUMBER: _ClassVar[int]
|
|
38
|
+
to: str
|
|
39
|
+
preview: str
|
|
40
|
+
subject: str
|
|
41
|
+
paragraphs: _containers.RepeatedScalarFieldContainer[str]
|
|
42
|
+
button: EmailButton
|
|
43
|
+
reply_to: str
|
|
44
|
+
image: EmailImage
|
|
45
|
+
def __init__(self, to: _Optional[str] = ..., preview: _Optional[str] = ..., subject: _Optional[str] = ..., paragraphs: _Optional[_Iterable[str]] = ..., button: _Optional[_Union[EmailButton, _Mapping]] = ..., reply_to: _Optional[str] = ..., image: _Optional[_Union[EmailImage, _Mapping]] = ...) -> None: ...
|
|
46
|
+
|
|
47
|
+
class BatchEmailRequest(_message.Message):
|
|
48
|
+
__slots__ = ("to", "preview", "subject", "paragraphs", "button", "reply_to", "image")
|
|
49
|
+
TO_FIELD_NUMBER: _ClassVar[int]
|
|
50
|
+
PREVIEW_FIELD_NUMBER: _ClassVar[int]
|
|
51
|
+
SUBJECT_FIELD_NUMBER: _ClassVar[int]
|
|
52
|
+
PARAGRAPHS_FIELD_NUMBER: _ClassVar[int]
|
|
53
|
+
BUTTON_FIELD_NUMBER: _ClassVar[int]
|
|
54
|
+
REPLY_TO_FIELD_NUMBER: _ClassVar[int]
|
|
55
|
+
IMAGE_FIELD_NUMBER: _ClassVar[int]
|
|
56
|
+
to: _containers.RepeatedScalarFieldContainer[str]
|
|
57
|
+
preview: str
|
|
58
|
+
subject: str
|
|
59
|
+
paragraphs: _containers.RepeatedScalarFieldContainer[str]
|
|
60
|
+
button: EmailButton
|
|
61
|
+
reply_to: str
|
|
62
|
+
image: EmailImage
|
|
63
|
+
def __init__(self, to: _Optional[_Iterable[str]] = ..., preview: _Optional[str] = ..., subject: _Optional[str] = ..., paragraphs: _Optional[_Iterable[str]] = ..., button: _Optional[_Union[EmailButton, _Mapping]] = ..., reply_to: _Optional[str] = ..., image: _Optional[_Union[EmailImage, _Mapping]] = ...) -> None: ...
|
|
64
|
+
|
|
65
|
+
class EmailResponse(_message.Message):
|
|
66
|
+
__slots__ = ("success", "message_id", "error")
|
|
67
|
+
SUCCESS_FIELD_NUMBER: _ClassVar[int]
|
|
68
|
+
MESSAGE_ID_FIELD_NUMBER: _ClassVar[int]
|
|
69
|
+
ERROR_FIELD_NUMBER: _ClassVar[int]
|
|
70
|
+
success: bool
|
|
71
|
+
message_id: str
|
|
72
|
+
error: str
|
|
73
|
+
def __init__(self, success: _Optional[bool] = ..., message_id: _Optional[str] = ..., error: _Optional[str] = ...) -> None: ...
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Base queue class with send/send_and_wait mechanics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import redis as redis_lib
|
|
9
|
+
|
|
10
|
+
from queue_sdk.redis_client import response_key, stream_name
|
|
11
|
+
from queue_sdk.types import EmailResponse
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseQueue:
|
|
17
|
+
"""Base queue providing core send/send_and_wait mechanics for any queue type."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, redis_client: redis_lib.Redis, environment: str, queue_name: str):
|
|
20
|
+
self._redis = redis_client
|
|
21
|
+
self._environment = environment
|
|
22
|
+
self._queue_name = queue_name
|
|
23
|
+
# Use hash tags for Redis Cluster slot compatibility
|
|
24
|
+
self._stream = stream_name(environment, "{" + queue_name + "}")
|
|
25
|
+
|
|
26
|
+
def _enqueue(self, payload: dict[str, Any]) -> str:
|
|
27
|
+
"""Add a message to the stream and return the request ID."""
|
|
28
|
+
request_id = str(uuid.uuid4())
|
|
29
|
+
message = {
|
|
30
|
+
"requestId": request_id,
|
|
31
|
+
"payload": json.dumps(payload),
|
|
32
|
+
"createdAt": datetime.now(timezone.utc).isoformat(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
self._redis.xadd(self._stream, message)
|
|
36
|
+
return request_id
|
|
37
|
+
|
|
38
|
+
def _enqueue_and_wait(
|
|
39
|
+
self, payload: dict[str, Any], timeout: Optional[int] = None
|
|
40
|
+
) -> EmailResponse:
|
|
41
|
+
"""Add a message and wait for the response."""
|
|
42
|
+
request_id = self._enqueue(payload)
|
|
43
|
+
wait_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
44
|
+
|
|
45
|
+
key = response_key(self._environment, self._queue_name, request_id)
|
|
46
|
+
|
|
47
|
+
# BRPOP blocks until a response is pushed or timeout
|
|
48
|
+
result = self._redis.brpop(key, timeout=wait_timeout)
|
|
49
|
+
|
|
50
|
+
if result is None:
|
|
51
|
+
return EmailResponse(
|
|
52
|
+
success=False,
|
|
53
|
+
message_id=request_id,
|
|
54
|
+
error=f"Timeout waiting for response after {wait_timeout}s",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
# result is (key, value) tuple
|
|
59
|
+
response_data = json.loads(result[1])
|
|
60
|
+
return EmailResponse(
|
|
61
|
+
success=response_data.get("success", False),
|
|
62
|
+
message_id=response_data.get("messageId", request_id),
|
|
63
|
+
error=response_data.get("error"),
|
|
64
|
+
processed_at=response_data.get("processedAt"),
|
|
65
|
+
)
|
|
66
|
+
except (json.JSONDecodeError, KeyError):
|
|
67
|
+
return EmailResponse(
|
|
68
|
+
success=False,
|
|
69
|
+
message_id=request_id,
|
|
70
|
+
error="Failed to parse response from queue",
|
|
71
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Email queue with typed methods."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import redis as redis_lib
|
|
6
|
+
|
|
7
|
+
from queue_sdk.queues.base_queue import BaseQueue
|
|
8
|
+
from queue_sdk.sanitize import sanitize, sanitize_list
|
|
9
|
+
from queue_sdk.types import (
|
|
10
|
+
BatchEmailResponse,
|
|
11
|
+
EmailResponse,
|
|
12
|
+
SendResult,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailQueue(BaseQueue):
|
|
17
|
+
"""Typed methods for sending emails via the queue."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, redis_client: redis_lib.Redis, environment: str):
|
|
20
|
+
super().__init__(redis_client, environment, "email")
|
|
21
|
+
|
|
22
|
+
def send(
|
|
23
|
+
self,
|
|
24
|
+
to: str,
|
|
25
|
+
preview: str,
|
|
26
|
+
subject: str,
|
|
27
|
+
paragraphs: list[str],
|
|
28
|
+
button: Optional[dict[str, str]] = None,
|
|
29
|
+
reply_to: Optional[str] = None,
|
|
30
|
+
image: Optional[dict[str, Any]] = None,
|
|
31
|
+
) -> SendResult:
|
|
32
|
+
"""Enqueue an email for processing (fire and forget)."""
|
|
33
|
+
payload = self._build_payload(to, preview, subject, paragraphs, button, reply_to, image)
|
|
34
|
+
message_id = self._enqueue(payload)
|
|
35
|
+
return SendResult(message_id=message_id)
|
|
36
|
+
|
|
37
|
+
def send_and_wait(
|
|
38
|
+
self,
|
|
39
|
+
to: str,
|
|
40
|
+
preview: str,
|
|
41
|
+
subject: str,
|
|
42
|
+
paragraphs: list[str],
|
|
43
|
+
button: Optional[dict[str, str]] = None,
|
|
44
|
+
reply_to: Optional[str] = None,
|
|
45
|
+
image: Optional[dict[str, Any]] = None,
|
|
46
|
+
timeout: Optional[int] = None,
|
|
47
|
+
) -> EmailResponse:
|
|
48
|
+
"""Enqueue an email and wait for processing confirmation."""
|
|
49
|
+
payload = self._build_payload(to, preview, subject, paragraphs, button, reply_to, image)
|
|
50
|
+
return self._enqueue_and_wait(payload, timeout)
|
|
51
|
+
|
|
52
|
+
def send_batch(
|
|
53
|
+
self,
|
|
54
|
+
to: list[str],
|
|
55
|
+
preview: str,
|
|
56
|
+
subject: str,
|
|
57
|
+
paragraphs: list[str],
|
|
58
|
+
button: Optional[dict[str, str]] = None,
|
|
59
|
+
reply_to: Optional[str] = None,
|
|
60
|
+
image: Optional[dict[str, Any]] = None,
|
|
61
|
+
) -> SendResult:
|
|
62
|
+
"""Enqueue a batch email for processing (fire and forget)."""
|
|
63
|
+
self._validate_batch(to)
|
|
64
|
+
payload = self._build_batch_payload(to, preview, subject, paragraphs, button, reply_to, image)
|
|
65
|
+
message_id = self._enqueue(payload)
|
|
66
|
+
return SendResult(message_id=message_id)
|
|
67
|
+
|
|
68
|
+
def send_batch_and_wait(
|
|
69
|
+
self,
|
|
70
|
+
to: list[str],
|
|
71
|
+
preview: str,
|
|
72
|
+
subject: str,
|
|
73
|
+
paragraphs: list[str],
|
|
74
|
+
button: Optional[dict[str, str]] = None,
|
|
75
|
+
reply_to: Optional[str] = None,
|
|
76
|
+
image: Optional[dict[str, Any]] = None,
|
|
77
|
+
timeout: Optional[int] = None,
|
|
78
|
+
) -> BatchEmailResponse:
|
|
79
|
+
"""Enqueue a batch email and wait for processing confirmation."""
|
|
80
|
+
self._validate_batch(to)
|
|
81
|
+
payload = self._build_batch_payload(to, preview, subject, paragraphs, button, reply_to, image)
|
|
82
|
+
response = self._enqueue_and_wait(payload, timeout)
|
|
83
|
+
|
|
84
|
+
return BatchEmailResponse(
|
|
85
|
+
success=response.success,
|
|
86
|
+
message_id=response.message_id,
|
|
87
|
+
error=response.error,
|
|
88
|
+
processed_at=response.processed_at,
|
|
89
|
+
total=getattr(response, "total", len(to)),
|
|
90
|
+
successful=getattr(response, "successful", len(to) if response.success else 0),
|
|
91
|
+
failed=getattr(response, "failed", 0 if response.success else len(to)),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _build_payload(
|
|
95
|
+
self,
|
|
96
|
+
to: str,
|
|
97
|
+
preview: str,
|
|
98
|
+
subject: str,
|
|
99
|
+
paragraphs: list[str],
|
|
100
|
+
button: Optional[dict[str, str]],
|
|
101
|
+
reply_to: Optional[str],
|
|
102
|
+
image: Optional[dict[str, Any]],
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Build and sanitize the email payload."""
|
|
105
|
+
payload: dict[str, Any] = {
|
|
106
|
+
"to": to,
|
|
107
|
+
"preview": sanitize(preview),
|
|
108
|
+
"subject": sanitize(subject),
|
|
109
|
+
"paragraphs": sanitize_list(paragraphs),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if button:
|
|
113
|
+
payload["button"] = {
|
|
114
|
+
"text": sanitize(button["text"]),
|
|
115
|
+
"href": button["href"],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if reply_to:
|
|
119
|
+
payload["replyTo"] = reply_to
|
|
120
|
+
|
|
121
|
+
if image:
|
|
122
|
+
payload["image"] = {
|
|
123
|
+
"src": image.get("src", ""),
|
|
124
|
+
"alt": image.get("alt", ""),
|
|
125
|
+
"width": image.get("width"),
|
|
126
|
+
"height": image.get("height"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return payload
|
|
130
|
+
|
|
131
|
+
def _build_batch_payload(
|
|
132
|
+
self,
|
|
133
|
+
to: list[str],
|
|
134
|
+
preview: str,
|
|
135
|
+
subject: str,
|
|
136
|
+
paragraphs: list[str],
|
|
137
|
+
button: Optional[dict[str, str]],
|
|
138
|
+
reply_to: Optional[str],
|
|
139
|
+
image: Optional[dict[str, Any]],
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Build and sanitize the batch email payload."""
|
|
142
|
+
payload = self._build_payload(
|
|
143
|
+
to="", # not used for batch
|
|
144
|
+
preview=preview,
|
|
145
|
+
subject=subject,
|
|
146
|
+
paragraphs=paragraphs,
|
|
147
|
+
button=button,
|
|
148
|
+
reply_to=reply_to,
|
|
149
|
+
image=image,
|
|
150
|
+
)
|
|
151
|
+
payload["to"] = to
|
|
152
|
+
payload["batch"] = True
|
|
153
|
+
return payload
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _validate_batch(to: list[str]) -> None:
|
|
157
|
+
"""Validate batch constraints."""
|
|
158
|
+
if len(to) > 100:
|
|
159
|
+
raise ValueError("Maximum 100 recipients allowed per batch request")
|
|
160
|
+
if len(to) == 0:
|
|
161
|
+
raise ValueError("At least one recipient is required")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Redis connection factory."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
from queue_sdk.types import QueueClientConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_redis_client(config: QueueClientConfig) -> redis.Redis:
|
|
11
|
+
"""Create a Redis client from the SDK config."""
|
|
12
|
+
url = config.redis_url
|
|
13
|
+
password = config.redis_password
|
|
14
|
+
|
|
15
|
+
# Full URL format (redis://... or rediss://...)
|
|
16
|
+
if url.startswith("redis://") or url.startswith("rediss://"):
|
|
17
|
+
parsed = urlparse(url)
|
|
18
|
+
effective_password = password or parsed.password or None
|
|
19
|
+
return redis.Redis(
|
|
20
|
+
host=parsed.hostname or "localhost",
|
|
21
|
+
port=parsed.port or 6379,
|
|
22
|
+
password=effective_password,
|
|
23
|
+
decode_responses=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Simple host:port format
|
|
27
|
+
parts = url.split(":")
|
|
28
|
+
host = parts[0] if parts[0] else "localhost"
|
|
29
|
+
port = int(parts[1]) if len(parts) > 1 else 6379
|
|
30
|
+
|
|
31
|
+
return redis.Redis(
|
|
32
|
+
host=host,
|
|
33
|
+
port=port,
|
|
34
|
+
password=password or None,
|
|
35
|
+
decode_responses=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stream_name(env: str, base: str) -> str:
|
|
40
|
+
"""Return the full stream name with environment prefix."""
|
|
41
|
+
if not env:
|
|
42
|
+
return base
|
|
43
|
+
return f"{env}:{base}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def response_key(env: str, queue: str, request_id: str) -> str:
|
|
47
|
+
"""Return the response list key for a given queue and request ID."""
|
|
48
|
+
key = f"{queue}:response:{request_id}"
|
|
49
|
+
if not env:
|
|
50
|
+
return key
|
|
51
|
+
return f"{env}:{key}"
|
queue_sdk/sanitize.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Basic HTML sanitization for email content."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_HTML_TAG_REGEX = re.compile(r"<[^>]*>")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sanitize(text: str) -> str:
|
|
9
|
+
"""Strip HTML tags from a string."""
|
|
10
|
+
return _HTML_TAG_REGEX.sub("", text)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sanitize_list(texts: list[str]) -> list[str]:
|
|
14
|
+
"""Sanitize each string in a list."""
|
|
15
|
+
return [sanitize(t) for t in texts]
|
queue_sdk/types.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Type definitions for the queue SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class QueueClientConfig:
|
|
9
|
+
"""Configuration for the QueueClient."""
|
|
10
|
+
|
|
11
|
+
redis_url: str
|
|
12
|
+
redis_password: str
|
|
13
|
+
environment: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EmailButton:
|
|
18
|
+
"""CTA button for email."""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
href: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EmailImage:
|
|
26
|
+
"""Optional image for email."""
|
|
27
|
+
|
|
28
|
+
src: str
|
|
29
|
+
alt: str = ""
|
|
30
|
+
width: Optional[int] = None
|
|
31
|
+
height: Optional[int] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class EmailData:
|
|
36
|
+
"""Data for a single email."""
|
|
37
|
+
|
|
38
|
+
to: str
|
|
39
|
+
preview: str
|
|
40
|
+
subject: str
|
|
41
|
+
paragraphs: list[str]
|
|
42
|
+
button: Optional[EmailButton] = None
|
|
43
|
+
reply_to: Optional[str] = None
|
|
44
|
+
image: Optional[EmailImage] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BatchEmailData:
|
|
49
|
+
"""Data for a batch email (same content to multiple recipients)."""
|
|
50
|
+
|
|
51
|
+
to: list[str]
|
|
52
|
+
preview: str
|
|
53
|
+
subject: str
|
|
54
|
+
paragraphs: list[str]
|
|
55
|
+
button: Optional[EmailButton] = None
|
|
56
|
+
reply_to: Optional[str] = None
|
|
57
|
+
image: Optional[EmailImage] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SendResult:
|
|
62
|
+
"""Result of a fire-and-forget send."""
|
|
63
|
+
|
|
64
|
+
message_id: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class EmailResponse:
|
|
69
|
+
"""Response from a processed email."""
|
|
70
|
+
|
|
71
|
+
success: bool
|
|
72
|
+
message_id: str
|
|
73
|
+
error: Optional[str] = None
|
|
74
|
+
processed_at: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class BatchEmailResponse:
|
|
79
|
+
"""Response from a processed batch email."""
|
|
80
|
+
|
|
81
|
+
success: bool
|
|
82
|
+
message_id: str
|
|
83
|
+
total: int
|
|
84
|
+
successful: int
|
|
85
|
+
failed: int
|
|
86
|
+
error: Optional[str] = None
|
|
87
|
+
processed_at: Optional[str] = None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
queue_sdk/__init__.py,sha256=x-e5r6aQKhRVrOESKXU26RkXiLk5G1jFOTGA3gRwSlA,459
|
|
2
|
+
queue_sdk/client.py,sha256=VO3-4ykjCQbu9L20NM4nZXKAijBTUziaCH8aLJ8wwaw,1656
|
|
3
|
+
queue_sdk/redis_client.py,sha256=piToSab1T-nt0cAG2ZWSXbgeyJE7o1MlsMX3u3tGzFg,1441
|
|
4
|
+
queue_sdk/sanitize.py,sha256=yUPDvcu531b_guYgYmI8H1jDf2Q72Z7vCetzmHoRHwQ,352
|
|
5
|
+
queue_sdk/types.py,sha256=pR_AhDYHE1NNE0k_GmwwQbaM0E_0YvIPr3_NAnR_ucs,1596
|
|
6
|
+
queue_sdk/generated/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
queue_sdk/generated/queue/email/v1/email_pb2.py,sha256=9VsYHPDrTRNPpPZjGEuE3l_jeIriV7o8s-0qRXsj3kw,3255
|
|
8
|
+
queue_sdk/generated/queue/email/v1/email_pb2.pyi,sha256=ilMIcDo-YCb3SOCWgiNl4vxMAaOaKRZcPEkMYzVFe7Y,3317
|
|
9
|
+
queue_sdk/queues/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
queue_sdk/queues/base_queue.py,sha256=eGrlN2xRwaTjEhR9BIElDZ5peLYmzwKIMvUp-4ZutBE,2524
|
|
11
|
+
queue_sdk/queues/email_queue.py,sha256=COqQ0UWBDZSwQ2EzQnkoJMlexbq2IX-269Ef9DAEoi0,5275
|
|
12
|
+
sf_queue_sdk-0.1.0.dist-info/METADATA,sha256=G53ZP-x1M8YJMXfBqfaWjc4FY8XaU9wBKwrrCymsuIk,168
|
|
13
|
+
sf_queue_sdk-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
sf_queue_sdk-0.1.0.dist-info/RECORD,,
|