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 +4 -4
- data/README.md +13 -1
- data/lib/nextmail/api_paths.rb +19 -0
- data/lib/nextmail/client.rb +4 -0
- data/lib/nextmail/constants.rb +82 -2
- data/lib/nextmail/defaults.rb +10 -0
- data/lib/nextmail/event_stream.rb +120 -0
- data/lib/nextmail/http.rb +64 -0
- data/lib/nextmail/live_event_stream.rb +169 -0
- data/lib/nextmail/protocol.rb +44 -3
- data/lib/nextmail/resources/calendar.rb +106 -12
- data/lib/nextmail/resources/contacts.rb +24 -9
- data/lib/nextmail/resources/emails.rb +5 -0
- data/lib/nextmail/resources/live_events.rb +21 -0
- data/lib/nextmail/resources/share_links.rb +32 -0
- data/lib/nextmail/resources/shared.rb +15 -0
- data/lib/nextmail/resources/tasks.rb +6 -6
- data/lib/nextmail/resources/threads.rb +16 -0
- data/lib/nextmail/resources/traffic.rb +36 -0
- data/lib/nextmail/transport.rb +118 -3
- data/lib/nextmail/version.rb +1 -1
- data/lib/nextmail/wire.rb +14 -0
- data/lib/nextmail.rb +2 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbdaac1fbcacdd3371bd98fdb8405c1679e09c69156634cb92f841f53227f855
|
|
4
|
+
data.tar.gz: f84a7bccb20b20fc80184e4294e7f9fc22827e8927914706cf7982bf5a51db71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/nextmail/api_paths.rb
CHANGED
|
@@ -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"
|
data/lib/nextmail/client.rb
CHANGED
|
@@ -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,
|
data/lib/nextmail/constants.rb
CHANGED
|
@@ -32,7 +32,13 @@ module Nextmail
|
|
|
32
32
|
SEND_FAILED: "send-failed"
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
|
-
ADDRESS_FORWARD_STATUSES = {
|
|
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 = {
|
data/lib/nextmail/defaults.rb
CHANGED
|
@@ -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
|