mailkite 0.19.0 → 0.20.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.
- checksums.yaml +4 -4
- data/lib/mailkite.rb +305 -5
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95b5f3983efb7676a89dcd463f2b157071b89a3d941e581fb67625054d7d1318
|
|
4
|
+
data.tar.gz: 1f917e51b7f5a21abb4c2229b789fc66a0ea6bf5bb2f77b893915b5c25bc35c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdcc7b60ac6cc8783fd521fcbd814201ee879ce516338c6949f867b9434a00b3579fb957af6737b3b5e31561b6eb86cf51e03083b7ee54a6606be13312983f63
|
|
7
|
+
data.tar.gz: 3dca6576f92595282a98e7288464b9d8cc60ae37077781ab76548231b8b043bcd1049e6eb8db53515a56349fb7aa39131599b4e870acbecf602dfacffb765998
|
data/lib/mailkite.rb
CHANGED
|
@@ -15,7 +15,7 @@ require "base64"
|
|
|
15
15
|
require "cgi"
|
|
16
16
|
|
|
17
17
|
module Mailkite
|
|
18
|
-
VERSION = "0.
|
|
18
|
+
VERSION = "0.20.0"
|
|
19
19
|
DEFAULT_BASE_URL = "https://api.mailkite.dev"
|
|
20
20
|
# Reject webhook events older than this (ms) to block replays. Pass 0 to disable.
|
|
21
21
|
DEFAULT_TOLERANCE_MS = 5 * 60 * 1000
|
|
@@ -29,7 +29,7 @@ module Mailkite
|
|
|
29
29
|
signature.split(",").each do |seg|
|
|
30
30
|
i = seg.index("=")
|
|
31
31
|
next unless i
|
|
32
|
-
parts[seg[0...i].strip] = seg[(i + 1)
|
|
32
|
+
parts[seg[0...i].strip] = seg[(i + 1)..-1].strip
|
|
33
33
|
end
|
|
34
34
|
t = parts["t"]
|
|
35
35
|
v1 = parts["v1"]
|
|
@@ -97,7 +97,7 @@ module Mailkite
|
|
|
97
97
|
body = cipher.update(plaintext.to_s.dup.force_encoding("UTF-8")) + cipher.final
|
|
98
98
|
ciphertext = body + cipher.auth_tag # GCM ct with 16-byte tag appended
|
|
99
99
|
|
|
100
|
-
wrapped = pub
|
|
100
|
+
wrapped = rsa_oaep_encrypt(pub, raw_key)
|
|
101
101
|
|
|
102
102
|
JSON.generate({
|
|
103
103
|
"v" => 1,
|
|
@@ -120,9 +120,9 @@ module Mailkite
|
|
|
120
120
|
wrapped = Base64.strict_decode64(env["wrappedKey"])
|
|
121
121
|
ct = Base64.strict_decode64(env["ciphertext"])
|
|
122
122
|
body = ct[0...-16]
|
|
123
|
-
tag = ct[-16
|
|
123
|
+
tag = ct[-16..-1]
|
|
124
124
|
|
|
125
|
-
raw_key = priv
|
|
125
|
+
raw_key = rsa_oaep_decrypt(priv, wrapped)
|
|
126
126
|
|
|
127
127
|
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
128
128
|
cipher.decrypt
|
|
@@ -132,6 +132,96 @@ module Mailkite
|
|
|
132
132
|
(cipher.update(body) + cipher.final).force_encoding("UTF-8")
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# ---- RSA-OAEP-SHA256 key wrapping ----------------------------------------
|
|
136
|
+
# `OpenSSL::PKey::PKey#encrypt`/`#decrypt` — the only API that lets us pick the OAEP
|
|
137
|
+
# digest — arrived in the openssl gem 3.0, which ships with Ruby 3.1. This gem supports
|
|
138
|
+
# Ruby 2.5+ (see the gemspec), and on anything older those methods simply do not exist:
|
|
139
|
+
# `encrypt`/`decrypt` used to die with `NoMethodError: undefined method 'encrypt'`.
|
|
140
|
+
# `public_encrypt`/`private_decrypt` are available everywhere but only offer
|
|
141
|
+
# PKCS1_OAEP_PADDING, which is OAEP with **SHA-1** — the wrong digest for our envelope.
|
|
142
|
+
#
|
|
143
|
+
# So on old openssl we do the EME-OAEP-SHA256 padding ourselves (RFC 8017 §7.1) on top of
|
|
144
|
+
# raw NO_PADDING RSA. Both paths produce and consume identical bytes; the test suite
|
|
145
|
+
# decrypts sdks/spec/encryption-vectors.json through whichever one this Ruby has, and
|
|
146
|
+
# through the fallback explicitly.
|
|
147
|
+
OAEP_NATIVE = OpenSSL::PKey::RSA.method_defined?(:encrypt) && OpenSSL::PKey::RSA.method_defined?(:decrypt)
|
|
148
|
+
private_constant :OAEP_NATIVE
|
|
149
|
+
|
|
150
|
+
OAEP_HLEN = 32 # SHA-256
|
|
151
|
+
private_constant :OAEP_HLEN
|
|
152
|
+
|
|
153
|
+
def self.rsa_oaep_encrypt(pub, data, force_fallback: false)
|
|
154
|
+
if OAEP_NATIVE && !force_fallback
|
|
155
|
+
return pub.encrypt(data, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")
|
|
156
|
+
end
|
|
157
|
+
pub.public_encrypt(oaep_encode(data, pub.n.num_bytes), OpenSSL::PKey::RSA::NO_PADDING)
|
|
158
|
+
end
|
|
159
|
+
private_class_method :rsa_oaep_encrypt
|
|
160
|
+
|
|
161
|
+
def self.rsa_oaep_decrypt(priv, data, force_fallback: false)
|
|
162
|
+
if OAEP_NATIVE && !force_fallback
|
|
163
|
+
return priv.decrypt(data, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")
|
|
164
|
+
end
|
|
165
|
+
oaep_decode(priv.private_decrypt(data, OpenSSL::PKey::RSA::NO_PADDING), priv.n.num_bytes)
|
|
166
|
+
end
|
|
167
|
+
private_class_method :rsa_oaep_decrypt
|
|
168
|
+
|
|
169
|
+
# MGF1 with SHA-256 (RFC 8017 §B.2.1).
|
|
170
|
+
def self.oaep_mgf1(seed, len)
|
|
171
|
+
out = "".b
|
|
172
|
+
counter = 0
|
|
173
|
+
while out.bytesize < len
|
|
174
|
+
out << OpenSSL::Digest::SHA256.digest(seed + [counter].pack("N"))
|
|
175
|
+
counter += 1
|
|
176
|
+
end
|
|
177
|
+
out[0, len]
|
|
178
|
+
end
|
|
179
|
+
private_class_method :oaep_mgf1
|
|
180
|
+
|
|
181
|
+
def self.oaep_xor(a, b)
|
|
182
|
+
a.unpack("C*").each_with_index.map { |x, i| x ^ b.getbyte(i) }.pack("C*")
|
|
183
|
+
end
|
|
184
|
+
private_class_method :oaep_xor
|
|
185
|
+
|
|
186
|
+
# EME-OAEP encode → the k-byte integer block RSA will raise to e.
|
|
187
|
+
def self.oaep_encode(msg, k)
|
|
188
|
+
msg = msg.b
|
|
189
|
+
ps_len = k - msg.bytesize - 2 * OAEP_HLEN - 2
|
|
190
|
+
raise OpenSSL::PKey::RSAError, "message too long for RSA-OAEP" if ps_len.negative?
|
|
191
|
+
|
|
192
|
+
db = OpenSSL::Digest::SHA256.digest("") + ("\x00".b * ps_len) + "\x01".b + msg
|
|
193
|
+
seed = OpenSSL::Random.random_bytes(OAEP_HLEN)
|
|
194
|
+
masked_db = oaep_xor(db, oaep_mgf1(seed, db.bytesize))
|
|
195
|
+
masked_seed = oaep_xor(seed, oaep_mgf1(masked_db, OAEP_HLEN))
|
|
196
|
+
"\x00".b + masked_seed + masked_db
|
|
197
|
+
end
|
|
198
|
+
private_class_method :oaep_encode
|
|
199
|
+
|
|
200
|
+
# EME-OAEP decode. Every malformed-input path falls through to ONE raise with one
|
|
201
|
+
# message: an attacker who can feed us envelopes must not learn *which* check failed
|
|
202
|
+
# (Manger's attack). This helper is for at-rest decryption, not an online oracle, but
|
|
203
|
+
# the shape costs nothing.
|
|
204
|
+
def self.oaep_decode(em, k)
|
|
205
|
+
ok = em.bytesize == k && k >= 2 * OAEP_HLEN + 2
|
|
206
|
+
raise OpenSSL::PKey::RSAError, "OAEP decoding error" unless ok
|
|
207
|
+
|
|
208
|
+
masked_seed = em[1, OAEP_HLEN]
|
|
209
|
+
masked_db = em[1 + OAEP_HLEN, k - OAEP_HLEN - 1]
|
|
210
|
+
seed = oaep_xor(masked_seed, oaep_mgf1(masked_db, OAEP_HLEN))
|
|
211
|
+
db = oaep_xor(masked_db, oaep_mgf1(seed, masked_db.bytesize))
|
|
212
|
+
|
|
213
|
+
ok &&= em.getbyte(0).zero?
|
|
214
|
+
ok &&= db[0, OAEP_HLEN] == OpenSSL::Digest::SHA256.digest("")
|
|
215
|
+
|
|
216
|
+
i = OAEP_HLEN
|
|
217
|
+
i += 1 while i < db.bytesize && db.getbyte(i).zero?
|
|
218
|
+
ok &&= i < db.bytesize && db.getbyte(i) == 1
|
|
219
|
+
raise OpenSSL::PKey::RSAError, "OAEP decoding error" unless ok
|
|
220
|
+
|
|
221
|
+
db[(i + 1)..-1] || "".b
|
|
222
|
+
end
|
|
223
|
+
private_class_method :oaep_decode
|
|
224
|
+
|
|
135
225
|
class Error < StandardError
|
|
136
226
|
attr_reader :status, :body
|
|
137
227
|
|
|
@@ -327,6 +417,26 @@ module Mailkite
|
|
|
327
417
|
request("POST", "/api/domains", body)
|
|
328
418
|
end
|
|
329
419
|
|
|
420
|
+
# Suggest a free, currently-unclaimed subdomain label. Read-only — it does
|
|
421
|
+
# not reserve the name. Read `base` from the response rather than
|
|
422
|
+
# hard-coding the pool zone.
|
|
423
|
+
def suggestSubdomain
|
|
424
|
+
request("GET", "/api/domains/subdomain/suggest")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Check whether a free subdomain label can be claimed. Cheap enough to call
|
|
428
|
+
# as the user types; `reason` is safe to show verbatim.
|
|
429
|
+
def checkSubdomain(name)
|
|
430
|
+
request("GET", "/api/domains/subdomain/check?name=#{CGI.escape(name)}")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Claim a free managed subdomain (`<label>.<base>`). Comes back already
|
|
434
|
+
# verified with an empty `dns` array — we host the zone, so there is nothing
|
|
435
|
+
# for the customer to publish.
|
|
436
|
+
def claimSubdomain(body)
|
|
437
|
+
request("POST", "/api/domains/subdomain", body)
|
|
438
|
+
end
|
|
439
|
+
|
|
330
440
|
def getDomain(id)
|
|
331
441
|
request("GET", "/api/domains/#{id}")
|
|
332
442
|
end
|
|
@@ -428,6 +538,44 @@ module Mailkite
|
|
|
428
538
|
request("POST", "/api/deliveries/#{id}/retry")
|
|
429
539
|
end
|
|
430
540
|
|
|
541
|
+
# Replay a whole selection of webhook deliveries in one call. `body` takes deliveryIds /
|
|
542
|
+
# messageIds / threadIds (they combine), at most 50 ids. Always answers 200 with a per-id
|
|
543
|
+
# `results` array, so branch on that rather than on the status.
|
|
544
|
+
def retryDeliveries(body)
|
|
545
|
+
request("POST", "/api/deliveries/retry", body)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Every captured attempt for one delivery, newest first — the request we sent and the
|
|
549
|
+
# response that came back. Captures are retained for 45 days.
|
|
550
|
+
def listDeliveryAttempts(id)
|
|
551
|
+
request("GET", "/api/deliveries/#{id}/attempts")
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# POST stored messages to one webhook route, including mail that arrived before the route
|
|
555
|
+
# existed. `body` is { messageIds: [...] }, at most 50.
|
|
556
|
+
def deliverToRoute(id, body)
|
|
557
|
+
request("POST", "/api/routes/#{id}/deliver", body)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Stored messages this route could be asked to deliver, newest first.
|
|
561
|
+
def listRouteCandidates(id, before = nil, limit = nil)
|
|
562
|
+
params = []
|
|
563
|
+
params << "before=#{CGI.escape(before.to_s)}" unless before.nil?
|
|
564
|
+
params << "limit=#{CGI.escape(limit.to_s)}" unless limit.nil?
|
|
565
|
+
path = "/api/routes/#{id}/candidates"
|
|
566
|
+
path += "?#{params.join('&')}" unless params.empty?
|
|
567
|
+
request("GET", path)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# --- Realtime -------------------------------------------------------
|
|
571
|
+
# Mint a short-lived, single-use token that authorises one Realtime API
|
|
572
|
+
# connection. Browsers need this because EventSource cannot set headers; the
|
|
573
|
+
# page passes it as ?token= on GET /v1/realtime. Inherits this credential's
|
|
574
|
+
# scope, expires in five minutes, and burns on first use.
|
|
575
|
+
def createRealtimeToken
|
|
576
|
+
request("POST", "/v1/realtime/token")
|
|
577
|
+
end
|
|
578
|
+
|
|
431
579
|
# --- Lists ----------------------------------------------------------
|
|
432
580
|
def listLists
|
|
433
581
|
request("GET", "/api/lists")
|
|
@@ -509,6 +657,20 @@ module Mailkite
|
|
|
509
657
|
request("GET", "/v1/me")
|
|
510
658
|
end
|
|
511
659
|
|
|
660
|
+
# Step 1 of linking an EXISTING account: register an OAuth client for this
|
|
661
|
+
# installation (RFC 7591). No pre-shared secret — the client is public and
|
|
662
|
+
# proves itself with PKCE. Public: no API key required.
|
|
663
|
+
def registerOauthClient(body)
|
|
664
|
+
request("POST", "/oauth/register", body)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Step 3 of linking: exchange the authorization code for an access token, or
|
|
668
|
+
# rotate a refresh token. Finish by calling getApiKey with the access token
|
|
669
|
+
# and storing the key. Public: no API key required.
|
|
670
|
+
def exchangeOauthToken(body)
|
|
671
|
+
request("POST", "/oauth/token", body)
|
|
672
|
+
end
|
|
673
|
+
|
|
512
674
|
# Get the account's unrestricted API key (mk_live_…). Read-or-create: the
|
|
513
675
|
# first call mints it.
|
|
514
676
|
def getApiKey
|
|
@@ -555,6 +717,19 @@ module Mailkite
|
|
|
555
717
|
request("POST", "/api/app-passwords", body)
|
|
556
718
|
end
|
|
557
719
|
|
|
720
|
+
# Edit an app password's "label", "address" pattern and "protocols". The
|
|
721
|
+
# domain is fixed for the life of the password: repointing a live
|
|
722
|
+
# credential would hand its holder mail they were never granted.
|
|
723
|
+
def updateAppPassword(id, body)
|
|
724
|
+
request("PATCH", "/api/app-passwords/#{id}", body)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Replace an app password's secret, keeping its scope. The old secret stops
|
|
728
|
+
# authenticating immediately; the new one is returned once.
|
|
729
|
+
def rotateAppPassword(id)
|
|
730
|
+
request("POST", "/api/app-passwords/#{id}/rotate")
|
|
731
|
+
end
|
|
732
|
+
|
|
558
733
|
# Revoke an app password. Takes effect immediately — any IMAP session or
|
|
559
734
|
# API call using it stops authenticating.
|
|
560
735
|
def deleteAppPassword(id)
|
|
@@ -590,6 +765,131 @@ module Mailkite
|
|
|
590
765
|
request("GET", "/api/billing/usage")
|
|
591
766
|
end
|
|
592
767
|
|
|
768
|
+
# --- Events ---------------------------------------------------------
|
|
769
|
+
# Record one application-level fact about a user — +user.created+,
|
|
770
|
+
# +trial.expiring+, +payment.failed+. Every enabled trigger listening for
|
|
771
|
+
# that name enrolls the contact with the payload as its input. Identify the
|
|
772
|
+
# subject with "email" or "contactId", never both; pass a "dedupeKey" to
|
|
773
|
+
# make retries idempotent.
|
|
774
|
+
def sendEvent(event)
|
|
775
|
+
request("POST", "/v1/events", event)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# List recorded events, newest first — the surface for confirming a POST
|
|
779
|
+
# landed and for debugging a sequence that did not trigger.
|
|
780
|
+
def listEvents(name = nil, email = nil)
|
|
781
|
+
params = []
|
|
782
|
+
params << "name=#{CGI.escape(name.to_s)}" unless name.nil?
|
|
783
|
+
params << "email=#{CGI.escape(email.to_s)}" unless email.nil?
|
|
784
|
+
path = "/v1/events"
|
|
785
|
+
path += "?#{params.join('&')}" unless params.empty?
|
|
786
|
+
request("GET", path)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# The distinct event names this account works with — both events actually
|
|
790
|
+
# posted and ones your sequences trigger on or wait for but that may never
|
|
791
|
+
# have been sent.
|
|
792
|
+
def listEventNames
|
|
793
|
+
request("GET", "/v1/events/names")
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# --- Sequences ------------------------------------------------------
|
|
797
|
+
# List your sequences, newest first, each with live enrollment counts.
|
|
798
|
+
# Archived sequences are omitted.
|
|
799
|
+
def listSequences
|
|
800
|
+
request("GET", "/v1/sequences")
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Create a sequence: a declared input shape, the steps a contact walks over
|
|
804
|
+
# time, and zero or more triggers. Created as a draft unless you pass
|
|
805
|
+
# status "active". The whole definition is validated up front.
|
|
806
|
+
def createSequence(sequence)
|
|
807
|
+
request("POST", "/v1/sequences", sequence)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Get one sequence with its definition and live enrollment counts.
|
|
811
|
+
def getSequence(id)
|
|
812
|
+
request("GET", "/v1/sequences/#{id}")
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Edit a sequence. Changing the STEPS bumps its version and contacts already
|
|
816
|
+
# in flight keep walking the version they started on, so an edit can never
|
|
817
|
+
# make someone skip or repeat a step.
|
|
818
|
+
def updateSequence(id, sequence)
|
|
819
|
+
request("PATCH", "/v1/sequences/#{id}", sequence)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Delete a sequence and retire every contact still walking it. The response
|
|
823
|
+
# reports how many were canceled.
|
|
824
|
+
def deleteSequence(id)
|
|
825
|
+
request("DELETE", "/v1/sequences/#{id}")
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Start a sequence for one contact directly — takes a sequence NAME or id,
|
|
829
|
+
# so "start the dunning sequence" needs no lookup. Returns the enrollment it
|
|
830
|
+
# created. Reach for sendEvent instead when your code only knows what
|
|
831
|
+
# HAPPENED and policy should decide what reacts.
|
|
832
|
+
def startSequence(sequence, body = nil)
|
|
833
|
+
request("POST", "/v1/sequences/#{sequence}/start", body)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Stop whatever is chasing someone — pass the "cancelKey" you set when
|
|
837
|
+
# starting, or "sequence" and "email" together when you did not set one.
|
|
838
|
+
# Always answers 200 with a count, so it is safe to fire blindly.
|
|
839
|
+
def stopSequence(body)
|
|
840
|
+
request("POST", "/v1/sequences/stop", body)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# --- Triggers -------------------------------------------------------
|
|
844
|
+
# List the triggers attached to a sequence — the doors into it.
|
|
845
|
+
def listTriggers(id)
|
|
846
|
+
request("GET", "/v1/sequences/#{id}/triggers")
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Attach a trigger: when this event arrives, enroll the contact it is about.
|
|
850
|
+
# Never bumps the sequence's version and never touches anyone already in
|
|
851
|
+
# flight. The sequence needs a "from" address first.
|
|
852
|
+
def createTrigger(id, trigger)
|
|
853
|
+
request("POST", "/v1/sequences/#{id}/triggers", trigger)
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Edit a trigger, or toggle "enabled" to switch the door off without
|
|
857
|
+
# deleting it. Everyone already walking the sequence carries on.
|
|
858
|
+
def updateTrigger(id, trigger)
|
|
859
|
+
request("PATCH", "/v1/triggers/#{id}", trigger)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# Detach a trigger. Stops future enrollments through that door and nothing
|
|
863
|
+
# else.
|
|
864
|
+
def deleteTrigger(id)
|
|
865
|
+
request("DELETE", "/v1/triggers/#{id}")
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# --- Enrollments ----------------------------------------------------
|
|
869
|
+
# List who is in a sequence and where each of them is. Filter with +status+.
|
|
870
|
+
def listEnrollments(id, status = nil)
|
|
871
|
+
path = "/v1/sequences/#{id}/enrollments"
|
|
872
|
+
path += "?status=#{CGI.escape(status.to_s)}" unless status.nil?
|
|
873
|
+
request("GET", path)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Get one enrollment — which sequence, which step, and what happens next.
|
|
877
|
+
def getEnrollment(id)
|
|
878
|
+
request("GET", "/v1/enrollments/#{id}")
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Every step this enrollment has executed, with the outcome and the reason
|
|
882
|
+
# for it. This is the "why didn't step 3 fire" view.
|
|
883
|
+
def listEnrollmentRuns(id)
|
|
884
|
+
request("GET", "/v1/enrollments/#{id}/runs")
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Cancel one specific run by its enrollment id. To stop whatever is chasing
|
|
888
|
+
# a contact without knowing which run, use stopSequence.
|
|
889
|
+
def cancelEnrollment(id)
|
|
890
|
+
request("DELETE", "/v1/enrollments/#{id}")
|
|
891
|
+
end
|
|
892
|
+
|
|
593
893
|
# --- Suppressions -----------------------------------------------------
|
|
594
894
|
# List suppressed addresses (unsubscribes, hard bounces, spam complaints,
|
|
595
895
|
# manual). Sends to a suppressed address are dropped before delivery.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mailkite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- MailKite
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mail
|
|
@@ -46,7 +46,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
46
46
|
requirements:
|
|
47
47
|
- - ">="
|
|
48
48
|
- !ruby/object:Gem::Version
|
|
49
|
-
version: '2.
|
|
49
|
+
version: '2.6'
|
|
50
50
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - ">="
|