mailmate 1.8.0 → 1.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d4531494a2e95ee32395c937a293085b055c6868327f9914702da62a7b039c6
4
- data.tar.gz: 3251b2608c1ede51d85425c205a274993d51b58b9cace6f04eb53981a04345bb
3
+ metadata.gz: dd43afdf338a47a4616748d1e3ecb7781466ee73c836030bb9fdd763af1e65e3
4
+ data.tar.gz: d0d87cfbe4166a9aa8868e237ff2fc628a3d00b57f8de8079eacdb4cb4282de1
5
5
  SHA512:
6
- metadata.gz: 4a162097210b4578cc6118e70607258523cbf1b0a82e50a6c50b9b10a3a30bef05c5dd394ab50204920d7967bb9103073a457259f1e4ec570920daff09b1031e
7
- data.tar.gz: 84fa48ec99f5e9df866899d4c805df8de7c1b427fd8ba868732baf79ad9c9b6c3c55cae92e00fec928ecc3c43fca7cd4572d8f2828f4a270494310dbdd7c97f7
6
+ metadata.gz: 052c4619e7cf07129903242c5d8081f61ae64a46371cdf04fc5df0a6f1dbbef982a4e944e5629d12aa82ff036f14016080fb0e77fc814e23f0a9595ef2064962
7
+ data.tar.gz: 1a9918739f80c5b5d46030214b58e9c8dcb87ba76e12c5dee14c9c79f2e96de34dc3bf2461f528edc9f22deb1e1f5497566e14c58d31f981e42cd8a819c76c65
data/README.md CHANGED
@@ -185,10 +185,11 @@ mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
185
185
  | `a <term>` | Any address header contains. |
186
186
  | `b <term>` | Body (plain text) contains. |
187
187
  | `m <term>` | Common headers OR body. |
188
- | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (N calendar units ending today — `1d` = today, `2d` = yesterday + today), or absolute `Y`, `Y-M`, `Y-M-D`. Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
188
+ | `d <date>` | Received: `Nh` (rolling clock hours, `24h` = last 24 hours), `Nd`/`Nw`/`Nm`/`Ny` (calendar units floored to the unit start, app parity — `1d` = today, `1w` = this week, `1y` = this year), absolute `Y`, `Y-M`, `Y-M-D`, or day-of-month `D` / `M-D` (`d 7` = the most recent 7th). Slash dates are month-first American (`8/9/2026` = Aug 9); `--european` flips to day-first. Comparisons on absolute dates: `d >2026-08` (after), `d <2026-08` (before), also `>=`/`<=`. |
189
189
  | `T <tag>` | Tags / IMAP keywords (`K` is a synonym). |
190
- | `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates). |
191
- | `has:attachment` | Root MIME type is `multipart/mixed` the standard attachment layout. |
190
+ | `is:<state>` | Message state: `is:unread`, `is:read`, `is:flagged`, `is:replied`, `is:draft`, `is:archived` (Gmail synonyms `starred`/`answered` work; `-is:unread` negates; `in:inbox`/`in:archive` translate onto `is:archived`). |
191
+ | `has:attachment` | Root MIME type is `multipart/mixed`; wrapper types (`signed`/`encrypted`/`related`) are opened and checked for real attachments. |
192
+ | `<header>:<term>` | Any indexed header contains (`delivered-to:joe`). A header this store has never seen is a usage error, not an empty result. |
192
193
  | `!<value>` | Negate, e.g. `f !smith` = From does NOT contain smith; works on dates too (`d !3d` = more than 3 days ago). |
193
194
 
194
195
  Dates match on the **display-zone day** — the same day the `date`/`time` output columns show. An impossible date term or combination (`d 2026-02-31`, `d >2026 d <2025`) is a usage error, not a silent empty result. Familiar foreign `key:value` tokens (`from:bob`, `date:today`, `after:2026-08-01`, `older_than:2w`) are auto-translated to quicksearch with each rewrite announced on stderr; unrecognized keys (`is:unread`) are searched as literal text, and an empty result says so.
@@ -433,6 +434,8 @@ A few rough edges to be aware of:
433
434
 
434
435
  ## Status
435
436
 
437
+ 1.9.0 — App-parity search release. Arbitrary header specs are native (`delivered-to:joe`, `x-mailer.name:mailmate` — subpaths search the whole header value); index-only by design: a header the store has never seen is a usage error suggesting quotes, never a `Mail.read` per candidate. Relative calendar units now floor to the unit start, matching the app's documented semantics (`1w` = this week, `1y` = this year; `Nh` stays a rolling clock window on purpose). Day-of-month terms (`d 7` = the most recent 7th, `d 12-25` = last Christmas) close the last known silent-empty. `is:archived` joins the state specs (path-derived, same source as the flags column), with `in:inbox`/`in:archive` translating onto it; `has:attachment` now opens `multipart/signed`/`encrypted`/`related` wrappers and checks for real attachments. New `rake release` runs the whole mechanical release — clean-tree + green-tests + unreleased-version guards, then build, tag, sync, `gem push`.
438
+
436
439
  1.8.0 — Reply derivation, and message-state specs.
437
440
 
438
441
  **Composing:** replies and forwards are now derived from the parent instead of hand-assembled. `mm-send` / `mm-draft` gain `--reply-to <id>`, `--reply-all-to <id>`, `--forward <id>` (eml-id or Message-ID), which compute `In-Reply-To`, the full `References` chain, recipients and the `Re:`/`Fwd:` subject, plus `--no-quote` and `--print-prefill` (derived fields as JSON, sends nothing). The MCP `send`/`draft` tools take the same `reply_to` / `reply_all_to` / `forward` arguments. Explicitly-passed fields win over derived ones, and overriding a visible field never drops the threading headers. A forward deliberately does *not* thread into the original conversation. The chain is built in exactly one place (`Mailmate::ReplyPrefill`), and `--header` values now route through one sanitizer (`Mailmate::HeaderValue`) — previously the MCP's own argv builder lacked the CR/LF defense that stops a parent `Message-ID` smuggling extra headers. All of it is documented once in [docs/Composing and threading.md](docs/Composing%20and%20threading.md), which the README, `--help` preambles and MCP instructions now point at rather than restate. Every `exe/` command answers `--version`, so consumers can check the installed version instead of probing for a flag (the CLIs pass unknown flags through to `emate`, which makes flag-probing unsafe). The gemspec finally carries `homepage` and `metadata`, so the rubygems page links back to the source.
@@ -405,6 +405,14 @@ module Mailmate
405
405
  # Gmail callers actually write.
406
406
  negate ||= operand.start_with?("-")
407
407
  specs << [:state, operand.delete_prefix("-").downcase, negate]
408
+ elsif !quoted && (hdr = header_spec_token(operand))
409
+ # Arbitrary header specs (delivered-to:joe) — native app
410
+ # syntax per the manual. Foreign keys (date:, from:, ...) never
411
+ # reach here: translate() rewrote the translatable ones before
412
+ # parsing, and the rest stay literal so zero_result_hint can
413
+ # suggest their quicksearch equivalent.
414
+ negate ||= operand.start_with?("-")
415
+ specs << [:header, hdr, negate]
408
416
  else
409
417
  # A bare term opening an or-group inherits the modifier in
410
418
  # force (`d 2024 or 2025`). Elsewhere it is MailMate's
@@ -420,13 +428,29 @@ module Mailmate
420
428
  [specs, in_force]
421
429
  end
422
430
 
431
+ # The downcased "name:value" for a bare token that should parse as an
432
+ # arbitrary-header spec, nil otherwise. Excluded: keys the translator
433
+ # owns (FOREIGN_KEYS — their untranslatable forms stay literal for the
434
+ # zero-result hint), state keys (is/has, handled first), and
435
+ # URL-shaped tokens (http://... is a term, not a search of the
436
+ # nonexistent "http" header).
437
+ def header_spec_token(operand)
438
+ m = operand.match(/\A-?([A-Za-z][\w-]*(?:\.[\w.-]+)?):(\S+)\z/)
439
+ return nil unless m
440
+ return nil if m[2].start_with?("/")
441
+ key = Mailmate::SearchSyntax.normalize_key(m[1].sub(/\..*/, ""))
442
+ return nil if Mailmate::SearchSyntax::FOREIGN_KEYS.include?(key)
443
+ return nil if %w[is has].include?(key)
444
+ "#{m[1]}:#{m[2]}".downcase
445
+ end
446
+
423
447
  # Static cost rank per spec field for AND evaluation order: compiled
424
448
  # date compare < header/tag index lookup < body matching (resolves
425
449
  # part-ids and walks every body segment). Used by order_specs.
426
450
  SPEC_COST = {
427
451
  date: 0,
428
452
  from: 1, recipients: 1, cc: 1, subject: 1, address_any: 1, any: 1,
429
- tag: 1, keyword: 1, state: 1,
453
+ tag: 1, keyword: 1, state: 1, header: 1,
430
454
  body: 2, message_or_body: 2,
431
455
  }.freeze
432
456
 
@@ -438,6 +462,7 @@ module Mailmate
438
462
  "flagged" => :flagged, "starred" => :flagged,
439
463
  "replied" => :replied, "answered" => :replied,
440
464
  "draft" => :draft,
465
+ "archived" => :archived, "archive" => :archived,
441
466
  "attachment" => :attachment, "attachments" => :attachment,
442
467
  }.freeze
443
468
 
@@ -528,16 +553,23 @@ module Mailmate
528
553
  if term =~ /\A(\d+)([dwmy])\z/
529
554
  n, u = Regexp.last_match(1).to_i, Regexp.last_match(2)
530
555
  return nil if n.zero? # a zero-length window matches nothing
531
- # N units ENDING today: `1d` = today only, `7d` = the last 7
532
- # calendar days including today. (Off-by-one fixed 2026-08-11 to
533
- # match the MailMate app, where `d 1d` is today's mail the old
534
- # cutoff of today-N made `d 1d` span two calendar days. For a
535
- # rolling 24-hour clock window, that's `d 24h` now.)
556
+ # Calendar units floored to the unit start, matching the app's
557
+ # documented semantics ("1y means this year and not 365 days"):
558
+ # 1d = today, 1w = this ISO week (from Monday), 1m = this month,
559
+ # 1y = this year; N reaches back N-1 further units. Only `Nh` is a
560
+ # rolling clock window that split is deliberate (2026-08-18):
561
+ # calendar words mean calendar spans, and "the last 24 hours" is
562
+ # spelled d 24h.
536
563
  cutoff = case u
537
564
  when "d" then today - (n - 1)
538
- when "w" then today - (n * 7 - 1)
539
- when "m" then (today << n) + 1
540
- when "y" then (today << (n * 12)) + 1
565
+ when "w"
566
+ start = today - (7 * (n - 1))
567
+ start - (start.cwday - 1)
568
+ when "m"
569
+ start = today << (n - 1)
570
+ Date.new(start.year, start.month, 1)
571
+ when "y"
572
+ Date.new(today.year - (n - 1), 1, 1)
541
573
  end
542
574
  return [ymd_int(cutoff), 9999_12_31]
543
575
  end
@@ -547,15 +579,34 @@ module Mailmate
547
579
 
548
580
  case parts.size
549
581
  when 1
550
- y = parts[0].to_i
551
- return nil if y.zero?
552
- [y * 10_000 + 101, y * 10_000 + 1231]
582
+ if parts[0].length == 4
583
+ y = parts[0].to_i
584
+ return nil if y.zero?
585
+ [y * 10_000 + 101, y * 10_000 + 1231]
586
+ else
587
+ # App semantics: a bare small number is a day of the current
588
+ # month — or the most recent month containing that day when it
589
+ # hasn't happened yet (`d 7` on the 5th = last month's 7th).
590
+ most_recent_day_range(parts[0].to_i, today)
591
+ end
553
592
  when 2
554
- # Year-first (2026-08) or month-first with a 4-digit year (8/2026).
555
- y, m = parts[1].length == 4 ? [parts[1], parts[0]] : [parts[0], parts[1]]
556
- y, m = y.to_i, m.to_i
557
- return nil if y.zero? || !(1..12).cover?(m)
558
- [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
593
+ if parts[1].length == 4
594
+ # Month-first with a 4-digit year (8/2026).
595
+ y, m = parts[1].to_i, parts[0].to_i
596
+ return nil if y.zero? || !(1..12).cover?(m)
597
+ [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
598
+ elsif parts[0].length == 4
599
+ # Year-first (2026-08).
600
+ y, m = parts[0].to_i, parts[1].to_i
601
+ return nil if y.zero? || !(1..12).cover?(m)
602
+ [y * 10_000 + m * 100 + 1, y * 10_000 + m * 100 + 31]
603
+ else
604
+ # No year: month + day, ordered per date_order, most recent
605
+ # occurrence (`d 12-25` in August = last year's Dec 25).
606
+ a, b = parts.map(&:to_i)
607
+ m, d = date_order == :dmy ? [b, a] : [a, b]
608
+ most_recent_month_day_range(m, d, today)
609
+ end
559
610
  when 3
560
611
  # ISO year-first, or slash-date with trailing 4-digit year ordered
561
612
  # per date_order. Impossible calendar dates (2026-02-31, month 13)
@@ -590,7 +641,14 @@ module Mailmate
590
641
  # would otherwise quietly match no message ever.
591
642
  if field == :state && !STATE_CANON.key?(term.split(":", 2).last)
592
643
  return "state term cannot match anything: #{term} " \
593
- "(known: is:unread is:read is:flagged is:replied is:draft has:attachment)"
644
+ "(known: is:unread is:read is:flagged is:replied is:draft is:archived has:attachment)"
645
+ end
646
+ if field == :header
647
+ name = term.split(":", 2).first.sub(/\..*/, "")
648
+ if reader_for(name).nil?
649
+ return "no '#{name}' header index — this MailMate store has never seen that " \
650
+ "header. Quote the token (\"#{term}\") to search it as literal text."
651
+ end
594
652
  end
595
653
  next unless field == :date
596
654
  range = hour_range_for(term) || date_range_for(term)
@@ -615,6 +673,33 @@ module Mailmate
615
673
  nil
616
674
  end
617
675
 
676
+ # Single day for the most recent occurrence of day-of-month `day`,
677
+ # stepping back past months that lack it (`d 31` in early March =
678
+ # Jan 31). nil when no month within a year works (day > 31).
679
+ def most_recent_day_range(day, today)
680
+ return nil unless (1..31).cover?(day)
681
+ 0.upto(12) do |back|
682
+ m = today << back
683
+ next unless Date.valid_date?(m.year, m.month, day)
684
+ candidate = Date.new(m.year, m.month, day)
685
+ return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
686
+ end
687
+ nil
688
+ end
689
+
690
+ # Single day for the most recent occurrence of month+day: this year if
691
+ # it has happened, else last year. nil for impossible dates.
692
+ def most_recent_month_day_range(month, day, today)
693
+ return nil unless (1..12).cover?(month) && (1..31).cover?(day)
694
+ [0, 1].each do |back|
695
+ y = today.year - back
696
+ next unless Date.valid_date?(y, month, day)
697
+ candidate = Date.new(y, month, day)
698
+ return [ymd_int(candidate), ymd_int(candidate)] if candidate <= today
699
+ end
700
+ nil
701
+ end
702
+
618
703
  # `13/8/2026` under month-first ordering is month 13 — almost certainly
619
704
  # a day-first date (and vice versa). Name the likely fix instead of
620
705
  # leaving the generic cannot-match.
@@ -785,27 +870,54 @@ module Mailmate
785
870
  end
786
871
 
787
872
  # term is the full lowercased token ("is:unread", "has:attachment").
788
- # Flag states read the #flags index; attachment presence reads the
789
- # indexed root content-type multipart/mixed is the standard
790
- # attachment layout (a Mail fallback checks real attachments when the
791
- # message is already loaded). Unknown state values never reach here:
873
+ # Flag states read the #flags index; archive state reads the path
874
+ # (same source as the flags output column); attachment presence reads
875
+ # the indexed root content-type multipart/mixed is the standard
876
+ # attachment layout. Wrapper types that can HIDE attachments
877
+ # (signed/encrypted/related) fall back to reading the message and
878
+ # asking Mail for real attachments; plain and alternative roots are
879
+ # trusted as attachment-free. Unknown state values never reach here:
792
880
  # date_spec_error rejects them up front.
793
- def state_matches?(eml_id, mail, term)
881
+ def state_matches?(eml_id, mail, path, term)
794
882
  state = STATE_CANON[term.split(":", 2).last]
795
883
  return false unless state
796
884
 
797
885
  case state
798
886
  when :unread
799
887
  eml_id ? !message_flags(eml_id).include?("\\Seen") : false
888
+ when :archived
889
+ path.to_s.include?("/Archive.mailbox/")
800
890
  when :attachment
801
891
  ct = eml_id ? (reader_for("content-type")&.value_for(eml_id.to_i) rescue nil).to_s : ""
802
- return ct.downcase.include?("multipart/mixed") unless ct.empty?
803
- mail ? mail.attachments.any? : false
892
+ if ct.empty?
893
+ m = mail || (path && (Mail.read(path) rescue nil))
894
+ return m ? m.attachments.any? : false
895
+ end
896
+ ctl = ct.downcase
897
+ return true if ctl.include?("multipart/mixed")
898
+ if ctl.match?(%r{multipart/(signed|encrypted|related)})
899
+ m = mail || (path && (Mail.read(path) rescue nil))
900
+ return m ? m.attachments.any? : false
901
+ end
902
+ false
804
903
  else
805
904
  message_flags(eml_id).include?(STATE_FLAGS[state])
806
905
  end
807
906
  end
808
907
 
908
+ # term is the downcased "name:value" (subpath allowed on the name and
909
+ # ignored: `x-mailer.name:mailmate` searches the whole x-mailer value,
910
+ # which substring matching covers anyway). Index-only by design — a
911
+ # header this store has never seen has no index, and date_spec_error
912
+ # reports that up front instead of this method quietly missing.
913
+ def header_matches?(eml_id, mail, term)
914
+ name, value = term.split(":", 2)
915
+ name = name.sub(/\..*/, "")
916
+ v = eml_id ? (reader_for(name)&.value_for(eml_id.to_i) rescue nil) : nil
917
+ v = (mail[name]&.to_s rescue nil) if v.nil? && mail
918
+ v.to_s.b.downcase.include?(value.b)
919
+ end
920
+
809
921
  def message_flags(eml_id)
810
922
  return [] unless eml_id
811
923
  reader_for("#flags")&.flags_for(eml_id.to_i) || []
@@ -906,7 +1018,9 @@ module Mailmate
906
1018
  when :date
907
1019
  date_matches?(mail, eml_id, term)
908
1020
  when :state
909
- state_matches?(eml_id, mail, term)
1021
+ state_matches?(eml_id, mail, path, term)
1022
+ when :header
1023
+ header_matches?(eml_id, mail, term)
910
1024
  when :any
911
1025
  %i[from recipients subject].any? { |f| field_value(eml_id, mail, f).include?(term_b) }
912
1026
  end
@@ -24,10 +24,11 @@ module Mailmate
24
24
  ["a <term>", "any address header contains"],
25
25
  ["b <term>", "body contains"],
26
26
  ["m <term>", "common headers OR body (same as a bare term)"],
27
- ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (N calendar units ending today; 1d = today), or Y, Y-M, Y-M-D"],
27
+ ["d <date>", "received: Nh (rolling clock hours), Nd|Nw|Nm|Ny (calendar, floored: 1d = today, 1w = this week, 1y = this year), Y | Y-M | Y-M-D, or D | M-D (day of month, most recent occurrence)"],
28
28
  ["T <tag>", "tag / IMAP keyword contains (K is a synonym)"],
29
- ["is:<state>", "message state: unread, read, flagged, replied, draft"],
30
- ["has:attachment", "root MIME is multipart/mixed (the standard attachment layout)"],
29
+ ["is:<state>", "message state: unread, read, flagged, replied, draft, archived"],
30
+ ["has:attachment", "root MIME is multipart/mixed (wrappers like signed/related are opened and checked)"],
31
+ ["<header>:<term>", "any indexed header contains (delivered-to:joe); unknown header = error"],
31
32
  ].freeze
32
33
 
33
34
  EXAMPLES = [
@@ -39,7 +40,8 @@ module Mailmate
39
40
  ["d 24h", "received in the last 24 hours (rolling, not calendar)"],
40
41
  ["d >=2026-05 d <2026-08", "received May through July 2026"],
41
42
  ["d 1h or 2026-08-09", "last hour, plus everything from Aug 9"],
42
- ["is:unread d 7d", "unread, received in the last 7 days"],
43
+ ["is:unread d 1w", "unread, received this week"],
44
+ ["d 7", "day 7 of this month (last month's if the 7th is still ahead)"],
43
45
  ["T urgent", "tagged 'urgent'"],
44
46
  ].freeze
45
47
 
@@ -69,8 +71,24 @@ module Mailmate
69
71
  date sent received time
70
72
  from to cc bcc subject body
71
73
  in label folder mailbox category filename
74
+ date_last_viewed
72
75
  ].freeze
73
76
 
77
+ # MailMate's OWN attribute vocabulary, minus the leading `#` — a model
78
+ # that has seen the engine's internals or MailMate's docs reaches for
79
+ # `date-received:` in good faith (dogfood 2026-08-13: an agent queried
80
+ # `date-received:8/13/2026-8/14/2026`, got zero rows with no advisory,
81
+ # and reported the mailbox empty). Keys are normalized (`-` → `_`,
82
+ # lowercased) and then aliased to the base foreign key here, so a
83
+ # compound key flows through the SAME translation and flagging pipeline
84
+ # as its base — nothing downstream knows compounds exist.
85
+ # `date_last_viewed` has no faithful `d` equivalent (viewed ≠ received),
86
+ # so it is only flagged (via FOREIGN_KEYS above), never translated.
87
+ KEY_ALIASES = {
88
+ "date_received" => "date", "received_date" => "date", "datereceived" => "date",
89
+ "date_sent" => "sent", "sent_date" => "sent", "datesent" => "sent", "sentdate" => "sent",
90
+ }.freeze
91
+
74
92
  # Spec placeholders for the zero-result hint, for foreign keys whose value
75
93
  # translate() could NOT rewrite (e.g. `after:8am`). Keys absent here still
76
94
  # get flagged, just without a suggested rewrite.
@@ -101,6 +119,8 @@ module Mailmate
101
119
  ["newer_than:2d / older_than:2w", "d 2d / d !2w"],
102
120
  ["after:2026-05 or since:2026-05", "d >=2026-05"],
103
121
  ["before:2026-08 / until:2026-08", "d <2026-08 / d <=2026-08"],
122
+ ["date:8/13/2026-8/14/2026 (also A..B)", "d >=2026-08-13 d <=2026-08-14"],
123
+ ["date-received: / date-sent: (MailMate heads)", "date: / sent:"],
104
124
  ].freeze
105
125
 
106
126
  module_function
@@ -123,7 +143,7 @@ module Mailmate
123
143
  # One token: an (optionally key:-prefixed) quoted string, or a bare run
124
144
  # of non-space. Quoted regions survive as single tokens so translate()
125
145
  # can leave a deliberate literal search (`s "date:today"`) alone.
126
- TOKEN_RX = /(?:[-!]?[A-Za-z_]+:)?"[^"]*"|(?:[-!]?[A-Za-z_]+:)?'[^']*'|\S+/
146
+ TOKEN_RX = /(?:[-!]?[A-Za-z_][A-Za-z_-]*:)?"[^"]*"|(?:[-!]?[A-Za-z_][A-Za-z_-]*:)?'[^']*'|\S+/
127
147
 
128
148
  # Rewrite foreign `key:value` tokens to their exact quicksearch
129
149
  # equivalent, leaving everything else byte-for-byte intact. Returns
@@ -170,8 +190,8 @@ module Mailmate
170
190
  # search for the literal text `s "date:today"` is not a mistake.
171
191
  def foreign_tokens(query)
172
192
  unquoted = query.to_s.gsub(/"[^"]*"|'[^']*'/, " ")
173
- unquoted.scan(/(?<![\w-])!?([A-Za-z_]+):(\S*)/).filter_map do |key, value|
174
- k = key.downcase
193
+ unquoted.scan(/(?<![\w-])!?([A-Za-z_][A-Za-z_-]*):(\S*)/).filter_map do |key, value|
194
+ k = normalize_key(key)
175
195
  next unless FOREIGN_KEYS.include?(k)
176
196
  ["#{key}:#{value}", k]
177
197
  end.uniq { |_token, k| k }
@@ -198,13 +218,21 @@ module Mailmate
198
218
 
199
219
  # ---- translation internals -------------------------------------------
200
220
 
221
+ # `Date-Received` / `date_received` / `datereceived` all mean the same
222
+ # thing to the person typing them; collapse to one spelling, then map
223
+ # MailMate-head aliases onto their base key.
224
+ def normalize_key(raw)
225
+ k = raw.downcase.tr("-", "_")
226
+ KEY_ALIASES.fetch(k, k)
227
+ end
228
+
201
229
  # nil = not a rewritable token (not key:value, unknown key, or a value
202
230
  # with no faithful equivalent).
203
231
  def translate_token(token, today, european = false)
204
- m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_]+):(?<value>.+)\z/m)
232
+ m = token.match(/\A(?<neg>[-!])?(?<key>[A-Za-z_][A-Za-z_-]*):(?<value>.+)\z/m)
205
233
  return nil unless m
206
234
 
207
- key = m[:key].downcase
235
+ key = normalize_key(m[:key])
208
236
  value = unquote(m[:value])
209
237
  return nil if value.empty?
210
238
 
@@ -234,6 +262,14 @@ module Mailmate
234
262
  (rel = parse_relative(value)) && "d #{rel}"
235
263
  when "older_than", "older"
236
264
  (rel = parse_relative(value)) && "d !#{rel}"
265
+ when "in"
266
+ # Archive state is path-derived, so Gmail's location idiom maps onto
267
+ # the is:archived state spec. Other locations (in:trash, in:spam)
268
+ # have no equivalent and stay flagged.
269
+ case value.downcase
270
+ when "archive", "archived" then "is:archived"
271
+ when "inbox" then "!is:archived"
272
+ end
237
273
  end
238
274
  end
239
275
 
@@ -255,11 +291,51 @@ module Mailmate
255
291
  return translate_after(v.delete_suffix("-today"), today, european) if v.end_with?("-today")
256
292
  return "d #{v}" if v =~ /\A\d+[dwmy]\z/
257
293
 
258
- (period = normalize_period(v, european)) && "d #{period}"
294
+ if (period = normalize_period(v, european))
295
+ "d #{period}"
296
+ else
297
+ translate_range(v, today, european)
298
+ end
299
+ end
300
+
301
+ # A two-sided absolute range (`8/13/2026-8/14/2026`, `2026-08-13..2026-08-14`)
302
+ # → the comparison pair `d >=A d <=B`. Inclusive on both ends: "the 13th
303
+ # to the 14th" includes the 14th in plain reading. Only fires when BOTH
304
+ # sides resolve — a side we can't parse means this isn't a range we
305
+ # understand, and no translation beats a wrong one. A reversed range
306
+ # still translates: the engine rejects an impossible date combination
307
+ # with an explicit error, which is exactly the visibility we want.
308
+ def translate_range(value, today, european)
309
+ bounds =
310
+ if value.include?("..")
311
+ a, b = value.split("..", 2)
312
+ [resolve_bound(a, today, european), resolve_bound(b, today, european)]
313
+ else
314
+ # Dates carry hyphens themselves (2026-08-13), so try each hyphen
315
+ # as the separator and take the first split where both sides parse.
316
+ value.length.times.filter_map { |i|
317
+ next unless value[i] == "-"
318
+ left = resolve_bound(value[0...i], today, european)
319
+ right = resolve_bound(value[(i + 1)..], today, european)
320
+ [left, right] if left && right
321
+ }.first
322
+ end
323
+ return nil unless bounds&.all?
324
+
325
+ "d >=#{bounds[0]} d <=#{bounds[1]}"
326
+ end
327
+
328
+ # One end of a range: a keyword day or any absolute period.
329
+ def resolve_bound(value, today, european)
330
+ v = value.to_s.strip.downcase
331
+ return today.strftime("%Y-%m-%d") if v == "today"
332
+ return (today - 1).strftime("%Y-%m-%d") if v == "yesterday"
333
+
334
+ normalize_period(v, european)
259
335
  end
260
336
 
261
337
  # Gmail's after: includes the named day; since: likewise → >=.
262
- def translate_after(value, today, european = false)
338
+ def translate_after(value, _today, european = false)
263
339
  v = value.downcase
264
340
  return "d 1d" if v == "today"
265
341
  return "d 2d" if v == "yesterday"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailmate
4
- VERSION = "1.8.0"
4
+ VERSION = "1.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailmate
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Murphy-Dye