teems 0.3.2 → 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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +84 -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/meeting.rb +4 -3
  25. data/lib/teems/commands/messages.rb +51 -8
  26. data/lib/teems/commands/sync.rb +45 -11
  27. data/lib/teems/commands/sync_folders.rb +118 -0
  28. data/lib/teems/commands/transcripts.rb +205 -137
  29. data/lib/teems/formatters/format_utils.rb +26 -2
  30. data/lib/teems/formatters/html_entities.rb +49 -0
  31. data/lib/teems/formatters/html_text.rb +181 -0
  32. data/lib/teems/formatters/ics_format.rb +112 -0
  33. data/lib/teems/formatters/link_cleaner.rb +91 -0
  34. data/lib/teems/formatters/link_collector.rb +70 -0
  35. data/lib/teems/formatters/mail_formatter.rb +58 -0
  36. data/lib/teems/formatters/mail_markdown.rb +70 -0
  37. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  38. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  39. data/lib/teems/formatters/markdown_formatter.rb +10 -1
  40. data/lib/teems/formatters/message_formatter.rb +7 -2
  41. data/lib/teems/formatters/output.rb +45 -8
  42. data/lib/teems/formatters/transcript_markdown.rb +16 -12
  43. data/lib/teems/models/chat.rb +19 -5
  44. data/lib/teems/models/event.rb +1 -23
  45. data/lib/teems/models/event_display.rb +40 -0
  46. data/lib/teems/models/ics_event.rb +60 -0
  47. data/lib/teems/models/inline_image.rb +83 -0
  48. data/lib/teems/models/mail_conversation.rb +62 -0
  49. data/lib/teems/models/mail_encryption.rb +41 -0
  50. data/lib/teems/models/mail_folder.rb +44 -0
  51. data/lib/teems/models/mail_message.rb +152 -0
  52. data/lib/teems/models/mail_rule.rb +38 -0
  53. data/lib/teems/models/message.rb +7 -4
  54. data/lib/teems/models/parsing.rb +5 -0
  55. data/lib/teems/runner.rb +11 -0
  56. data/lib/teems/services/api_client.rb +23 -11
  57. data/lib/teems/services/browser.rb +17 -0
  58. data/lib/teems/services/clipboard.rb +17 -0
  59. data/lib/teems/services/inline_image_downloader.rb +115 -0
  60. data/lib/teems/services/mail_folders.rb +76 -0
  61. data/lib/teems/services/mail_id_cache.rb +51 -0
  62. data/lib/teems/services/mail_legacy_files.rb +96 -0
  63. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  64. data/lib/teems/services/mail_sync_engine.rb +98 -0
  65. data/lib/teems/services/mail_sync_folder.rb +142 -0
  66. data/lib/teems/services/mail_sync_store.rb +116 -0
  67. data/lib/teems/services/smime_content.rb +36 -0
  68. data/lib/teems/services/smime_decryptor.rb +36 -0
  69. data/lib/teems/services/sync_dir_naming.rb +16 -10
  70. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  71. data/lib/teems/services/sync_dir_planning.rb +130 -0
  72. data/lib/teems/services/sync_engine.rb +13 -7
  73. data/lib/teems/services/sync_images.rb +55 -0
  74. data/lib/teems/services/sync_store.rb +20 -43
  75. data/lib/teems/services/token_extractor.rb +4 -0
  76. data/lib/teems/support/control_chars.rb +19 -0
  77. data/lib/teems/support/mime_header.rb +61 -0
  78. data/lib/teems/support/mime_part.rb +97 -0
  79. data/lib/teems/support/signed_data.rb +23 -0
  80. data/lib/teems/support/subprocess.rb +2 -0
  81. data/lib/teems/support/time_parsing.rb +9 -1
  82. data/lib/teems/version.rb +1 -1
  83. data/lib/teems.rb +67 -2
  84. metadata +51 -1
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # One line saying what an Inbox rule does, e.g.
6
+ # from jira@example.com, except subject has "urgent" -> move to Inbox/Jira, stop
7
+ # Keys this does not know are spelled out from their Graph names, so nothing is hidden.
8
+ class MailRuleText
9
+ LABELS = {
10
+ 'fromAddresses' => 'from', 'sentToAddresses' => 'to', 'subjectContains' => 'subject has',
11
+ 'bodyContains' => 'body has', 'bodyOrSubjectContains' => 'subject or body has',
12
+ 'senderContains' => 'sender has', 'recipientContains' => 'recipient has',
13
+ 'headerContains' => 'header has', 'moveToFolder' => 'move to', 'copyToFolder' => 'copy to',
14
+ 'assignCategories' => 'categories', 'markImportance' => 'importance',
15
+ 'forwardTo' => 'FORWARD to', 'forwardAsAttachmentTo' => 'FORWARD as attachment to',
16
+ 'redirectTo' => 'REDIRECT to'
17
+ }.freeze
18
+ # Conditions and actions that are simply on
19
+ SWITCHES = { 'markAsRead' => 'mark read', 'stopProcessingRules' => 'stop',
20
+ 'delete' => 'delete', 'permanentDelete' => 'PERMANENTLY delete',
21
+ 'isMeetingRequest' => 'meeting requests', 'hasAttachments' => 'with attachments' }.freeze
22
+ FOLDER_ACTIONS = %w[moveToFolder copyToFolder].freeze
23
+
24
+ # folder_names: Graph folder ID => path, to name a rule's destination
25
+ def initialize(folder_names = {})
26
+ @folder_names = folder_names
27
+ end
28
+
29
+ def describe(rule)
30
+ exceptions = phrases(rule.exceptions)
31
+ tests = phrases(rule.conditions).join(', ')
32
+ tests = 'all mail' if tests.empty?
33
+ tests += ", except #{exceptions.join(', ')}" if exceptions.any?
34
+ "#{tests} -> #{phrases(rule.actions).join(', ')}"
35
+ end
36
+
37
+ private
38
+
39
+ def phrases(settings) = settings.filter_map { |key, value| phrase(key, value) }
40
+
41
+ def phrase(key, value)
42
+ return if [false, nil, []].include?(value)
43
+
44
+ switch = value == true
45
+ label = (switch ? SWITCHES : LABELS).fetch(key) { spelled(key) }
46
+ switch ? label : "#{label} #{values_text(key, value)}"
47
+ end
48
+
49
+ def values_text(key, value) = Array(value).map { |item| item_text(key, item) }.join(', ')
50
+
51
+ def item_text(key, item)
52
+ return @folder_names.fetch(item, 'another folder') if FOLDER_ACTIONS.include?(key)
53
+ return recipient_text(item) if item.is_a?(Hash)
54
+
55
+ key.end_with?('Contains') ? %("#{item}") : item.to_s
56
+ end
57
+
58
+ def recipient_text(item)
59
+ address = item['emailAddress'] || item
60
+ address.values_at('address', 'name').compact.first.to_s
61
+ end
62
+
63
+ # isAutomaticReply -> is automatic reply
64
+ def spelled(key) = key.gsub(/[A-Z]/) { |letter| " #{letter.downcase}" }
65
+ end
66
+ end
67
+ end
@@ -15,10 +15,12 @@ module Teems
15
15
  '1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
16
16
  }.freeze
17
17
 
18
- def initialize(chat_name:, chat_type: nil, synced_at: nil)
18
+ # image_link: optional callable returning a relative path for a locally saved InlineImage
19
+ def initialize(chat_name:, chat_type: nil, synced_at: nil, image_link: nil)
19
20
  @chat_name = chat_name
20
21
  @chat_type = chat_type
21
22
  @synced_at = synced_at
23
+ @image_link = image_link
22
24
  end
23
25
 
24
26
  # Format an array of Message objects into a Markdown string.
@@ -68,6 +70,7 @@ module Teems
68
70
  content = msg.content
69
71
  result = content.to_s.empty? ? [] : [content]
70
72
  result.concat(format_message_attachments(msg))
73
+ result.concat(msg.images.map { |image| format_image(image) })
71
74
  result.concat(format_message_reactions(msg))
72
75
  end
73
76
 
@@ -93,6 +96,12 @@ module Teems
93
96
  url&.start_with?('https://') ? "\u{1F4CE} [#{name}](#{url})" : "\u{1F4CE} #{name}"
94
97
  end
95
98
 
99
+ def format_image(image)
100
+ label = "image: #{image.label}"
101
+ path = @image_link&.call(image)
102
+ path ? "![#{label}](#{path})" : "[#{label}]"
103
+ end
104
+
96
105
  def format_message_reactions(msg)
97
106
  reactions = msg.reactions
98
107
  return [] unless displayable_reactions?(reactions)
@@ -21,8 +21,8 @@ module Teems
21
21
 
22
22
  def format(message)
23
23
  content = highlight_mentions(message.content, message.mentions)
24
- [format_header(message), " #{content}", format_attachments(message), format_reactions(message)]
25
- .compact.join("\n")
24
+ [format_header(message), " #{content}", format_attachments(message), format_images(message),
25
+ format_reactions(message)].compact.join("\n")
26
26
  end
27
27
 
28
28
  private
@@ -44,6 +44,11 @@ module Teems
44
44
  " #{@output.gray("\u{1F4CE} #{attachment_names(attachments)}")}"
45
45
  end
46
46
 
47
+ def format_images(message)
48
+ summary = FormatUtils.image_summary(message.images)
49
+ " #{@output.gray("\u{1F5BC}\u{FE0F} #{summary}")}" unless summary.empty?
50
+ end
51
+
47
52
  def format_reactions(message)
48
53
  reactions = message.reactions
49
54
  return unless reactions.any?
@@ -22,6 +22,14 @@ module Teems
22
22
  end
23
23
  end
24
24
 
25
+ # Stands in for stdout or stderr once its reader has gone away (see Output#to_stdout)
26
+ module NullIO
27
+ def self.puts(*) = nil
28
+ def self.print(*) = nil
29
+ def self.flush = self
30
+ def self.tty? = false
31
+ end
32
+
25
33
  # Terminal output with ANSI color support
26
34
  class Output
27
35
  include OutputColors
@@ -52,23 +60,34 @@ module Teems
52
60
  def tty? = @io.tty?
53
61
 
54
62
  def puts(message = '')
55
- write_unless_quiet { @io.puts(message) }
63
+ write_unless_quiet { |io| io.puts(message) }
56
64
  end
57
65
 
58
66
  def print(message)
59
- write_unless_quiet { @io.print(message) }
67
+ write_unless_quiet { |io| io.print(message) }
60
68
  end
61
69
 
62
70
  def flush
63
- @io.flush
71
+ to_stdout(&:flush)
64
72
  end
65
73
 
66
74
  def error(message)
67
- @err.puts(colorize("#{red('Error:')} #{message}"))
75
+ to_stderr { |err| err.puts(colorize("#{red('Error:')} #{message}")) }
68
76
  end
69
77
 
70
78
  def warn(message)
71
- write_unless_quiet { @err.puts(colorize("#{yellow('Warning:')} #{message}")) }
79
+ note("#{yellow('Warning:')} #{message}")
80
+ end
81
+
82
+ # A question for the person at the terminal: on stderr, so piped stdout stays data, and
83
+ # shown under --quiet too, since an unseen question would just hang (stderr is unbuffered)
84
+ def prompt(message)
85
+ to_stderr { |err| err.print(message) }
86
+ end
87
+
88
+ # Commentary that should not mix with data on stdout (shown unless --quiet)
89
+ def note(message)
90
+ to_stderr { |err| err.puts(colorize(message)) } unless quiet?
72
91
  end
73
92
 
74
93
  def success(message)
@@ -82,13 +101,31 @@ module Teems
82
101
  def debug(message)
83
102
  return unless verbose?
84
103
 
85
- @err.puts(colorize("#{gray('[debug]')} #{message}"))
104
+ to_stderr { |err| err.puts(colorize("#{gray('[debug]')} #{message}")) }
86
105
  end
87
106
 
88
107
  private
89
108
 
90
- def write_unless_quiet
91
- yield unless quiet?
109
+ def write_unless_quiet(&)
110
+ to_stdout(&) unless quiet?
111
+ end
112
+
113
+ # A reader that stops early (`teems ... | head`) is not an error: drop the
114
+ # rest of stdout and let the command finish quietly. Raising instead would
115
+ # surface as "Unexpected error: Broken pipe", or be caught by a per-item
116
+ # rescue and reported as a failed chat or download.
117
+ def to_stdout
118
+ yield @io
119
+ rescue Errno::EPIPE
120
+ @io = NullIO
121
+ end
122
+
123
+ # The same for stderr, which `teems ... 2>&1 | head` closes along with stdout. A
124
+ # warning that raised here would also escape the per-item rescues that report it.
125
+ def to_stderr
126
+ yield @err
127
+ rescue Errno::EPIPE
128
+ @err = NullIO
92
129
  end
93
130
 
94
131
  def wrap(color, text)
@@ -7,8 +7,18 @@ module Teems
7
7
  # Converts Teams WebVTT transcripts into speaker-turn Markdown for local search indexes.
8
8
  # Consecutive cues from the same speaker are merged so each paragraph carries context.
9
9
  class TranscriptMarkdown
10
+ # One timed WebVTT cue: its start timestamp, speaker (nil when unattributed), and plain text
10
11
  Cue = Data.define(:start, :speaker, :text)
11
12
 
13
+ # Consecutive cues from one speaker, merged into a paragraph until it is too long to index well
14
+ Turn = Struct.new(:start, :speaker, :text) do
15
+ def self.from(cue) = new(cue.start, cue.speaker, +cue.text)
16
+
17
+ def accepts?(cue) = cue.speaker == speaker && text.length < MAX_TURN_CHARS
18
+
19
+ def add(cue) = text << ' ' << cue.text
20
+ end
21
+
12
22
  TIMING = /\A(?<start>(?:\d+:)?\d{2}:\d{2}\.\d{3})\s+-->/
13
23
  VOICE = /<v(?:\.[^\s>]+)?\s+([^>]+)>/
14
24
  ENTITIES = {
@@ -39,11 +49,7 @@ module Teems
39
49
 
40
50
  def append_cue(turns, cue)
41
51
  last = turns.last
42
- if last && last[:speaker] == cue.speaker && last[:text].length < MAX_TURN_CHARS
43
- last[:text] << ' ' << cue.text
44
- else
45
- turns << { start: cue.start, speaker: cue.speaker, text: +cue.text }
46
- end
52
+ last&.accepts?(cue) ? last.add(cue) : turns << Turn.from(cue)
47
53
  end
48
54
 
49
55
  def cues
@@ -51,12 +57,10 @@ module Teems
51
57
  normalized.split(/\n{2,}/).filter_map { |block| parse_cue(block) }
52
58
  end
53
59
 
60
+ # Cue text is every line after the timing line; identifier lines before it are skipped
54
61
  def parse_cue(block)
55
- lines = block.lines.map(&:chomp)
56
- timing_index = lines.index { |line| TIMING.match?(line) }
57
- return unless timing_index
58
-
59
- build_cue(lines[timing_index][TIMING, :start], lines[(timing_index + 1)..].join(' '))
62
+ timing, *text = block.lines(chomp: true).drop_while { |line| !TIMING.match?(line) }
63
+ build_cue(timing[TIMING, :start], text.join(' ')) if timing
60
64
  end
61
65
 
62
66
  def build_cue(start, raw)
@@ -85,8 +89,8 @@ module Teems
85
89
  end
86
90
 
87
91
  def format_turn(turn)
88
- speaker = turn[:speaker] || 'Unknown speaker'
89
- "**#{speaker}** (#{turn[:start].sub(/\.\d+\z/, '')}): #{turn[:text]}"
92
+ speaker = turn.speaker || 'Unknown speaker'
93
+ "**#{speaker}** (#{turn.start.sub(/\.\d+\z/, '')}): #{turn.text}"
90
94
  end
91
95
  end
92
96
  end
@@ -30,18 +30,22 @@ module Teems
30
30
  end
31
31
 
32
32
  def self.from_ngmsg(data)
33
- thread_props = data['threadProperties'] || {}
33
+ id = data['id']
34
34
  props = data['properties'] || {}
35
35
  new(
36
- id: data['id'],
37
- topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
38
- chat_type: normalize_chat_type(thread_props['threadType']),
39
- created_at: parse_time(thread_props['createdat']),
36
+ id: id,
40
37
  last_updated: parse_time(props['lastimreceivedtime']),
38
+ **ngmsg_thread(id, data['threadProperties'] || {}),
41
39
  **ngmsg_status(props, data['lastMessage'])
42
40
  )
43
41
  end
44
42
 
43
+ def self.ngmsg_thread(id, thread_props)
44
+ { topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
45
+ chat_type: ngmsg_chat_type(id, thread_props),
46
+ created_at: parse_time(thread_props['createdat']) }
47
+ end
48
+
45
49
  def self.ngmsg_status(props, last_message)
46
50
  horizon = props['consumptionhorizon']
47
51
  last_msg_id = last_message&.dig('id')
@@ -52,6 +56,13 @@ module Teems
52
56
  }
53
57
  end
54
58
 
59
+ # ng.msg reports 1:1 chats with threadType 'chat', like group chats. They are marked by
60
+ # productThreadType 'OneToOneChat' and a "19:<user>_<user>@unq.gbl.spaces" ID.
61
+ def self.ngmsg_chat_type(id, thread_props)
62
+ one_on_one = thread_props['productThreadType'] == 'OneToOneChat' || id.to_s.end_with?('@unq.gbl.spaces')
63
+ one_on_one ? 'oneOnOne' : normalize_chat_type(thread_props['threadType'])
64
+ end
65
+
55
66
  # Normalize ng.msg threadType to a consistent chat type.
56
67
  # ng.msg uses: 'chat' (group), 'meeting', 'topic' (channel), 'space'
57
68
  # Normalized to: 'group', 'meeting', 'channel', 'space'
@@ -79,6 +90,9 @@ module Teems
79
90
 
80
91
  def chat_type_label = CHAT_TYPE_LABELS.fetch(chat_type, chat_type)
81
92
 
93
+ # What sync uses to name and place the chat's directory
94
+ def sync_identity = { chat_id: id, display_name: display_name, chat_type: chat_type }
95
+
82
96
  def unread? = unread
83
97
  def favorite? = favorite
84
98
  def pinned? = pinned
@@ -24,6 +24,7 @@ module Teems
24
24
  :event_type
25
25
  ) do
26
26
  extend Parsing
27
+ include EventDisplay
27
28
 
28
29
  def self.from_api(data)
29
30
  new(**event_attrs(data))
@@ -90,29 +91,6 @@ module Teems
90
91
  def cancelled? = is_cancelled
91
92
  def recurring? = %w[occurrence exception].include?(event_type)
92
93
 
93
- def time_range_display
94
- return 'ALL DAY' if all_day?
95
- return '' unless start_time && end_time
96
-
97
- "#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
98
- end
99
-
100
- def date_display
101
- if all_day?
102
- "#{start_time&.strftime('%Y-%m-%d')} (all day)"
103
- elsif start_time && end_time
104
- "#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
105
- end
106
- end
107
-
108
- def create_summary_lines
109
- lines = []
110
- lines << date_display if date_display
111
- lines << "Location: #{location}" if location && !location.empty?
112
- lines << "Teams link: #{online_meeting_url}" if online_meeting_url
113
- lines
114
- end
115
-
116
94
  def required_attendees = attendees.select { |att| att[:type] == 'required' }
117
95
  def optional_attendees = attendees.select { |att| att[:type] == 'optional' }
118
96
  def accepted_attendees = attendees.select { |att| att[:response] == 'accepted' }
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Date and time labels for an Event (expects start_time, end_time, all_day?, location, online_meeting_url)
6
+ module EventDisplay
7
+ def time_range_display
8
+ return 'ALL DAY' if all_day?
9
+ return '' unless start_time && end_time
10
+
11
+ "#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
12
+ end
13
+
14
+ def date_display
15
+ if all_day?
16
+ "#{all_day_span} (all day)"
17
+ elsif start_time && end_time
18
+ "#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
19
+ end
20
+ end
21
+
22
+ # Graph ends all-day events at midnight after the last day, so a span is "first to last"
23
+ def all_day_span
24
+ first_day = start_time&.to_date
25
+ last_day = end_time&.to_date&.prev_day
26
+ return first_day.to_s unless first_day && last_day && last_day > first_day
27
+
28
+ "#{first_day} to #{last_day}"
29
+ end
30
+
31
+ def create_summary_lines
32
+ lines = []
33
+ lines << date_display if date_display
34
+ lines << "Location: #{location}" if location && !location.empty?
35
+ lines << "Teams link: #{online_meeting_url}" if online_meeting_url
36
+ lines
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # A calendarView event as `teems cal ics` exports it. Built from Graph data requested
6
+ # with Prefer: outlook.timezone="UTC", so timed events carry UTC times and all-day
7
+ # events carry their (floating) dates.
8
+ IcsEvent = Data.define(:uid, :subject, :starts, :ends, :all_day, :location, :organizer, :attendees,
9
+ :body, :join_url, :status) do
10
+ def self.from_api(data)
11
+ new(uid: data['iCalUId'] || data['id'], subject: data['subject'].to_s, **time_attrs(data),
12
+ location: data.dig('location', 'displayName').to_s, organizer: Event.parse_organizer(data['organizer']),
13
+ attendees: Event.parse_attendees(data['attendees']), body: data['body'] || {},
14
+ join_url: data.dig('onlineMeeting', 'joinUrl').to_s, status: status_attrs(data))
15
+ end
16
+
17
+ # Dates for all-day events (Graph sends their midnights without a zone), UTC Times otherwise
18
+ def self.time_attrs(data)
19
+ all_day = data['isAllDay'] || false
20
+ parser = all_day ? :graph_date : :graph_utc
21
+ { starts: send(parser, data['start']), ends: send(parser, data['end']), all_day: all_day }
22
+ end
23
+
24
+ def self.graph_date(value) = graph_stamp(value) { |stamp| Date.parse(stamp) }
25
+
26
+ def self.graph_utc(value) = graph_stamp(value) { |stamp| Time.parse("#{stamp.delete_suffix('Z')}Z").utc }
27
+
28
+ def self.graph_stamp(value)
29
+ stamp = Hash(value)['dateTime']
30
+ stamp && yield(stamp)
31
+ rescue ArgumentError
32
+ nil
33
+ end
34
+
35
+ def self.status_attrs(data) = { cancelled: data['isCancelled'] || false, show_as: data['showAs'].to_s }
36
+
37
+ def dated? = [starts, ends].all?
38
+
39
+ def cancelled? = status[:cancelled]
40
+
41
+ def free? = status[:show_as] == 'free'
42
+
43
+ # Plain-text body, links listed at the end
44
+ def description
45
+ content = body['content'].to_s
46
+ body['contentType'] == 'html' ? Formatters::HtmlText.convert(content).to_s : content.strip
47
+ end
48
+
49
+ # True when one of addresses organizes the event or has accepted it, so it is already
50
+ # on that address's own calendar. An invitation alone does not count.
51
+ def involves?(addresses)
52
+ return false if addresses.empty?
53
+
54
+ accepted = attendees.select { |attendee| attendee[:response] == 'accepted' }
55
+ [Hash(organizer)[:email], *accepted.map { |attendee| attendee[:email] }]
56
+ .any? { |email| addresses.include?(email.to_s.downcase) }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Teems
6
+ module Models
7
+ # Extracts inline images (screenshots pasted into a message) from Teams message HTML.
8
+ # Teams stores them in its AMS object store and references them with
9
+ # <img itemtype="http://schema.skype.com/AMSImage" src="https://<ams-host>/v1/objects/<id>/views/imgo">.
10
+ module InlineImageParsing
11
+ IMG_TAG = /<img\b[^>]*>/i
12
+ ATTRIBUTE = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
13
+ AMS_ITEMTYPE = 'schema.skype.com/AMSImage'
14
+ AMS_PATH = %r{\Ahttps://[^/\s]+/v1/objects/([^/?#\s]+)/views/}
15
+
16
+ module_function
17
+
18
+ def parse(html)
19
+ return [] unless html.is_a?(String) && html.match?(/<img/i)
20
+
21
+ html.scan(IMG_TAG).filter_map { |tag| image_from(tag_attributes(tag)) }.uniq(&:url)
22
+ end
23
+
24
+ def tag_attributes(tag)
25
+ tag.scan(ATTRIBUTE).to_h { |name, double, single| [name.downcase, CGI.unescapeHTML(double || single)] }
26
+ end
27
+
28
+ def image_from(attrs)
29
+ url = attrs['src'].to_s
30
+ return unless ams_image?(attrs, url)
31
+
32
+ InlineImage.new(id: attrs['itemid'] || url[AMS_PATH, 1], url: url, alt: attrs['alt'],
33
+ width: dimension(attrs['width']), height: dimension(attrs['height']),
34
+ format: attrs['itemscope'])
35
+ end
36
+
37
+ def ams_image?(attrs, url)
38
+ url.start_with?('https://') && (attrs['itemtype'].to_s.include?(AMS_ITEMTYPE) || url.match?(AMS_PATH))
39
+ end
40
+
41
+ def dimension(value) = Integer(value.to_s, exception: false)&.then { |num| num.positive? ? num : nil }
42
+ end
43
+
44
+ # An image pasted inline into a Teams message body
45
+ InlineImage = Data.define(:id, :url, :alt, :width, :height, :format) do
46
+ def self.parse_html(html) = InlineImageParsing.parse(html)
47
+
48
+ # Rebuilds an image from its stored (JSON) hash form
49
+ def self.from_h(hash)
50
+ return unless hash.is_a?(Hash)
51
+
52
+ attrs = hash.transform_keys(&:to_sym).slice(*members)
53
+ attrs[:url] ? new(**attrs) : nil
54
+ end
55
+
56
+ # Only the URL is required; the other attributes default to nil
57
+ def initialize(url:, **optional)
58
+ super(url: url, id: nil, alt: nil, width: nil, height: nil, format: nil, **optional)
59
+ end
60
+
61
+ # The original upload: `src` usually points at a recompressed JPEG preview,
62
+ # while this view is what Teams opens for "view full image".
63
+ def full_size_url
64
+ url.sub(%r{(/v1/objects/[^/]+/views/)[^/?#]+}, '\1imgpsh_fullsize_anim')
65
+ end
66
+
67
+ # JSON form: the stored attributes plus the original-resolution URL
68
+ def as_json = to_h.merge(full_size_url: full_size_url)
69
+
70
+ def dimensions = width && height ? "#{width}x#{height}" : nil
71
+
72
+ def label
73
+ name = alt.to_s.strip
74
+ name = 'image' if name.empty?
75
+ size = dimensions
76
+ size ? "#{name} (#{size})" : name
77
+ end
78
+
79
+ # Filesystem-safe name derived from the AMS object id
80
+ def file_stem = (id || url[%r{/v1/objects/([^/]+)/}, 1] || 'image').to_s.gsub(/[^\w.-]/, '_')
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # A conversation's messages (oldest first), Graph folder ID => path for naming the folder
6
+ # each one sits in (a thread spans the Inbox, Sent Items, Archive and so on), message ID =>
7
+ # attachment names once with_attachment_names has fetched them, and message ID => how a
8
+ # decrypted message was opened (see MailEncryption)
9
+ MailConversation = Data.define(:messages, :folders, :attachments, :encryptions) do
10
+ def initialize(messages:, folders:, attachments: {}, encryptions: {}) = super
11
+
12
+ def folder(message) = folders[message.folder_id]
13
+
14
+ def with_attachment_names(api)
15
+ with(attachments: messages.to_h { |message| [message.id, message.attachment_names_from(api)] })
16
+ end
17
+
18
+ # Marks these messages S/MIME encrypted and not opened; the envelope is not listed as a file
19
+ def with_locked(message_ids)
20
+ no_files = message_ids.to_h { |id| [id, []] }
21
+ locked = no_files.transform_values { { type: 'smime', decrypted: false } }
22
+ with(encryptions: encryptions.merge(locked), attachments: attachments.merge(no_files))
23
+ end
24
+
25
+ # opened: message ID => Services::SmimeContent::Result for each message decrypted
26
+ def with_decrypted(opened)
27
+ with(messages: messages.map { |message| opened[message.id]&.apply_to(message) || message },
28
+ attachments: attachments.merge(opened.transform_values(&:attachment_names)),
29
+ encryptions: encryptions.merge(opened.transform_values { |content| MailEncryption.decrypted(content) }))
30
+ end
31
+
32
+ # Each message's JSON (from the block) with its folder path
33
+ def json = messages.map { |message| yield(message).merge(folder: folder(message)) }
34
+
35
+ # As `read --json` gives each message, plus its folder
36
+ def full_json
37
+ json do |message|
38
+ encryption = encryption(message)
39
+ hash = message.full_json_hash(attachments: names(message))
40
+ encryption ? hash.merge(encryption: encryption) : hash
41
+ end
42
+ end
43
+
44
+ # As `read` prints each message, with its folder (and how it was protected) before the ID
45
+ def texts = messages.map { |message| message.to_text(attachments: names(message), notes: notes(message)) }
46
+
47
+ # list's tab-separated columns plus the folder path
48
+ def tsv_rows = messages.map { |message| "#{message.tsv_row}\t#{folder(message).to_s.tr("\t", ' ')}" }
49
+
50
+ private
51
+
52
+ def names(message) = attachments.fetch(message.id, [])
53
+
54
+ def encryption(message) = message.encryption_in(encryptions)
55
+
56
+ def notes(message)
57
+ protection = MailEncryption.notes(encryption(message), web_link: message.web_link)
58
+ { 'Folder' => folder(message) }.compact.merge(protection)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # How protected mail was shown, as { type: 'smime' | 'purview', decrypted:, signed: } (signed
6
+ # only once decrypted), and the header lines `read` and `thread --full` print for it. Purview
7
+ # mail gets its Outlook on the web link, where Exchange decrypts it for you.
8
+ module MailEncryption
9
+ LOCKED_HINT = 'S/MIME encrypted; read it with --decrypt (macOS asks for your PIV PIN)'
10
+
11
+ # 'purview' (rights-managed, Content-Class rpmsg.message), 'smime' (an S/MIME envelope),
12
+ # or nil, from a message's internet headers. Drafts have none, and Exchange wraps most
13
+ # S/MIME mail as TNEF (see envelope?), so nil is not proof of plain mail.
14
+ def self.from_headers(headers)
15
+ fields = Array(headers).to_h { |header| [header['name'].to_s.downcase, header['value'].to_s.downcase] }
16
+ return 'purview' if fields['content-class'].to_s.start_with?('rpmsg.message')
17
+
18
+ type, params = Support::MimeHeader.field(fields['content-type'])
19
+ 'smime' if Support::MimePart::PKCS7_TYPES.include?(type) && params['smime-type'] != 'signed-data'
20
+ end
21
+
22
+ def self.notes(encryption, web_link: nil)
23
+ return {} unless encryption
24
+
25
+ type = encryption[:type]
26
+ return { 'Encryption' => MailMessage::PROTECTION_NOTES[type], 'Open' => web_link }.compact if type == 'purview'
27
+ return { 'Encryption' => LOCKED_HINT } unless encryption[:decrypted]
28
+
29
+ { 'Encryption' => 'S/MIME, decrypted', 'Signature' => encryption[:signed] ? 'Signed (not checked)' : '' }
30
+ .reject { |_name, value| value.empty? }
31
+ end
32
+
33
+ def self.decrypted(content) = { type: 'smime', decrypted: true, signed: content.signed }
34
+
35
+ # An S/MIME envelope (smime.p7m) among a message's Graph attachments
36
+ def self.envelope?(attachment)
37
+ Support::MimePart::PKCS7_TYPES.include?(Support::MimeHeader.field(attachment['contentType']).first)
38
+ end
39
+ end
40
+ end
41
+ end