sf-queue-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,56 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ .venv/
13
+ venv/
14
+ *.egg
15
+
16
+ # Go
17
+ /bin/
18
+ *.exe
19
+ *.dll
20
+ *.so
21
+ *.dylib
22
+
23
+ # IDE
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+ *~
28
+ .vscode/
29
+
30
+ # Environment
31
+ .env
32
+ .env.local
33
+ .env.*.local
34
+
35
+ # Local testing (standalone project, uses npm not pnpm to avoid workspace conflicts)
36
+ local-testing/node_modules/
37
+ local-testing/.env
38
+ local-testing/package-lock.json
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Build artifacts
45
+ *.log
46
+ coverage/
47
+ .nyc_output/
48
+
49
+ # Lock files are committed
50
+ # uv.lock
51
+ # pnpm-lock.yaml
52
+
53
+ # Generated files (committed for SDK distribution)
54
+ # packages/go/proto-go/ -- committed
55
+ # packages/ts/queue-sdk/src/generated/ -- committed
56
+ # packages/py/queue-sdk/queue_sdk/generated/ -- committed
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: sf-queue-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for sf-queue - Redis-based queue system
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: redis>=5.0.0
@@ -0,0 +1,202 @@
1
+ # sf-queue-sdk
2
+
3
+ Python SDK for sf-queue. Enqueues emails via Redis Streams with optional blocking confirmation from the Go consumer service.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install sf-queue-sdk
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```python
14
+ from queue_sdk import QueueClient
15
+
16
+ client = QueueClient(
17
+ redis_url="redis://localhost:6379",
18
+ redis_password="your-password",
19
+ environment="staging", # prefixes stream names: staging:{email}
20
+ )
21
+ ```
22
+
23
+ ## Single Email
24
+
25
+ ### Fire and forget
26
+
27
+ Enqueues the email and returns immediately. Does not wait for the consumer to process it.
28
+
29
+ ```python
30
+ result = client.email.send(
31
+ to="user@example.com",
32
+ preview="Welcome to StudyFetch!",
33
+ subject="Welcome to StudyFetch!",
34
+ paragraphs=[
35
+ "Hey there,",
36
+ "Welcome to the StudyFetch community!",
37
+ "Thanks for joining us.",
38
+ ],
39
+ button={
40
+ "text": "Go to Platform",
41
+ "href": "https://www.studyfetch.com/platform",
42
+ },
43
+ )
44
+
45
+ print("Enqueued:", result.message_id)
46
+ ```
47
+
48
+ ### Send and wait for confirmation
49
+
50
+ Enqueues the email and blocks until the Go consumer processes it (or timeout).
51
+
52
+ ```python
53
+ result = client.email.send_and_wait(
54
+ to="user@example.com",
55
+ preview="Reset your password",
56
+ subject="StudyFetch: Reset Your Password",
57
+ paragraphs=[
58
+ "Hi There,",
59
+ "Click the button below to reset your password.",
60
+ ],
61
+ button={
62
+ "text": "Reset Password",
63
+ "href": "https://www.studyfetch.com/reset?token=abc",
64
+ },
65
+ timeout=30, # optional, default 30s
66
+ )
67
+
68
+ print(result.success) # True or False
69
+ print(result.message_id) # request ID
70
+ print(result.error) # error message if failed
71
+ ```
72
+
73
+ ## Batch Email
74
+
75
+ Send the same email content to multiple recipients (up to 100). The Go consumer sends to each recipient individually.
76
+
77
+ ### Fire and forget
78
+
79
+ ```python
80
+ result = client.email.send_batch(
81
+ to=[
82
+ "student1@example.com",
83
+ "student2@example.com",
84
+ "student3@example.com",
85
+ ],
86
+ preview="You have been invited to join a class!",
87
+ subject="StudyFetch: Class Invitation",
88
+ paragraphs=[
89
+ "Hi There,",
90
+ 'You have been invited to join "Intro to CS" on StudyFetch!',
91
+ "Click the button below to accept the invite.",
92
+ ],
93
+ button={
94
+ "text": "Accept Invite",
95
+ "href": "https://www.studyfetch.com/invite/abc",
96
+ },
97
+ )
98
+
99
+ print("Enqueued:", result.message_id)
100
+ ```
101
+
102
+ ### Send and wait for confirmation
103
+
104
+ ```python
105
+ result = client.email.send_batch_and_wait(
106
+ to=[
107
+ "student1@example.com",
108
+ "student2@example.com",
109
+ "student3@example.com",
110
+ ],
111
+ preview="You have been invited to join a class!",
112
+ subject="StudyFetch: Class Invitation",
113
+ paragraphs=[
114
+ "Hi There,",
115
+ 'You have been invited to join "Intro to CS" on StudyFetch!',
116
+ "Click the button below to accept the invite.",
117
+ ],
118
+ button={
119
+ "text": "Accept Invite",
120
+ "href": "https://www.studyfetch.com/invite/abc",
121
+ },
122
+ timeout=30,
123
+ )
124
+
125
+ print(result.success) # True if at least some sent
126
+ print(result.message_id) # request ID
127
+ print(result.total) # 3
128
+ print(result.successful) # number sent successfully
129
+ print(result.failed) # number that failed
130
+ print(result.error) # error message if all failed
131
+ ```
132
+
133
+ ## All Email Fields
134
+
135
+ | Field | Type | Required | Description |
136
+ |-------|------|----------|-------------|
137
+ | `to` | `str` | Yes (single) | Recipient email address |
138
+ | `to` | `list[str]` | Yes (batch) | List of recipient emails (max 100) |
139
+ | `preview` | `str` | Yes | Preview text shown in email clients |
140
+ | `subject` | `str` | Yes | Email subject line |
141
+ | `paragraphs` | `list[str]` | Yes | Body content as paragraph strings |
142
+ | `button` | `{"text": str, "href": str}` | No | Call-to-action button |
143
+ | `reply_to` | `str` | No | Reply-to email address |
144
+ | `image` | `{"src": str, "alt"?: str, "width"?: int, "height"?: int}` | No | Image in email body |
145
+
146
+ ## Optional Fields
147
+
148
+ ```python
149
+ # With all optional fields
150
+ client.email.send(
151
+ to="support@studyfetch.com",
152
+ preview="Support Request",
153
+ subject="StudyFetch: Support Request",
154
+ paragraphs=["Hi There,", "You received a support request.", issue],
155
+ reply_to="requester@example.com",
156
+ image={
157
+ "src": "https://example.com/logo.png",
158
+ "alt": "Logo",
159
+ "width": 150,
160
+ "height": 50,
161
+ },
162
+ )
163
+ ```
164
+
165
+ ## Migrating from sendEmail / sendBatchEmail
166
+
167
+ The SDK is a drop-in replacement. Field names match the existing functions:
168
+
169
+ ```python
170
+ # BEFORE
171
+ send_email(to=to, preview=preview, subject=subject, paragraphs=paragraphs, button=button)
172
+
173
+ # AFTER (fire and forget)
174
+ client.email.send(to=to, preview=preview, subject=subject, paragraphs=paragraphs, button=button)
175
+
176
+ # AFTER (wait for confirmation)
177
+ client.email.send_and_wait(to=to, preview=preview, subject=subject, paragraphs=paragraphs, button=button)
178
+
179
+ # BEFORE (batch)
180
+ send_batch_email(to=[...], preview=preview, subject=subject, paragraphs=paragraphs, button=button)
181
+
182
+ # AFTER (batch, fire and forget)
183
+ client.email.send_batch(to=[...], preview=preview, subject=subject, paragraphs=paragraphs, button=button)
184
+
185
+ # AFTER (batch, wait for confirmation)
186
+ client.email.send_batch_and_wait(to=[...], preview=preview, subject=subject, paragraphs=paragraphs, button=button)
187
+ ```
188
+
189
+ ## Methods and Response Types
190
+
191
+ | Method | Return Type | Fields |
192
+ |--------|-------------|--------|
193
+ | `send()` | `SendResult` | `message_id` |
194
+ | `send_and_wait()` | `EmailResponse` | `success`, `message_id`, `error?`, `processed_at?` |
195
+ | `send_batch()` | `SendResult` | `message_id` |
196
+ | `send_batch_and_wait()` | `BatchEmailResponse` | `success`, `message_id`, `error?`, `processed_at?`, `total`, `successful`, `failed` |
197
+
198
+ ## Cleanup
199
+
200
+ ```python
201
+ client.disconnect()
202
+ ```
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "sf-queue-sdk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for sf-queue - Redis-based queue system"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "redis>=5.0.0",
8
+ ]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["queue_sdk"]
@@ -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
+ ]
@@ -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}"
@@ -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]
@@ -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