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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +77 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/sync.rb +15 -8
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/formatters/format_utils.rb +23 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/runner.rb +6 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +63 -2
- metadata +48 -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
|