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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mailkite.rb +305 -5
  3. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa6c0fe1916c302b913ce3ec7d39ca3b9e78633791fdfaf0d372306bc71bc134
4
- data.tar.gz: 7795b6f4b60abcaadd6de5167a1326c1b0c5e5d8eb510ab4aeb141220c499459
3
+ metadata.gz: 95b5f3983efb7676a89dcd463f2b157071b89a3d941e581fb67625054d7d1318
4
+ data.tar.gz: 1f917e51b7f5a21abb4c2229b789fc66a0ea6bf5bb2f77b893915b5c25bc35c9
5
5
  SHA512:
6
- metadata.gz: 836d902177a964a2094203c597a857222cef22f6f6d51eefd0ab5ddff1baecd636c5a4fb310aa1c658f84e128c2f9e99ef52b1f963f4d3d5f8d6c825f8f46b92
7
- data.tar.gz: 438ec7af44244d1cffd79b609dddffcc11cbe9655dc3796a690ac7997d10fe8fc5c229a073dbbcb34cae2ec17640f367ed1659196d4fb3d0ded23e43f2a70015
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.19.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)..].strip
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.encrypt(raw_key, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")
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.decrypt(wrapped, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")
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.19.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-08-03 00:00:00.000000000 Z
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.5'
49
+ version: '2.6'
50
50
  required_rubygems_version: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="