teems 0.3.3 → 0.8.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +77 -0
  4. data/bin/teems +8 -1
  5. data/lib/teems/api/calendar.rb +45 -14
  6. data/lib/teems/api/client.rb +19 -4
  7. data/lib/teems/api/mail.rb +132 -0
  8. data/lib/teems/api/mail_actions.rb +22 -0
  9. data/lib/teems/api/mail_rules.rb +24 -0
  10. data/lib/teems/cli.rb +2 -0
  11. data/lib/teems/commands/base.rb +31 -7
  12. data/lib/teems/commands/cal.rb +109 -28
  13. data/lib/teems/commands/cal_ics.rb +47 -0
  14. data/lib/teems/commands/confirmation.rb +19 -0
  15. data/lib/teems/commands/help.rb +4 -0
  16. data/lib/teems/commands/mail.rb +207 -0
  17. data/lib/teems/commands/mail_decrypting.rb +46 -0
  18. data/lib/teems/commands/mail_help.rb +85 -0
  19. data/lib/teems/commands/mail_rules.rb +201 -0
  20. data/lib/teems/commands/mail_sync.rb +92 -0
  21. data/lib/teems/commands/mail_thread.rb +87 -0
  22. data/lib/teems/commands/mail_triage.rb +241 -0
  23. data/lib/teems/commands/meet.rb +159 -0
  24. data/lib/teems/commands/sync.rb +15 -8
  25. data/lib/teems/commands/sync_folders.rb +118 -0
  26. data/lib/teems/formatters/format_utils.rb +23 -2
  27. data/lib/teems/formatters/html_entities.rb +49 -0
  28. data/lib/teems/formatters/html_text.rb +181 -0
  29. data/lib/teems/formatters/ics_format.rb +112 -0
  30. data/lib/teems/formatters/link_cleaner.rb +91 -0
  31. data/lib/teems/formatters/link_collector.rb +70 -0
  32. data/lib/teems/formatters/mail_formatter.rb +58 -0
  33. data/lib/teems/formatters/mail_markdown.rb +70 -0
  34. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  35. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  36. data/lib/teems/formatters/output.rb +45 -8
  37. data/lib/teems/models/chat.rb +19 -5
  38. data/lib/teems/models/event.rb +1 -23
  39. data/lib/teems/models/event_display.rb +40 -0
  40. data/lib/teems/models/ics_event.rb +60 -0
  41. data/lib/teems/models/mail_conversation.rb +62 -0
  42. data/lib/teems/models/mail_encryption.rb +41 -0
  43. data/lib/teems/models/mail_folder.rb +44 -0
  44. data/lib/teems/models/mail_message.rb +152 -0
  45. data/lib/teems/models/mail_rule.rb +38 -0
  46. data/lib/teems/runner.rb +6 -0
  47. data/lib/teems/services/api_client.rb +23 -11
  48. data/lib/teems/services/browser.rb +17 -0
  49. data/lib/teems/services/clipboard.rb +17 -0
  50. data/lib/teems/services/mail_folders.rb +76 -0
  51. data/lib/teems/services/mail_id_cache.rb +51 -0
  52. data/lib/teems/services/mail_legacy_files.rb +96 -0
  53. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  54. data/lib/teems/services/mail_sync_engine.rb +98 -0
  55. data/lib/teems/services/mail_sync_folder.rb +142 -0
  56. data/lib/teems/services/mail_sync_store.rb +116 -0
  57. data/lib/teems/services/smime_content.rb +36 -0
  58. data/lib/teems/services/smime_decryptor.rb +36 -0
  59. data/lib/teems/services/sync_dir_naming.rb +16 -10
  60. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  61. data/lib/teems/services/sync_dir_planning.rb +130 -0
  62. data/lib/teems/services/sync_store.rb +20 -43
  63. data/lib/teems/services/token_extractor.rb +4 -0
  64. data/lib/teems/support/control_chars.rb +19 -0
  65. data/lib/teems/support/mime_header.rb +61 -0
  66. data/lib/teems/support/mime_part.rb +97 -0
  67. data/lib/teems/support/signed_data.rb +23 -0
  68. data/lib/teems/support/time_parsing.rb +9 -1
  69. data/lib/teems/version.rb +1 -1
  70. data/lib/teems.rb +63 -2
  71. metadata +48 -1
@@ -2,10 +2,17 @@
2
2
 
3
3
  module Teems
4
4
  module Services
5
- # Directory naming helpers for SyncStore
5
+ # Directory naming helpers for SyncStore.
6
+ #
7
+ # A chat directory name ends with the chat's full sanitized ID, so two chats can't share one.
8
+ # Older teems versions keyed generic labels ("Group Chat") by a 20-character ID prefix and
9
+ # named chats by their title alone, which merged unrelated chats. Those names are kept for
10
+ # chats that still own their directory (see SyncDirPlanning); SyncDirOwnership handles
11
+ # directories that ended up shared.
6
12
  module SyncDirNaming
7
- GENERIC_LABELS = ['Group Chat', '1:1 Chat', 'Meeting Chat', 'Channel', 'Space'].freeze
8
13
  MAX_DIR_NAME_LENGTH = 100
14
+ # Most filesystems limit one path component to 255 bytes
15
+ MAX_DIR_NAME_BYTES = 255
9
16
  TYPE_DIRS = {
10
17
  'oneOnOne' => 'dms', 'group' => 'groups', 'meeting' => 'meetings',
11
18
  'channel' => 'channels', 'space' => 'spaces'
@@ -21,21 +28,20 @@ module Teems
21
28
  id.gsub(/[:@]/, '_')
22
29
  end
23
30
 
24
- def sanitize_display_name(name)
31
+ def sanitize_display_name(name, max_bytes: MAX_DIR_NAME_BYTES)
25
32
  return nil if name.to_s.strip.empty?
26
33
 
27
- sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')
28
- sanitized = sanitized[0, MAX_DIR_NAME_LENGTH].gsub(/[\s.]+\z/, '')
34
+ sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')[0, MAX_DIR_NAME_LENGTH]
35
+ sanitized = sanitized.byteslice(0, max_bytes).scrub('').gsub(/[\s.]+\z/, '')
29
36
  sanitized.empty? ? nil : sanitized
30
37
  end
31
38
 
39
+ # "<display name> (<full sanitized chat ID>)", or just the sanitized ID when there is no name
32
40
  def build_dir_name(chat_id, display_name)
33
- sanitized = sanitize_display_name(display_name)
34
41
  safe_id = sanitize_id(chat_id)
35
- return safe_id unless sanitized
36
- return sanitized unless GENERIC_LABELS.include?(display_name.strip)
37
-
38
- "#{sanitized} (#{safe_id[0, 20]})"
42
+ label_bytes = [MAX_DIR_NAME_BYTES - safe_id.bytesize - ' ()'.bytesize, 0].max
43
+ label = sanitize_display_name(display_name, max_bytes: label_bytes)
44
+ label ? "#{label} (#{safe_id})" : safe_id
39
45
  end
40
46
  end
41
47
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Keeps every chat directory owned by exactly one chat.
6
+ #
7
+ # Older teems versions could give several chats the same directory (see SyncDirNaming), and
8
+ # syncing then merged their messages there. Stored messages carry no chat ID, so a shared
9
+ # directory can't be split back into its chats reliably. Instead it is detached: the directory
10
+ # and its files are left untouched, and each chat that used it forgets it (recording the path
11
+ # under 'legacy_shared_dir') and re-syncs into its own directory, starting from the oldest
12
+ # message in the shared one ('resync_from') so no history is left behind only there.
13
+ module SyncDirOwnership
14
+ # One chat's claim on a directory relative to chats/. A detached claim (attached: false) is a
15
+ # directory the chat no longer syncs into but whose files still hold its older messages.
16
+ DirClaim = Data.define(:chat_id, :path, :attached) do
17
+ def key = SyncDirOwnership.fold(path)
18
+
19
+ # A chat's claims: the directory it syncs into, and the shared one it was detached from
20
+ def self.for_entry(chat_id, entry)
21
+ name, legacy = entry.values_at('dir_name', 'legacy_shared_dir')
22
+ own = new(chat_id, File.join(SyncDirNaming.type_dir(entry['chat_type']), name), true) if name
23
+ [own, (new(chat_id, legacy, false) if legacy)].compact
24
+ end
25
+ end
26
+
27
+ # Directory names compare case-insensitively and NFC-normalized, as on macOS and Windows
28
+ def self.fold(path) = path.unicode_normalize(:nfc).downcase(:fold)
29
+
30
+ # Directories that more than one chat has synced into, with the chats still syncing into
31
+ # them: { "groups/Group Chat (19_abc)" => [chat_id, ...] }. With only:, just that chat.
32
+ # Chats that never synced (404 on their first sync) don't count: nothing of theirs is there.
33
+ def shared_dirs(state, only: nil)
34
+ owner_claims(state.fetch('chats', {})).group_by(&:key).values.filter_map do |claims|
35
+ shared_dir_entry(claims, only)
36
+ end.to_h
37
+ end
38
+
39
+ # Detaches shared directories (see #shared_dirs) from their chats; returns what it detached
40
+ def detach_shared_dirs(state, only: nil)
41
+ shared = shared_dirs(state, only: only)
42
+ chats = state['chats']
43
+ oldest = shared.keys.to_h { |path| [path, oldest_message_time(path)] }
44
+ shared.flat_map { |path, ids| ids.product([path]) }.each do |id, path|
45
+ detach_entry(chats[id], path, oldest[path])
46
+ end
47
+ shared
48
+ end
49
+
50
+ # Directories chats were detached from that still exist: { path => [chat_id, ...] }
51
+ def legacy_shared_dirs(state)
52
+ detached = dir_claims(state.fetch('chats', {})).reject(&:attached)
53
+ existing = detached.group_by(&:path).select { |path, _claims| File.directory?(chats_path(path)) }
54
+ existing.transform_values { |claims| claims.map(&:chat_id) }
55
+ end
56
+
57
+ # Where a detached chat's re-sync starts: the oldest message in the directory it left
58
+ def resync_from(state, chat_id) = parse_synced_at(state.dig('chats', chat_id, 'resync_from'))
59
+
60
+ private
61
+
62
+ def owner_claims(chats) = dir_claims(chats.select { |_id, entry| ever_synced?(entry) })
63
+
64
+ def dir_claims(chats) = chats.flat_map { |id, entry| DirClaim.for_entry(id, entry) }
65
+
66
+ # Only update_chat_state writes the first two. last_synced_at alone isn't enough: cursors get
67
+ # cleared to force a re-fetch, which doesn't make the chat's existing files go away. A detached
68
+ # chat's older messages are in its legacy_shared_dir.
69
+ def ever_synced?(entry) = entry.values_at('last_synced_at', 'message_count', 'legacy_shared_dir').any?
70
+
71
+ def shared_dir_entry(claims, only)
72
+ return if claims.map(&:chat_id).uniq.one?
73
+
74
+ attached = claims.select(&:attached)
75
+ ids = attached.map(&:chat_id)
76
+ ids &= [only] if only
77
+ [attached.first.path, ids] unless ids.empty?
78
+ end
79
+
80
+ # Any claim by another chat blocks a new name, even one that never synced (it may be mid-run)
81
+ def dir_claimed?(others, path) = claims_dir?(dir_claims(others), path)
82
+
83
+ # Whether another chat has files in the directory, so it must not be moved or reused
84
+ def owned?(others, path) = claims_dir?(owner_claims(others), path)
85
+
86
+ def claims_dir?(claims, path)
87
+ key = SyncDirOwnership.fold(path)
88
+ claims.any? { |claim| claim.key == key }
89
+ end
90
+
91
+ def fallback_dir_name(chat_id) = "#{sanitize_id(chat_id)}-#{Digest::SHA256.hexdigest(chat_id)[0, 8]}"
92
+
93
+ def detach_entry(entry, path, oldest)
94
+ entry.delete('dir_name')
95
+ entry.delete('last_synced_at')
96
+ entry['legacy_shared_dir'] = path
97
+ entry['resync_from'] = oldest.iso8601 if oldest
98
+ end
99
+
100
+ # Read-only on purpose: unlike load_json_or_default it never renames a corrupt file
101
+ def oldest_message_time(path)
102
+ messages = JSON.parse(File.read(File.join(chats_path(path), 'messages.json')))
103
+ Array(messages).filter_map { |msg| parse_synced_at(msg['created_at']) if msg.is_a?(Hash) }.min
104
+ rescue SystemCallError, JSON::ParserError
105
+ nil
106
+ end
107
+
108
+ def chats_path(path) = File.join(sync_dir, SyncStore::CHATS_DIR, path)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Decides which directory each chat syncs into.
6
+ #
7
+ # Existing directory names are kept, so upgrading doesn't rename everything. A chat gets a new
8
+ # "<name> (<full ID>)" directory (see SyncDirNaming) only when it is new, was detached from a
9
+ # shared directory, or its display name or type changed. The name is checked against every
10
+ # other chat's directories when it is assigned, which is what prevents collisions.
11
+ #
12
+ # plan_chat_dir only updates state, so a dry run can preview every move; ensure_chat_dir also
13
+ # carries the move out on disk.
14
+ module SyncDirPlanning
15
+ # A chat's directory change, with paths relative to chats/. Kinds:
16
+ # :kept same directory as before
17
+ # :new first directory for the chat
18
+ # :split new directory for a chat detached from the shared directory in from
19
+ # :release new directory; from holds another chat's files too, so it stays put
20
+ # :move from will be (or was) renamed to to
21
+ # :repoint to already exists, so the chat switches to it and from is left in place
22
+ # :retarget from no longer exists (e.g. an interrupted run already moved it)
23
+ DirMove = Data.define(:chat_id, :from, :to, :kind) do
24
+ def to_s = "#{from} → #{to}"
25
+ end
26
+
27
+ # A chat's state entry plus the display name and type this sync found for it. A nil name or
28
+ # type means unknown (sync --chat has no chat metadata), so the stored one is kept.
29
+ ChatDirRequest = Data.define(:chat_id, :entry, :wanted, :migrate) do
30
+ def self.build(chats, chat_info)
31
+ chat_id = chat_info[:chat_id]
32
+ entry = chats[chat_id] ||= {}
33
+ wanted = { 'display_name' => chat_info[:display_name] || entry['display_name'],
34
+ 'chat_type' => chat_info[:chat_type] || entry['chat_type'] }
35
+ new(chat_id, entry, wanted, chat_info[:migrate])
36
+ end
37
+
38
+ def current_dir
39
+ name = entry['dir_name']
40
+ dir_for(name, entry['chat_type']) if name
41
+ end
42
+
43
+ # migrate: true treats every chat as changed, giving it a full-ID name
44
+ def unchanged? = !migrate && entry.slice('display_name', 'chat_type') == wanted
45
+
46
+ def display_name = wanted['display_name']
47
+
48
+ def wanted_dir(name) = dir_for(name, wanted['chat_type'])
49
+
50
+ def assign(name)
51
+ entry.merge!('dir_name' => name, 'chat_type' => wanted['chat_type'])
52
+ wanted_dir(name)
53
+ end
54
+
55
+ # The move for a chat that had no directory: from is the shared one it was detached from
56
+ def first_move(to)
57
+ legacy = entry['legacy_shared_dir']
58
+ DirMove.new(chat_id, legacy, to, legacy ? :split : :new)
59
+ end
60
+
61
+ private
62
+
63
+ def dir_for(name, chat_type) = File.join(SyncDirNaming.type_dir(chat_type), name)
64
+ end
65
+
66
+ # Picks the chat's directory and records it in state without touching the filesystem
67
+ def plan_chat_dir(state, chat_info)
68
+ chat_id = chat_info[:chat_id]
69
+ chats = state['chats'] ||= {}
70
+ request = ChatDirRequest.build(chats, chat_info)
71
+ others = chats.except(chat_id)
72
+ from = request.current_dir
73
+ to = keep_dir?(request, others, from) ? from : assign_dir(request, others, chat_id)
74
+ from ? DirMove.new(chat_id, from, to, existing_dir_kind(others, from, to)) : request.first_move(to)
75
+ end
76
+
77
+ # Like plan_chat_dir, then renames the old directory when that is safe. If the rename fails, the
78
+ # chat's entry is restored so state never points at a directory its files weren't moved to.
79
+ def ensure_chat_dir(state, chat_info:)
80
+ entry = (state['chats'] ||= {})[chat_info[:chat_id]] ||= {}
81
+ saved = entry.dup
82
+ move = plan_chat_dir(state, chat_info)
83
+ rename_chat_dir(move) if move.kind == :move
84
+ move
85
+ rescue SystemCallError
86
+ entry.replace(saved)
87
+ raise
88
+ end
89
+
90
+ # Records directory changes (paths relative to root) as dir-maps/<time>.json; returns its path
91
+ def write_dir_map(moves, time: Time.now)
92
+ dir = File.join(sync_dir, 'dir-maps')
93
+ FileUtils.mkdir_p(dir)
94
+ path = File.join(dir, "#{time.strftime('%Y%m%d-%H%M%S')}.json")
95
+ map = { 'root' => File.join(sync_dir, SyncStore::CHATS_DIR), 'moves' => moves.map(&:to_h) }
96
+ atomic_write(path, JSON.pretty_generate(map))
97
+ path
98
+ end
99
+
100
+ private
101
+
102
+ def keep_dir?(request, others, from) = from && request.unchanged? && !owned?(others, from)
103
+
104
+ def assign_dir(request, others, chat_id)
105
+ name = build_dir_name(chat_id, request.display_name)
106
+ name = fallback_dir_name(chat_id) if dir_claimed?(others, request.wanted_dir(name))
107
+ request.assign(name)
108
+ end
109
+
110
+ def existing_dir_kind(others, from, to)
111
+ return :kept if from == to
112
+ return :release if owned?(others, from)
113
+
114
+ from_path = chats_path(from)
115
+ return :retarget unless File.directory?(from_path)
116
+
117
+ target_taken?(from_path, chats_path(to)) ? :repoint : :move
118
+ end
119
+
120
+ # On a case-insensitive volume a case-only rename finds the chat's own directory at the new path
121
+ def target_taken?(from_path, to_path) = File.exist?(to_path) && !File.identical?(from_path, to_path)
122
+
123
+ def rename_chat_dir(move)
124
+ new_path = chats_path(move.to)
125
+ FileUtils.mkdir_p(File.dirname(new_path))
126
+ File.rename(chats_path(move.from), new_path)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Teems
4
4
  module Services
5
- # File I/O helpers for SyncStore: atomic writes, backup, and JSON persistence
5
+ # File I/O helpers for SyncStore and MailSyncStore: atomic writes, backup, and JSON persistence
6
6
  module SyncFileOps
7
7
  private
8
8
 
9
+ # The temporary name carries the PID, so two processes never write the same one
9
10
  def atomic_write(path, content)
10
- tmp_path = "#{path}.tmp"
11
+ tmp_path = "#{path}.tmp.#{Process.pid}"
11
12
  File.write(tmp_path, content)
12
13
  File.rename(tmp_path, path)
13
14
  end
@@ -20,7 +21,7 @@ module Teems
20
21
  end
21
22
 
22
23
  def load_json_or_default(path, default)
23
- return default unless File.exist?(path)
24
+ return default unless File.file?(path)
24
25
 
25
26
  JSON.parse(File.read(path))
26
27
  rescue JSON::ParserError
@@ -48,13 +49,11 @@ module Teems
48
49
 
49
50
  # Chat state mutation operations for SyncStore
50
51
  module SyncStateMutation
52
+ # A nil display name or chat type (sync --chat) keeps the stored one
51
53
  def update_chat_state(state, chat_id, attrs:)
52
- display_name, synced_at, count, chat_type =
53
- attrs.values_at(:display_name, :last_synced_at, :message_count, :chat_type)
54
54
  entry = (state['chats'] ||= {})[chat_id] ||= {}
55
- entry.merge!('last_synced_at' => synced_at.iso8601, 'message_count' => count,
56
- 'display_name' => display_name, 'chat_type' => chat_type,
57
- 'dir_name' => build_dir_name(chat_id, display_name))
55
+ dir_name = dir_name_for(entry, chat_id, attrs[:display_name])
56
+ entry.merge!(synced_fields(attrs, dir_name)).delete('resync_from')
58
57
  state
59
58
  end
60
59
 
@@ -68,6 +67,13 @@ module Teems
68
67
 
69
68
  private
70
69
 
70
+ def synced_fields(attrs, dir_name)
71
+ display_name, synced_at, count, chat_type =
72
+ attrs.values_at(:display_name, :last_synced_at, :message_count, :chat_type)
73
+ { 'last_synced_at' => synced_at.iso8601, 'message_count' => count, 'display_name' => display_name,
74
+ 'chat_type' => chat_type, 'dir_name' => dir_name }.compact
75
+ end
76
+
71
77
  def apply_unavailable(entry)
72
78
  entry.merge!('unavailable' => true, 'unavailable_at' => Time.now.iso8601)
73
79
  end
@@ -79,9 +85,11 @@ module Teems
79
85
  def apply_display_info(entry, chat_id, display_name)
80
86
  return unless display_name
81
87
 
82
- entry.merge!('display_name' => display_name,
83
- 'dir_name' => build_dir_name(chat_id, display_name))
88
+ entry.merge!('display_name' => display_name, 'dir_name' => dir_name_for(entry, chat_id, display_name))
84
89
  end
90
+
91
+ # ensure_chat_dir owns the directory name; this only fills it in for callers that skipped it
92
+ def dir_name_for(entry, chat_id, display_name) = entry['dir_name'] || build_dir_name(chat_id, display_name)
85
93
  end
86
94
 
87
95
  # Chat directory resolution for SyncStore
@@ -118,40 +126,17 @@ module Teems
118
126
  end
119
127
  end
120
128
 
121
- # Directory rename helpers for SyncStore
122
- module SyncRenameOps
123
- private
124
-
125
- def rename_entry_dir(entry, new_dir_name, chat_type)
126
- old_name = entry['dir_name']
127
- old_type = entry['chat_type']
128
- if old_name && (old_name != new_dir_name || old_type != chat_type)
129
- move_chat_dir(chat_type_path(old_type, old_name),
130
- chat_type_path(chat_type, new_dir_name))
131
- end
132
- entry.merge!('dir_name' => new_dir_name, 'chat_type' => chat_type)
133
- end
134
-
135
- def move_chat_dir(old_path, new_path)
136
- return if old_path == new_path || !File.directory?(old_path) || File.exist?(new_path)
137
-
138
- FileUtils.mkdir_p(File.dirname(new_path))
139
- File.rename(old_path, new_path)
140
- end
141
-
142
- def chat_type_path(type, name) = File.join(sync_dir, SyncStore::CHATS_DIR, type_dir(type), name)
143
- end
144
-
145
129
  # Manages local sync state and file storage for the sync command.
146
130
  # Stores chat history as Markdown + JSON in XDG data directory.
147
131
  class SyncStore
148
132
  include SyncDirNaming
133
+ include SyncDirOwnership
134
+ include SyncDirPlanning
149
135
  include SyncFileOps
150
136
  include SyncStateQuery
151
137
  include SyncStateMutation
152
138
  include SyncChatDir
153
139
  include SyncChatWrite
154
- include SyncRenameOps
155
140
 
156
141
  SYNC_DIR = 'sync'
157
142
  STATE_FILE = 'sync_state.json'
@@ -180,14 +165,6 @@ module Teems
180
165
  FileUtils.mkdir_p(sync_dir)
181
166
  atomic_write(File.join(sync_dir, STATE_FILE), JSON.pretty_generate(state))
182
167
  end
183
-
184
- def ensure_dir_name(state, chat_info:)
185
- chat_id, display_name, chat_type = chat_info.values_at(:chat_id, :display_name, :chat_type)
186
- new_dir_name = build_dir_name(chat_id, display_name)
187
- entry = (state['chats'] ||= {})[chat_id] ||= {}
188
- rename_entry_dir(entry, new_dir_name, chat_type)
189
- new_dir_name
190
- end
191
168
  end
192
169
  end
193
170
  end
@@ -298,6 +298,10 @@ module Teems
298
298
  try_headless_extract || try_safari_oauth || safari_extract
299
299
  end
300
300
 
301
+ # Only the headless helper: renews tokens from the cached sign-in session without
302
+ # opening Safari or any window, or returns nil when there is no such session
303
+ def extract_silently = try_headless_extract
304
+
301
305
  def manual_instructions = MANUAL_TOKEN_INSTRUCTIONS
302
306
 
303
307
  private
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Removes C0 and C1 control characters, other than tab and newline, from text a sender
6
+ # controls. Printed to a terminal they are escape sequences: ESC ]52 sets the clipboard
7
+ # on terminals that allow OSC 52, and others move the cursor or rewrite the screen.
8
+ module ControlChars
9
+ CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/
10
+
11
+ module_function
12
+
13
+ def strip(text)
14
+ string = text.to_s
15
+ string.match?(CONTROLS) ? string.gsub(CONTROLS, '') : string
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Header fields with parameters, e.g. `text/plain; charset="utf-8"` (RFC 2045), including
6
+ # RFC 2231 (filename*=utf-8''...) and RFC 2047 encoded words (=?utf-8?B?...?=)
7
+ module MimeHeader
8
+ ENCODED_WORD = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/
9
+ # Parameters split on ";" outside quotes
10
+ PARAMS = /(?:"(?:\\.|[^"\\])*"|[^;"])+/
11
+
12
+ module_function
13
+
14
+ # [lowercased value, { lowercased name => value }]
15
+ def field(text)
16
+ value, *params = text.to_s.scan(PARAMS)
17
+ [value.to_s.strip.downcase, params.to_h { |param| parameter(param) }]
18
+ end
19
+
20
+ def parameter(text)
21
+ name, value = text.split('=', 2)
22
+ value = value.to_s.strip
23
+ value = value[1...-1].gsub(/\\(.)/, '\1') if value.start_with?('"') && value.end_with?('"')
24
+ [name.strip.downcase, value]
25
+ end
26
+
27
+ # A parameter's text, preferring the RFC 2231 form (name*) when present
28
+ def decoded(params, name)
29
+ extended = params["#{name}*"]
30
+ return percent_decoded(extended) if extended
31
+
32
+ plain = params[name]
33
+ words_decoded(plain) if plain
34
+ end
35
+
36
+ def percent_decoded(text)
37
+ charset, _language, encoded = text.split("'", 3)
38
+ return text unless encoded
39
+
40
+ transcode(encoded.gsub(/%(\h\h)/) { ::Regexp.last_match(1).hex.chr }, charset)
41
+ end
42
+
43
+ def words_decoded(text)
44
+ text.gsub(ENCODED_WORD) do
45
+ charset, kind, encoded = ::Regexp.last_match.captures
46
+ bytes = kind.casecmp?('B') ? encoded.unpack1('m') : encoded.tr('_', ' ').unpack1('M')
47
+ transcode(bytes, charset)
48
+ end
49
+ end
50
+
51
+ # Bytes in charset as UTF-8; unknown charsets are read as UTF-8, and bad bytes become "?"
52
+ def transcode(bytes, charset)
53
+ text = bytes.dup
54
+ encoding = Encoding.find(charset.to_s.strip.empty? ? 'UTF-8' : charset)
55
+ text.force_encoding(encoding).encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
56
+ rescue ArgumentError
57
+ text.force_encoding('UTF-8').scrub('?')
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # One MIME entity (RFC 2045/2046): headers, a decoded body, and parts when multipart.
6
+ # Enough to read a decrypted S/MIME message: text and HTML bodies, attachment names,
7
+ # nested multiparts.
8
+ class MimePart
9
+ SIGNED_MULTIPART = 'multipart/signed'
10
+ PKCS7_TYPES = %w[application/pkcs7-mime application/x-pkcs7-mime].freeze
11
+
12
+ attr_reader :headers, :body
13
+
14
+ def self.parse(raw)
15
+ raw = raw.b
16
+ return new({}, raw.sub(/\A\r?\n/, '')) if raw.match?(/\A\r?\n/)
17
+
18
+ head, body = raw.split(/\r?\n\r?\n/, 2)
19
+ new(header_fields(head.to_s), body.to_s)
20
+ end
21
+
22
+ # Unfolded, names lowercased; the first of a repeated header wins
23
+ def self.header_fields(head)
24
+ head.gsub(/\r?\n[ \t]+/, ' ').split(/\r?\n/).each_with_object({}) do |line, fields|
25
+ name, value = line.split(':', 2)
26
+ fields[name.strip.downcase] ||= value.strip if value
27
+ end
28
+ end
29
+
30
+ def initialize(headers, body)
31
+ @headers = headers
32
+ @body = body
33
+ end
34
+
35
+ def content_type = type_field.first.then { |type| type.empty? ? 'text/plain' : type }
36
+ def multipart? = content_type.start_with?('multipart/')
37
+ def text? = %w[text/plain text/html].include?(content_type)
38
+
39
+ def parts
40
+ boundary = type_field.last['boundary']
41
+ return [] unless multipart? && boundary
42
+
43
+ sections = body.split(/(?:\A|\r?\n)--#{Regexp.escape(boundary.b)}/).drop(1)
44
+ sections.take_while { |section| !section.start_with?('--') }.map do |section|
45
+ MimePart.parse(section.sub(/\A[ \t]*\r?\n/, ''))
46
+ end
47
+ end
48
+
49
+ # The parts with no parts of their own, in order (this part when it is not multipart)
50
+ def leaves = multipart? ? parts.flat_map(&:leaves) : [self]
51
+
52
+ # [the content inside any signature layers, whether there were any]
53
+ def unsigned
54
+ inner = signed_content
55
+ inner ? [inner.unsigned.first, true] : [self, false]
56
+ end
57
+
58
+ # The part a signature wraps (multipart/signed keeps its first part, opaque signed
59
+ # PKCS #7 gives up the content inside it), or nil when this part is not signed
60
+ def signed_content
61
+ type = content_type
62
+ return parts.first if type == SIGNED_MULTIPART
63
+
64
+ MimePart.parse(SignedData.content(decoded)) if PKCS7_TYPES.include?(type)
65
+ end
66
+
67
+ # The body with its transfer encoding undone, as bytes
68
+ def decoded
69
+ case headers['content-transfer-encoding'].to_s.strip.downcase
70
+ when 'base64' then body.unpack1('m')
71
+ when 'quoted-printable' then body.gsub("\r\n", "\n").unpack1('M')
72
+ else body
73
+ end
74
+ end
75
+
76
+ # The decoded body as UTF-8 text, from its charset
77
+ def text = MimeHeader.transcode(decoded, type_field.last['charset'])
78
+
79
+ def filename
80
+ params = MimeHeader.field(headers['content-disposition']).last
81
+ MimeHeader.decoded(params, 'filename') || MimeHeader.decoded(type_field.last, 'name')
82
+ end
83
+
84
+ # Something a person attached, as opposed to the message body or an image shown inline
85
+ def attachment?
86
+ disposition = MimeHeader.field(headers['content-disposition']).first
87
+ return true if disposition == 'attachment'
88
+
89
+ disposition.empty? && !text? && !headers.key?('content-id') && !filename.to_s.empty?
90
+ end
91
+
92
+ private
93
+
94
+ def type_field = @type_field ||= MimeHeader.field(headers['content-type'])
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Teems
6
+ module Support
7
+ # The content inside an opaque S/MIME signature (PKCS #7 signed-data). The signature is not
8
+ # checked: this only unwraps it so the message can be read.
9
+ module SignedData
10
+ module_function
11
+
12
+ def content(der)
13
+ envelope = OpenSSL::PKCS7.new(der)
14
+ raise Error, 'This message is encrypted again inside; teems opens one layer' unless envelope.type == :signed
15
+
16
+ envelope.verify([], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOSIGS)
17
+ envelope.data.to_s
18
+ rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError => e
19
+ raise Error, "Could not read the signed content: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -3,17 +3,25 @@
3
3
  module Teems
4
4
  module Support
5
5
  # Shared time-input parsing for commands that accept schedule inputs
6
- # like "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
6
+ # like "now", "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
7
7
  module TimeParsing
8
8
  private
9
9
 
10
10
  def parse_time_input(raw)
11
+ return current_minute if raw.strip.casecmp?('now')
12
+
11
13
  date, time_str = split_time_input(raw)
12
14
  return unless time_str
13
15
 
14
16
  date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
15
17
  end
16
18
 
19
+ # The start of the current minute, as "HH:MM" would give it
20
+ def current_minute
21
+ now = Time.now
22
+ Time.new(now.year, now.month, now.day, now.hour, now.min, 0)
23
+ end
24
+
17
25
  def split_time_input(raw)
18
26
  base = Date.today
19
27
  split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
data/lib/teems/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teems
4
- VERSION = '0.3.3'
4
+ VERSION = '0.8.0'
5
5
  end