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,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Options for `teems mail rules add`
6
+ module MailRuleOptions
7
+ FLAGS = { '--mark-read' => :mark_read, '--stop' => :stop, '--delete' => :delete_mail,
8
+ '--disabled' => :disabled }.freeze
9
+ VALUES = { '--from' => :from, '--sent-to' => :sent_to, '--subject' => :subject, '--body' => :body,
10
+ '--category' => :category, '--move-to' => :move_to, '--copy-to' => :copy_to,
11
+ '--importance' => :importance }.freeze
12
+ # Values that add up when repeated (each one matches, or each category is assigned)
13
+ REPEATABLE = %i[from sent_to subject body category].freeze
14
+ KEYS = (FLAGS.values + VALUES.values).freeze
15
+ end
16
+
17
+ # Turns `rules add` options into a Graph messageRule. Only actions that keep mail in the
18
+ # mailbox exist here: a rule teems builds never forwards or redirects.
19
+ class MailRuleBuilder
20
+ CONDITIONS = { from: 'fromAddresses', sent_to: 'sentToAddresses', subject: 'subjectContains',
21
+ body: 'bodyContains' }.freeze
22
+ ADDRESSES = %i[from sent_to].freeze
23
+ IMPORTANCE = %w[low normal high].freeze
24
+
25
+ def initialize(options, folders)
26
+ @options = options
27
+ @folders = folders
28
+ end
29
+
30
+ # String keys, as Graph returns rules, so a preview reads like a listed rule
31
+ def rule(name, sequence:)
32
+ conditions = self.conditions
33
+ actions = self.actions
34
+ raise Error, 'A rule needs a condition: --from, --sent-to, --subject or --body' if conditions.empty?
35
+ raise Error, 'A rule needs an action: --move-to, --copy-to, --mark-read, --category, --importance or --delete' \
36
+ if actions.empty?
37
+
38
+ { 'displayName' => name, 'sequence' => sequence, 'isEnabled' => !@options[:disabled],
39
+ 'conditions' => conditions, 'actions' => actions }
40
+ end
41
+
42
+ def conditions
43
+ CONDITIONS.filter_map { |option, key| (values = @options[option]) && [key, condition(option, values)] }.to_h
44
+ end
45
+
46
+ def actions
47
+ raise Error, 'Use --move-to or --delete, not both' if @options[:move_to] && @options[:delete_mail]
48
+
49
+ { 'moveToFolder' => folder_id(:move_to), 'copyToFolder' => folder_id(:copy_to),
50
+ 'markAsRead' => switch(:mark_read), 'assignCategories' => @options[:category],
51
+ 'markImportance' => importance, 'delete' => switch(:delete_mail),
52
+ 'stopProcessingRules' => switch(:stop) }.compact
53
+ end
54
+
55
+ private
56
+
57
+ def condition(option, values)
58
+ return values unless ADDRESSES.include?(option)
59
+
60
+ values.map { |address| { 'emailAddress' => { 'address' => address } } }
61
+ end
62
+
63
+ # Rules need the folder's real ID, not a well-known name
64
+ def folder_id(option)
65
+ name = @options[option]
66
+ name && @folders.resolve(name).id
67
+ end
68
+
69
+ def switch(option) = @options[option] || nil
70
+
71
+ def importance
72
+ level = @options[:importance] or return
73
+ level = level.downcase
74
+ return level if IMPORTANCE.include?(level)
75
+
76
+ raise Error, "--importance must be #{IMPORTANCE.join(', ')}"
77
+ end
78
+ end
79
+
80
+ # `teems mail rules`: list, add, enable, disable, and delete Inbox rules
81
+ module MailRules
82
+ RULE_ACTIONS = { 'list' => :list_rules, 'add' => :add_rule, 'enable' => :enable_rule,
83
+ 'disable' => :disable_rule, 'delete' => :delete_rule }.freeze
84
+
85
+ private
86
+
87
+ def mail_rules
88
+ verb = positional_args[1] || 'list'
89
+ handler = RULE_ACTIONS[verb]
90
+ return error("Usage: teems mail rules <#{RULE_ACTIONS.keys.join('|')}> (teems mail --help)") unless handler
91
+
92
+ send(handler)
93
+ end
94
+
95
+ def misplaced_rule_options?
96
+ positional_args.first(2) != %w[rules add] && MailRuleOptions::KEYS.any? { |key| @options.key?(key) }
97
+ end
98
+
99
+ def list_rules
100
+ rules = fetch_rules
101
+ formatter = rule_formatter(rules)
102
+ return json_result(formatter.json(rules)) if @options[:json]
103
+ return print_lines(formatter.tsv_rows(rules)) if @options[:tsv]
104
+
105
+ print_lines(rules.empty? ? [output.gray('No Inbox rules')] : formatter.rows(rules))
106
+ end
107
+
108
+ def add_rule
109
+ name = Support::ControlChars.strip(positional_args[2].to_s).strip
110
+ return error('Usage: teems mail rules add <name> <condition>... <action>...') if name.empty?
111
+
112
+ body = new_rule(name)
113
+ @options[:dry_run] ? rule_preview(body) : create_rule(body)
114
+ end
115
+
116
+ # Last in order, under a name no other rule has (so the name can pick it out later)
117
+ def new_rule(name)
118
+ rules = fetch_rules
119
+ raise Error, "A rule named #{name} already exists" if rules.any? { |rule| rule.name.casecmp?(name) }
120
+
121
+ MailRuleBuilder.new(@options, mail_folders).rule(name, sequence: rules.map(&:sequence).max.to_i + 1)
122
+ end
123
+
124
+ def rule_preview(body)
125
+ return json_result(body) if @options[:json]
126
+
127
+ rule = Models::MailRule.from_api(body)
128
+ print_lines(["#{rule.name}: #{rule_formatter([rule]).describe(rule)}",
129
+ output.gray('Would add this rule (dry run, nothing changed)')])
130
+ end
131
+
132
+ def create_rule(body)
133
+ rule = Models::MailRule.from_api(rules_api.create(body))
134
+ formatter = rule_formatter([rule])
135
+ return json_result(formatter.json([rule]).first) if @options[:json]
136
+
137
+ done("Added rule #{rule.name}: #{formatter.describe(rule)}")
138
+ end
139
+
140
+ def enable_rule = switch_rule(true)
141
+ def disable_rule = switch_rule(false)
142
+
143
+ def switch_rule(enabled)
144
+ rule = find_rule
145
+ name = rule.name
146
+ state = enabled ? 'enabled' : 'disabled'
147
+ return noted("Rule #{name} is already #{state}") if rule.enabled == enabled
148
+ raise Error, "Rule #{name} forwards or redirects mail; teems will not enable it" if enabled && rule.forwards?
149
+
150
+ rules_api.update(rule.id, { isEnabled: enabled })
151
+ done("Rule #{name} #{state}")
152
+ end
153
+
154
+ def delete_rule
155
+ rule = find_rule
156
+ name = rule.name
157
+ return noted("Would delete rule #{name} (dry run, nothing changed)") if @options[:dry_run]
158
+ return error('Cancelled; nothing changed') unless rule_delete_confirmed?(name)
159
+
160
+ rules_api.destroy(rule.id)
161
+ done("Deleted rule #{name}")
162
+ end
163
+
164
+ # Kept for the retry after a token refresh, so the question is not asked twice
165
+ def rule_delete_confirmed?(name)
166
+ @rule_delete_confirmed ||= @options[:yes] ||
167
+ confirm?("Delete rule #{name}",
168
+ refusal: "Deleting rule #{name} needs --yes without a terminal")
169
+ end
170
+
171
+ # By ID, then name (ignoring case), then the number `rules` lists
172
+ def find_rule
173
+ ref = positional_args[2].to_s.strip
174
+ raise Error, "Usage: teems mail rules #{positional_args[1]} <name|number|id>" if ref.empty?
175
+
176
+ matches = rule_matches(fetch_rules, ref)
177
+ return matches.first if matches.one?
178
+ raise Error, "No Inbox rule matches #{ref} (see teems mail rules)" if matches.empty?
179
+
180
+ raise Error, "#{matches.length} rules are named #{ref}; use the ID (teems mail rules --tsv)"
181
+ end
182
+
183
+ def rule_matches(rules, ref)
184
+ [->(rule) { rule.id == ref }, ->(rule) { rule.name.casecmp?(ref) }, ->(rule) { rule.sequence.to_s == ref }]
185
+ .lazy.map { |test| rules.select(&test) }.find(&:any?) || []
186
+ end
187
+
188
+ def fetch_rules = rules_api.list.map { |data| Models::MailRule.from_api(data) }.sort_by(&:sequence)
189
+
190
+ # Folder paths only when a rule moves or copies mail (the folder tree costs requests)
191
+ def rule_formatter(rules)
192
+ names = rules.flat_map(&:folder_ids).empty? ? {} : mail_folders.paths_by_id
193
+ Formatters::MailRuleFormatter.new(output: output, folder_names: names)
194
+ end
195
+
196
+ def done(message) = 0.tap { success(message) }
197
+ def noted(message) = 0.tap { info(message) }
198
+ def rules_api = runner.mail_rules_api
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # `teems mail sync`: incremental local copy of mail folders via Graph delta queries
6
+ module MailSyncing
7
+ DEFAULT_SINCE_DAYS = 90
8
+
9
+ private
10
+
11
+ def sync_mail
12
+ since = sync_since
13
+ return error('--since must be a number of days or "all"') if since == :invalid
14
+
15
+ store = Services::MailSyncStore.new(**sync_store_args)
16
+ result = with_private_umask { store.with_lock { sync_folders(store, since) } }
17
+ return result unless result == :locked
18
+
19
+ info('Another teems mail sync is running; skipping')
20
+ 0
21
+ end
22
+
23
+ def sync_store_args
24
+ dir = @options[:dir]
25
+ dir ? { root: dir } : {}
26
+ end
27
+
28
+ # Time limiting a folder's first sync, nil for everything, or :invalid
29
+ def sync_since
30
+ value = @options.fetch(:since, DEFAULT_SINCE_DAYS.to_s).to_s.strip.downcase
31
+ return nil if value == 'all'
32
+
33
+ days = Integer(value, exception: false)
34
+ days&.positive? ? Time.now - (days * 86_400) : :invalid
35
+ end
36
+
37
+ def sync_folder_names
38
+ names = [*positional_args.drop(1), *@options[:folders]]
39
+ names.empty? ? ['Inbox'] : names.uniq
40
+ end
41
+
42
+ # A folder that fails is reported and skipped so the others still sync; the exit
43
+ # status is 1 when any failed
44
+ def sync_folders(store, since)
45
+ engine = sync_engine(store, since)
46
+ names = sync_folder_names
47
+ results = names.filter_map { |name| sync_one(engine, store, name) }.to_h
48
+ output_json(results) if @options[:json]
49
+ results.size == names.size ? 0 : 1
50
+ end
51
+
52
+ def sync_engine(store, since)
53
+ options = { since: since, attachments: @options[:attachments], on_warning: ->(message) { warn(message) } }
54
+ Services::MailSyncEngine.new(api: mail_api, store: store, options: options)
55
+ end
56
+
57
+ # [path, stats], or nil when the folder failed. Expired tokens are raised for
58
+ # with_token_refresh; a folder interrupted by anything else, local file errors
59
+ # included, resumes on the next run.
60
+ def sync_one(engine, store, name)
61
+ folder = mail_folders.resolve(name)
62
+ path = folder.path
63
+ sync_info("Syncing #{path} -> #{store.folder_dir(path)}")
64
+ stats = engine.sync(folder)
65
+ sync_info(" #{sync_summary(stats)}")
66
+ [path, stats]
67
+ rescue Teems::Error, SystemCallError => e
68
+ raise if e.is_a?(ApiError) && token_expired?(e)
69
+
70
+ warn("#{name}: #{e.message}")
71
+ nil
72
+ end
73
+
74
+ # Progress lines go to stdout, so --json leaves them out to keep its output parseable
75
+ def sync_info(message) = @options[:json] || info(message)
76
+
77
+ def sync_summary(stats)
78
+ created, updated, unchanged, adopted, removed = stats.values_at(*Services::MailSyncFolder::STAT_KEYS)
79
+ "#{created} new, #{updated} updated, #{unchanged} unchanged, " \
80
+ "#{adopted} existing files adopted, #{removed} removed from folder"
81
+ end
82
+
83
+ # Synced mail is private: new files and directories are owner-only
84
+ def with_private_umask
85
+ previous = File.umask(0o077)
86
+ yield
87
+ ensure
88
+ File.umask(previous)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # `teems mail thread <id>`: every message in a message's conversation, from any folder,
6
+ # oldest first. --full prints each one as `read` would, and --decrypt opens the S/MIME
7
+ # ones with the PIV card on the way (see MailDecrypting).
8
+ module MailThread
9
+ DIVIDER = '-' * 72
10
+
11
+ private
12
+
13
+ def show_thread
14
+ messages = thread_messages(resolve_message_id(thread_token))
15
+ id_cache.remember(messages)
16
+ conversation = Models::MailConversation.new(messages: messages, folders: mail_folders.paths_by_id)
17
+ @options[:full] ? show_full_thread(conversation) : show_thread_list(conversation)
18
+ end
19
+
20
+ def thread_token
21
+ token = positional_args[1] or raise Error, 'Usage: teems mail thread <id> [--full [--decrypt]]'
22
+ raise Error, '--decrypt works with --full' if @options[:decrypt] && !@options[:full]
23
+
24
+ token
25
+ end
26
+
27
+ def thread_messages(message_id)
28
+ fields = @options[:full] ? Api::Mail::MESSAGE_FIELDS : Api::Mail::LIST_FIELDS
29
+ mail_api.conversation(conversation_of(message_id), fields: fields)
30
+ end
31
+
32
+ def conversation_of(message_id)
33
+ mail_api.conversation_id(message_id) or raise Error, 'That message belongs to no conversation'
34
+ end
35
+
36
+ def show_thread_list(conversation)
37
+ return json_result(conversation.json(&:to_json_hash)) if @options[:json]
38
+ return print_lines(conversation.tsv_rows) if @options[:tsv]
39
+
40
+ print_lines(mail_formatter.thread_rows(conversation))
41
+ end
42
+
43
+ def show_full_thread(conversation)
44
+ full = full_conversation(conversation)
45
+ return json_result(full.full_json) if @options[:json]
46
+
47
+ print_lines([full.texts.join("\n\n#{output.gray(DIVIDER)}\n\n")])
48
+ end
49
+
50
+ # With attachment names, and encrypted messages marked (or opened, with --decrypt)
51
+ def full_conversation(conversation)
52
+ envelopes = envelopes_in(conversation.messages)
53
+ full = conversation.with_attachment_names(mail_api).with_locked(envelopes.keys)
54
+ @options[:decrypt] ? full.with_decrypted(open_envelopes(envelopes)) : full
55
+ end
56
+
57
+ # Message ID => S/MIME envelope (an smime.p7m attachment) for each encrypted message, found
58
+ # as `read` finds it: Graph hands encrypted mail over with no body
59
+ def envelopes_in(messages)
60
+ messages.select(&:envelope_candidate?).each_with_object({}) do |message, found|
61
+ id = message.id
62
+ envelope = envelope_attachment(id)
63
+ found[id] = envelope if envelope
64
+ end
65
+ end
66
+
67
+ # Message ID => decrypted content for each envelope that opens. One that will not (no
68
+ # matching key) stays locked with a warning, and the rest carry on.
69
+ def open_envelopes(envelopes)
70
+ warn('No S/MIME encrypted messages in this thread') if envelopes.empty?
71
+ envelopes.each_with_object({}) do |(id, envelope), opened|
72
+ content = open_envelope(id, envelope)
73
+ opened[id] = content if content
74
+ end
75
+ end
76
+
77
+ def open_envelope(message_id, envelope)
78
+ decrypt_content(message_id, envelope)
79
+ rescue ApiError
80
+ raise
81
+ rescue Error => e
82
+ warn("#{Models::MailMessage.short_id(message_id)}: #{e.message}")
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Due dates for `teems mail flag --due`: YYYY-MM-DD, today, tomorrow, or a weekday name
6
+ # (the next one, today included; three letters are enough)
7
+ module MailDueDate
8
+ WEEKDAYS = %w[sunday monday tuesday wednesday thursday friday saturday].freeze
9
+
10
+ def self.parse(text, today: Date.today)
11
+ word = text.to_s.strip.downcase
12
+ relative(word, today) || weekday(word, today) || Date.iso8601(word)
13
+ rescue Date::Error
14
+ nil
15
+ end
16
+
17
+ def self.relative(word, today) = { 'today' => today, 'tomorrow' => today + 1 }[word]
18
+
19
+ def self.weekday(word, today)
20
+ day = WEEKDAYS.index { |name| name.start_with?(word) } if word.length >= 3
21
+ day && (today + ((day - today.wday) % 7))
22
+ end
23
+ end
24
+
25
+ # Messages for `teems mail` triage: named by ID, or selected with --search or --folder
26
+ module MailSelection
27
+ # Graph's $search returns at most 1,000 messages; folder selections stop there too
28
+ BULK_MAX = 1000
29
+ # Where --thread leaves the rest of a conversation alone: your own messages, and ones already put away
30
+ THREAD_SKIPS = %w[sentitems drafts deleteditems junkemail].freeze
31
+
32
+ # Message IDs to act on; a selection (not IDs given by hand) also has the messages
33
+ Targets = Data.define(:ids, :messages, :selected) do
34
+ def self.named(ids) = new(ids: ids, messages: [], selected: false)
35
+ def self.selected(messages) = new(ids: messages.map(&:id), messages: messages, selected: true)
36
+
37
+ def count = ids.length
38
+ def empty? = ids.empty?
39
+ def short_ids = ids.map { |id| Models::MailMessage.short_id(id) }
40
+ def to_json_hash = selected ? { messages: messages.map(&:to_json_hash) } : { ids: ids }
41
+ end
42
+
43
+ private
44
+
45
+ def triage_targets
46
+ ids = positional_args.drop(1)
47
+ check_selection(ids)
48
+ return selected_targets if selecting?
49
+ raise Error, "Usage: teems mail #{triage_action} <id>... (or --search QUERY, --folder NAME)" if ids.empty?
50
+
51
+ named = ids.map { |token| resolve_message_id(token) }
52
+ @options[:thread] ? thread_targets(named) : Targets.named(named)
53
+ end
54
+
55
+ def selecting? = @options[:search] || @options[:folders]
56
+
57
+ def check_selection(ids)
58
+ check_thread
59
+ raise Error, 'Give message IDs or --search/--folder, not both' if selecting? && ids.any?
60
+ return if only_filters.empty? || (@options[:folders] && !@options[:search])
61
+
62
+ raise Error, '--unread and --flagged select with --folder alone'
63
+ end
64
+
65
+ def selected_targets
66
+ messages = @options[:search] ? search_selection : folder_selection
67
+ id_cache.remember(messages)
68
+ Targets.selected(messages)
69
+ end
70
+
71
+ def check_thread
72
+ raise Error, '--thread works with message IDs, not --search or --folder' if @options[:thread] && selecting?
73
+ end
74
+
75
+ # The named messages and the rest of their conversations, except what sits in THREAD_SKIPS
76
+ def thread_targets(named)
77
+ skipped = thread_skipped_folders
78
+ kept = mail_api.conversations_of(named).select do |message|
79
+ named.include?(message.id) || !skipped.include?(message.folder_id)
80
+ end
81
+ id_cache.remember(kept)
82
+ Targets.selected(kept)
83
+ end
84
+
85
+ def thread_skipped_folders
86
+ folders = mail_folders
87
+ THREAD_SKIPS.map { |name| folders.resolve(name).id }
88
+ end
89
+
90
+ def search_selection
91
+ folder = @options[:folders]&.last
92
+ mail_api.search(query: @options[:search], count: bulk_limit, folder: folder && mail_folders.resolve_id(folder))
93
+ end
94
+
95
+ def folder_selection
96
+ folder = mail_folders.resolve_id(@options[:folders].last)
97
+ mail_api.messages(folder: folder, count: bulk_limit, only: only_filters)
98
+ end
99
+
100
+ def bulk_limit
101
+ limit = @options[:limit] || BULK_MAX
102
+ raise Error, "-n must be between 1 and #{BULK_MAX}" unless limit.between?(1, BULK_MAX)
103
+
104
+ limit
105
+ end
106
+
107
+ def resolve_message_id(token)
108
+ id_cache.resolve(token) or
109
+ raise Error, "Unknown message ID: #{token} (run teems mail list, or pass the full ID)"
110
+ end
111
+ end
112
+
113
+ # mark-read, mark-unread, flag, unflag, archive, move, and delete for `teems mail`. A
114
+ # selection is confirmed first (or --yes), and --dry-run lists what would change without
115
+ # changing it.
116
+ module MailTriage
117
+ include MailSelection
118
+
119
+ # What to do, as [phrase for --dry-run and the prompt, phrase once done]
120
+ ACTIONS = {
121
+ 'mark-read' => ['mark %<count>s read', 'Marked %<count>s read'],
122
+ 'mark-unread' => ['mark %<count>s unread', 'Marked %<count>s unread'],
123
+ 'flag' => ['flag %<count>s', 'Flagged %<count>s'],
124
+ 'unflag' => ['unflag %<count>s', 'Unflagged %<count>s'],
125
+ 'archive' => ['archive %<count>s', 'Archived %<count>s'],
126
+ 'move' => ['move %<count>s to %<folder>s', 'Moved %<count>s to %<folder>s'],
127
+ 'delete' => ['move %<count>s to Deleted Items', 'Moved %<count>s to Deleted Items']
128
+ }.freeze
129
+ # The change each action makes, as [:update, properties] or [:move, destination]
130
+ CHANGES = {
131
+ 'mark-read' => [:update, { isRead: true }], 'mark-unread' => [:update, { isRead: false }],
132
+ 'unflag' => [:update, { flag: { flagStatus: 'notFlagged' } }],
133
+ 'archive' => %i[move archive], 'delete' => %i[move deleteditems]
134
+ }.freeze
135
+
136
+ private
137
+
138
+ def triage
139
+ change = triage_change
140
+ targets = triage_targets
141
+ return triage_preview(targets) if @options[:dry_run]
142
+ return 0.tap { info('No messages matched') } if targets.empty?
143
+ return error('Cancelled; nothing changed') unless confirmed?(targets)
144
+
145
+ triage_report(apply_change(change, targets.ids), targets)
146
+ end
147
+
148
+ def triage_action = positional_args.first
149
+
150
+ # [:update, properties] or [:move, destination folder]
151
+ def triage_change
152
+ action = triage_action
153
+ raise Error, '--due works with flag only' if @options[:due] && action != 'flag'
154
+ raise Error, '--to works with move only' if @options[:to] && action != 'move'
155
+
156
+ return [:update, flag_value] if action == 'flag'
157
+
158
+ CHANGES.fetch(action) { [:move, move_destination] }
159
+ end
160
+
161
+ def move_destination
162
+ name = @options[:to] or raise Error, 'Usage: teems mail move <id>... --to <folder>'
163
+ mail_folders.resolve_id(name)
164
+ end
165
+
166
+ def flag_value
167
+ due_text = @options[:due]
168
+ return { flag: { flagStatus: 'flagged' } } unless due_text
169
+
170
+ due = MailDueDate.parse(due_text) or
171
+ raise Error, "Unknown due date: #{due_text} (use YYYY-MM-DD, today, tomorrow, or a weekday)"
172
+ { flag: { flagStatus: 'flagged', startDateTime: graph_date([Date.today, due].min),
173
+ dueDateTime: graph_date(due) } }
174
+ end
175
+
176
+ def graph_date(date) = { dateTime: "#{date.iso8601}T00:00:00", timeZone: detect_timezone }
177
+
178
+ # A selection needs --yes, or a "y" at the prompt; messages named by ID go ahead
179
+ def confirmed?(targets)
180
+ return true if @confirmed || !targets.selected || @options[:yes]
181
+
182
+ @confirmed = answered_yes?(targets.count)
183
+ end
184
+
185
+ def answered_yes?(count)
186
+ confirm?(triage_phrase(0, count).sub(/\A./, &:upcase),
187
+ refusal: "#{count_text(count)} selected; pass --yes to go ahead (or --dry-run to list them)")
188
+ end
189
+
190
+ # { done: [ids], failed: { id => message } }. An expired token stops the run, so the retry
191
+ # after a refresh starts over (every action is safe to repeat)
192
+ def apply_change(change, ids)
193
+ kind, value = change
194
+ actions = runner.mail_actions
195
+ ids.each_with_object({ done: [], failed: {} }) do |id, result|
196
+ actions.public_send(kind, id, value)
197
+ result[:done] << id
198
+ rescue ApiError => e
199
+ raise if token_expired?(e)
200
+
201
+ result[:failed][id] = e.message
202
+ end
203
+ end
204
+
205
+ def triage_preview(targets)
206
+ return json_result({ action: triage_action, dry_run: true, **targets.to_json_hash }) if @options[:json]
207
+
208
+ rows = targets.selected ? mail_formatter.message_rows(targets.messages) : targets.short_ids
209
+ print_lines(rows)
210
+ info("Would #{triage_phrase(0, targets.count)} (dry run, nothing changed)")
211
+ 0
212
+ end
213
+
214
+ def triage_report(result, targets)
215
+ done, failed = result.values_at(:done, :failed)
216
+ return triage_json(done, failed) if @options[:json]
217
+
218
+ success(triage_phrase(1, done.length)) if done.any?
219
+ failed.each { |id, message| warn("#{Models::MailMessage.short_id(id)}: #{message}") }
220
+ note_cap(targets)
221
+ failed.empty? ? 0 : error("#{count_text(failed.length)} failed")
222
+ end
223
+
224
+ def triage_json(done, failed)
225
+ output_json(action: triage_action, done: done, failed: failed.map { |id, message| { id: id, error: message } })
226
+ failed.empty? ? 0 : 1
227
+ end
228
+
229
+ def note_cap(targets)
230
+ limit = targets.selected && bulk_limit
231
+ info("Stopped at #{limit} messages; run again (or raise -n) for the rest") if limit && targets.count >= limit
232
+ end
233
+
234
+ def triage_phrase(index, count)
235
+ format(ACTIONS.fetch(triage_action)[index], count: count_text(count), folder: @options[:to])
236
+ end
237
+
238
+ def count_text(count) = count == 1 ? '1 message' : "#{count} messages"
239
+ end
240
+ end
241
+ end