nextmail 0.0.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9a95ef0efdad1840601c1e9b93d29a8835ce66ece247254f797d5d69d5a0fe2
4
- data.tar.gz: 90eb6c5cb4bf078a7e1b5906142b5a177eac61daaf5ebc2826778c6ac1f30a3b
3
+ metadata.gz: dbdaac1fbcacdd3371bd98fdb8405c1679e09c69156634cb92f841f53227f855
4
+ data.tar.gz: f84a7bccb20b20fc80184e4294e7f9fc22827e8927914706cf7982bf5a51db71
5
5
  SHA512:
6
- metadata.gz: 9c3125ed20b91ac82fedac71f7b1144f3c802c9257ae896e26863b9b3c54a228aa21f9f99086fba6a7815e193fbff59a7b265e6450285ece3eda405b8aabdce0
7
- data.tar.gz: b2cad49d82ab24d0b83b63a23978f55162ff0de442aeec62d0af09daff29d731bd2e2d92c7171456e5d16475b3a0e6e042fc002d8f979da6cbfcf9b96fb84a10
6
+ metadata.gz: 8eab86537fc254e7c2da37545a72774ce9c01095892c514e4936da54ef287f508bc579a2ab93612980479490f044148e4d547598d4c4f8fcf82a749320ab9d8c
7
+ data.tar.gz: 2724b5a4b6d5d5fa385c138e25946de4c58d706ed85da7c746d0f678e2d30345eb5cff951418fc9ab16b936f09cdb0c6084de13a01cb08c39b4113ffd0187f61
data/README.md CHANGED
@@ -81,6 +81,18 @@ end
81
81
 
82
82
  Every paginated resource has `list` for one page (`Nextmail::Page` with `items`, `has_more?` and `next_cursor`), `list_all` for every page at once as an Array, and `iterate`, which yields each item to a block, or without one returns an Enumerator that fetches the next page only when it gets there, so `first(10)` stops early. `addresses.list_all` is the exception and returns the whole address book.
83
83
 
84
+ ### Following the mailbox
85
+ ```ruby
86
+ client.live_events.stream(types: ["message.received"]) do |event|
87
+ next if event[:type] == "reset"
88
+
89
+ thread = client.threads.get(event[:data][:threadId])
90
+ puts thread[:id]
91
+ end
92
+ ```
93
+
94
+ `live_events.stream` yields each change to the mailbox as it happens, a message received or sent, a conversation changed or deleted, a delivery that moved on, and runs until the block breaks or raises. It reconnects by itself and resumes where it left off, and when it cannot it yields a `reset` first, so list again what you keep in sync. Each stream holds a connection of its own, so give it a thread of its own when the program has other work to do. To wait for one message instead, such as a sign-in code, `threads.wait_for_email(from: "github.com", timeout: 60)` holds the request open until it arrives.
95
+
84
96
  ### Errors
85
97
  ```ruby
86
98
  template = "order-shipped"
@@ -176,7 +188,7 @@ client.raw.request("/something-new", method: :post, body: {name: "Invoices"})
176
188
 
177
189
  The path must begin with a single `/`, and a path whose finished URL leaves the base URL's origin raises before a request is sent, so the credential never reaches another host.
178
190
 
179
- `adapter:` swaps the HTTP layer for anything that responds to `call(request)` and returns `Nextmail::HttpResponse`, which is how a test drives the client without a network.
191
+ `adapter:` swaps the HTTP layer for anything that responds to `call(request)` and returns `Nextmail::HttpResponse`, which is how a test drives the client without a network. To stream, it also responds to `stream(request, idle_timeout:)` and yields one `Nextmail::HttpStream` whose `body` gives the chunks as they arrive. Without it, `live_events.stream` and `client.raw.stream` get the body whole, once the server ends it.
180
192
 
181
193
  When a newer version is on RubyGems the client says so once on a TTY. `NEXTMAIL_DISABLE_UPDATE_NOTICE=1` or `disable_update_notice: true` turns that off.
182
194
 
@@ -28,6 +28,8 @@ module Nextmail
28
28
  ACCOUNT_USERNAME = "/account/username"
29
29
  ACCOUNT_USERNAME_AVAILABILITY = "/account/username/availability"
30
30
  ADDRESSES = "/addresses"
31
+ ADDRESS_BOOKS = "/contacts/address-books"
32
+ ADDRESS_BOOK_MEMBER = "/contacts/address-books/:id/members/:memberId"
31
33
  ANALYTICS_MAILBOX = "/analytics/mailbox"
32
34
  ANALYTICS_SENDING = "/analytics/sending"
33
35
  APP_HOST = "/app-host"
@@ -101,6 +103,9 @@ module Nextmail
101
103
  BROADCAST_RESEND = "/broadcasts/:id/resend"
102
104
  BROADCAST_SEND = "/broadcasts/:id/send"
103
105
  BROADCAST_STATS = "/broadcasts/:id/stats"
106
+ CALENDAR_CALENDAR = "/calendar/calendars/:id"
107
+ CALENDAR_CALENDARS = "/calendar/calendars"
108
+ CALENDAR_CALENDAR_MEMBER = "/calendar/calendars/:id/members/:memberId"
104
109
  CALENDAR_EVENT = "/calendar/events/:id"
105
110
  CALENDAR_EVENTS = "/calendar/events"
106
111
  CALENDAR_EVENT_CANCEL = "/calendar/events/:id/cancel"
@@ -116,9 +121,14 @@ module Nextmail
116
121
  CALENDAR_IMPORT = "/calendar/imports/:id"
117
122
  CALENDAR_IMPORTS = "/calendar/imports"
118
123
  CALENDAR_IMPORT_UNDO = "/calendar/imports/:id/undo"
124
+ CALENDAR_LOGIN = "/calendar/logins/:id"
125
+ CALENDAR_LOGINS = "/calendar/logins"
126
+ CALENDAR_LOGIN_PROFILE_LINK = "/calendar/logins/profile-link"
119
127
  CALENDAR_SUBSCRIPTION = "/calendar/subscriptions/:id"
120
128
  CALENDAR_SUBSCRIPTIONS = "/calendar/subscriptions"
121
129
  CALENDAR_SUBSCRIPTION_REFRESH = "/calendar/subscriptions/:id/refresh"
130
+ CALENDAR_TODO = "/calendar/todos/:id"
131
+ CALENDAR_TODOS = "/calendar/todos"
122
132
  CHAT = "/chats/:id"
123
133
  CHATS = "/chats"
124
134
  CHAT_MESSAGES = "/chats/:id/messages"
@@ -198,6 +208,7 @@ module Nextmail
198
208
  EMAILS_CHECK = "/emails/check"
199
209
  EMAILS_COMPOSE = "/emails/compose"
200
210
  EMAILS_EVENTS = "/emails/events"
211
+ EMAILS_RAW = "/emails/raw"
201
212
  EMAILS_REWRITE = "/emails/rewrite"
202
213
  EMAILS_SUBJECT = "/emails/subject"
203
214
  EMAILS_TRANSLATE = "/emails/translate"
@@ -283,6 +294,7 @@ module Nextmail
283
294
  LABEL_COLORS = "/labels/colors"
284
295
  LABEL_TEST = "/labels/:id/test"
285
296
  LANGUAGES = "/languages"
297
+ LIVE_EVENTS = "/live-events"
286
298
  MAILBOX_EXPORT = "/exports/:id"
287
299
  MAILBOX_EXPORTS = "/exports"
288
300
  MAILBOX_EXPORTS_PREVIEW = "/exports/preview"
@@ -328,6 +340,10 @@ module Nextmail
328
340
  SENDING_STREAMS = "/sending/streams"
329
341
  SENDING_STREAM_RESUME = "/sending/streams/:stream/resume"
330
342
  SETTINGS = "/settings"
343
+ SHARED_ITEM = "/shared/:token"
344
+ SHARED_ITEM_COPY = "/shared/:token/copy"
345
+ SHARE_LINK = "/share-links/:kind/:itemId"
346
+ SHARE_LINKS = "/share-links"
331
347
  SIGNATURE = "/signatures/:id"
332
348
  SIGNATURES = "/signatures"
333
349
  SIGNATURE_COPY = "/signatures/:id/copy"
@@ -385,7 +401,9 @@ module Nextmail
385
401
  THREAD = "/threads/:id"
386
402
  THREADS = "/threads"
387
403
  THREADS_COUNTS = "/threads/counts"
404
+ THREADS_WAIT = "/threads/wait"
388
405
  THREAD_ASSIGNEE = "/threads/:id/assignee"
406
+ THREAD_AUTOMATIONS = "/threads/:id/automations"
389
407
  THREAD_COMMENT = "/threads/:id/comments/:commentId"
390
408
  THREAD_COMMENTS = "/threads/:id/comments"
391
409
  THREAD_DELIVERY = "/threads/:id/delivery"
@@ -424,6 +442,7 @@ module Nextmail
424
442
  TRACKED_OPENS = "/tracking/:id/opens"
425
443
  TRACKING = "/tracking"
426
444
  TRACKING_STATS = "/tracking/stats"
445
+ TRAFFIC = "/traffic"
427
446
  WEBHOOK = "/webhooks/:id"
428
447
  WEBHOOKS = "/webhooks"
429
448
  WEBHOOKS_ACTIVITY = "/webhooks/activity"
@@ -8,6 +8,7 @@ module Nextmail
8
8
  keys: Resources::Keys,
9
9
  addresses: Resources::Addresses,
10
10
  languages: Resources::Languages,
11
+ live_events: Resources::LiveEvents,
11
12
  emails: Resources::Emails,
12
13
  templates: Resources::Templates,
13
14
  tracking: Resources::Tracking,
@@ -21,6 +22,8 @@ module Nextmail
21
22
  forms: Resources::Forms,
22
23
  automations: Resources::Automations,
23
24
  events: Resources::Events,
25
+ share_links: Resources::ShareLinks,
26
+ shared: Resources::Shared,
24
27
  broadcasts: Resources::Broadcasts,
25
28
  domains: Resources::Domains,
26
29
  app_host: Resources::AppHost,
@@ -35,6 +38,7 @@ module Nextmail
35
38
  roles: Resources::Roles,
36
39
  members: Resources::Members,
37
40
  suppressions: Resources::Suppressions,
41
+ traffic: Resources::Traffic,
38
42
  sending: Resources::Sending,
39
43
  files: Resources::Files,
40
44
  knowledge: Resources::Knowledge,
@@ -32,7 +32,13 @@ module Nextmail
32
32
  SEND_FAILED: "send-failed"
33
33
  }.freeze
34
34
 
35
- ADDRESS_FORWARD_STATUSES = {LIVE: "live", PENDING: "pending", REFUSED: "refused", PAUSED: "paused"}.freeze
35
+ ADDRESS_FORWARD_STATUSES = {
36
+ LIVE: "live",
37
+ PENDING: "pending",
38
+ REFUSED: "refused",
39
+ PAUSED: "paused",
40
+ BOUNCED: "bounced"
41
+ }.freeze
36
42
 
37
43
  ADDRESS_GROUP_POSTING_POLICIES = {ANYONE: "anyone", MEMBERS: "members", WORKSPACE: "workspace"}.freeze
38
44
 
@@ -66,7 +72,8 @@ module Nextmail
66
72
  WEBHOOK_DISABLED: "webhook_disabled",
67
73
  REWRITE_DOMAIN_REMOVED: "rewrite_domain_removed",
68
74
  FORWARD_PENDING: "forward_pending",
69
- FORWARD_REFUSED: "forward_refused"
75
+ FORWARD_REFUSED: "forward_refused",
76
+ FORWARD_BOUNCED: "forward_bounced"
70
77
  }.freeze
71
78
 
72
79
  ADDRESS_ROUTE_TEST_STEPS = {
@@ -232,6 +239,8 @@ module Nextmail
232
239
  REPLY: "reply"
233
240
  }.freeze
234
241
 
242
+ AUTOMATION_THREAD_ROLES = {STARTED: "started", SENT: "sent"}.freeze
243
+
235
244
  AUTOMATION_TRIGGER_KINDS = {
236
245
  AUDIENCE_JOINED: "audience_joined",
237
246
  FORM_SUBMITTED: "form_submitted",
@@ -396,6 +405,8 @@ module Nextmail
396
405
 
397
406
  CALENDAR_FEED_MODES = {FULL: "full", BUSY: "busy"}.freeze
398
407
 
408
+ CALENDAR_KINDS = {EVENTS: "events", TODOS: "todos"}.freeze
409
+
399
410
  CALENDAR_REMINDER_ACTIONS = {DISPLAY: "DISPLAY", EMAIL: "EMAIL", AUDIO: "AUDIO"}.freeze
400
411
 
401
412
  CALENDAR_RESPONSES = {ACCEPTED: "ACCEPTED", DECLINED: "DECLINED", TENTATIVE: "TENTATIVE"}.freeze
@@ -439,6 +450,10 @@ module Nextmail
439
450
  FAILED: "failed"
440
451
  }.freeze
441
452
 
453
+ COLLECTION_ACCESS_LEVELS = {READ: "read", WRITE: "write"}.freeze
454
+
455
+ COLLECTION_RIGHTS = {OWNER: "owner", WRITE: "write", READ: "read"}.freeze
456
+
442
457
  CONNECTED_APP_KINDS = {APP: "app", CLI: "cli"}.freeze
443
458
 
444
459
  CONNECTED_APP_STATUSES = {
@@ -1095,6 +1110,26 @@ module Nextmail
1095
1110
  {code: "tpi", label: "Tok Pisin", native: "Tok Pisin", flag: "🇵🇬", rtl: false}.freeze
1096
1111
  ].freeze
1097
1112
 
1113
+ LIVE_EVENT_TYPES = {
1114
+ MESSAGE_RECEIVED: "message.received",
1115
+ MESSAGE_SENT: "message.sent",
1116
+ THREAD_CHANGED: "thread.changed",
1117
+ THREAD_DELETED: "thread.deleted",
1118
+ MESSAGE_STATUS_CHANGED: "message.status_changed"
1119
+ }.freeze
1120
+
1121
+ LIVE_RESET_REASONS = {EXPIRED: "expired", UNKNOWN: "unknown"}.freeze
1122
+
1123
+ LIVE_SEND_STATUSES = {
1124
+ DELIVERED: "delivered",
1125
+ DELIVERY_DELAYED: "delivery_delayed",
1126
+ BOUNCED: "bounced",
1127
+ COMPLAINED: "complained",
1128
+ FAILED: "failed",
1129
+ SUPPRESSED: "suppressed",
1130
+ CANCELLED: "cancelled"
1131
+ }.freeze
1132
+
1098
1133
  LOGIN_BACKGROUND_KINDS = {PRESET: "preset", COLOR: "color", IMAGE: "image"}.freeze
1099
1134
 
1100
1135
  LOGIN_BACKGROUND_PRESETS = {
@@ -1147,6 +1182,7 @@ module Nextmail
1147
1182
  ICLOUD: "icloud",
1148
1183
  FASTMAIL: "fastmail",
1149
1184
  ZOHO: "zoho",
1185
+ OPENEMAIL: "openemail",
1150
1186
  OTHER: "other"
1151
1187
  }.freeze
1152
1188
 
@@ -1169,6 +1205,7 @@ module Nextmail
1169
1205
  PROTON: "proton",
1170
1206
  ZOHO: "zoho",
1171
1207
  FASTMAIL: "fastmail",
1208
+ OPENEMAIL: "openemail",
1172
1209
  RESEND: "resend",
1173
1210
  SENDGRID: "sendgrid",
1174
1211
  MAILGUN: "mailgun",
@@ -1327,6 +1364,8 @@ module Nextmail
1327
1364
  TWO_FACTOR_REQUIREMENT_CHANGED: "two-factor-requirement-changed",
1328
1365
  ADDRESS_PASSWORD_SET: "address-password-set",
1329
1366
  ADDRESS_PASSWORD_REMOVED: "address-password-removed",
1367
+ CALENDAR_LOGIN_CREATED: "calendar-login-created",
1368
+ CALENDAR_LOGIN_REVOKED: "calendar-login-revoked",
1330
1369
  DIRECTORY_SYNC_CONNECTED: "directory-sync-connected",
1331
1370
  DIRECTORY_SYNC_DISCONNECTED: "directory-sync-disconnected",
1332
1371
  DIRECTORY_SYNC_TOKEN_ROTATED: "directory-sync-token-rotated",
@@ -1407,6 +1446,8 @@ module Nextmail
1407
1446
 
1408
1447
  SETTINGS_WEEK_STARTS = {MONDAY: "monday", SUNDAY: "sunday"}.freeze
1409
1448
 
1449
+ SHARE_KINDS = {TEMPLATE: "template", FORM: "form", AUTOMATION: "automation"}.freeze
1450
+
1410
1451
  STEP_UP_ERROR_CODES = {
1411
1452
  STEP_UP_REQUIRED: "step_up_required",
1412
1453
  STEP_UP_NOT_APPLICABLE: "step_up_not_applicable",
@@ -1517,6 +1558,45 @@ module Nextmail
1517
1558
 
1518
1559
  THREAD_SUMMARY_STATES = {READY: "ready", PENDING: "pending", FAILED: "failed", NONE: "none"}.freeze
1519
1560
 
1561
+ TODO_STATES = {OPEN: "open", DONE: "done", ALL: "all"}.freeze
1562
+
1563
+ TODO_STATUSES = {
1564
+ NEEDS_ACTION: "NEEDS-ACTION",
1565
+ IN_PROCESS: "IN-PROCESS",
1566
+ COMPLETED: "COMPLETED",
1567
+ CANCELLED: "CANCELLED"
1568
+ }.freeze
1569
+
1570
+ TRAFFIC_DIRECTIONS = {INBOUND: "inbound", OUTBOUND: "outbound"}.freeze
1571
+
1572
+ TRAFFIC_OUTCOMES = {
1573
+ DELIVERED: "delivered",
1574
+ DEFERRED: "deferred",
1575
+ BOUNCED: "bounced",
1576
+ SUPPRESSED: "suppressed",
1577
+ COMPLAINED: "complained",
1578
+ REJECTED: "rejected",
1579
+ FAILED: "failed",
1580
+ FORWARDED: "forwarded",
1581
+ FILED: "filed",
1582
+ SPAM: "spam",
1583
+ ROUTED: "routed",
1584
+ REFUSED: "refused",
1585
+ DROPPED: "dropped",
1586
+ REPORT: "report"
1587
+ }.freeze
1588
+
1589
+ TRAFFIC_REASONS = {
1590
+ UNKNOWN_RECIPIENT: "unknown_recipient",
1591
+ ADDRESS_LIMITS: "address_limits",
1592
+ BLOCKED_SENDER: "blocked_sender",
1593
+ RULE: "rule",
1594
+ GROUP_POLICY: "group_policy",
1595
+ UNREADABLE: "unreadable",
1596
+ TOO_LARGE: "too_large",
1597
+ REPORT_REFUSED: "report_refused"
1598
+ }.freeze
1599
+
1520
1600
  UNSUBSCRIBE_METHODS = {ONE_CLICK: "one-click", EMAIL: "email", LINK: "link"}.freeze
1521
1601
 
1522
1602
  UNSUBSCRIBE_SOURCES = {
@@ -24,6 +24,8 @@ module Nextmail
24
24
  IPV4_DECIMAL_PART = /\A(?:0|[1-9]\d*)\z/
25
25
  IPV4_OCTAL_PART = /\A0[0-7]+\z/
26
26
  IPV4_HEX_PART = /\A0x[0-9a-f]*\z/i
27
+ EVENT_STREAM_LINE_END = /[\r\n]/
28
+ EVENT_STREAM_RETRY_VALUE = /\A\d+\z/
27
29
 
28
30
  CONTENT_TYPES_BY_EXTENSION = {
29
31
  ".png" => "image/png",
@@ -91,6 +93,7 @@ module Nextmail
91
93
  RAW_BODY_SHAPE: "Pass the bytes as a String, an IO or a Pathname.",
92
94
  HEADER_SHAPE: "A header name must be a token and a header value must not contain a line break.",
93
95
  TIMEOUT_SHAPE: "Pass timeout: as a number of seconds, or 0 for no timeout.",
96
+ RAW_MESSAGE_SHAPE: "A raw message must be base64 text, or its bytes as a binary String or an IO",
94
97
  ATTACHMENT_CONTENT_SHAPE: "An attachment's content must be base64 text, or the raw bytes as a binary String " \
95
98
  "(File.binread), an IO or a Pathname, which are encoded for you.",
96
99
  CROP_SHAPE: "Pass crop: as a Hash of four numbers, :x, :y, :width and :height, each a fraction of the image " \
@@ -98,6 +101,13 @@ module Nextmail
98
101
  SEGMENT_ENCODING: "An id must be valid UTF-8 text.",
99
102
  TIMED_OUT: "Request timed out after %<seconds>ss.",
100
103
  UNREACHABLE: "Could not reach %<base_url>s: %<reason>s",
104
+ STREAM_IDLE: "The stream sent nothing for %<seconds>ss.",
105
+ STREAM_BROKEN: "The stream from %<base_url>s broke off: %<reason>s",
106
+ STREAM_BLOCK_TOO_LARGE: "The event stream sent more than %<limit>s bytes without ending an event.",
107
+ STREAM_BLOCK_REQUIRED: "Pass a block: the stream stays open while the block runs and closes when it returns.",
108
+ NOT_AN_EVENT_STREAM: "The API answered %<status>s with %<media_type>s where an event stream was expected.",
109
+ NO_CONTENT_TYPE: "no content type",
110
+ WAIT_TIMEOUT_SHAPE: "Pass timeout: as a whole number of seconds from 0 to 60, how long the API waits for a message.",
101
111
  NOT_JSON: "The API answered %<status>s with a body that is not JSON.",
102
112
  UNRECOGNISED_BODY: "The API responded %<status>s with a body this client did not recognise.",
103
113
  WEBHOOK_MISSING_HEADER: "Missing the X-Webhook-Signature header.",
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nextmail
4
+ EventStreamRecord = Data.define(:id, :event, :data, :retry_ms)
5
+
6
+ class EventStreamParser
7
+ FIELDS = Protocol::EVENT_STREAM_FIELDS
8
+ SYNTAX = Protocol::EVENT_STREAM_SYNTAX
9
+ CARRIAGE_RETURN = SYNTAX[:CARRIAGE_RETURN].ord
10
+ LINE_FEED = SYNTAX[:LINE_FEED].ord
11
+
12
+ def initialize
13
+ @pending = String.new(encoding: Encoding::BINARY)
14
+ @skip_line_feed = false
15
+ @first_line = true
16
+ clear
17
+ end
18
+
19
+ def push(chunk)
20
+ records = []
21
+ text = chunk.to_s.b
22
+ return records if text.empty?
23
+
24
+ text = text.byteslice(1, text.bytesize - 1) if @skip_line_feed && text.getbyte(0) == LINE_FEED
25
+ @skip_line_feed = false
26
+ start = 0
27
+
28
+ while (index = text.index(Defaults::EVENT_STREAM_LINE_END, start))
29
+ line(@pending + text.byteslice(start, index - start), records)
30
+ @pending = String.new(encoding: Encoding::BINARY)
31
+ start = line_end(text, index) + 1
32
+ end
33
+
34
+ @pending << text.byteslice(start, text.bytesize - start)
35
+ refuse_oversized_block
36
+ records
37
+ end
38
+
39
+ def inspect
40
+ "#<#{self.class.name}>"
41
+ end
42
+
43
+ private
44
+
45
+ def line_end(text, index)
46
+ return index unless text.getbyte(index) == CARRIAGE_RETURN
47
+
48
+ if index + 1 == text.bytesize
49
+ @skip_line_feed = true
50
+ index
51
+ else
52
+ (text.getbyte(index + 1) == LINE_FEED) ? index + 1 : index
53
+ end
54
+ end
55
+
56
+ def line(bytes, records)
57
+ text = bytes.force_encoding(Encoding::UTF_8).scrub
58
+ text = text.delete_prefix(Decode::BYTE_ORDER_MARK) if @first_line
59
+ @first_line = false
60
+ return dispatch(records) if text.empty?
61
+ return if text.start_with?(SYNTAX[:COLON])
62
+
63
+ name, _colon, value = text.partition(SYNTAX[:COLON])
64
+ field(name, value.delete_prefix(SYNTAX[:SPACE]))
65
+ end
66
+
67
+ def field(name, value)
68
+ case name
69
+ when FIELDS[:EVENT] then @event = value
70
+ when FIELDS[:DATA] then data(value)
71
+ when FIELDS[:ID]
72
+ return if value.include?(SYNTAX[:NULL])
73
+
74
+ @id = value
75
+ when FIELDS[:RETRY]
76
+ return unless Defaults::EVENT_STREAM_RETRY_VALUE.match?(value)
77
+
78
+ @retry_ms = Integer(value, 10)
79
+ else
80
+ return
81
+ end
82
+
83
+ @touched = true
84
+ end
85
+
86
+ def data(value)
87
+ @data << value
88
+ @stored += value.bytesize
89
+ end
90
+
91
+ def dispatch(records)
92
+ if @touched
93
+ records << EventStreamRecord.new(
94
+ id: @id,
95
+ event: @event.empty? ? SYNTAX[:DEFAULT_EVENT] : @event,
96
+ data: @data.empty? ? nil : @data.join(SYNTAX[:LINE_FEED]),
97
+ retry_ms: @retry_ms
98
+ )
99
+ end
100
+
101
+ clear
102
+ end
103
+
104
+ def clear
105
+ @id = nil
106
+ @event = ""
107
+ @data = []
108
+ @retry_ms = nil
109
+ @touched = false
110
+ @stored = 0
111
+ end
112
+
113
+ def refuse_oversized_block
114
+ limit = SYNTAX[:MAX_BLOCK_CHARS]
115
+ return if @stored + @pending.bytesize <= limit
116
+
117
+ raise NetworkError, format(Defaults::MESSAGES[:STREAM_BLOCK_TOO_LARGE], limit:)
118
+ end
119
+ end
120
+ end
data/lib/nextmail/http.rb CHANGED
@@ -31,6 +31,38 @@ module Nextmail
31
31
  end
32
32
  end
33
33
 
34
+ HttpStream = Data.define(:status, :headers, :body) do
35
+ def ok?
36
+ status.between?(200, 299)
37
+ end
38
+
39
+ def header(name)
40
+ headers[name.to_s.downcase]
41
+ end
42
+
43
+ def each_chunk(&)
44
+ body.each(&)
45
+ end
46
+
47
+ def read
48
+ text = String.new(encoding: Encoding::BINARY)
49
+ each_chunk { |chunk| text << chunk.to_s.b }
50
+ text
51
+ end
52
+
53
+ def inspect
54
+ "#<data #{self.class.name} status=#{status.inspect}, headers=#{headers.inspect}>"
55
+ end
56
+
57
+ def to_s
58
+ inspect
59
+ end
60
+
61
+ def pretty_print(printer)
62
+ printer.text(inspect)
63
+ end
64
+ end
65
+
34
66
  class NetHttpAdapter
35
67
  def initialize(max_idle: Defaults::MAX_IDLE_CONNECTIONS, keep_alive_timeout: Defaults::KEEP_ALIVE_TIMEOUT)
36
68
  @max_idle = max_idle
@@ -56,6 +88,18 @@ module Nextmail
56
88
  response
57
89
  end
58
90
 
91
+ def stream(request, idle_timeout: nil, &)
92
+ deadline = request.timeout && (clock + request.timeout)
93
+ uri = URI.parse(request.url)
94
+ connection = connect(uri, remaining(deadline))
95
+
96
+ begin
97
+ receive(connection, uri, request, deadline, idle_timeout, &)
98
+ ensure
99
+ finish(connection)
100
+ end
101
+ end
102
+
59
103
  def close
60
104
  connections = @lock.synchronize do
61
105
  drained = @idle.values.flatten
@@ -91,6 +135,26 @@ module Nextmail
91
135
  HttpResponse.new(status: response.code.to_i, headers: response.each_header.to_h, body:)
92
136
  end
93
137
 
138
+ def receive(connection, uri, request, deadline, idle_timeout)
139
+ limit(connection, deadline)
140
+ outbound = Net::HTTPGenericRequest.new(request.method, !request.body.nil?, request.method != "HEAD",
141
+ uri.request_uri, headers_for(request))
142
+ outbound.body = request.body unless request.body.nil?
143
+
144
+ catch do |done|
145
+ connection.request(outbound) do |incoming|
146
+ connection.read_timeout = idle_timeout
147
+ yield HttpStream.new(status: incoming.code.to_i, headers: incoming.each_header.to_h, body: chunks_of(incoming))
148
+ throw done
149
+ end
150
+ end
151
+ nil
152
+ end
153
+
154
+ def chunks_of(incoming)
155
+ Enumerator.new { |chunks| incoming.read_body { |chunk| chunks << chunk } }
156
+ end
157
+
94
158
  def limit(connection, deadline)
95
159
  left = remaining(deadline)
96
160
  raise Net::ReadTimeout if left && left <= 0