blob: 871ec5098f30ed4f686f04e7c700cd5782d16ea1 (
plain) (
blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
|
;;; himalaya-client.el --- Asynchronous Himalaya client -*- lexical-binding: t; -*-
(require 'json)
(require 'message)
(require 'mml)
(require 'subr-x)
(defgroup himalaya-client nil
"Himalaya mail client."
:group 'mail)
(defcustom himalaya-client-program "himalaya"
"Himalaya executable."
:type 'string
:group 'himalaya-client)
(defcustom himalaya-client-page-size 30
"Number of envelopes requested per page."
:type 'integer
:group 'himalaya-client)
(defcustom himalaya-client-download-directory (expand-file-name "Downloads" "~")
"Directory used for downloaded attachments."
:type 'directory
:group 'himalaya-client)
(defcustom himalaya-client-drafts-folder "Drafts"
"Folder used for saved drafts."
:type 'string
:group 'himalaya-client)
(defun himalaya-client--concise-error (text fallback)
(let ((message (string-trim
(replace-regexp-in-string "[[:space:]\n\r]+" " " (or text "")))))
(if (string-empty-p message)
fallback
(truncate-string-to-width message 300 nil nil "…"))))
(defun himalaya-client--process-result (process stdout stderr callback plain-output)
(unless (process-get process 'himalaya-client-finished)
(process-put process 'himalaya-client-finished t)
(let ((status (process-exit-status process))
(output (with-current-buffer stdout (buffer-string)))
(error-output (with-current-buffer stderr (buffer-string))))
(kill-buffer stdout)
(kill-buffer stderr)
(cond
((not (zerop status))
(funcall callback nil
(himalaya-client--concise-error
(if (string-empty-p (string-trim error-output)) output error-output)
(format "Himalaya exited with status %d" status))))
((not (string-empty-p error-output))
(funcall callback nil
(himalaya-client--concise-error
error-output
"Himalaya wrote to stderr")))
(plain-output
(funcall callback output nil))
(t
(let (result parse-error)
(condition-case error
(setq result
(unless (string-empty-p (string-trim output))
(json-parse-string output
:object-type 'plist
:array-type 'list
:null-object nil
:false-object nil)))
(error
(setq parse-error
(format "Invalid Himalaya JSON: %s"
(error-message-string error)))))
(if parse-error
(funcall callback nil parse-error)
(funcall callback result nil))))))))
(defun himalaya-client-request (arguments callback &optional input plain-output)
"Run Himalaya with ARGUMENTS and call CALLBACK with result and error."
(let ((stdout (generate-new-buffer " *himalaya-client-out*"))
(stderr (generate-new-buffer " *himalaya-client-err*"))
process)
(condition-case error
(progn
(setq process
(make-process
:name "himalaya-client"
:buffer stdout
:stderr stderr
:command (append (list himalaya-client-program "--quiet" "--output"
(if plain-output "plain" "json"))
arguments)
:connection-type 'pipe
:coding '(utf-8-unix . utf-8-unix)
:noquery t
:sentinel
(lambda (finished-process _event)
(when (memq (process-status finished-process) '(exit signal))
(himalaya-client--process-result
finished-process stdout stderr callback plain-output)))))
(when (process-live-p process)
(when input
(process-send-string process input))
(process-send-eof process))
process)
(error
(when process
(process-put process 'himalaya-client-finished t))
(kill-buffer stdout)
(kill-buffer stderr)
(funcall callback nil (error-message-string error))
nil))))
(defun himalaya-client--value (object key)
(let ((name (substring (symbol-name key) 1)))
(cond
((hash-table-p object)
(or (gethash key object)
(gethash (intern name) object)
(gethash name object)))
((and (listp object) (keywordp (car object)))
(plist-get object key))
((listp object)
(or (alist-get key object)
(alist-get (intern name) object)
(alist-get name object nil nil #'equal))))))
(defun himalaya-client--flag-name (flag)
(when (or (stringp flag) (symbolp flag))
(let ((name (replace-regexp-in-string
"\\`[:\\\\]+" "" (if (symbolp flag) (symbol-name flag) flag))))
(unless (string-empty-p name)
(downcase name)))))
(defun himalaya-client--flag-names (flags)
(cond
((null flags) nil)
((or (stringp flags) (symbolp flags))
(let ((name (himalaya-client--flag-name flags)))
(and name (list name))))
((vectorp flags)
(himalaya-client--flag-names (append flags nil)))
((hash-table-p flags)
(let ((name (or (gethash :name flags)
(gethash 'name flags)
(gethash "name" flags)))
names)
(if name
(himalaya-client--flag-names name)
(maphash
(lambda (key value)
(when value
(setq names (append names (himalaya-client--flag-names key)))))
flags)
names)))
((and (listp flags) (keywordp (car flags)))
(let ((name (plist-get flags :name))
names)
(if name
(himalaya-client--flag-names name)
(while flags
(when (cadr flags)
(setq names
(append names (himalaya-client--flag-names (car flags)))))
(setq flags (cddr flags)))
names)))
((listp flags)
(let (names)
(dolist (flag flags names)
(setq names (append names (himalaya-client--flag-names flag))))))))
(defun himalaya-client--address-value (address key alternate)
(or (himalaya-client--value address key)
(himalaya-client--value address alternate)))
(defun himalaya-client--normalize-envelope (envelope)
(let* ((from (himalaya-client--value envelope :from))
(to (himalaya-client--value envelope :to))
(flags (delete-dups
(himalaya-client--flag-names
(himalaya-client--value envelope :flags))))
(id (himalaya-client--value envelope :id)))
(list :id (and id (format "%s" id))
:subject (or (himalaya-client--value envelope :subject) "")
:from-name (or (himalaya-client--address-value from :name :display-name) "")
:from-address (or (himalaya-client--address-value from :addr :address) "")
:to-name (or (himalaya-client--address-value to :name :display-name) "")
:to-address (or (himalaya-client--address-value to :addr :address) "")
:date (or (himalaya-client--value envelope :date) "")
:flags flags
:unread (not (member "seen" flags))
:starred (and (member "flagged" flags) t)
:deleted (and (member "deleted" flags) t)
:draft (and (member "draft" flags) t)
:answered (and (member "answered" flags) t)
:has-attachment
(and (or (himalaya-client--value envelope :has_attachment)
(himalaya-client--value envelope :has-attachment))
t))))
(defun himalaya-client--as-list (value)
(cond
((null value) nil)
((vectorp value) (append value nil))
((listp value) value)
(t (list value))))
(defun himalaya-client--strings (values)
(mapcar (lambda (value) (format "%s" value))
(himalaya-client--as-list values)))
(defun himalaya-client--query-string (value)
(cond
((null value) nil)
((stringp value) (unless (string-empty-p (string-trim value)) value))
((listp value) (string-join (himalaya-client--strings value) " "))
(t (format "%s" value))))
(defun himalaya-client--envelope-query (query sort)
(let ((filter (himalaya-client--query-string query))
(ordering (himalaya-client--query-string sort)))
(when (and ordering
(not (string-match-p "\\`[[:space:]]*order[[:space:]]+by\\_>" ordering)))
(setq ordering (concat "order by " ordering)))
(string-join (delq nil (list filter ordering)) " ")))
(defun himalaya-client-accounts (callback)
"Fetch configured accounts and call CALLBACK."
(himalaya-client-request '("account" "list") callback))
(defun himalaya-client-folders (account callback)
"Fetch folders for ACCOUNT and call CALLBACK."
(himalaya-client-request
(list "folder" "list" "--account" account)
callback))
(defun himalaya-client-envelopes (account folder page query sort callback)
"Fetch one envelope page and call CALLBACK with normalized envelopes."
(let ((arguments
(list "envelope" "list"
"--account" account
"--folder" folder
"--page" (number-to-string (or page 1))
"--page-size" (number-to-string himalaya-client-page-size)))
(search (himalaya-client--envelope-query query sort)))
(when (not (string-empty-p search))
(setq arguments (append arguments (list search))))
(himalaya-client-request
arguments
(lambda (result error)
(if error
(funcall callback nil error)
(funcall callback
(mapcar #'himalaya-client--normalize-envelope result)
nil))))))
(defun himalaya-client-read (account folder id callback)
"Preview message ID in FOLDER for ACCOUNT and call CALLBACK."
(himalaya-client-request
(list "message" "read" "--account" account "--folder" folder "--preview"
(format "%s" id))
callback))
(defun himalaya-client-thread (account folder id callback)
"Preview the thread containing ID and call CALLBACK."
(himalaya-client-request
(list "message" "thread" "--account" account "--folder" folder "--preview"
(format "%s" id))
callback))
(defun himalaya-client--operation-name (operation allowed)
(let ((name (downcase (format "%s" operation))))
(unless (member name allowed)
(error "Unsupported Himalaya operation: %s" operation))
name))
(defun himalaya-client-flag (account folder operation ids flags callback)
"Apply flag OPERATION to IDS in FOLDER and call CALLBACK."
(let ((name (himalaya-client--operation-name operation '("add" "set" "remove"))))
(himalaya-client-request
(append (list "flag" name "--account" account "--folder" folder)
(himalaya-client--strings ids)
(himalaya-client--strings flags))
callback)))
(defun himalaya-client-transfer (account folder operation target ids callback)
"Apply transfer OPERATION to IDS and call CALLBACK."
(let ((name (himalaya-client--operation-name operation '("copy" "move"))))
(himalaya-client-request
(append (list "message" name "--account" account "--folder" folder target)
(himalaya-client--strings ids))
callback)))
(defun himalaya-client-delete (account folder ids callback)
"Mark IDS deleted in FOLDER and call CALLBACK."
(himalaya-client-flag account folder "add" ids '("deleted") callback))
(defun himalaya-client-expunge (account folder callback)
"Expunge deleted messages from FOLDER and call CALLBACK."
(himalaya-client-request
(list "folder" "expunge" "--account" account folder)
callback))
(defun himalaya-client-download (account folder ids callback)
"Download attachments from IDS and call CALLBACK."
(himalaya-client-request
(append (list "attachment" "download"
"--account" account
"--folder" folder
"--downloads-dir"
(expand-file-name himalaya-client-download-directory))
(himalaya-client--strings ids))
callback))
(defvar-local himalaya-client-compose-account nil)
(defvar-local himalaya-client-compose-pending nil)
(define-derived-mode himalaya-client-compose-mode message-mode "Himalaya Compose"
"Edit a Himalaya MML message.")
(defun himalaya-client--compose-contents ()
(buffer-substring-no-properties (point-min) (point-max)))
(defun himalaya-client--compose-mime ()
(let ((contents (himalaya-client--compose-contents))
(directory default-directory)
(options message-options)
(separator mail-header-separator))
(with-temp-buffer
(let ((default-directory directory)
(message-options options)
(mail-header-separator separator)
(message-inhibit-body-encoding nil))
(insert contents)
(mml-to-mime)
(buffer-string)))))
(defun himalaya-client--finish-compose (buffer action result error)
(if error
(progn
(when (buffer-live-p buffer)
(with-current-buffer buffer
(setq himalaya-client-compose-pending nil)))
(message "Himalaya could not %s message: %s" action error))
(when (buffer-live-p buffer)
(with-current-buffer buffer
(set-buffer-modified-p nil))
(kill-buffer buffer))
(message "Himalaya message %s%s"
(if (string= action "send") "sent" "saved to drafts")
(if (and (stringp result) (not (string-empty-p (string-trim result))))
(format ": %s" (string-trim result))
""))))
(defun himalaya-client-compose-send ()
"Send the current Himalaya MML message."
(interactive)
(unless (derived-mode-p 'himalaya-client-compose-mode)
(user-error "Not in a Himalaya compose buffer"))
(when himalaya-client-compose-pending
(user-error "A Himalaya compose operation is already running"))
(let ((buffer (current-buffer))
(account himalaya-client-compose-account)
(contents (himalaya-client--compose-contents)))
(setq himalaya-client-compose-pending t)
(himalaya-client-request
(list "template" "send" "--account" account)
(lambda (result error)
(himalaya-client--finish-compose buffer "send" result error))
contents)))
(defun himalaya-client-compose-save-draft ()
"Save the current compose buffer to the drafts folder."
(interactive)
(unless (derived-mode-p 'himalaya-client-compose-mode)
(user-error "Not in a Himalaya compose buffer"))
(when himalaya-client-compose-pending
(user-error "A Himalaya compose operation is already running"))
(let ((buffer (current-buffer))
(account himalaya-client-compose-account)
(mime (himalaya-client--compose-mime)))
(setq himalaya-client-compose-pending t)
(himalaya-client-request
(list "message" "save"
"--account" account
"--folder" himalaya-client-drafts-folder)
(lambda (result error)
(himalaya-client--finish-compose buffer "save" result error))
mime
t)))
(define-key himalaya-client-compose-mode-map (kbd "C-c C-c")
#'himalaya-client-compose-send)
(define-key himalaya-client-compose-mode-map (kbd "C-c C-d")
#'himalaya-client-compose-save-draft)
(define-key himalaya-client-compose-mode-map (kbd "C-c C-a")
#'mml-attach-file)
(defun himalaya-client--template-arguments (account folder id kind)
(let ((name (if kind (downcase (format "%s" kind)) "new")))
(unless (member name '("new" "reply" "reply-all" "forward"))
(user-error "Unsupported compose kind: %s" kind))
(when (and (not (string= name "new")) (or (null folder) (null id)))
(user-error "Compose kind %s requires a folder and message ID" name))
(cond
((string= name "new")
(list "template" "write" "--account" account))
((string= name "reply")
(list "template" "reply" "--account" account "--folder" folder
(format "%s" id)))
((string= name "reply-all")
(list "template" "reply" "--account" account "--folder" folder "--all"
(format "%s" id)))
(t
(list "template" "forward" "--account" account "--folder" folder
(format "%s" id))))))
(defun himalaya-client--place-template-cursor (cursor)
(let ((row (or (himalaya-client--value cursor :row) 1))
(column (or (himalaya-client--value cursor :col) 0)))
(goto-char (point-min))
(forward-line (max 0 (1- row)))
(move-to-column column)))
(defun himalaya-client--open-compose-buffer (account template)
(let ((content (if (stringp template)
template
(himalaya-client--value template :content)))
(cursor (and (listp template)
(himalaya-client--value template :cursor)))
(buffer (generate-new-buffer "*Himalaya compose*")))
(unless (stringp content)
(kill-buffer buffer)
(error "Himalaya returned an invalid message template"))
(with-current-buffer buffer
(himalaya-client-compose-mode)
(setq himalaya-client-compose-account account)
(insert content)
(if cursor
(himalaya-client--place-template-cursor cursor)
(goto-char (point-max))))
(pop-to-buffer buffer)))
(defun himalaya-client-compose (account &optional folder id kind)
"Open an asynchronous compose buffer for ACCOUNT."
(interactive "sAccount: ")
(let ((arguments (himalaya-client--template-arguments account folder id kind)))
(himalaya-client-request
arguments
(lambda (template error)
(if error
(message "Himalaya could not compose message: %s" error)
(condition-case compose-error
(himalaya-client--open-compose-buffer account template)
(error
(message "Himalaya could not compose message: %s"
(error-message-string compose-error)))))))))
(provide 'himalaya-client)
;;; himalaya-client.el ends here
|