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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # IndexReader — decodes MailMate's binary header indexes at
4
+ # `~/Library/Application Support/MailMate/Database.noindex/Headers/`.
5
+ #
6
+ # Index format (verified against `#flags.offsets` and `#forwarding.offsets`):
7
+ #
8
+ # .cache Newline-separated string table.
9
+ # .offsets Concatenated 12-byte records, three little-endian uint32 each:
10
+ # [0..4) message body-part ID
11
+ # [4..8) start byte offset into .cache
12
+ # [8..12) end byte offset into .cache (exclusive)
13
+ # value = cache[start...end] (start == end → empty / "no value")
14
+ # .plist Old-style plist with offsetsFileSize / stringsFileSize sentinels.
15
+ #
16
+ # Specific accessors:
17
+ # `flags.flag(eml_id)` → Array<String> of IMAP keywords (`\Seen`,
18
+ # `\Flagged`, `$Forwarded`, `$Muted`, custom tags…)
19
+ # or [] if the message has no flags / isn't indexed.
20
+ #
21
+ # IndexReader instances cache both files in memory and build a hash from
22
+ # msg_id → (start,end) for O(1) lookup. Construction cost ≈ 5–20 ms for
23
+ # 50–200k records; memory ≈ a few MB. For a CLI invocation that's fine; the
24
+ # evaluator instantiates one lazily when first needed.
25
+
26
+ module Mailmate
27
+ # @api public
28
+ class IndexReader
29
+ RECORD_SIZE = 12
30
+
31
+ class << self
32
+ # Per-process cache of readers keyed by [name, db_headers]. Including
33
+ # db_headers means a Mailmate.config swap (e.g. a test pointing at a
34
+ # different tmpdir) doesn't return stale readers built from the old
35
+ # path.
36
+ def for(name)
37
+ @cache ||= {}
38
+ @cache[cache_key(name)] ||= new(name)
39
+ end
40
+
41
+ # Invalidate cached readers. With no argument, drops the entire cache
42
+ # (useful for tests or when MailMate's database swaps out). With a name,
43
+ # invalidates only entries for that name across all db_headers — the
44
+ # common case (cache-bust after a write) doesn't need to thread config
45
+ # through.
46
+ def reset!(name = nil)
47
+ if name.nil?
48
+ @cache = nil
49
+ elsif @cache
50
+ @cache.delete_if { |(n, _dir), _reader| n == name }
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def cache_key(name)
57
+ [name, Mailmate.config.db_headers]
58
+ end
59
+ end
60
+
61
+ attr_reader :name
62
+
63
+ def initialize(name)
64
+ @name = name
65
+ base = "#{Mailmate.config.db_headers}/#{name}"
66
+ raise ArgumentError, "Index not found: #{name} (looked at #{base}.{cache,offsets})" \
67
+ unless File.exist?("#{base}.cache") && File.exist?("#{base}.offsets")
68
+
69
+ @cache_bytes = File.binread("#{base}.cache")
70
+ @offsets_bytes = File.binread("#{base}.offsets")
71
+ build_index!
72
+ end
73
+
74
+ # Returns the raw cached value for a given .eml body-part ID, or nil if
75
+ # the message isn't in this index.
76
+ def value_for(eml_id)
77
+ pair = @index[eml_id.to_i]
78
+ return nil unless pair
79
+ @cache_bytes[pair[0]...pair[1]]
80
+ end
81
+
82
+ # `#flags.flag` semantics: the cache stores a space-separated list of IMAP
83
+ # keywords. Split into individual flag tokens.
84
+ def flags_for(eml_id)
85
+ v = value_for(eml_id)
86
+ return [] if v.nil? || v.empty?
87
+ v.split(/\s+/).reject(&:empty?)
88
+ end
89
+
90
+ # Number of records (mostly for diagnostics).
91
+ def size
92
+ @index.size
93
+ end
94
+
95
+ # Iterate every recorded eml-id. Yields just the id; callers that also
96
+ # want the value should pair this with `value_for`. Exists so other gem
97
+ # modules don't have to reach into `@index` directly.
98
+ def each_eml_id(&block)
99
+ return enum_for(:each_eml_id) unless block
100
+ @index.each_key(&block)
101
+ end
102
+
103
+ # Iterate every (eml_id, raw_value) pair. The value comes back as the
104
+ # bare cache substring; callers that need parsed form (e.g. flag tokens)
105
+ # should massage it themselves.
106
+ def each_record
107
+ return enum_for(:each_record) unless block_given?
108
+ @index.each_key do |eml_id|
109
+ yield eml_id, value_for(eml_id)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def build_index!
116
+ @index = {}
117
+ n = @offsets_bytes.bytesize / RECORD_SIZE
118
+ i = 0
119
+ while i < n
120
+ rec = @offsets_bytes[i * RECORD_SIZE, RECORD_SIZE].unpack("V3")
121
+ @index[rec[0]] = [rec[1], rec[2]]
122
+ i += 1
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lexer for MailMate filter expressions. Emits a flat array of [kind, value]
4
+ # tokens. Whitespace is skipped. Keywords (`and`, `or`, `not`, `exists`) are
5
+ # recognized at lex time; `ago`, `days`, `months`, etc. stay as :ident and are
6
+ # disambiguated by the parser.
7
+
8
+ module Mailmate
9
+ # @api private
10
+ module Lexer
11
+ KEYWORDS = %w[and or not exists].freeze
12
+ OP_FLAG_CHARS = "cafx" # operator-modifier flags, e.g. =[c], >[f], !=[x]
13
+
14
+ class Error < StandardError; end
15
+
16
+ def self.lex(input)
17
+ tokens = []
18
+ i = 0
19
+ while i < input.length
20
+ c = input[i]
21
+
22
+ # Whitespace
23
+ if c =~ /\s/
24
+ i += 1
25
+ next
26
+ end
27
+
28
+ # Punctuation
29
+ if c == "("
30
+ tokens << [:lparen]; i += 1; next
31
+ elsif c == ")"
32
+ tokens << [:rparen]; i += 1; next
33
+ elsif c == "."
34
+ tokens << [:dot]; i += 1; next
35
+ end
36
+
37
+ # Variable reference: $SENT, $PERSONAL_INBOX
38
+ if c == "$"
39
+ j = i + 1
40
+ j += 1 while j < input.length && input[j] =~ /[A-Z_]/
41
+ raise Error, "empty variable name at #{i}" if j == i + 1
42
+ tokens << [:var, input[(i + 1)...j]]
43
+ i = j
44
+ next
45
+ end
46
+
47
+ # String: '...' Only `\\` and `\'` are recognized escapes; other
48
+ # backslashes are preserved literally so IMAP keywords like
49
+ # `\Seen` / `\Flagged` survive intact.
50
+ if c == "'"
51
+ j = i + 1
52
+ buf = +""
53
+ while j < input.length && input[j] != "'"
54
+ if input[j] == "\\" && j + 1 < input.length && (input[j + 1] == "\\" || input[j + 1] == "'")
55
+ buf << input[j + 1]
56
+ j += 2
57
+ else
58
+ buf << input[j]
59
+ j += 1
60
+ end
61
+ end
62
+ raise Error, "unterminated string starting at #{i}" if j >= input.length
63
+ tokens << [:string, buf]
64
+ i = j + 1
65
+ next
66
+ end
67
+
68
+ # Operator: !=, =, ~, !~, <, <=, >, >=, with optional [flags]
69
+ if "!=~<>".include?(c)
70
+ op = c
71
+ j = i + 1
72
+ if c == "!" && j < input.length && (input[j] == "=" || input[j] == "~")
73
+ op = "!" + input[j]
74
+ j += 1
75
+ elsif (c == "<" || c == ">") && j < input.length && input[j] == "="
76
+ op = c + "="
77
+ j += 1
78
+ end
79
+ # Optional [flags]
80
+ flags = []
81
+ if j < input.length && input[j] == "["
82
+ k = j + 1
83
+ while k < input.length && OP_FLAG_CHARS.include?(input[k])
84
+ flags << input[k]
85
+ k += 1
86
+ end
87
+ raise Error, "expected ] after operator flags at #{j}" if k >= input.length || input[k] != "]"
88
+ j = k + 1
89
+ end
90
+ tokens << [:op, op, flags]
91
+ i = j
92
+ next
93
+ end
94
+
95
+ # Number: 0-9+
96
+ if c =~ /\d/
97
+ j = i
98
+ j += 1 while j < input.length && input[j] =~ /\d/
99
+ tokens << [:number, input[i...j].to_i]
100
+ i = j
101
+ next
102
+ end
103
+
104
+ # Shorthand: # or ## followed by ident
105
+ if c == "#"
106
+ j = i
107
+ j += 1 while j < input.length && input[j] == "#"
108
+ start = j
109
+ j += 1 while j < input.length && input[j] =~ /[a-zA-Z0-9_-]/
110
+ raise Error, "empty shorthand at #{i}" if start == j
111
+ tokens << [:shorthand, input[i...j]]
112
+ i = j
113
+ next
114
+ end
115
+
116
+ # Identifier: [a-zA-Z_][a-zA-Z0-9_-]*
117
+ if c =~ /[a-zA-Z_]/
118
+ j = i
119
+ j += 1 while j < input.length && input[j] =~ /[a-zA-Z0-9_-]/
120
+ word = input[i...j]
121
+ if KEYWORDS.include?(word)
122
+ tokens << [:keyword, word]
123
+ else
124
+ tokens << [:ident, word]
125
+ end
126
+ i = j
127
+ next
128
+ end
129
+
130
+ raise Error, "unexpected char #{c.inspect} at position #{i}"
131
+ end
132
+ tokens << [:eof]
133
+ tokens
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Reads MailMate's mailbox configuration: the user's `Mailboxes.plist` plus
6
+ # the bundled `standardMailboxes.plist`/`defaultMailboxes.plist`. Builds an
7
+ # in-memory graph keyed by UUID with name → uuid lookup.
8
+
9
+ module Mailmate
10
+ # @api public
11
+ class MailboxGraph
12
+ APP_RESOURCES = "/Applications/MailMate.app/Contents/Resources"
13
+
14
+ SPECIAL_UUIDS = %w[ALL_MESSAGES INBOX SENT DRAFTS ARCHIVE JUNK TRASH FLAGGED MAILBOXES PERSONAL_INBOX].freeze
15
+
16
+ # Mailbox = a Hash with keys: :uuid, :name, :parent, :set, :filter
17
+ attr_reader :by_uuid, :by_name
18
+
19
+ def self.load
20
+ new.tap(&:load!)
21
+ end
22
+
23
+ def initialize
24
+ @by_uuid = {}
25
+ @by_name = {}
26
+ end
27
+
28
+ def load!
29
+ load_plist!("#{APP_RESOURCES}/standardMailboxes.plist")
30
+ load_plist!("#{APP_RESOURCES}/defaultMailboxes.plist")
31
+ load_plist!(Mailmate.config.mailboxes_plist)
32
+ build_name_index!
33
+ self
34
+ end
35
+
36
+ def lookup(name_or_uuid)
37
+ @by_uuid[name_or_uuid] || @by_name[name_or_uuid]
38
+ end
39
+
40
+ private
41
+
42
+ def load_plist!(path)
43
+ return unless File.exist?(path)
44
+ data = JSON.parse(`plutil -convert json -o - #{shellesc(path)}`)
45
+ boxes = (data["mailboxes"] || []) + (data["deltaMailboxes"] || [])
46
+ boxes.each do |m|
47
+ next unless m["uuid"]
48
+ existing = @by_uuid[m["uuid"]] || {}
49
+ @by_uuid[m["uuid"]] = existing.merge(
50
+ uuid: m["uuid"],
51
+ name: m["name"] || existing[:name],
52
+ parent: m["parentUUID"] || existing[:parent],
53
+ set: m["set"] || existing[:set],
54
+ filter: m["filter"] || existing[:filter],
55
+ symbol: m["symbol"] || existing[:symbol],
56
+ )
57
+ end
58
+ end
59
+
60
+ def build_name_index!
61
+ @by_uuid.each do |uuid, m|
62
+ next unless m[:name]
63
+ # Prefer user-defined entries (later loads) over defaults; later loads
64
+ # already overwrite earlier ones in @by_uuid via merge, but the name
65
+ # index can still get clobbered when two mailboxes share a name.
66
+ # We keep the most-recently-loaded — and for tied cases, prefer the
67
+ # one that has a filter (smart mailboxes are what users address by name).
68
+ existing = @by_name[m[:name]]
69
+ @by_name[m[:name]] = uuid if existing.nil? || @by_uuid[existing][:filter].nil? || m[:filter]
70
+ end
71
+ end
72
+
73
+ def shellesc(s)
74
+ "'#{s.gsub("'", "'\\\\''")}'"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Message — thin wrapper that pairs a parsed Mail::Message with its `.eml`
4
+ # body-part ID and on-disk path. Stage B+ attribute paths (`#flags.flag`,
5
+ # `#date-last-viewed`, …) need the body-part ID to look up MailMate's binary
6
+ # Database.noindex/Headers indexes; carrying it alongside the Mail object lets
7
+ # the resolver fetch indexed values without re-deriving the ID.
8
+ #
9
+ # Delegates the headers-and-body interface back to Mail so existing code that
10
+ # expects a `Mail::Message` keeps working unchanged.
11
+
12
+ module Mailmate
13
+ # @api public
14
+ class Message
15
+ attr_reader :mail, :eml_id, :path
16
+
17
+ def initialize(mail, eml_id, path)
18
+ @mail = mail
19
+ @eml_id = eml_id.to_i
20
+ @path = path
21
+ end
22
+
23
+ # Delegate the headers/body methods Attributes uses.
24
+ %i[from to cc bcc reply_to sender subject date message_id received body
25
+ text_part html_part attachments].each do |m|
26
+ define_method(m) { mail.public_send(m) }
27
+ end
28
+
29
+ def [](key); mail[key]; end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ # @api public
5
+ #
6
+ # Build a MailMate `mid:` URL from a Message-ID. MailMate registers the
7
+ # `mid:` scheme (RFC 2392) and resolves it to a specific message. The angle
8
+ # brackets that bracket the Message-ID in headers must be percent-encoded.
9
+ module MidUrl
10
+ # Returns `mid:%3C<message-id>%3E`. Accepts the Message-ID with or without
11
+ # surrounding angle brackets — strips them either way before encoding.
12
+ # URL-encodes characters that would break the URL parser, notably `[` and
13
+ # `]` (which appear in Message-IDs containing IPv4 literals, e.g.
14
+ # `<id@[169.254.16.253]>`). Other URL-reserved characters (`@`, `.`, `-`,
15
+ # `_`, etc.) are preserved — MailMate's parser accepts them as-is.
16
+ def self.for(message_id)
17
+ raise ArgumentError, "Message-ID required" if message_id.nil? || message_id.to_s.empty?
18
+ id = message_id.to_s.sub(/\A</, "").sub(/>\z/, "")
19
+ encoded = id.gsub(/[\[\]<>\s]/) { |c| "%%%02X" % c.ord }
20
+ "mid:%3C#{encoded}%3E"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ # Operator evaluator + date arithmetic for MailMate filter expressions.
7
+
8
+ module Mailmate
9
+ # @api private
10
+ module Operators
11
+ # Compare a single LHS value against a single RHS, applying modifier flags.
12
+ # Returns true/false. lhs may be String, Time/DateTime, Numeric, or
13
+ # Mailmate::Attributes::AddressValue (which to_s renders as "Name <addr>").
14
+ def self.compare(lhs, op, flags, rhs)
15
+ lhs_norm = normalize(lhs, flags)
16
+ rhs_norm = normalize(rhs, flags)
17
+
18
+ case op
19
+ when "=" then lhs_norm == rhs_norm
20
+ when "!=" then lhs_norm != rhs_norm
21
+ when "~" then lhs_norm.to_s.include?(rhs_norm.to_s)
22
+ when "!~" then !lhs_norm.to_s.include?(rhs_norm.to_s)
23
+ when "<", "<=", ">", ">="
24
+ compare_ordered(lhs_norm, op, rhs_norm)
25
+ else
26
+ raise ArgumentError, "unknown operator: #{op.inspect}"
27
+ end
28
+ end
29
+
30
+ def self.normalize(v, flags)
31
+ return v if v.is_a?(Time) || v.is_a?(DateTime) || v.is_a?(Numeric)
32
+ s = v.to_s
33
+ s = s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "") if flags.include?("a")
34
+ s = s.downcase if flags.include?("c")
35
+ s
36
+ end
37
+
38
+ def self.compare_ordered(lhs, op, rhs)
39
+ lhs_v = coerce_for_ordering(lhs)
40
+ rhs_v = coerce_for_ordering(rhs)
41
+ return false if lhs_v.nil? || rhs_v.nil?
42
+ case op
43
+ when "<" then lhs_v < rhs_v
44
+ when "<=" then lhs_v <= rhs_v
45
+ when ">" then lhs_v > rhs_v
46
+ when ">=" then lhs_v >= rhs_v
47
+ end
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ def self.coerce_for_ordering(v)
53
+ return v.to_time if v.is_a?(DateTime)
54
+ return v if v.is_a?(Time) || v.is_a?(Numeric)
55
+ s = v.to_s
56
+ # Try Time, fall back to Integer
57
+ begin
58
+ return Time.parse(s)
59
+ rescue ArgumentError
60
+ end
61
+ Integer(s) rescue Float(s) rescue nil
62
+ end
63
+
64
+ # ---- Date arithmetic ----
65
+
66
+ # Resolve a relative date (n units ago) to a Time.
67
+ # `flags` may include "f", which floors to the start of the unit
68
+ # (matches MailMate's >[f] / <[f] semantics).
69
+ def self.relative_date(n, unit, flags = [])
70
+ now = Time.now
71
+ base =
72
+ case unit
73
+ when :day then now - n * 86_400
74
+ when :week then now - n * 7 * 86_400
75
+ when :month
76
+ y = now.year
77
+ m = now.month - n
78
+ while m <= 0
79
+ m += 12
80
+ y -= 1
81
+ end
82
+ Time.new(y, m, [now.day, last_day_of_month(y, m)].min, now.hour, now.min, now.sec)
83
+ when :year
84
+ Time.new(now.year - n, now.month, now.day, now.hour, now.min, now.sec)
85
+ end
86
+
87
+ return base unless flags.include?("f")
88
+
89
+ case unit
90
+ when :day
91
+ Time.new(base.year, base.month, base.day)
92
+ when :week
93
+ # Snap to start of ISO week (Monday)
94
+ d = base.to_date
95
+ d -= (d.cwday - 1) # Mon = 1
96
+ Time.new(d.year, d.month, d.day)
97
+ when :month
98
+ Time.new(base.year, base.month, 1)
99
+ when :year
100
+ Time.new(base.year, 1, 1)
101
+ else
102
+ base
103
+ end
104
+ end
105
+
106
+ def self.last_day_of_month(y, m)
107
+ Date.new(y, m, -1).day
108
+ end
109
+ end
110
+ end