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,609 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "optparse"
5
+ require "date"
6
+ require "csv"
7
+
8
+ module Mailmate
9
+ module CLI
10
+ # `mmsearch` — search MailMate's `.eml` files using a subset of MailMate's
11
+ # quicksearch syntax. Output is CSV with optional column-aligned padding.
12
+ #
13
+ # Ported from the standalone mailmate-search script. See `~/.claude/skills/email/SKILL.md`
14
+ # for usage examples and the search-string syntax reference.
15
+ # @api private
16
+ module Search
17
+ extend self
18
+
19
+ MODIFIERS = {
20
+ "f" => :from, "t" => :recipients, "c" => :cc, "s" => :subject,
21
+ "a" => :address_any, "b" => :body, "m" => :message_or_body,
22
+ "d" => :date, "T" => :tag, "K" => :keyword
23
+ }.freeze
24
+
25
+ HEADER_FIELDS = %i[from recipients cc subject address_any any].freeze
26
+
27
+ VALID_FIELDS = %w[id path mailbox from to cc bcc reply-to subject date time
28
+ message-id direction party flags read archive].freeze
29
+
30
+ HEADER_LABELS = {
31
+ "direction" => "dir",
32
+ "read" => "r",
33
+ "archive" => "a",
34
+ }.freeze
35
+
36
+ FIELD_TIERS = {
37
+ "id" => :index, "path" => :index, "mailbox" => :index,
38
+ "date" => :index, "time" => :index,
39
+ "read" => :index,
40
+ "archive" => :index,
41
+ "flags" => :index,
42
+ "from" => :header, "to" => :header, "cc" => :header, "bcc" => :header,
43
+ "reply-to" => :header, "subject" => :header, "message-id" => :header,
44
+ "direction" => :header, "party" => :header,
45
+ }.freeze
46
+
47
+ DEFAULT_SEARCH = "d 1d"
48
+ DEFAULT_FIELDS = "flags date time direction party subject"
49
+
50
+ def run(argv)
51
+ opts = {
52
+ mailbox: "all", limit: nil, headers_only: false,
53
+ header: true, align: true, sort: :asc,
54
+ }
55
+
56
+ parser = build_parser(opts)
57
+ parser.parse!(argv)
58
+
59
+ search_string = argv[0] || DEFAULT_SEARCH
60
+ fields_arg = (opts[:fields] || argv[1] || DEFAULT_FIELDS).to_s
61
+ extra_fields = fields_arg.strip.split(/\s+/).reject(&:empty?)
62
+ fields = (["id"] + extra_fields).uniq
63
+
64
+ imap_root = Mailmate.config.imap_root
65
+ unless File.directory?(imap_root)
66
+ warn "MailMate IMAP root not found: #{imap_root}"
67
+ return 1
68
+ end
69
+
70
+ unknown = fields - VALID_FIELDS
71
+ unless unknown.empty?
72
+ warn "Unknown field(s): #{unknown.join(", ")}"
73
+ warn "Valid: #{VALID_FIELDS.join(", ")}"
74
+ return 2
75
+ end
76
+
77
+ dirs, smart_filters, smart_graph = resolve_mailbox_with_graph(opts[:mailbox])
78
+ if dirs.empty?
79
+ warn "No mailbox directories resolved."
80
+ return 1
81
+ end
82
+
83
+ specs = parse_search(search_string)
84
+
85
+ # Compose + parse the smart-mailbox filter exactly once. The same AST
86
+ # feeds the evaluator, the tier classifier, and the literals extractor.
87
+ composed_ast = nil
88
+ composed_str = nil
89
+ smart_evaluator =
90
+ if smart_filters.any?
91
+ composed_str = compose_smart_filters(smart_filters)
92
+ begin
93
+ composed_ast = Mailmate.compile_filter(composed_str)
94
+ var_resolver = smart_graph ? Mailmate::VarResolver.new(smart_graph) : nil
95
+ Mailmate::Evaluator.new(composed_ast, var_resolver: var_resolver)
96
+ rescue Mailmate::Lexer::Error, Mailmate::Parser::Error => e
97
+ warn "Smart-mailbox filter parse error: #{e.message}\n filter: #{composed_str}"
98
+ return 1
99
+ end
100
+ end
101
+
102
+ filter_tier = composed_ast ? Mailmate::FilterClassifier.tier(composed_ast) : :index
103
+ specs_tier =
104
+ if specs.empty?
105
+ :index
106
+ elsif specs.any? { |field, _, _| (field == :body || field == :message_or_body) && !opts[:headers_only] }
107
+ :full
108
+ elsif specs.all? { |field, _, _| field == :date }
109
+ :index
110
+ else
111
+ :header
112
+ end
113
+ fields_tier_ = fields_tier(fields)
114
+ filter_only_tier = Mailmate::FilterClassifier.combine_tiers(filter_tier, specs_tier)
115
+ load_tier = Mailmate::FilterClassifier.combine_tiers(filter_only_tier, fields_tier_)
116
+
117
+ smart_literals = composed_ast ? Mailmate::FilterClassifier.header_literals(composed_ast) : []
118
+
119
+ rows = collect_rows(
120
+ dirs: dirs, specs: specs, fields: fields,
121
+ smart_evaluator: smart_evaluator, smart_literals: smart_literals,
122
+ filter_only_tier: filter_only_tier, load_tier: load_tier,
123
+ opts: opts,
124
+ )
125
+
126
+ sort_rows!(rows, opts[:sort])
127
+ emit_output(rows, fields, opts)
128
+ 0
129
+ end
130
+
131
+ # ---- sort ---------------------------------------------------------------
132
+
133
+ # Sorts `rows` in place by the message's absolute send instant (UTC), so
134
+ # senders in different timezones still order correctly. The first column
135
+ # is always `id` (forced in `run`), which lets us hit the `#date` index
136
+ # without re-reading any .eml.
137
+ def sort_rows!(rows, mode)
138
+ return rows if mode == :none || rows.size < 2
139
+ reader = Mailmate::IndexReader.for("#date") rescue nil
140
+ epoch = Time.at(0)
141
+ rows.sort_by! do |r|
142
+ s = reader && (reader.value_for(r[0].to_i) rescue nil)
143
+ (s && !s.empty? && (Time.parse(s) rescue nil)) || epoch
144
+ end
145
+ rows.reverse! if mode == :desc
146
+ rows
147
+ end
148
+
149
+ # ---- option parsing -----------------------------------------------------
150
+
151
+ def build_parser(opts)
152
+ OptionParser.new do |o|
153
+ o.banner = "Usage: mmsearch [search-string] [fields] [options]"
154
+ o.separator ""
155
+ o.separator "Search MailMate's `.eml` files. Output is CSV with column-aligned padding."
156
+ o.separator ""
157
+ o.separator "POSITIONAL ARGS"
158
+ o.separator " search-string Quicksearch expression. Default: 'd 1d'. Pass '' to disable."
159
+ o.separator " fields Space-separated columns to add (id is always first)."
160
+ o.separator " Default: 'flags date time direction party subject'."
161
+ o.separator ""
162
+ o.separator "OPTIONS"
163
+ o.on("--mailbox X", "Mailbox to search (default: all)") { |v| opts[:mailbox] = v }
164
+ o.on("--fields F", "Fields list (alt to 2nd positional)") { |v| opts[:fields] = v }
165
+ o.on("--limit N", Integer, "Stop after N matches") { |n| opts[:limit] = n }
166
+ o.on("--headers-only", "Skip body matching") { opts[:headers_only] = true }
167
+ o.on("--no-header", "Suppress column header row") { opts[:header] = false }
168
+ o.on("--no-align", "Plain CSV (no column padding)") { opts[:align] = false }
169
+ o.on("--sort MODE", %w[asc desc none],
170
+ "Sort rows by date+time: asc (default), desc, none") { |v| opts[:sort] = v.to_sym }
171
+ o.separator ""
172
+ o.separator "SEARCH-STRING SYNTAX"
173
+ o.separator " Mirrors MailMate's toolbar quicksearch. Specs combine with AND."
174
+ o.separator " Wrap multi-word terms in \"double quotes\". Prefix operand with ! to negate."
175
+ o.separator ""
176
+ o.separator " <term> common headers (from/to/cc/subject) OR body contains <term>"
177
+ o.separator " f <term> from contains"
178
+ o.separator " t <term> to/cc (recipients) contains"
179
+ o.separator " c <term> cc contains"
180
+ o.separator " s <term> subject contains"
181
+ o.separator " a <term> any address header contains"
182
+ o.separator " b <term> body contains (slow — disables prefilter)"
183
+ o.separator " m <term> common headers OR body (same as bare term)"
184
+ o.separator " d <date> received date: Nd|Nw|Nm|Ny (relative), or Y, Y-M, Y-M-D"
185
+ o.separator " T <tag> tag / IMAP keyword contains (K is a synonym)"
186
+ o.separator ""
187
+ o.separator " Examples:"
188
+ o.separator " mmsearch 'f medium d 7d' from Medium in last 7 days"
189
+ o.separator " mmsearch 's \"rent due\" !draft' subject has rent due, no 'draft'"
190
+ o.separator " mmsearch 'd 2026-05' received in May 2026"
191
+ end
192
+ end
193
+
194
+ # ---- mailbox resolution -------------------------------------------------
195
+
196
+ def all_message_dirs
197
+ Dir.glob("#{Mailmate.config.imap_root}/*/**/Messages").select { |p| File.directory?(p) }
198
+ end
199
+
200
+ def resolve_account(name)
201
+ root = Mailmate.config.imap_root
202
+ return name if File.directory?("#{root}/#{name}")
203
+ encoded = name.gsub("@", "%40")
204
+ candidates = Dir.glob("#{root}/#{encoded}@*").map { |p| File.basename(p) }
205
+ case candidates.size
206
+ when 0 then nil
207
+ when 1 then candidates.first
208
+ else
209
+ warn "Ambiguous account '#{name}': #{candidates.join(", ")}"
210
+ nil
211
+ end
212
+ end
213
+
214
+ def resolve_mailbox(arg)
215
+ root = Mailmate.config.imap_root
216
+ return [all_message_dirs, []] if arg == "all"
217
+
218
+ if arg.include?("/")
219
+ account, rest = arg.split("/", 2)
220
+ if (encoded = resolve_account(account))
221
+ nested = rest.split("/").map { |s| "#{s}.mailbox" }.join("/")
222
+ cand = "#{root}/#{encoded}/#{nested}/Messages"
223
+ return [[cand], []] if File.directory?(cand)
224
+ end
225
+ end
226
+
227
+ if (encoded = resolve_account(arg))
228
+ dirs = Dir.glob("#{root}/#{encoded}/**/Messages").select { |p| File.directory?(p) }
229
+ return [dirs, []]
230
+ end
231
+
232
+ matches = Dir.glob("#{root}/*/**/#{arg}.mailbox/Messages").select { |p| File.directory?(p) }
233
+ return [matches, []] unless matches.empty?
234
+
235
+ # Fall back: try MailMate's smart-mailbox graph.
236
+ graph = Mailmate::MailboxGraph.load
237
+ if (uuid = graph.by_name[arg]) || graph.by_uuid[arg]
238
+ uuid ||= arg
239
+ res = Mailmate::SourceResolver.new(graph).resolve(uuid)
240
+ return [res[:dirs], res[:filters], graph]
241
+ end
242
+
243
+ warn "Mailbox not resolved: '#{arg}'."
244
+ [[], []]
245
+ end
246
+
247
+ def resolve_mailbox_with_graph(arg)
248
+ result = resolve_mailbox(arg)
249
+ result.size == 2 ? [*result, nil] : result
250
+ end
251
+
252
+ def compose_smart_filters(filters)
253
+ return "" if filters.empty?
254
+ return filters.first if filters.size == 1
255
+ "(#{filters.map { |f| "(#{f})" }.join(" and ")})"
256
+ end
257
+
258
+ # ---- search-string parsing ----------------------------------------------
259
+
260
+ def tokenize(str)
261
+ tokens = []
262
+ i = 0
263
+ while i < str.length
264
+ c = str[i]
265
+ if c == " " || c == "\t"
266
+ i += 1
267
+ elsif c == "\""
268
+ j = str.index("\"", i + 1) || str.length
269
+ tokens << str[(i + 1)...j]
270
+ i = j + 1
271
+ else
272
+ j = i
273
+ j += 1 while j < str.length && str[j] != " "
274
+ tokens << str[i...j]
275
+ i = j
276
+ end
277
+ end
278
+ tokens
279
+ end
280
+
281
+ def parse_search(str)
282
+ tokens = tokenize(str)
283
+ specs = []
284
+ i = 0
285
+ while i < tokens.size
286
+ tok = tokens[i]
287
+ field = MODIFIERS[tok]
288
+ if field && i + 1 < tokens.size
289
+ operand = tokens[i + 1]
290
+ negate = operand.start_with?("!")
291
+ operand = operand[1..] if negate
292
+ specs << [field, operand.downcase, negate]
293
+ i += 2
294
+ else
295
+ negate = tok.start_with?("!")
296
+ operand = negate ? tok[1..] : tok
297
+ # Bare terms default to MailMate's "Common" specifier — common
298
+ # headers OR body — matching the UI quicksearch behavior. Pass
299
+ # --headers-only to skip the body scan when speed matters.
300
+ specs << [:message_or_body, operand.downcase, negate]
301
+ i += 1
302
+ end
303
+ end
304
+ specs
305
+ end
306
+
307
+ # ---- date matching ------------------------------------------------------
308
+
309
+ def date_matches?(mail, eml_id, term)
310
+ d = nil
311
+ if eml_id
312
+ s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
313
+ if s && !s.empty?
314
+ d = (Time.parse(s) rescue nil)
315
+ end
316
+ end
317
+ if d.nil? && mail
318
+ raw = mail.date
319
+ d = raw.respond_to?(:to_time) ? raw.to_time : raw
320
+ end
321
+ return false unless d
322
+
323
+ if term =~ /\A(\d+)([dwmy])\z/
324
+ n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
325
+ cutoff = case u
326
+ when "d" then Date.today - n
327
+ when "w" then Date.today - (n * 7)
328
+ when "m" then Date.today << n
329
+ when "y" then Date.today << (n * 12)
330
+ end
331
+ return d.to_date >= cutoff
332
+ end
333
+
334
+ norm = term.tr("/.", "-")
335
+ parts = norm.split("-")
336
+ case parts.size
337
+ when 1 then d.year.to_s == parts[0]
338
+ when 2 then d.year.to_s == parts[0] && d.month == parts[1].to_i
339
+ when 3 then d.to_date == Date.new(parts[0].to_i, parts[1].to_i, parts[2].to_i)
340
+ else false
341
+ end
342
+ rescue StandardError
343
+ false
344
+ end
345
+
346
+ # ---- field-value matching -----------------------------------------------
347
+
348
+ def field_value(mail, field)
349
+ case field
350
+ when :from
351
+ [Array(mail.from), mail[:from]&.value.to_s].flatten.join(" ").downcase
352
+ when :recipients
353
+ [Array(mail.to), Array(mail.cc), mail[:to]&.value.to_s, mail[:cc]&.value.to_s]
354
+ .flatten.join(" ").downcase
355
+ when :cc
356
+ [Array(mail.cc), mail[:cc]&.value.to_s].flatten.join(" ").downcase
357
+ when :subject
358
+ mail.subject.to_s.downcase
359
+ when :address_any
360
+ [mail[:from], mail[:to], mail[:cc], mail[:reply_to], mail[:sender]]
361
+ .compact.map { |h| h.value.to_s }.join(" ").downcase
362
+ when :tag, :keyword
363
+ [mail["x-keywords"]&.value, mail["keywords"]&.value].compact.join(" ").downcase
364
+ end
365
+ end
366
+
367
+ def text_body(mail)
368
+ (mail.text_part&.decoded || mail.body.decoded).to_s.force_encoding("UTF-8").scrub.downcase
369
+ rescue StandardError
370
+ ""
371
+ end
372
+
373
+ def matches?(mail, eml_id, specs, headers_only)
374
+ specs.all? do |field, term, negate|
375
+ hit =
376
+ case field
377
+ when :from, :recipients, :cc, :subject, :address_any, :tag, :keyword
378
+ field_value(mail, field).include?(term)
379
+ when :body
380
+ headers_only ? false : text_body(mail).include?(term)
381
+ when :message_or_body
382
+ common = %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
383
+ common || (!headers_only && text_body(mail).include?(term))
384
+ when :date
385
+ date_matches?(mail, eml_id, term)
386
+ when :any
387
+ %i[from recipients subject].any? { |f| field_value(mail, f).include?(term) }
388
+ end
389
+ negate ? !hit : hit
390
+ end
391
+ end
392
+
393
+ # ---- pre-filter ---------------------------------------------------------
394
+
395
+ def can_prefilter?(specs)
396
+ specs.any? do |field, term, negate|
397
+ !negate && HEADER_FIELDS.include?(field) && term.bytesize >= 3 && term.ascii_only?
398
+ end
399
+ end
400
+
401
+ def header_block(path)
402
+ bytes = +""
403
+ File.open(path, "rb") do |f|
404
+ while (chunk = f.read(4096))
405
+ bytes << chunk
406
+ idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
407
+ return bytes[0..idx].downcase if idx
408
+ break if bytes.bytesize > 65_536
409
+ end
410
+ end
411
+ bytes.downcase
412
+ end
413
+
414
+ def prefilter_pass?(path, specs, smart_literals = [])
415
+ return true if !can_prefilter?(specs) && smart_literals.empty?
416
+ hdr = header_block(path)
417
+ specs.each do |field, term, negate|
418
+ next if negate
419
+ next unless HEADER_FIELDS.include?(field)
420
+ next unless term.bytesize >= 3 && term.ascii_only?
421
+ return false unless hdr.include?(term)
422
+ end
423
+ smart_literals.each do |lit|
424
+ return false unless hdr.include?(lit)
425
+ end
426
+ true
427
+ rescue StandardError
428
+ true
429
+ end
430
+
431
+ # ---- timestamp ----------------------------------------------------------
432
+
433
+ # Absolute send time for an eml_id, preferring the MailMate `#date` index
434
+ # (cheap, no .eml read). Falls back to the parsed mail's Date header.
435
+ def message_time(eml_id, mail)
436
+ s = (Mailmate::IndexReader.for("#date").value_for(eml_id.to_i) rescue nil)
437
+ if s && !s.empty?
438
+ t = (Time.parse(s) rescue nil)
439
+ return t if t
440
+ end
441
+ raw = mail&.date
442
+ return nil unless raw
443
+ raw.respond_to?(:to_time) ? raw.to_time : raw
444
+ rescue StandardError
445
+ nil
446
+ end
447
+
448
+ # ---- field extraction ---------------------------------------------------
449
+
450
+ def outbound?(path, mail)
451
+ return true if path.include?("/Sent Mail.mailbox/") ||
452
+ path.include?("/Sent Messages.mailbox/") ||
453
+ path.include?("/Drafts.mailbox/")
454
+ from = Array(mail.from).first.to_s.downcase
455
+ Mailmate::Identity.mine?(from)
456
+ end
457
+
458
+ def party_for(mail, outbound)
459
+ if outbound
460
+ others = Mailmate::Identity.reject_mine(Array(mail.to) + Array(mail.cc))
461
+ others = Array(mail.to) if others.empty?
462
+ others.join("; ")
463
+ else
464
+ Array(mail.from).join("; ")
465
+ end
466
+ end
467
+
468
+ def extract(field, eml_id, path, mail)
469
+ case field
470
+ when "id" then eml_id
471
+ when "path" then path
472
+ when "mailbox" then path.sub("#{Mailmate.config.imap_root}/", "").sub(%r{/Messages/[^/]+\.eml\z}, "")
473
+ when "date"
474
+ t = message_time(eml_id, mail)
475
+ Mailmate.localize(t)&.strftime("%Y-%m-%d")
476
+ when "time"
477
+ t = message_time(eml_id, mail)
478
+ Mailmate.localize(t)&.strftime("%H:%M")
479
+ when "read"
480
+ flags = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue [])
481
+ flags.include?("\\Seen") ? "R" : "U"
482
+ when "archive"
483
+ path.include?("/Archive.mailbox/") ? "A" : "P"
484
+ when "flags"
485
+ archive = path.include?("/Archive.mailbox/") ? "A" : "P"
486
+ seen = (Mailmate::IndexReader.for("#flags").flags_for(eml_id.to_i) rescue []).include?("\\Seen")
487
+ "#{archive}#{seen ? 'R' : 'U'}"
488
+ when "from" then mail ? Array(mail.from).join("; ") : nil
489
+ when "to" then mail ? Array(mail.to).join("; ") : nil
490
+ when "cc" then mail ? Array(mail.cc).join("; ") : nil
491
+ when "bcc" then mail ? Array(mail.bcc).join("; ") : nil
492
+ when "reply-to" then mail ? Array(mail.reply_to).join("; ") : nil
493
+ when "subject" then mail&.subject.to_s
494
+ when "message-id" then mail&.message_id.to_s
495
+ when "direction" then mail ? (outbound?(path, mail) ? "→" : "←") : nil
496
+ when "party" then mail ? party_for(mail, outbound?(path, mail)) : nil
497
+ end.to_s
498
+ end
499
+
500
+ def fields_tier(fields)
501
+ ts = fields.map { |f| FIELD_TIERS[f] || :header }.uniq
502
+ return :full if ts.include?(:full)
503
+ return :header if ts.include?(:header)
504
+ :index
505
+ end
506
+
507
+ # ---- driver loop --------------------------------------------------------
508
+
509
+ def load_message(path, tier)
510
+ case tier
511
+ when :index then nil
512
+ when :header
513
+ bytes = +""
514
+ File.open(path, "rb") do |f|
515
+ while (chunk = f.read(4096))
516
+ bytes << chunk
517
+ idx = bytes.index("\r\n\r\n") || bytes.index("\n\n")
518
+ break if idx
519
+ break if bytes.bytesize > 65_536
520
+ end
521
+ end
522
+ Mail.new(bytes)
523
+ when :full
524
+ Mail.read(path)
525
+ end
526
+ end
527
+
528
+ def collect_rows(dirs:, specs:, fields:, smart_evaluator:, smart_literals:, filter_only_tier:, load_tier:, opts:)
529
+ rows = []
530
+ catch(:done) do
531
+ dirs.each do |dir|
532
+ Dir.each_child(dir) do |fname|
533
+ next unless fname.end_with?(".eml")
534
+ eml_id = fname.sub(".eml", "")
535
+ path = "#{dir}/#{fname}"
536
+
537
+ next unless prefilter_pass?(path, specs, smart_literals)
538
+
539
+ if filter_only_tier == :index
540
+ if smart_evaluator
541
+ next unless smart_evaluator.matches?(Mailmate::Message.new(nil, eml_id, path))
542
+ end
543
+ if !specs.empty?
544
+ next unless matches?(nil, eml_id, specs, opts[:headers_only])
545
+ end
546
+ end
547
+
548
+ mail = nil
549
+ if load_tier != :index
550
+ begin
551
+ mail = load_message(path, load_tier)
552
+ rescue StandardError => e
553
+ warn "[skip] #{path}: #{e.message}"
554
+ next
555
+ end
556
+ end
557
+
558
+ if filter_only_tier != :index
559
+ if !specs.empty?
560
+ next unless matches?(mail, eml_id, specs, opts[:headers_only])
561
+ end
562
+ if smart_evaluator
563
+ next unless smart_evaluator.matches?(Mailmate::Message.new(mail, eml_id, path))
564
+ end
565
+ end
566
+
567
+ rows << fields.map { |f| extract(f, eml_id, path, mail) }
568
+ throw :done if opts[:limit] && rows.size >= opts[:limit]
569
+ end
570
+ end
571
+ end
572
+ rows
573
+ end
574
+
575
+ # ---- output -------------------------------------------------------------
576
+
577
+ def csv_quote(cell)
578
+ cell = cell.to_s.gsub(/[\r\n]+/, " ")
579
+ if cell.include?(",") || cell.include?("\"")
580
+ "\"#{cell.gsub("\"", "\"\"")}\""
581
+ else
582
+ cell
583
+ end
584
+ end
585
+
586
+ def emit_output(rows, fields, opts)
587
+ header_row = fields.map { |f| HEADER_LABELS[f] || f }
588
+
589
+ if opts[:align]
590
+ display_rows = rows.map { |r| r.map { |c| csv_quote(c) } }
591
+ display_rows.unshift(header_row) if opts[:header]
592
+ widths = Array.new(fields.size, 0)
593
+ display_rows.each do |r|
594
+ r.each_with_index { |c, i| widths[i] = c.length if c.length > widths[i] }
595
+ end
596
+ display_rows.each do |r|
597
+ padded = r.each_with_index.map do |c, i|
598
+ i == r.size - 1 ? c : c.ljust(widths[i])
599
+ end
600
+ puts padded.join(",")
601
+ end
602
+ else
603
+ puts CSV.generate_line(header_row) if opts[:header]
604
+ rows.each { |r| puts CSV.generate_line(r) }
605
+ end
606
+ end
607
+ end
608
+ end
609
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailmate
4
+ module CLI
5
+ # `mm-send` — send mail through MailMate's `emate` CLI with a markdown body.
6
+ # Replaces the 6-line bash wrapper that previously lived at
7
+ # ~/.claude/skills/email/send-email. All flags pass through to `emate
8
+ # mailto`; `--markup markdown` is enforced.
9
+ # @api private
10
+ module Send
11
+ extend self
12
+
13
+ EMATE_PATH = "/Applications/MailMate.app/Contents/Resources/emate"
14
+
15
+ # Returns the exit status of the spawned `emate` invocation. Uses
16
+ # `system` (not `exec`) so the caller — and the test suite — can
17
+ # actually observe the result.
18
+ def run(argv)
19
+ Mailmate::PlatformError.check_darwin!(component: "mm-send")
20
+ unless File.executable?(EMATE_PATH)
21
+ warn "mm-send: emate not found at #{EMATE_PATH}. Is MailMate installed?"
22
+ return 1
23
+ end
24
+ system(EMATE_PATH, "mailto", "--markup", "markdown", *argv)
25
+ $?.exitstatus
26
+ end
27
+ end
28
+ end
29
+ end