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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Mailmate
|
|
7
|
+
# @api private
|
|
8
|
+
#
|
|
9
|
+
# The Config class is the storage and loader; consumers should reach it
|
|
10
|
+
# through `Mailmate.config` rather than constructing instances directly.
|
|
11
|
+
#
|
|
12
|
+
# Mailmate.config — process-wide configuration with three-layer loading:
|
|
13
|
+
#
|
|
14
|
+
# 1. Built-in defaults (macOS-standard paths, no identities)
|
|
15
|
+
# 2. YAML at ~/.config/mailmate/config.yml (silently ignored if missing)
|
|
16
|
+
# 3. Environment variables (override YAML)
|
|
17
|
+
#
|
|
18
|
+
# Personal data — identity addresses, custom paths — lives in the YAML or
|
|
19
|
+
# env vars, never in the gem source. `mmdiscover` populates the YAML on
|
|
20
|
+
# first run from MailMate's own Sources.plist + Identities.plist.
|
|
21
|
+
class Config
|
|
22
|
+
DEFAULT_APP_SUPPORT_DIR = File.expand_path("~/Library/Application Support/MailMate")
|
|
23
|
+
DEFAULT_CONFIG_PATH = File.expand_path("~/.config/mailmate/config.yml")
|
|
24
|
+
|
|
25
|
+
attr_reader :app_support_dir, :identities, :display_timezone
|
|
26
|
+
|
|
27
|
+
def self.instance
|
|
28
|
+
@instance ||= new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replace the singleton with a freshly-loaded config. Accepts the same
|
|
32
|
+
# kwargs as `.new`, so tests can scope a config block without poking
|
|
33
|
+
# ivars. Passing no args reloads from defaults (YAML at the default path,
|
|
34
|
+
# real ENV).
|
|
35
|
+
def self.reload!(yaml_path: DEFAULT_CONFIG_PATH, env: ENV)
|
|
36
|
+
@instance = new(yaml_path: yaml_path, env: env)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(yaml_path: DEFAULT_CONFIG_PATH, env: ENV)
|
|
40
|
+
@app_support_dir = DEFAULT_APP_SUPPORT_DIR
|
|
41
|
+
@identities = []
|
|
42
|
+
@display_timezone = nil
|
|
43
|
+
|
|
44
|
+
load_yaml!(yaml_path)
|
|
45
|
+
apply_env!(env)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Derived paths. Each follows from app_support_dir; if a user has a
|
|
49
|
+
# non-default MailMate install, overriding app_support_dir flows through.
|
|
50
|
+
def imap_root
|
|
51
|
+
File.join(app_support_dir, "Messages.noindex", "IMAP")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def db_headers
|
|
55
|
+
File.join(app_support_dir, "Database.noindex", "Headers")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mailboxes_plist
|
|
59
|
+
File.join(app_support_dir, "Mailboxes.plist")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sources_plist
|
|
63
|
+
File.join(app_support_dir, "Sources.plist")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def identities_plist
|
|
67
|
+
File.join(app_support_dir, "Identities.plist")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
KNOWN_KEYS = %w[app_support_dir identities display_timezone].freeze
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def load_yaml!(path)
|
|
75
|
+
return unless File.exist?(path)
|
|
76
|
+
data = YAML.safe_load_file(path) || {}
|
|
77
|
+
unless data.is_a?(Hash)
|
|
78
|
+
warn "Mailmate.config: #{path} did not parse as a YAML mapping; ignoring."
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
unknown = data.keys - KNOWN_KEYS
|
|
83
|
+
unless unknown.empty?
|
|
84
|
+
warn "Mailmate.config: unknown key(s) in #{path}: #{unknown.join(", ")} (valid: #{KNOWN_KEYS.join(", ")})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if data.key?("app_support_dir")
|
|
88
|
+
val = data["app_support_dir"]
|
|
89
|
+
if val.is_a?(String) && !val.empty?
|
|
90
|
+
@app_support_dir = File.expand_path(val)
|
|
91
|
+
else
|
|
92
|
+
warn "Mailmate.config: app_support_dir in #{path} must be a non-empty string; ignoring (#{val.inspect})"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if data.key?("identities")
|
|
97
|
+
val = data["identities"]
|
|
98
|
+
if val.is_a?(Array)
|
|
99
|
+
@identities = val.map(&:to_s)
|
|
100
|
+
else
|
|
101
|
+
warn "Mailmate.config: identities in #{path} must be a list; ignoring (#{val.inspect})"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if data.key?("display_timezone")
|
|
106
|
+
val = data["display_timezone"]
|
|
107
|
+
if val.nil? || (val.is_a?(String) && val.empty?)
|
|
108
|
+
@display_timezone = nil
|
|
109
|
+
elsif val.is_a?(String)
|
|
110
|
+
@display_timezone = val
|
|
111
|
+
else
|
|
112
|
+
warn "Mailmate.config: display_timezone in #{path} must be a string (e.g. '-07:00'); ignoring (#{val.inspect})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_env!(env)
|
|
118
|
+
if (v = env["MAILMATE_APP_SUPPORT_DIR"]) && !v.empty?
|
|
119
|
+
@app_support_dir = File.expand_path(v)
|
|
120
|
+
end
|
|
121
|
+
if (v = env["MAILMATE_IDENTITIES"]) && !v.empty?
|
|
122
|
+
@identities = v.split(",").map(&:strip).reject(&:empty?)
|
|
123
|
+
end
|
|
124
|
+
if (v = env["MAILMATE_DISPLAY_TIMEZONE"]) && !v.empty?
|
|
125
|
+
@display_timezone = v
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Convenience: `Mailmate.config.imap_root` etc.
|
|
131
|
+
def self.config
|
|
132
|
+
Config.instance
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
# @api public
|
|
5
|
+
#
|
|
6
|
+
# Detect duplicate Message-ID copies across MailMate's tree. The same RFC
|
|
7
|
+
# Message-ID can appear in multiple `.eml` files — Gmail's label-creates-a-copy
|
|
8
|
+
# semantics produce this for any message that hits a labeled mailbox, and
|
|
9
|
+
# self-to-self messages can end up in both Sent and INBOX folders.
|
|
10
|
+
#
|
|
11
|
+
# Why this matters: MailMate's `mid:` URL resolves to a single message
|
|
12
|
+
# non-deterministically, so an action keyed by Message-ID can land on a
|
|
13
|
+
# different `.eml` file than the one the user typed. `mailmate-modify` warns
|
|
14
|
+
# when this is the case.
|
|
15
|
+
#
|
|
16
|
+
# Implementation uses MailMate's own `message-id` index — O(n) over the
|
|
17
|
+
# decoded index instead of a recursive `grep -rli` over the whole IMAP tree.
|
|
18
|
+
# [[Mailmate scripts speed]] §1.
|
|
19
|
+
module DuplicateScanner
|
|
20
|
+
# Returns Array<Integer> of eml-ids that share `message_id`. The array is
|
|
21
|
+
# ordered as `#message-id` records them; callers don't depend on the order.
|
|
22
|
+
def self.eml_ids_for(message_id)
|
|
23
|
+
return [] if message_id.nil? || message_id.empty?
|
|
24
|
+
|
|
25
|
+
reader = Mailmate::IndexReader.for("message-id")
|
|
26
|
+
target = strip_brackets(message_id).downcase
|
|
27
|
+
|
|
28
|
+
ids = []
|
|
29
|
+
iterate(reader) do |eml_id, cached|
|
|
30
|
+
next if cached.nil?
|
|
31
|
+
ids << eml_id if strip_brackets(cached).downcase == target
|
|
32
|
+
end
|
|
33
|
+
ids
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Convenience: is there more than one copy of this Message-ID in the tree?
|
|
37
|
+
def self.duplicate?(message_id)
|
|
38
|
+
eml_ids_for(message_id).size > 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build a Hash{Message-ID => Array<eml_id>} for every duplicated Message-ID
|
|
42
|
+
# in the index. One full pass; useful as a session-cached lookup when many
|
|
43
|
+
# messages will be processed in a batch.
|
|
44
|
+
def self.duplicates
|
|
45
|
+
reader = Mailmate::IndexReader.for("message-id")
|
|
46
|
+
|
|
47
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
48
|
+
iterate(reader) do |eml_id, cached|
|
|
49
|
+
next if cached.nil? || cached.empty?
|
|
50
|
+
groups[strip_brackets(cached).downcase] << eml_id
|
|
51
|
+
end
|
|
52
|
+
groups.select { |_, ids| ids.size > 1 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Internal — iterate the IndexReader's records. Delegates to the
|
|
56
|
+
# reader's public `each_record` API.
|
|
57
|
+
def self.iterate(reader, &block)
|
|
58
|
+
reader.each_record(&block)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.strip_brackets(s)
|
|
62
|
+
s.to_s.sub(/\A</, "").sub(/>\z/, "").strip
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
# @api public
|
|
5
|
+
#
|
|
6
|
+
# Map an `.eml` body-part ID to its absolute on-disk path.
|
|
7
|
+
#
|
|
8
|
+
# Two implementations:
|
|
9
|
+
# - The fast path uses MailMate's `#source` index
|
|
10
|
+
# (`Database.noindex/Headers/#source.{cache,offsets}`), which carries the
|
|
11
|
+
# `imap://account@host/path` URL for every indexed message. O(1) lookup.
|
|
12
|
+
# - The fallback walks the IMAP tree with `Dir.glob`, the same shape as the
|
|
13
|
+
# `find -name <id>.eml` shell-out the original scripts used. Slower
|
|
14
|
+
# (seconds on a cold cache) but always works.
|
|
15
|
+
#
|
|
16
|
+
# The fast path wins ~99% of the time. The fallback exists for messages
|
|
17
|
+
# MailMate hasn't yet indexed (e.g. immediately after a fresh IMAP push) and
|
|
18
|
+
# for edge cases where `#source` is unreadable.
|
|
19
|
+
module EmlLookup
|
|
20
|
+
# Returns an absolute path string, or nil if not found.
|
|
21
|
+
def self.path_for(eml_id)
|
|
22
|
+
via_index(eml_id) || via_glob(eml_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reverse-lookup: given an RFC Message-ID (with or without angle brackets),
|
|
26
|
+
# return the local eml-id (integer) or nil. O(n) scan of the message-id
|
|
27
|
+
# index — fine for one-shot CLI lookups; cache the result if you need it
|
|
28
|
+
# repeatedly.
|
|
29
|
+
def self.eml_id_for_message_id(message_id)
|
|
30
|
+
needle = message_id.to_s.strip
|
|
31
|
+
return nil if needle.empty?
|
|
32
|
+
|
|
33
|
+
candidates = needle.start_with?("<") && needle.end_with?(">") ?
|
|
34
|
+
[needle, needle[1..-2]] :
|
|
35
|
+
[needle, "<#{needle}>"]
|
|
36
|
+
|
|
37
|
+
Mailmate::IndexReader.for("message-id").each_record do |eml_id, value|
|
|
38
|
+
return eml_id if candidates.include?(value)
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resolve an identifier that may be either an eml-id (all digits) or an
|
|
46
|
+
# RFC Message-ID (anything else) to a local eml-id.
|
|
47
|
+
def self.resolve_id(input)
|
|
48
|
+
s = input.to_s.strip
|
|
49
|
+
return s.to_i if s =~ /\A\d+\z/
|
|
50
|
+
eml_id_for_message_id(s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Force the index path (useful for tests and benchmarking).
|
|
54
|
+
def self.via_index(eml_id)
|
|
55
|
+
url = source_url_for(eml_id)
|
|
56
|
+
return nil if url.nil?
|
|
57
|
+
url_to_path(url, eml_id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Force the glob fallback.
|
|
61
|
+
def self.via_glob(eml_id)
|
|
62
|
+
matches = Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages/#{eml_id}.eml")
|
|
63
|
+
matches.first
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Lookup the `imap://...` URL recorded in `#source` for this eml_id.
|
|
67
|
+
# Returns nil if the index doesn't have a record for it.
|
|
68
|
+
def self.source_url_for(eml_id)
|
|
69
|
+
Mailmate::IndexReader.for("#source").value_for(eml_id)
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
# #source index missing (non-default MailMate install? fresh sync?).
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Convert an `imap://account@host/mailbox/path` URL to the on-disk
|
|
76
|
+
# absolute path of the .eml file. Internal but exposed for tests.
|
|
77
|
+
def self.url_to_path(url, eml_id)
|
|
78
|
+
stripped = url.sub(%r{\Aimap://}, "")
|
|
79
|
+
account, mailbox_path = stripped.split("/", 2)
|
|
80
|
+
return nil if account.nil? || mailbox_path.nil? || mailbox_path.empty?
|
|
81
|
+
|
|
82
|
+
mailbox_dirs = mailbox_path.split("/").map { |seg| "#{seg}.mailbox" }.join("/")
|
|
83
|
+
File.join(Mailmate.config.imap_root, account, mailbox_dirs, "Messages", "#{eml_id}.eml")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ast"
|
|
4
|
+
require_relative "attributes"
|
|
5
|
+
require_relative "operators"
|
|
6
|
+
|
|
7
|
+
# Evaluator: walks a parsed AST and tests it against a Mail::Message or
|
|
8
|
+
# Mailmate::Message. `var_resolver` is required if the AST contains
|
|
9
|
+
# VarRefNode (i.e. `$SENT.foo`, `$PERSONAL_INBOX.foo` etc.).
|
|
10
|
+
|
|
11
|
+
module Mailmate
|
|
12
|
+
# @api public
|
|
13
|
+
class Evaluator
|
|
14
|
+
def initialize(filter_ast, var_resolver: nil)
|
|
15
|
+
@ast = filter_ast
|
|
16
|
+
@var_resolver = var_resolver
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matches?(message)
|
|
20
|
+
eval_node(@ast, message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def eval_node(node, message)
|
|
26
|
+
case node
|
|
27
|
+
when AST::AndNode then node.children.all? { |c| eval_node(c, message) }
|
|
28
|
+
when AST::OrNode then node.children.any? { |c| eval_node(c, message) }
|
|
29
|
+
when AST::NotNode then !eval_node(node.child, message)
|
|
30
|
+
when AST::ExistsNode
|
|
31
|
+
v = Attributes.resolve(message, node.path)
|
|
32
|
+
!(v.nil? || (v.respond_to?(:empty?) && v.empty?))
|
|
33
|
+
when AST::CompareNode
|
|
34
|
+
eval_compare(node, message)
|
|
35
|
+
else
|
|
36
|
+
raise "unhandled node: #{node.class}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Multi-value path semantics:
|
|
41
|
+
# `path = X` → ANY value equals X
|
|
42
|
+
# `path ~ X` → ANY value contains X
|
|
43
|
+
# `path != X` → NO value equals X
|
|
44
|
+
# `path !~ X` → NO value contains X
|
|
45
|
+
# `path < X` etc → ANY value compares
|
|
46
|
+
#
|
|
47
|
+
# When the RHS is a `$VAR.attr` reference, RHS becomes a *set* of values;
|
|
48
|
+
# the operator then asks whether any LHS × any RHS satisfies. Negation
|
|
49
|
+
# still inverts the positive form.
|
|
50
|
+
def eval_compare(node, message)
|
|
51
|
+
lhs_values = Array(Attributes.resolve(message, node.path)).compact
|
|
52
|
+
rhs_values = resolve_value_set(node.value, node.flags, message)
|
|
53
|
+
|
|
54
|
+
pos_op = positive_op(node.op)
|
|
55
|
+
positive_match = lhs_values.any? do |l|
|
|
56
|
+
rhs_values.any? { |r| Operators.compare(l, pos_op, node.flags, r) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
negated_op?(node.op) ? !positive_match : positive_match
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def positive_op(op)
|
|
63
|
+
case op
|
|
64
|
+
when "!=" then "="
|
|
65
|
+
when "!~" then "~"
|
|
66
|
+
else op
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def negated_op?(op)
|
|
71
|
+
op == "!=" || op == "!~"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resolve the AST's RHS value to an array of comparable values.
|
|
75
|
+
# Bare values become a one-element array; VarRefNode delegates to the
|
|
76
|
+
# var_resolver, which returns the full set.
|
|
77
|
+
def resolve_value_set(value_node, op_flags, _message)
|
|
78
|
+
case value_node
|
|
79
|
+
when AST::LiteralStringNode then [value_node.value]
|
|
80
|
+
when AST::NumberNode then [value_node.value]
|
|
81
|
+
when AST::AbsoluteDateNode then [value_node.time]
|
|
82
|
+
when AST::RelativeDateNode then [Operators.relative_date(value_node.n, value_node.unit, op_flags)]
|
|
83
|
+
when AST::VarRefNode
|
|
84
|
+
unless @var_resolver
|
|
85
|
+
raise "Filter contains $#{value_node.var} but no var_resolver was provided"
|
|
86
|
+
end
|
|
87
|
+
@var_resolver.resolve(value_node.var, value_node.path)
|
|
88
|
+
else
|
|
89
|
+
raise "unhandled value: #{value_node.class}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ast"
|
|
4
|
+
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# FilterClassifier — walks an AST and answers questions about what's
|
|
8
|
+
# required to evaluate it. Used by `mailmate-search` to pick the cheapest
|
|
9
|
+
# `.eml` loading tier per query:
|
|
10
|
+
#
|
|
11
|
+
# :index — every path is index-backed; never open the .eml at all.
|
|
12
|
+
# :header — every path resolves from headers (or index); read header block only.
|
|
13
|
+
# :full — at least one path needs body content; full Mail.read.
|
|
14
|
+
#
|
|
15
|
+
# Also extracts ASCII string literals from top-level AND-chained
|
|
16
|
+
# header-path comparisons so the raw-bytes pre-filter can short-circuit
|
|
17
|
+
# without parsing.
|
|
18
|
+
|
|
19
|
+
module Mailmate
|
|
20
|
+
module FilterClassifier
|
|
21
|
+
# Heads served by an on-disk index in Database.noindex/Headers/<name>.
|
|
22
|
+
INDEX_BACKED_HEADS = %w[
|
|
23
|
+
#flags ##tags
|
|
24
|
+
#date #date-received #date-sent #date-last-viewed
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Heads that resolve from .eml header bytes (vs. body).
|
|
28
|
+
HEADER_HEADS = %w[
|
|
29
|
+
from to cc bcc reply-to sender resent-from resent-to resent-cc resent-bcc
|
|
30
|
+
subject list-id message-id in-reply-to references
|
|
31
|
+
x-mailer user-agent x-newsreader received delivered-to
|
|
32
|
+
#recipient #any-address #mailer ##thread-id
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Heads that require the body to resolve.
|
|
36
|
+
BODY_HEADS = %w[#unquoted #quoted #common #commonplus].freeze
|
|
37
|
+
|
|
38
|
+
# Heads whose literals appear textually in the .eml header bytes
|
|
39
|
+
# (so they're safe pre-filter candidates).
|
|
40
|
+
PREFILTER_HEADS = %w[
|
|
41
|
+
from to cc bcc reply-to sender
|
|
42
|
+
subject list-id message-id in-reply-to references
|
|
43
|
+
x-mailer user-agent x-newsreader
|
|
44
|
+
#recipient #any-address #mailer
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# Returns one of :index, :header, :full.
|
|
48
|
+
def self.tier(ast)
|
|
49
|
+
heads = collect_path_heads(ast)
|
|
50
|
+
return :full if heads.any? { |h| BODY_HEADS.include?(h) }
|
|
51
|
+
return :index if heads.all? { |h| INDEX_BACKED_HEADS.include?(h) }
|
|
52
|
+
:header
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Same idea but for a list of attribute paths (e.g. fields the user wants
|
|
56
|
+
# output, or paths the user-search specs reference).
|
|
57
|
+
def self.tier_for_paths(paths)
|
|
58
|
+
heads = paths.map { |p| Array(p).first }.compact
|
|
59
|
+
return :full if heads.any? { |h| BODY_HEADS.include?(h) }
|
|
60
|
+
return :index if !heads.empty? && heads.all? { |h| INDEX_BACKED_HEADS.include?(h) }
|
|
61
|
+
:header
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Combine multiple tiers; the strictest wins. (full > header > index)
|
|
65
|
+
def self.combine_tiers(*tiers)
|
|
66
|
+
return :full if tiers.include?(:full)
|
|
67
|
+
return :header if tiers.include?(:header)
|
|
68
|
+
:index
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ASCII literals from top-level AND-chained header-path comparisons,
|
|
72
|
+
# which are guaranteed to appear (substring-wise) in any matching message's
|
|
73
|
+
# raw header bytes. Skips OR branches (literals there are alternatives,
|
|
74
|
+
# not requirements) and skips negated comparisons.
|
|
75
|
+
def self.header_literals(ast)
|
|
76
|
+
walk_top_and(ast).flat_map { |node| literal_from(node) }.compact.uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ---- internals ----
|
|
80
|
+
|
|
81
|
+
def self.collect_path_heads(ast)
|
|
82
|
+
heads = []
|
|
83
|
+
walk(ast) do |n|
|
|
84
|
+
case n
|
|
85
|
+
when AST::CompareNode, AST::ExistsNode
|
|
86
|
+
heads << n.path[0]
|
|
87
|
+
when AST::VarRefNode
|
|
88
|
+
# var refs are evaluated by walking the referenced mailbox,
|
|
89
|
+
# which is its own search. The OUTER caller doesn't need to load
|
|
90
|
+
# body for a var ref — only headers/index, depending on the
|
|
91
|
+
# path's head on the LHS side. The inner mailbox walk classifies
|
|
92
|
+
# itself when it runs (via VarResolver's own header-only scan).
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
heads
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.walk(ast, &blk)
|
|
99
|
+
yield ast
|
|
100
|
+
case ast
|
|
101
|
+
when AST::AndNode, AST::OrNode then ast.children.each { |c| walk(c, &blk) }
|
|
102
|
+
when AST::NotNode then walk(ast.child, &blk)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.walk_top_and(ast)
|
|
107
|
+
case ast
|
|
108
|
+
when AST::AndNode then ast.children.flat_map { |c| walk_top_and(c) }
|
|
109
|
+
else [ast]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.literal_from(node)
|
|
114
|
+
return [] unless node.is_a?(AST::CompareNode)
|
|
115
|
+
return [] if %w[!= !~].include?(node.op)
|
|
116
|
+
return [] unless PREFILTER_HEADS.include?(node.path[0])
|
|
117
|
+
return [] unless node.value.is_a?(AST::LiteralStringNode)
|
|
118
|
+
s = node.value.value
|
|
119
|
+
return [] unless s.bytesize >= 3 && s.ascii_only?
|
|
120
|
+
[s.downcase]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
# @api public
|
|
5
|
+
#
|
|
6
|
+
# Read just the header block of an `.eml` file. Pulls in a small amount of
|
|
7
|
+
# the file from disk (up to ~64 KB), stops at the first blank line that
|
|
8
|
+
# separates headers from body, and extracts named header values.
|
|
9
|
+
#
|
|
10
|
+
# Cheap relative to `Mail.read` — no MIME parsing, no body decode — when all
|
|
11
|
+
# the caller wants is `Message-ID` or `From` from a known `.eml` path.
|
|
12
|
+
#
|
|
13
|
+
# Stateless utility surface, so it's a module (cf. AppleScriptDriver, which
|
|
14
|
+
# is a class because it carries per-invocation state).
|
|
15
|
+
module HeaderReader
|
|
16
|
+
extend self
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_BYTES = 65_536
|
|
19
|
+
CHUNK_SIZE = 4_096
|
|
20
|
+
|
|
21
|
+
# Returns the raw bytes of the header block, capped at `max_bytes`.
|
|
22
|
+
# Truncates at the first blank line (the header/body separator) so callers
|
|
23
|
+
# looking for "To:" or similar don't accidentally match a `To:` line that
|
|
24
|
+
# appears in the body.
|
|
25
|
+
def read_block(path, max_bytes: DEFAULT_MAX_BYTES)
|
|
26
|
+
header_bytes = +""
|
|
27
|
+
File.open(path, "rb") do |f|
|
|
28
|
+
while (chunk = f.read(CHUNK_SIZE))
|
|
29
|
+
header_bytes << chunk
|
|
30
|
+
break if header_bytes.index("\r\n\r\n") || header_bytes.index("\n\n")
|
|
31
|
+
break if header_bytes.bytesize > max_bytes
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if (i = header_bytes.index("\r\n\r\n"))
|
|
36
|
+
header_bytes[0, i]
|
|
37
|
+
elsif (i = header_bytes.index("\n\n"))
|
|
38
|
+
header_bytes[0, i]
|
|
39
|
+
else
|
|
40
|
+
header_bytes
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Extract a named header's value from a path. Case-insensitive. Handles
|
|
45
|
+
# RFC 5322 §2.2.3 header folding — a value continued on subsequent lines
|
|
46
|
+
# with leading whitespace is unfolded into a single line (CRLF + WSP
|
|
47
|
+
# collapsed to a single space). Returns nil if the header isn't present
|
|
48
|
+
# in the first `max_bytes` of the file.
|
|
49
|
+
def header(path, name, max_bytes: DEFAULT_MAX_BYTES)
|
|
50
|
+
block = read_block(path, max_bytes: max_bytes)
|
|
51
|
+
# Match the header name at start of line, then the rest of that line and
|
|
52
|
+
# any subsequent continuation lines (lines beginning with WSP).
|
|
53
|
+
pattern = /^#{Regexp.escape(name)}:[\t ]*(.*(?:\r?\n[\t ].*)*)/i
|
|
54
|
+
match = block.match(pattern)
|
|
55
|
+
return nil unless match
|
|
56
|
+
unfold(match[1])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Collapse CRLF + WSP runs into a single space (RFC 5322 §2.2.3
|
|
60
|
+
# "unfolding"). Trailing whitespace is stripped.
|
|
61
|
+
def unfold(raw)
|
|
62
|
+
raw.gsub(/\r?\n[\t ]+/, " ").strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Convenience for the Message-ID specifically. Strips the surrounding angle
|
|
66
|
+
# brackets if present, returning the bare ID suitable for building a
|
|
67
|
+
# `mid:` URL via Mailmate::MidUrl.for.
|
|
68
|
+
def message_id(path)
|
|
69
|
+
val = header(path, "Message-ID")
|
|
70
|
+
return nil if val.nil?
|
|
71
|
+
val.match(/<([^>]+)>/)&.captures&.first || val
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailmate
|
|
4
|
+
# @api public
|
|
5
|
+
#
|
|
6
|
+
# "Is this address one of mine?" — answered against the identity list
|
|
7
|
+
# configured at `Mailmate.config.identities`. Personal data lives in user
|
|
8
|
+
# config (YAML or env vars), never in the gem source. This is the
|
|
9
|
+
# canonical public surface; `Mailmate::Config` is data-only.
|
|
10
|
+
module Identity
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
# Returns true if `address` matches any configured identity
|
|
14
|
+
# (case-insensitive, whitespace-trimmed). Returns false for nil / empty
|
|
15
|
+
# input, and false when no identities are configured — the "I don't know
|
|
16
|
+
# who you are yet" state, deliberately safe rather than surprising.
|
|
17
|
+
def mine?(address)
|
|
18
|
+
return false if address.nil? || address.to_s.empty?
|
|
19
|
+
addr = address.to_s.downcase.strip
|
|
20
|
+
list.include?(addr)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The configured identity list, as an array of lowercase strings.
|
|
24
|
+
def list
|
|
25
|
+
Mailmate.config.identities.map { |a| a.to_s.downcase.strip }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Reject "my" addresses from an array. Useful for "who's the other party
|
|
29
|
+
# in this conversation?" — pass the To/Cc/Bcc list, get back just the
|
|
30
|
+
# external addresses.
|
|
31
|
+
def reject_mine(addresses)
|
|
32
|
+
Array(addresses).reject { |a| mine?(a.to_s) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|