mailmate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast"
4
+ require "time"
5
+
6
+ # Recursive-descent parser for MailMate filter expressions.
7
+ #
8
+ # Grammar (informal, top-down):
9
+ # filter = expr eof
10
+ # expr = or_expr
11
+ # or_expr = and_expr ('or' and_expr)*
12
+ # and_expr = not_expr (('and' | <implicit>) not_expr)* # implicit AND
13
+ # not_expr = ['not'] term
14
+ # term = '(' expr ')'
15
+ # | clause
16
+ # clause = path 'exists'
17
+ # | path op value
18
+ # path = (ident | shorthand) ('.' ident)*
19
+ # op = OP_TOKEN (carries flags from lexer)
20
+ # value = string | number | rel_date | abs_date | varref
21
+ # rel_date = number unit 'ago' # unit: day(s), week(s), month(s), year(s)
22
+ # abs_date = string # parsed as Time.parse if value contains a date pattern
23
+ # varref = '$' VAR ('.' (ident|shorthand))*
24
+
25
+ module Mailmate
26
+ # @api private
27
+ #
28
+ # Filter-language parser. Use `Mailmate.compile_filter` as the public
29
+ # surface.
30
+ class Parser
31
+ class Error < StandardError; end
32
+
33
+ UNITS = {
34
+ "day" => :day, "days" => :day,
35
+ "week" => :week, "weeks" => :week,
36
+ "month" => :month, "months" => :month,
37
+ "year" => :year, "years" => :year,
38
+ }.freeze
39
+
40
+ DATE_LIKE = /\A\s*\d{4}[-\/.]\d{1,2}([-\/.]\d{1,2}|.*\d{2}:\d{2})/.freeze
41
+
42
+ def self.parse(tokens)
43
+ new(tokens).parse_filter
44
+ end
45
+
46
+ def initialize(tokens)
47
+ @tokens = tokens
48
+ @i = 0
49
+ end
50
+
51
+ def parse_filter
52
+ expr = parse_expr
53
+ expect(:eof)
54
+ expr
55
+ end
56
+
57
+ private
58
+
59
+ # ---------- token utilities ----------
60
+
61
+ def peek(off = 0); @tokens[@i + off]; end
62
+ def consume; t = @tokens[@i]; @i += 1; t; end
63
+
64
+ def at?(kind, value = nil)
65
+ t = peek
66
+ return false unless t && t[0] == kind
67
+ value.nil? || t[1] == value
68
+ end
69
+
70
+ def expect(kind, value = nil)
71
+ t = peek
72
+ ok = t && t[0] == kind && (value.nil? || t[1] == value)
73
+ raise Error, "expected #{kind}#{value ? " #{value.inspect}" : ""} at #{@i}, got #{t.inspect}" unless ok
74
+ consume
75
+ end
76
+
77
+ # ---------- grammar ----------
78
+
79
+ def parse_expr
80
+ parse_or
81
+ end
82
+
83
+ def parse_or
84
+ left = parse_and
85
+ while at?(:keyword, "or")
86
+ consume
87
+ right = parse_and
88
+ if left.is_a?(AST::OrNode)
89
+ left.children << right
90
+ else
91
+ left = AST::OrNode.new([left, right])
92
+ end
93
+ end
94
+ left
95
+ end
96
+
97
+ def parse_and
98
+ left = parse_not
99
+ loop do
100
+ if at?(:keyword, "and")
101
+ consume
102
+ right = parse_not
103
+ elsif implicit_and_continues?
104
+ right = parse_not
105
+ else
106
+ break
107
+ end
108
+ if left.is_a?(AST::AndNode)
109
+ left.children << right
110
+ else
111
+ left = AST::AndNode.new([left, right])
112
+ end
113
+ end
114
+ left
115
+ end
116
+
117
+ # Implicit AND only continues if the next token can start a clause/term and
118
+ # we're at the top level. This matches MailMate's "implicit and" between
119
+ # consecutive clauses without an explicit connector.
120
+ def implicit_and_continues?
121
+ t = peek
122
+ return false unless t
123
+ kind = t[0]
124
+ return true if kind == :lparen || kind == :ident || kind == :shorthand
125
+ return true if kind == :keyword && t[1] == "not"
126
+ false
127
+ end
128
+
129
+ def parse_not
130
+ if at?(:keyword, "not")
131
+ consume
132
+ AST::NotNode.new(parse_term)
133
+ else
134
+ parse_term
135
+ end
136
+ end
137
+
138
+ def parse_term
139
+ if at?(:lparen)
140
+ consume
141
+ e = parse_expr
142
+ expect(:rparen)
143
+ return e
144
+ end
145
+ parse_clause
146
+ end
147
+
148
+ def parse_clause
149
+ path = parse_path
150
+
151
+ if at?(:keyword, "exists")
152
+ consume
153
+ return AST::ExistsNode.new(path)
154
+ end
155
+
156
+ raise Error, "expected operator after path at #{@i}, got #{peek.inspect}" unless at?(:op)
157
+ _, op, flags = consume
158
+
159
+ value = parse_value
160
+ AST::CompareNode.new(path, op, flags, value)
161
+ end
162
+
163
+ def parse_path
164
+ parts = []
165
+ t = peek
166
+ raise Error, "expected ident or shorthand at #{@i}, got #{t.inspect}" unless t && (t[0] == :ident || t[0] == :shorthand)
167
+ parts << consume[1]
168
+ while at?(:dot)
169
+ consume
170
+ nt = peek
171
+ raise Error, "expected ident or shorthand after '.' at #{@i}, got #{nt.inspect}" unless nt && (nt[0] == :ident || nt[0] == :shorthand)
172
+ parts << consume[1]
173
+ end
174
+ parts
175
+ end
176
+
177
+ def parse_value
178
+ t = peek
179
+ case t[0]
180
+ when :var
181
+ var = consume[1]
182
+ path = []
183
+ while at?(:dot)
184
+ consume
185
+ nt = peek
186
+ raise Error, "expected ident or shorthand after '.' in $var path" unless nt && (nt[0] == :ident || nt[0] == :shorthand)
187
+ path << consume[1]
188
+ end
189
+ AST::VarRefNode.new(var, path)
190
+ when :number
191
+ n = consume[1]
192
+ # Possibly a relative date: NUMBER UNIT 'ago'
193
+ if at?(:ident) && UNITS.key?(peek[1])
194
+ unit_word = consume[1]
195
+ if at?(:ident, "ago")
196
+ consume
197
+ return AST::RelativeDateNode.new(n, UNITS[unit_word])
198
+ else
199
+ raise Error, "expected 'ago' after number unit, got #{peek.inspect}"
200
+ end
201
+ end
202
+ AST::NumberNode.new(n)
203
+ when :string
204
+ s = consume[1]
205
+ if s =~ DATE_LIKE
206
+ begin
207
+ return AST::AbsoluteDateNode.new(Time.parse(s))
208
+ rescue ArgumentError
209
+ # fall through to literal string
210
+ end
211
+ end
212
+ AST::LiteralStringNode.new(s)
213
+ else
214
+ raise Error, "expected value at #{@i}, got #{t.inspect}"
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ # @api public
5
+ #
6
+ # Raised when a subsystem that needs macOS-specific bits (AppleScript,
7
+ # MailMate's on-disk layout, the `open` and `osascript` commands) is invoked
8
+ # on a non-macOS host. Library-only code paths (parser, evaluator over
9
+ # synthetic fixtures) keep working anywhere; this error only surfaces at
10
+ # actual integration points.
11
+ class PlatformError < StandardError
12
+ def self.check_darwin!(component:)
13
+ return if RUBY_PLATFORM.include?("darwin")
14
+ raise new(
15
+ "#{component} requires macOS (RUBY_PLATFORM=#{RUBY_PLATFORM}). " \
16
+ "The mailmate gem's library code (filter parser, evaluator) works " \
17
+ "on any platform, but integration with MailMate itself is macOS-only.",
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves a mailbox UUID (or smart-mailbox `set`) to a list of `Messages/`
4
+ # directories on disk. Walks the `set` chain to handle nested smart mailboxes:
5
+ # the chain ends at a special UUID (ALL_MESSAGES, INBOX, SENT, etc.) which
6
+ # maps to actual on-disk paths.
7
+ #
8
+ # Also returns an accumulated list of filters from any smart mailboxes
9
+ # encountered along the way — these AND with the leaf mailbox's filter.
10
+
11
+ module Mailmate
12
+ # @api public
13
+ class SourceResolver
14
+ # Standard-mailbox name patterns. Each special UUID maps to one or more
15
+ # `*.mailbox` directories per account. Gmail nests system folders under
16
+ # `[Gmail]/...`; iCloud is flat with different names.
17
+ SPECIAL_DIRS = {
18
+ "INBOX" => ["INBOX.mailbox"],
19
+ "DRAFTS" => ["[Gmail].mailbox/Drafts.mailbox", "Drafts.mailbox"],
20
+ "SENT" => ["[Gmail].mailbox/Sent Mail.mailbox", "Sent Messages.mailbox"],
21
+ "ARCHIVE" => ["[Gmail].mailbox/Archive.mailbox", "Archive.mailbox"],
22
+ "JUNK" => ["[Gmail].mailbox/Spam.mailbox", "Junk.mailbox"],
23
+ "TRASH" => ["[Gmail].mailbox/Trash.mailbox", "Deleted Messages.mailbox"],
24
+ }.freeze
25
+
26
+ # ALL_MESSAGES: union of INBOX/SENT/DRAFTS/ARCHIVE plus any custom labels.
27
+ # Excludes Trash/Junk (per MailMate help: "All Messages" is everything except
28
+ # deleted/junk).
29
+ ALL_MESSAGES_EXCLUDES = %r{/(?:Trash|Junk|Spam|Deleted Messages)\.mailbox/Messages/?\z}.freeze
30
+
31
+ def initialize(graph)
32
+ @graph = graph
33
+ end
34
+
35
+ # Resolve a mailbox spec to {dirs:, filters:}.
36
+ # `spec` may be a UUID, a name, or a special UUID literal.
37
+ # Returns:
38
+ # :dirs => Array<String> of absolute paths to `Messages/` directories
39
+ # :filters => Array<String> of filter expressions to AND together
40
+ def resolve(spec)
41
+ filters = []
42
+ uuid = spec
43
+ visited = []
44
+ loop do
45
+ raise ArgumentError, "Cycle in mailbox resolution: #{visited.inspect}" if visited.include?(uuid)
46
+ visited << uuid
47
+
48
+ m = @graph.by_uuid[uuid] || @graph.by_uuid[@graph.by_name[uuid]]
49
+
50
+ # Collect this node's filter (if it has one). The special UUIDs
51
+ # FLAGGED / SENT / etc. can themselves be smart mailboxes (e.g.
52
+ # Brian's "Flagged" with filter `#flags.flag = '\Flagged'`); the
53
+ # filter has to be picked up before we resolve the special to dirs.
54
+ filters << m[:filter] if m && m[:filter]
55
+
56
+ next_uuid = m && m[:set]
57
+
58
+ # If `set` points elsewhere, walk to it (handles nested smart mailboxes).
59
+ if next_uuid && next_uuid != uuid
60
+ uuid = next_uuid
61
+ next
62
+ end
63
+
64
+ # Terminal — resolve to on-disk dirs.
65
+ if MailboxGraph::SPECIAL_UUIDS.include?(uuid)
66
+ return { dirs: special_dirs(uuid), filters: filters }
67
+ end
68
+
69
+ raise ArgumentError, "Mailbox #{spec.inspect} can't be resolved to disk paths " \
70
+ "(uuid=#{uuid}, name=#{m && m[:name].inspect})"
71
+ end
72
+ end
73
+
74
+ def special_dirs(uuid)
75
+ case uuid
76
+ when "ALL_MESSAGES"
77
+ all_message_dirs
78
+ when "INBOX", "DRAFTS", "SENT", "ARCHIVE", "JUNK", "TRASH"
79
+ per_account_dirs(SPECIAL_DIRS[uuid])
80
+ else
81
+ # Unknown special — return empty list and let the caller fail loudly.
82
+ []
83
+ end
84
+ end
85
+
86
+ def all_message_dirs
87
+ Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages")
88
+ .select { |p| File.directory?(p) }
89
+ .reject { |p| p =~ ALL_MESSAGES_EXCLUDES }
90
+ end
91
+
92
+ def per_account_dirs(suffixes)
93
+ result = []
94
+ Dir.glob("#{Mailmate.config.imap_root}/*").each do |account|
95
+ next unless File.directory?(account)
96
+ suffixes.each do |suffix|
97
+ d = "#{account}/#{suffix}/Messages"
98
+ result << d if File.directory?(d)
99
+ end
100
+ end
101
+ result
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "attributes"
5
+ require_relative "source_resolver"
6
+ require_relative "message"
7
+
8
+ # @api private
9
+ #
10
+ # VarResolver — resolves `$VAR.attr` references in smart-mailbox filters.
11
+ #
12
+ # Semantics: `LHS = $VAR.attr` means "LHS equals SOME value of `attr` taken
13
+ # over all messages in mailbox VAR". The set is built once per (var, attr)
14
+ # pair and cached for the lifetime of the resolver.
15
+ #
16
+ # Variable lookup: `$VAR` → mailbox UUID, with fallback to name lookup.
17
+ # `$SENT` → SENT (special UUID); `$PERSONAL_INBOX` → PERSONAL_INBOX (UUID
18
+ # of a smart mailbox in MailMate's defaults).
19
+ #
20
+ # Cycle detection: if `$A` resolves through the graph to a filter that
21
+ # references `$A`, raise. (None of Brian's current filters cycle.)
22
+
23
+ module Mailmate
24
+ class VarResolver
25
+ class CycleError < StandardError; end
26
+ class UnsupportedVar < StandardError; end
27
+
28
+ def initialize(graph)
29
+ @graph = graph
30
+ @source_resolver = SourceResolver.new(graph)
31
+ @cache = {}
32
+ @visiting = []
33
+ end
34
+
35
+ # Returns an Array<String|AddressValue|...> of values seen for `attr_path`
36
+ # in the mailbox named `var_name`. Empty array if no matches.
37
+ def resolve(var_name, attr_path)
38
+ key = [var_name, attr_path]
39
+ return @cache[key] if @cache.key?(key)
40
+
41
+ raise CycleError, "var-resolution cycle: #{(@visiting + [var_name]).join(" → ")}" \
42
+ if @visiting.include?(var_name)
43
+ @visiting << var_name
44
+
45
+ begin
46
+ # 1. Resolve the variable's mailbox to dirs + smart-filter chain.
47
+ uuid = MailboxGraph::SPECIAL_UUIDS.include?(var_name) ? var_name : @graph.by_name[var_name]
48
+ raise UnsupportedVar, "Unknown mailbox referenced: $#{var_name}" unless uuid
49
+
50
+ res = @source_resolver.resolve(uuid)
51
+ dirs = res[:dirs]
52
+ filter_str = compose_filter(res[:filters])
53
+
54
+ # 2. Build a child evaluator if there's an inner filter, with the
55
+ # *same* var resolver so nested $vars resolve.
56
+ inner_eval = filter_str ? Evaluator.new(Mailmate.compile_filter(filter_str), var_resolver: self) : nil
57
+
58
+ # 3. Walk dirs, collect attr_path values from matching messages.
59
+ values = []
60
+ dirs.each do |dir|
61
+ Dir.each_child(dir) do |fname|
62
+ next unless fname.end_with?(".eml")
63
+ path = "#{dir}/#{fname}"
64
+ eml_id = fname.sub(".eml", "").to_i
65
+ begin
66
+ # Header-only parse: most $var attrs are headers, and a full
67
+ # Mail.read on each Sent message would be slow.
68
+ mail = Mail.new(read_header_block(path))
69
+ rescue StandardError
70
+ next
71
+ end
72
+ msg = Message.new(mail, eml_id, path)
73
+ next if inner_eval && !inner_eval.matches?(msg)
74
+
75
+ v = Attributes.resolve(msg, attr_path)
76
+ Array(v).each { |x| values << x.to_s if x }
77
+ end
78
+ end
79
+
80
+ @cache[key] = values
81
+ ensure
82
+ @visiting.pop
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def compose_filter(filters)
89
+ return nil if filters.empty?
90
+ filters.size == 1 ? filters.first : "(#{filters.map { |f| "(#{f})" }.join(" and ")})"
91
+ end
92
+
93
+ # Header-only read: stop at the first blank line (capped at 64KB).
94
+ # Same primitive used by the raw-bytes pre-filter in mailmate-search.
95
+ def read_header_block(path)
96
+ bytes = +""
97
+ File.open(path, "rb") do |f|
98
+ while (chunk = f.read(4096))
99
+ bytes << chunk
100
+ idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
101
+ return bytes[0..idx] if idx
102
+ break if bytes.bytesize > 65_536
103
+ end
104
+ end
105
+ bytes
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mailmate.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api public
4
+ #
5
+ # mailmate — Ruby toolkit for MailMate on macOS.
6
+ #
7
+ # Public surface:
8
+ # Mailmate.config → singleton Config (data only; use the accessor)
9
+ # Mailmate.compile_filter(string) → filter AST root
10
+ # Mailmate::Identity.mine?(addr) → is this address one of mine?
11
+ # Mailmate::IndexReader.for(name) → decoded Database.noindex/Headers/<name>
12
+ # Mailmate::EmlLookup.path_for(eml_id) → eml-id → absolute path
13
+ # Mailmate::HeaderReader.header(path, name) → read one header from an .eml
14
+ # Mailmate::MidUrl.for(message_id) → build a mid:%3C...%3E URL
15
+ # Mailmate::DuplicateScanner.duplicates → Hash{Message-ID => Array<eml_id>}
16
+ # Mailmate::AppleScriptDriver.new(...) → drive MailMate via AppleScript
17
+ # Mailmate::Evaluator.new(ast).matches?(msg) → smart-mailbox filter evaluation
18
+ # Mailmate::MailboxGraph.load → graph of all configured mailboxes
19
+ # Mailmate::SourceResolver.new(graph) → resolves mailbox → on-disk dirs
20
+ # Mailmate::Message → thin wrapper over Mail + eml_id
21
+ # Mailmate::PlatformError → raised when macOS bits are needed elsewhere
22
+ #
23
+ # Internal (subject to change without notice; do not depend on these):
24
+ # Mailmate::Config (use Mailmate.config), Lexer, Parser, AST, Operators,
25
+ # Attributes, FilterClassifier, VarResolver, Mailmate::CLI::*.
26
+
27
+ module Mailmate
28
+ end
29
+
30
+ require_relative "mailmate/version"
31
+ require_relative "mailmate/platform_error"
32
+ require_relative "mailmate/config"
33
+ require_relative "mailmate/identity"
34
+ require_relative "mailmate/header_reader"
35
+ require_relative "mailmate/mid_url"
36
+ require_relative "mailmate/eml_lookup"
37
+ require_relative "mailmate/duplicate_scanner"
38
+ require_relative "mailmate/applescript_driver"
39
+ require_relative "mailmate/ast"
40
+ require_relative "mailmate/lexer"
41
+ require_relative "mailmate/parser"
42
+ require_relative "mailmate/message"
43
+ require_relative "mailmate/index_reader"
44
+ require_relative "mailmate/attributes"
45
+ require_relative "mailmate/operators"
46
+ require_relative "mailmate/evaluator"
47
+ require_relative "mailmate/mailbox_graph"
48
+ require_relative "mailmate/source_resolver"
49
+ require_relative "mailmate/var_resolver"
50
+ require_relative "mailmate/filter_classifier"
51
+
52
+ module Mailmate
53
+ def self.compile_filter(str)
54
+ Parser.parse(Lexer.lex(str))
55
+ end
56
+
57
+ # Convert a Time to the configured display zone.
58
+ # If `display_timezone` is set in config, use it as the offset (e.g.
59
+ # "-07:00"). Otherwise fall back to the system local zone (which honors
60
+ # macOS's DST rules, so Mountain users get MDT in summer and MST in winter).
61
+ def self.localize(time)
62
+ return nil unless time
63
+ t = time.respond_to?(:to_time) ? time.to_time : time
64
+ zone = config.display_timezone
65
+ if zone && !zone.empty?
66
+ t.getlocal(zone)
67
+ else
68
+ t.getlocal
69
+ end
70
+ rescue StandardError
71
+ time
72
+ end
73
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailmate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Murphy-Dye
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mail
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: |
69
+ mailmate is a Ruby library and CLI for working with MailMate's on-disk
70
+ storage and AppleScript surface. It includes a smart-mailbox filter
71
+ engine (lexer/parser/evaluator over MailMate's filter language), readers
72
+ for the binary header indexes, and CLI tools for searching, reading,
73
+ modifying, and sending mail via MailMate.
74
+
75
+ Requires macOS with MailMate installed. Some library pieces (parser,
76
+ evaluator, fixture-driven tests) work on any platform; the integration
77
+ pieces (AppleScript driver, filesystem readers) raise Mailmate::PlatformError
78
+ on non-macOS hosts.
79
+ email:
80
+ - brian@murphydye.com
81
+ executables:
82
+ - mm-modify
83
+ - mm-send
84
+ - mmdiscover
85
+ - mmmessage
86
+ - mmsearch
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE.txt
91
+ - README.md
92
+ - config.yml.example
93
+ - exe/mm-modify
94
+ - exe/mm-send
95
+ - exe/mmdiscover
96
+ - exe/mmmessage
97
+ - exe/mmsearch
98
+ - lib/mailmate.rb
99
+ - lib/mailmate/applescript_driver.rb
100
+ - lib/mailmate/ast.rb
101
+ - lib/mailmate/attributes.rb
102
+ - lib/mailmate/cli/discover.rb
103
+ - lib/mailmate/cli/message.rb
104
+ - lib/mailmate/cli/modify.rb
105
+ - lib/mailmate/cli/search.rb
106
+ - lib/mailmate/cli/send.rb
107
+ - lib/mailmate/config.rb
108
+ - lib/mailmate/duplicate_scanner.rb
109
+ - lib/mailmate/eml_lookup.rb
110
+ - lib/mailmate/evaluator.rb
111
+ - lib/mailmate/filter_classifier.rb
112
+ - lib/mailmate/header_reader.rb
113
+ - lib/mailmate/identity.rb
114
+ - lib/mailmate/index_reader.rb
115
+ - lib/mailmate/lexer.rb
116
+ - lib/mailmate/mailbox_graph.rb
117
+ - lib/mailmate/message.rb
118
+ - lib/mailmate/mid_url.rb
119
+ - lib/mailmate/operators.rb
120
+ - lib/mailmate/parser.rb
121
+ - lib/mailmate/platform_error.rb
122
+ - lib/mailmate/source_resolver.rb
123
+ - lib/mailmate/var_resolver.rb
124
+ - lib/mailmate/version.rb
125
+ licenses:
126
+ - MIT
127
+ metadata: {}
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '3.0'
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 4.0.10
143
+ specification_version: 4
144
+ summary: Ruby toolkit for MailMate on macOS — search, read, modify, send, and smart-mailbox
145
+ evaluation
146
+ test_files: []