sendping 1.0.0

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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Send and inspect emails. Inbound mail lives under SendPing::Emails::Receiving.
5
+ module Emails
6
+ class << self
7
+ # Send a single email. POST /emails
8
+ # SendPing::Emails.send({ from: "...", to: ["..."], subject: "...", html: "..." })
9
+ #
10
+ # Write ordinary links. Anchors, markdown links and bare URLs/domains in
11
+ # BOTH the :html and :text bodies are converted to tracked redirects at
12
+ # send time (when the sending domain has click tracking on), so a click is
13
+ # recorded whichever alternative the recipient's mail client renders.
14
+ # Opt-out links are never wrapped, and the content you send is stored and
15
+ # returned UNCHANGED — only the delivered copy carries the tracking URL.
16
+ # Pass { idempotency_key: "order-123" } as the second argument to retry safely.
17
+ def send(params, options = {})
18
+ Client.request(:post, "/emails", body: params, options: options)
19
+ end
20
+
21
+ # Send up to 100 emails in one request. POST /emails/batch (alias of Batch.send).
22
+ # Batch items reject `attachments` and `scheduled_at` — send those
23
+ # individually via Emails.send.
24
+ def batch(payloads, options = {})
25
+ Client.request(:post, "/emails/batch", body: payloads, options: options)
26
+ end
27
+
28
+ # List sent emails (trimmed list items) — cursor pagination plus optional
29
+ # server-side filters: `campaign_id`, `automation_id`, `source`
30
+ # ("individual" for all one-off sends with no campaign/automation origin,
31
+ # "api" for one-off sends made with an API key — which also covers mail
32
+ # sent before the send origin was recorded — or "dashboard" for one-off
33
+ # mail composed in the dashboard: the composer and inbox replies/forwards;
34
+ # honoured only when neither `campaign_id` nor `automation_id` is
35
+ # supplied), `domain_id`, `status` (matched case-insensitively
36
+ # against the row's `last_event`), `folder` (one of "outbox", "sent",
37
+ # "scheduled" or "failed" — any other value is rejected with a 422) and
38
+ # `search` (recipients, subject and sender). `q` is the server's alias
39
+ # for `search`, honoured only when `search` is absent. GET /emails
40
+ def list(params = {})
41
+ query = Client.pagination(params).merge(
42
+ Client.filters(params, :campaign_id, :automation_id, :source, :domain_id, :status, :search, :q, :folder)
43
+ )
44
+ Client.request(:get, "/emails", query: query)
45
+ end
46
+
47
+ # Per-source send metrics, one row per origin. `kind` is "campaign",
48
+ # "automation", "api" (one-off API-key sends, including mail sent before
49
+ # the send origin was recorded) or "individual" (dashboard-composed
50
+ # one-offs); `id`, `name`, `subject` and `status` are null for both the
51
+ # "api" and "individual" rows. Not paginated. GET /emails/sources
52
+ def sources
53
+ Client.request(:get, "/emails/sources")
54
+ end
55
+
56
+ # Retrieve a sent email and its events. GET /emails/:id
57
+ def get(email_id)
58
+ Client.request(:get, "/emails/#{Client.path_escape(email_id)}")
59
+ end
60
+
61
+ # List a sent email's attachments. GET /emails/:id/attachments
62
+ def list_attachments(email_id)
63
+ Client.request(:get, "/emails/#{Client.path_escape(email_id)}/attachments")
64
+ end
65
+
66
+ # Retrieve one attachment's metadata. GET /emails/:id/attachments/:attachment_id
67
+ def get_attachment(email_id, attachment_id)
68
+ Client.request(:get, "/emails/#{Client.path_escape(email_id)}/attachments/#{Client.path_escape(attachment_id)}")
69
+ end
70
+
71
+ # Reschedule a scheduled email. PATCH /emails/:id
72
+ # SendPing::Emails.update("email_id", { scheduled_at: "2026-08-01T09:00:00Z" })
73
+ def update(email_id, params)
74
+ Client.request(:patch, "/emails/#{Client.path_escape(email_id)}", body: params)
75
+ end
76
+
77
+ # Cancel a scheduled email. POST /emails/:id/cancel
78
+ def cancel(email_id)
79
+ Client.request(:post, "/emails/#{Client.path_escape(email_id)}/cancel")
80
+ end
81
+ end
82
+
83
+ # Inbound (received) email.
84
+ module Receiving
85
+ class << self
86
+ # List received emails — cursor pagination plus an optional
87
+ # `received_for` filter (only messages received for that address).
88
+ # With no `limit` and no cursor the endpoint returns up to 1000 rows in
89
+ # one response; pass `limit` to get normal 1-100 pages.
90
+ # GET /emails/receiving
91
+ def list(params = {})
92
+ query = Client.pagination(params).merge(Client.filters(params, :received_for))
93
+ Client.request(:get, "/emails/receiving", query: query)
94
+ end
95
+
96
+ # Per-address inbound stats (totals, replies, last received).
97
+ # Not paginated. GET /emails/receiving/addresses
98
+ def list_addresses
99
+ Client.request(:get, "/emails/receiving/addresses")
100
+ end
101
+
102
+ # Retrieve a received email. GET /emails/receiving/:id
103
+ def get(email_id)
104
+ Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}")
105
+ end
106
+
107
+ # List a received email's attachments. With no `limit` and no `after`
108
+ # one page carries up to 1,000 of them and `has_more` reports any
109
+ # truncation; supplying either paginates normally.
110
+ # GET /emails/receiving/:id/attachments
111
+ def list_attachments(email_id, params = {})
112
+ Client.request(
113
+ :get,
114
+ "/emails/receiving/#{Client.path_escape(email_id)}/attachments",
115
+ query: Client.pagination(params)
116
+ )
117
+ end
118
+
119
+ # Download one attachment as raw bytes (binary String).
120
+ # GET /emails/receiving/:id/attachments/:attachment_id
121
+ def get_attachment(email_id, attachment_id)
122
+ Client.request(
123
+ :get,
124
+ "/emails/receiving/#{Client.path_escape(email_id)}/attachments/#{Client.path_escape(attachment_id)}",
125
+ raw: true
126
+ )
127
+ end
128
+
129
+ # Download the original RFC822/MIME message as raw bytes (binary String).
130
+ # GET /emails/receiving/:id/raw
131
+ def get_raw(email_id)
132
+ Client.request(:get, "/emails/receiving/#{Client.path_escape(email_id)}/raw", raw: true)
133
+ end
134
+
135
+ # Forward a received email. POST /emails/receiving/:id/forward
136
+ # Receiving.forward(id, { from: "you@yourdomain.com", to: "team@you.com" })
137
+ def forward(email_id, params, options = {})
138
+ Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/forward", body: params, options: options)
139
+ end
140
+
141
+ # Reply to a received email's sender, threaded into the conversation.
142
+ # POST /emails/receiving/:id/reply
143
+ def reply(email_id, params, options = {})
144
+ Client.request(:post, "/emails/receiving/#{Client.path_escape(email_id)}/reply", body: params, options: options)
145
+ end
146
+
147
+ # Delete a received email. DELETE /emails/receiving/:id
148
+ def delete(email_id)
149
+ Client.request(:delete, "/emails/receiving/#{Client.path_escape(email_id)}")
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # Batch send — SendPing::Batch.send([...]). POST /emails/batch
156
+ # Batch items reject `attachments` and `scheduled_at` — send those
157
+ # individually via SendPing::Emails.send.
158
+ module Batch
159
+ class << self
160
+ def send(payloads, options = {})
161
+ Client.request(:post, "/emails/batch", body: payloads, options: options)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Raised for every non-2xx API response. Mirrors the API error shape
5
+ # { statusCode, name, message }:
6
+ #
7
+ # begin
8
+ # SendPing::Emails.send(params)
9
+ # rescue SendPing::Error => e
10
+ # e.status_code # => 422
11
+ # e.name # => "validation_error"
12
+ # e.message # => "The `from` address must use a verified domain."
13
+ # end
14
+ #
15
+ # Match on #name, never on #message — messages are scrubbed of provider
16
+ # identifiers server-side and are not a stable contract. A handler may also
17
+ # answer with a status other than the one a name usually maps to, so read
18
+ # #status_code rather than assuming one from the name.
19
+ #
20
+ # Some errors carry additive fields on top of those three. The whole parsed
21
+ # body is kept on #body, with the common extras surfaced as readers that
22
+ # return nil on an ordinary error:
23
+ #
24
+ # rescue SendPing::Error => e
25
+ # if (cap = e.limit) # WHICH quota ran out
26
+ # cap["kind"] # => "emails_daily"
27
+ # cap["used"]; cap["limit"] # => 100, 100
28
+ # cap.dig("next_plan", "name") # => "Pro"
29
+ # end
30
+ # e.reputation # reputation gates
31
+ # e.sent # a batch that failed part way through
32
+ # e.sent_count # — do NOT resend these
33
+ # end
34
+ class Error < StandardError
35
+ attr_reader :status_code, :error_name
36
+
37
+ # The full parsed error body ({} when the response was not a JSON object).
38
+ # Read it for any additive field newer than this SDK version.
39
+ attr_reader :body
40
+
41
+ def initialize(message = nil, status_code: nil, error_name: nil, body: nil)
42
+ super(message)
43
+ @status_code = status_code
44
+ @error_name = error_name
45
+ @body = body.is_a?(Hash) ? body : {}
46
+ end
47
+
48
+ # The API error `name` (e.g. "validation_error", "not_found").
49
+ def name
50
+ @error_name
51
+ end
52
+
53
+ # Original email for a failed or unconfirmed send; inspect before resending.
54
+ def id
55
+ value = @body["id"]
56
+ value.is_a?(String) ? value : nil
57
+ end
58
+
59
+ # Reserved prefix, including uncertain sends. Never automatically resend it.
60
+ def reserved
61
+ value = @body["reserved"]
62
+ value.is_a?(Array) ? value : nil
63
+ end
64
+
65
+ # The never-attempted tail; distinct from uncertain reserved items.
66
+ def unsent_count
67
+ value = @body["unsent_count"]
68
+ value.is_a?(Integer) ? value : nil
69
+ end
70
+
71
+ # The plan/quota cap this request hit, else nil. Carried by
72
+ # plan_limit_reached, every *_quota_exceeded, contact_limit_reached and
73
+ # ai_credits_exceeded — it says WHICH quota ran out, how much of it was
74
+ # used, and the cheapest plan that would fit.
75
+ def limit
76
+ hash_field("limit")
77
+ end
78
+
79
+ # The reputation-gate detail on reputation_paused /
80
+ # reputation_limit_exceeded, else nil. Carries at least "retryable" and
81
+ # "scope" ("tenant" | "domain" | "platform").
82
+ def reputation
83
+ hash_field("reputation")
84
+ end
85
+
86
+ # The emails that were already sent before a batch failed part way through
87
+ # (POST /emails/batch with an idempotency_key), else nil. Do NOT resend them.
88
+ def sent
89
+ value = @body["sent"]
90
+ value.is_a?(Array) ? value : nil
91
+ end
92
+
93
+ # How many emails went out before a batch failed part way through, else nil.
94
+ # Falls back to #sent's size when the body carried the list but not the count.
95
+ def sent_count
96
+ count = @body["sent_count"]
97
+ return count if count.is_a?(Integer)
98
+
99
+ sent&.size
100
+ end
101
+
102
+ private
103
+
104
+ def hash_field(key)
105
+ value = @body[key]
106
+ value.is_a?(Hash) ? value : nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Custom events that automations trigger on. Events are DOMAIN-FIRST:
5
+ # `domain` is required on send, and only that domain's automations fire.
6
+ module Events
7
+ class << self
8
+ # Send a custom event. POST /events/send
9
+ # SendPing::Events.send({ event: "signup.completed", domain: "yourdomain.com",
10
+ # email: "user@example.com", payload: { plan: "pro" } })
11
+ # Identify the contact by `contact_id` OR `email`. Event names cannot
12
+ # start with the reserved "sendping:" prefix.
13
+ #
14
+ # NOTE: only POST /emails, POST /emails/batch, and received-email reply/forward honour `Idempotency-Key`.
15
+ # An `idempotency_key` passed here is still forwarded, but the server
16
+ # ignores it, so a retry ingests a SECOND event and can enroll the contact
17
+ # twice — de-duplicate on your side instead.
18
+ def send(params, options = {})
19
+ Client.require_domain!(params, "Events.send")
20
+ Client.request(:post, "/events/send", body: params, options: options)
21
+ end
22
+
23
+ # Create a custom-event definition (name + optional payload schema).
24
+ # Schema values are one of "string", "number", "boolean", "date".
25
+ # POST /events
26
+ # SendPing::Events.create({ name: "signup.completed", schema: { plan: "string" } })
27
+ #
28
+ # NOTE: `options[:idempotency_key]` carries no guarantee here — see
29
+ # `send`. A duplicate event name is already a 422 validation_error.
30
+ def create(params, options = {})
31
+ Client.request(:post, "/events", body: params, options: options)
32
+ end
33
+
34
+ # Update a definition's payload schema. PATCH /events/:id
35
+ # The event NAME is immutable (automations reference it) — passing `name`
36
+ # is a 422; pass `schema: nil` to clear the schema.
37
+ # SendPing::Events.update("evt_1", { schema: { plan: "string" } })
38
+ def update(event_id, params)
39
+ Client.request(:patch, "/events/#{Client.path_escape(event_id)}", body: params)
40
+ end
41
+
42
+ # List custom-event definitions. GET /events
43
+ def list(params = {})
44
+ Client.request(:get, "/events", query: Client.pagination(params))
45
+ end
46
+
47
+ # Delete a custom-event definition. DELETE /events/:id
48
+ def delete(event_id)
49
+ Client.request(:delete, "/events/#{Client.path_escape(event_id)}")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # API request logs.
5
+ module Logs
6
+ class << self
7
+ # List logs — cursor pagination plus optional server-side `method`
8
+ # (e.g. "POST") and `status` (e.g. 429) filters. GET /logs
9
+ def list(params = {})
10
+ query = Client.pagination(params)
11
+ method = Client.opt(params, :method)
12
+ status = Client.opt(params, :status)
13
+ query[:method] = method if method
14
+ query[:status] = status unless status.nil?
15
+ Client.request(:get, "/logs", query: query)
16
+ end
17
+
18
+ # Retrieve one log with request/response bodies. GET /logs/:id
19
+ def get(log_id)
20
+ Client.request(:get, "/logs/#{Client.path_escape(log_id)}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Read-only results of the in-email poll widget.
5
+ module Polls
6
+ class << self
7
+ # One summary row per email that has poll responses. GET /polls
8
+ def list(params = {})
9
+ Client.request(:get, "/polls", query: Client.pagination(params))
10
+ end
11
+
12
+ # The aggregated answer breakdown for one email. GET /polls/:email_id
13
+ def get(email_id)
14
+ Client.request(:get, "/polls/#{Client.path_escape(email_id)}")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Segments are DOMAIN-FIRST: `domain` is required on create and list. Names
5
+ # are unique within a domain (reusable across domains); every domain also
6
+ # carries an auto-created "General" (all contacts) segment.
7
+ module Segments
8
+ class << self
9
+ # POST /segments
10
+ # SendPing::Segments.create({ domain: "yourdomain.com", name: "VIP",
11
+ # filter: { status: "subscribed" } })
12
+ def create(params)
13
+ Client.require_domain!(params, "Segments.create")
14
+ Client.request(:post, "/segments", body: params)
15
+ end
16
+
17
+ # GET /segments/:id
18
+ def get(segment_id)
19
+ Client.request(:get, "/segments/#{Client.path_escape(segment_id)}")
20
+ end
21
+
22
+ # List a domain's segments (`domain` required). GET /segments?domain=
23
+ def list(params)
24
+ domain = Client.require_domain!(params, "Segments.list")
25
+ Client.request(:get, "/segments", query: { domain: domain }.merge(Client.pagination(params)))
26
+ end
27
+
28
+ # Preview the contacts a segment currently resolves to (filter matches
29
+ # plus explicit memberships). With no pagination params the response is
30
+ # capped at 1,000 contacts and sets `has_more` — a segment can hold far
31
+ # more than that, so page with `limit` + `after` to read all of it.
32
+ # GET /segments/:id/contacts
33
+ def contacts(segment_id, params = {})
34
+ Client.request(
35
+ :get,
36
+ "/segments/#{Client.path_escape(segment_id)}/contacts",
37
+ query: Client.pagination(params)
38
+ )
39
+ end
40
+
41
+ # PATCH /segments/:id
42
+ def update(segment_id, params)
43
+ Client.request(:patch, "/segments/#{Client.path_escape(segment_id)}", body: params)
44
+ end
45
+
46
+ # DELETE /segments/:id
47
+ def delete(segment_id)
48
+ Client.request(:delete, "/segments/#{Client.path_escape(segment_id)}")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ module Templates
5
+ class << self
6
+ # POST /templates — params: { name:, alias:, subject:, html:, text:, variables: [...] }
7
+ def create(params)
8
+ Client.request(:post, "/templates", body: params)
9
+ end
10
+
11
+ # GET /templates/:id (an alias works anywhere an id is accepted)
12
+ def get(template_id)
13
+ Client.request(:get, "/templates/#{Client.path_escape(template_id)}")
14
+ end
15
+
16
+ # GET /templates
17
+ def list(params = {})
18
+ Client.request(:get, "/templates", query: Client.pagination(params))
19
+ end
20
+
21
+ # PATCH /templates/:id
22
+ def update(template_id, params)
23
+ Client.request(:patch, "/templates/#{Client.path_escape(template_id)}", body: params)
24
+ end
25
+
26
+ # Duplicate a template. POST /templates/:id/duplicate — params: { name:, alias: }
27
+ def duplicate(template_id, params = {})
28
+ Client.request(:post, "/templates/#{Client.path_escape(template_id)}/duplicate", body: params)
29
+ end
30
+
31
+ # Publish a template (make its latest draft live). POST /templates/:id/publish
32
+ def publish(template_id)
33
+ Client.request(:post, "/templates/#{Client.path_escape(template_id)}/publish")
34
+ end
35
+
36
+ # DELETE /templates/:id
37
+ def delete(template_id)
38
+ Client.request(:delete, "/templates/#{Client.path_escape(template_id)}")
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ # Topics (granular subscriptions) are DOMAIN-FIRST: `domain` is required on
5
+ # create and list. Topic names are reusable across domains.
6
+ module Topics
7
+ class << self
8
+ # POST /topics
9
+ # SendPing::Topics.create({ domain: "yourdomain.com", name: "Product updates",
10
+ # default_subscription: "opt_in" })
11
+ def create(params)
12
+ Client.require_domain!(params, "Topics.create")
13
+ Client.request(:post, "/topics", body: params)
14
+ end
15
+
16
+ # GET /topics/:id
17
+ def get(topic_id)
18
+ Client.request(:get, "/topics/#{Client.path_escape(topic_id)}")
19
+ end
20
+
21
+ # List a domain's topics (`domain` required). GET /topics?domain=
22
+ def list(params)
23
+ domain = Client.require_domain!(params, "Topics.list")
24
+ Client.request(:get, "/topics", query: { domain: domain }.merge(Client.pagination(params)))
25
+ end
26
+
27
+ # PATCH /topics/:id
28
+ def update(topic_id, params)
29
+ Client.request(:patch, "/topics/#{Client.path_escape(topic_id)}", body: params)
30
+ end
31
+
32
+ # DELETE /topics/:id
33
+ def delete(topic_id)
34
+ Client.request(:delete, "/topics/#{Client.path_escape(topic_id)}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendPing
4
+ VERSION = "1.0.0"
5
+ end