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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/config.yml.example +21 -0
- data/exe/mm-modify +8 -0
- data/exe/mm-send +8 -0
- data/exe/mmdiscover +8 -0
- data/exe/mmmessage +8 -0
- data/exe/mmsearch +8 -0
- data/lib/mailmate/applescript_driver.rb +103 -0
- data/lib/mailmate/ast.rb +33 -0
- data/lib/mailmate/attributes.rb +289 -0
- data/lib/mailmate/cli/discover.rb +170 -0
- data/lib/mailmate/cli/message.rb +109 -0
- data/lib/mailmate/cli/modify.rb +190 -0
- data/lib/mailmate/cli/search.rb +609 -0
- data/lib/mailmate/cli/send.rb +29 -0
- data/lib/mailmate/config.rb +134 -0
- data/lib/mailmate/duplicate_scanner.rb +65 -0
- data/lib/mailmate/eml_lookup.rb +86 -0
- data/lib/mailmate/evaluator.rb +93 -0
- data/lib/mailmate/filter_classifier.rb +123 -0
- data/lib/mailmate/header_reader.rb +74 -0
- data/lib/mailmate/identity.rb +35 -0
- data/lib/mailmate/index_reader.rb +126 -0
- data/lib/mailmate/lexer.rb +136 -0
- data/lib/mailmate/mailbox_graph.rb +77 -0
- data/lib/mailmate/message.rb +31 -0
- data/lib/mailmate/mid_url.rb +23 -0
- data/lib/mailmate/operators.rb +110 -0
- data/lib/mailmate/parser.rb +218 -0
- data/lib/mailmate/platform_error.rb +21 -0
- data/lib/mailmate/source_resolver.rb +104 -0
- data/lib/mailmate/var_resolver.rb +108 -0
- data/lib/mailmate/version.rb +5 -0
- data/lib/mailmate.rb +73 -0
- metadata +146 -0
|
@@ -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
|
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: []
|