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,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