Commit42722ce4Recorded2 Apr 2026Repositorybureau

Fix batch operation crashes and date search invalidArguments error

Message

Batch ops (archive-batch, trash-batch, move-batch) crashed when comma-separated ID strings contained empty entries from trailing commas, double commas, or whitespace-only segments. Added parse-id-list helper that filters empty entries and errors on empty input.

Date-based email search returned JMAP invalidArguments when dates had partial times (e.g. "2026-04-01T18:30" missing seconds) or timezone offsets (e.g. "+03:00" getting Z appended). Added normalize-utc-date to properly normalize any ISO 8601 variant to JMAP UTCDate format.

Changed
 src/bureau/email.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
 test/test-bureau.sgl | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 147 insertions(+), 15 deletions(-)
Diff
src/bureau/email.sglmodified
@@ -11,7 +11,9 @@
11
(sigil jmap)
12
(bureau connection)
13
(bureau format))
14
(export register-email-tools!)
+14
(export register-email-tools!
+15
parse-id-list
+16
normalize-utc-date)
17
(begin
18
19
;; ============================================================
@@ -156,6 +158,59 @@
158
(required . ("id" "name" "path"))))
159
160
+161
;; ============================================================
+162
;; Helpers
+163
;; ============================================================
+164
+165
;; Parse a comma-separated ID string into a list, filtering
+166
;; out empty entries (from trailing commas, double commas, etc.)
+167
(define (parse-id-list ids-str)
+168
(let ((ids (filter (lambda (s) (not (string=? s "")))
+169
(map string-trim (string-split ids-str ",")))))
+170
(when (null? ids)
+171
(error "No valid email IDs provided"))
+172
ids))
+173
+174
;; Normalize a date string to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ).
+175
;; Handles: date-only, datetime with/without Z, partial times, timezone offsets.
+176
(define (normalize-utc-date str)
+177
(let ((t-pos (string-find str "T")))
+178
(if (not t-pos)
+179
;; Date only: append midnight UTC
+180
(string-append str "T00:00:00Z")
+181
;; Has time component: normalize it
+182
(let* ((date-part (substring str 0 t-pos))
+183
(after-t (substring str (+ t-pos 1) (string-length str)))
+184
;; Strip trailing Z if present
+185
(after-t (if (string-ends-with? after-t "Z")
+186
(substring after-t 0 (- (string-length after-t) 1))
+187
after-t))
+188
;; Strip timezone offset (+HH:MM or -HH:MM at end of time)
+189
(time-part (let ((plus-pos (string-find after-t "+")))
+190
(if plus-pos
+191
(substring after-t 0 plus-pos)
+192
;; Search for minus after the hour digits (position > 2)
+193
(let loop ((i (- (string-length after-t) 1)))
+194
(if (< i 3) after-t
+195
(if (char=? (string-ref after-t i) #\-)
+196
(substring after-t 0 i)
+197
(loop (- i 1))))))))
+198
;; Strip fractional seconds (.NNN)
+199
(time-part (let ((dot-pos (string-find time-part ".")))
+200
(if dot-pos
+201
(substring time-part 0 dot-pos)
+202
time-part)))
+203
;; Ensure HH:MM:SS format (add :00 if only HH:MM)
+204
(colon-count (fold (lambda (acc c)
+205
(if (char=? c #\:) (+ acc 1) acc))
+206
0
+207
(string->list time-part)))
+208
(time-part (if (= colon-count 1)
+209
(string-append time-part ":00")
+210
time-part)))
+211
(string-append date-part "T" time-part "Z")))))
+212
+213
214
;; ============================================================
215
;; Tool Handlers
216
;; ============================================================
@@ -181,18 +236,10 @@
236
(filter (if subject (dict-set filter subject: subject) filter))
237
(filter (if text (dict-set filter text: text) filter))
238
(filter (if after
184
(dict-set filter after:
185
(if (string-contains? after "T")
186
(if (string-ends-with? after "Z") after
187
(string-append after "Z"))
188
(string-append after "T00:00:00Z")))
+239
(dict-set filter after: (normalize-utc-date after))
240
filter))
241
(filter (if before
191
(dict-set filter before:
192
(if (string-contains? before "T")
193
(if (string-ends-with? before "Z") before
194
(string-append before "Z"))
195
(string-append before "T00:00:00Z")))
+242
(dict-set filter before: (normalize-utc-date before))
243
filter))
244
(filter (if unread-only
245
(dict-set filter notKeyword: "$seen")
@@ -384,7 +431,7 @@
431
(define (tool-email-trash-batch args)
432
(let* ((client (ensure-jmap-client))
433
(ids-str (dict-ref args ids:))
387
(ids (map string-trim (string-split ids-str ",")))
+434
(ids (parse-id-list ids-str))
435
(trash (or (jmap-mailbox-by-role client "trash")
436
(error "Trash mailbox not found")))
437
(trash-id (dict-ref trash id:)))
@@ -394,7 +441,7 @@
441
(define (tool-email-archive-batch args)
442
(let* ((client (ensure-jmap-client))
443
(ids-str (dict-ref args ids:))
397
(ids (map string-trim (string-split ids-str ",")))
+444
(ids (parse-id-list ids-str))
445
(archive (or (jmap-mailbox-by-role client "archive")
446
(error "Archive mailbox not found")))
447
(archive-id (dict-ref archive id:)))
@@ -404,7 +451,7 @@
451
(define (tool-email-move-batch args)
452
(let* ((client (ensure-jmap-client))
453
(ids-str (dict-ref args ids:))
407
(ids (map string-trim (string-split ids-str ",")))
+454
(ids (parse-id-list ids-str))
455
(mailbox-name (dict-ref args mailbox:))
456
(mb (resolve-mailbox client mailbox-name))
457
(mb-id (dict-ref mb id:)))
test/test-bureau.sglmodified
@@ -11,7 +11,8 @@
11
(sigil caldav)
12
(bureau format)
13
(bureau calendar)
14
(bureau config))
+14
(bureau config)
+15
(bureau email))
16
17
18
;; ============================================================
@@ -369,3 +370,87 @@
370
(test "credentials-valid? with missing vars"
371
;; When env vars aren't set, should return #f
372
(assert-true (boolean? (bureau-credentials-valid?)))))
+373
+374
+375
;; ============================================================
+376
;; ID Parsing (batch operations)
+377
;; ============================================================
+378
+379
(test-group "parse-id-list"
+380
+381
(test "simple comma-separated IDs"
+382
(assert-equal '("id1" "id2" "id3")
+383
(parse-id-list "id1,id2,id3")))
+384
+385
(test "IDs with spaces"
+386
(assert-equal '("id1" "id2" "id3")
+387
(parse-id-list "id1, id2, id3")))
+388
+389
(test "trailing comma filtered"
+390
(assert-equal '("id1" "id2")
+391
(parse-id-list "id1,id2,")))
+392
+393
(test "leading comma filtered"
+394
(assert-equal '("id1" "id2")
+395
(parse-id-list ",id1,id2")))
+396
+397
(test "double comma filtered"
+398
(assert-equal '("id1" "id2")
+399
(parse-id-list "id1,,id2")))
+400
+401
(test "whitespace-only entries filtered"
+402
(assert-equal '("id1" "id2")
+403
(parse-id-list "id1, ,id2")))
+404
+405
(test "single ID"
+406
(assert-equal '("id1")
+407
(parse-id-list "id1")))
+408
+409
(test "empty string raises error"
+410
(assert-error (parse-id-list "")))
+411
+412
(test "only commas raises error"
+413
(assert-error (parse-id-list ",,,"))))
+414
+415
+416
;; ============================================================
+417
;; Date Normalization (JMAP UTCDate)
+418
;; ============================================================
+419
+420
(test-group "normalize-utc-date"
+421
+422
(test "date only"
+423
(assert-equal "2026-04-01T00:00:00Z"
+424
(normalize-utc-date "2026-04-01")))
+425
+426
(test "full UTC datetime passes through"
+427
(assert-equal "2026-04-01T18:30:00Z"
+428
(normalize-utc-date "2026-04-01T18:30:00Z")))
+429
+430
(test "datetime without Z gets Z appended"
+431
(assert-equal "2026-04-01T18:30:00Z"
+432
(normalize-utc-date "2026-04-01T18:30:00")))
+433
+434
(test "partial time (no seconds) gets :00 added"
+435
(assert-equal "2026-04-01T18:30:00Z"
+436
(normalize-utc-date "2026-04-01T18:30")))
+437
+438
(test "partial time with Z"
+439
(assert-equal "2026-04-01T14:00:00Z"
+440
(normalize-utc-date "2026-04-01T14:00Z")))
+441
+442
(test "timezone offset +HH:MM stripped"
+443
(assert-equal "2026-04-01T18:30:00Z"
+444
(normalize-utc-date "2026-04-01T18:30:00+03:00")))
+445
+446
(test "timezone offset -HH:MM stripped"
+447
(assert-equal "2026-04-01T18:30:00Z"
+448
(normalize-utc-date "2026-04-01T18:30:00-05:00")))
+449
+450
(test "fractional seconds stripped"
+451
(assert-equal "2026-04-01T18:30:00Z"
+452
(normalize-utc-date "2026-04-01T18:30:00.123Z")))
+453
+454
(test "fractional seconds with offset stripped"
+455
(assert-equal "2026-04-01T18:30:00Z"
+456
(normalize-utc-date "2026-04-01T18:30:00.000+03:00"))))