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.
- sf_queue_sdk-0.1.0/.gitignore +56 -0
- sf_queue_sdk-0.1.0/PKG-INFO +6 -0
- sf_queue_sdk-0.1.0/README.md +202 -0
- sf_queue_sdk-0.1.0/pyproject.toml +15 -0
- sf_queue_sdk-0.1.0/queue_sdk/__init__.py +25 -0
- sf_queue_sdk-0.1.0/queue_sdk/client.py +58 -0
- sf_queue_sdk-0.1.0/queue_sdk/generated/__init__.py +0 -0
- sf_queue_sdk-0.1.0/queue_sdk/generated/queue/email/v1/email_pb2.py +45 -0
- sf_queue_sdk-0.1.0/queue_sdk/generated/queue/email/v1/email_pb2.pyi +73 -0
- sf_queue_sdk-0.1.0/queue_sdk/queues/__init__.py +0 -0
- sf_queue_sdk-0.1.0/queue_sdk/queues/base_queue.py +71 -0
- sf_queue_sdk-0.1.0/queue_sdk/queues/email_queue.py +161 -0
- sf_queue_sdk-0.1.0/queue_sdk/redis_client.py +51 -0
- sf_queue_sdk-0.1.0/queue_sdk/sanitize.py +15 -0
- sf_queue_sdk-0.1.0/queue_sdk/types.py +87 -0
|
@@ -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,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
|