├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── autoload └── health │ └── galore.vim ├── doc ├── galore.txt └── tags ├── examples ├── gpg-tui.lua ├── keybindings.lua ├── khard.lua └── snippets.lua ├── galore.desktop ├── install_local.sh ├── lua └── galore │ ├── address-util.lua │ ├── autocrypt │ └── init.lua │ ├── browser.lua │ ├── builder.lua │ ├── callback.lua │ ├── compose.lua │ ├── config.lua │ ├── crypt-utils.lua │ ├── debug.lua │ ├── default.lua │ ├── gmime-util.lua │ ├── health.lua │ ├── hooks.lua │ ├── hooks │ ├── post.lua │ └── send.lua │ ├── init.lua │ ├── jobs.lua │ ├── lib │ ├── buffer.lua │ ├── ordered.lua │ └── timers.lua │ ├── log.lua │ ├── message_browser.lua │ ├── message_view.lua │ ├── notmuch-util.lua │ ├── notmuch.lua │ ├── opts.lua │ ├── render.lua │ ├── runtime.lua │ ├── saved.lua │ ├── telescope.lua │ ├── templates.lua │ ├── test_utils.lua │ ├── thread_browser.lua │ ├── thread_message_browser.lua │ ├── thread_view.lua │ ├── ui.lua │ ├── url.lua │ ├── util.lua │ └── views.lua ├── meson.build ├── plugin └── galore.lua ├── res ├── galore.svg ├── message_view.png ├── overview.png ├── saved.png ├── telescope.png └── thread_message.png ├── src ├── galore-autocrypt.c ├── galore-autocrypt.h ├── galore-filter-reply.c ├── galore-filter-reply.h ├── galore-gpgme-utils.c ├── galore-gpgme-utils.h ├── galore.c ├── galore.h └── meson.build ├── stylua.toml ├── syntax ├── galore-browser.vim ├── galore-saved.vim └── galore-threads.vim ├── test ├── ci_init.sh ├── init.sh ├── minimal.vim └── test │ └── init_spec.lua └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .cache/ 3 | build/ 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | # Make code directory 4 | RUN mkdir -p code/galore 5 | 6 | # update, software-properties-common, git 7 | RUN apt-get update && \ 8 | apt install -y software-properties-common && \ 9 | apt install -y git && \ 10 | apt install -y curl && \ 11 | apt install -y build-essential && \ 12 | apt install -y luarocks &&\ 13 | apt install -y notmuch &&\ 14 | apt install -y libnotmuch-dev &&\ 15 | apt install -y gobject-introspection &&\ 16 | apt install -y libgirepository1.0-dev &&\ 17 | apt install -y libgmime-3.0-dev &&\ 18 | apt install -y meson 19 | 20 | RUN add-apt-repository --yes ppa:neovim-ppa/unstable && \ 21 | apt-get install -y neovim 22 | 23 | # RUN luarocks install argparse && luarocks install luacheck 24 | RUN luarocks install luacheck 25 | RUN luarocks --lua-version 5.1 install lgi 26 | RUN mkdir -p /tmp/ 27 | WORKDIR /tmp 28 | 29 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 30 | ENV PATH="/root/.cargo/bin:${PATH}" 31 | 32 | RUN cargo install stylua 33 | RUN cargo install lemmy-help --features=cli 34 | 35 | # 'nvim-lua/popup.nvim', 36 | 37 | # 'hrsh7th/nvim-cmp', -- optional 38 | # 'dagle/cmp-notmuch', -- optional 39 | # 'dagle/cmp-mates', -- optional 40 | 41 | # Clone dependencies 42 | RUN git clone https://github.com/nvim-lua/plenary.nvim.git /code/plenary.nvim 43 | RUN git clone https://github.com/nvim-treesitter/nvim-treesitter.git /code/nvim-treesitter 44 | RUN git clone https://github.com/nvim-telescope/telescope.nvim.git /code/telescope 45 | RUN git clone https://github.com/nvim-telescope/telescope-file-browser.nvim.git /code/filebrowser 46 | RUN git clone https://github.com/dagle/notmuch-lua.git /code/notmuch-lua 47 | 48 | # Run tests when run container 49 | # CMD bash 50 | CMD cd /code/galore && \ 51 | make test 52 | # make lint && \ 53 | # make stylua && \ 54 | # make emmy && \ 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | THIS_DIR := $(dir $(abspath $(firstword $(MAKEFILE_LIST)))) 2 | 3 | install: 4 | # do xdg etc 5 | install -d ${HOME}/.local/share/icons/scalable/apps/ ${HOME}/.local/share/applications/ 6 | install galore.desktop ${HOME}/.local/share/applications/ 7 | install res/galore.svg ${HOME}/.local/share/icons/scalable/apps/ 8 | update-desktop-database ~/.local/share/applications 9 | # xdg-mime default galore.desktop x-scheme-handler/mailto 10 | 11 | lint: 12 | luacheck lua 13 | # add linting to src/ for C 14 | 15 | stylua: 16 | stylua lua/ 17 | 18 | format: 19 | clang-format --style=file --dry-run -Werror src/.c src/.h 20 | 21 | # TODO finnish this 22 | # gdi: 23 | # gir-to-vimdoc 24 | 25 | test/testdir: 26 | test/init.sh 27 | 28 | # this will kinda ruin your install if you run ready-pod 29 | # Fix this later 30 | test: test/testdir 31 | ./install_local.sh 32 | LUA_PATH="/usr/share/lua/5.1/?.lua;/usr/local/share/lua/5.1/?.lua" nvim --headless --clean \ 33 | -u test/minimal.vim \ 34 | -c "PlenaryBustedDirectory test/test {minimal_init = 'test/minimal.vim'}" 35 | 36 | pod-build: 37 | podman build . -t galore 38 | # podman build --no-cache . -t galore 39 | 40 | ready-pod: 41 | podman run -v $(shell pwd):/code/galore -t galore 42 | 43 | clean: 44 | $(RM) -r build 45 | $(RM) -r test/testdir 46 | 47 | .PHONY: lint format stylua clean test debug 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | ![Requires](https://img.shields.io/badge/requires-neovim%200.7%2B-green?style=flat-square&logo=neovim) 6 | 7 | ![License](https://img.shields.io/badge/license-GPL%20v2-brightgreen?style=flat-square) 8 | ![Status](https://img.shields.io/badge/status-WIP-informational?style=flat-square) 9 | 10 | # mail galore - A notmuch client for neovim 11 | 12 | [Installation](#installation) 13 | • 14 | [Usage](#usage) 15 | • 16 | [Pictures](#pictures) 17 | 18 |
19 | 20 | ## Intro 21 | Notmuch is a great way to index your email and neovim is great for viewing and editing 22 | text. Combine the two and you have a great email client. 23 | The idea is to be more powerful than mutt but way easier to configure. 24 | 25 | Other than the basic notmuch features Galore has support for: 26 | - cmp for address completion 27 | - telescope for fuzzy searching emails 28 | - encrypting and decrypting emails 29 | - signing and verifying emails 30 | 31 | The idea is to provide a good email experiance by default where you configure 32 | every everything to your needs 33 | 34 | ## Installation 35 | WIP, mail galore is under heavy development, expect crashes and thing changing. 36 | Atm it's pre 0.1 software. If you don't intend to read code / write patches, you should wait. 37 | Galore requires the git branch of neovim or 0.7 to work. 38 | 39 | Galore uses luajit and the C-bindings to do its magic, depending on notmuch and gmime. 40 | To view html emails, you need a htmlviewer, by default it uses w3m. 41 | 42 | To be able to run qeuries async it uses another program called [nm-livesearch](https://github.com/dagle/nm-livesearch), 43 | it's used to both populate browser windows and telescope. You *need* to install it before using galore 44 | 45 | Some html emails are just to impossible to render in galore (or any text email client), for these 46 | you need full blown webbrowser. For this you can use a tool like 47 | [browser-pipe](https://github.com/dagle/browser-pipe) and then pipe the html into it. 48 | (look in config for an example) 49 | 50 | You need to install neovim and notmuch. 51 | 52 | Then using your favorite plugin-manager install galore. 53 | 54 | With packer: 55 | ``` lua 56 | use {'dagle/galore', run = 'bash install_local.sh', 57 | rocks = {'lgi'}, -- or install lgi with your package manager, doesn't seem to work with packer atm 58 | requires = { 59 | 'nvim-telescope/telescope.nvim', 60 | 'nvim-telescope/telescope-file-browser.nvim', 61 | 'nvim-lua/popup.nvim', 62 | 'nvim-lua/plenary.nvim', 63 | 'dagle/notmuch-lua', 64 | 'hrsh7th/nvim-cmp', -- optional 65 | 'dagle/cmp-notmuch', -- optional 66 | 'dagle/cmp-mates', -- optional 67 | } 68 | } 69 | ``` 70 | You need to install **telescope** and **cmp** if you want support for that 71 | 72 | It also has optional support for the address book mates, so install that for 73 | mates support (will be moved to a seperate plugin later) 74 | 75 | After installing do 76 | :checkhealth galore 77 | To make sure everything is working 78 | 79 | ## Usage 80 | After installing galore, you need to add the following to init: 81 | ``` lua 82 | local galore = require('galore') 83 | galore.setup() 84 | ``` 85 | and when you want to launch galore do: 86 | ``` lua 87 | galore.open 88 | ``` 89 | or 90 | ``` 91 | :Galore 92 | ``` 93 | 94 | By default, galore tries to read values from the notmuch config file. 95 | You can also override options by passing values to setup, look in config.lua 96 | for default values (will be documented in the future). 97 | 98 | ### Telescope 99 | Galore exports the following telescope functions (require 'galore.telescope' to use them) 100 | - notmuch_search 101 | - load_draft 102 | - attach_file (only works in compose) 103 | - save_files (only works in message_view) 104 | 105 | ### Cmp 106 | Galore has 2 ways to find emails addresses, 107 | first uses mates vcard system and seconds uses notmuch to fetch addresses. 108 | 109 | Add 110 | ``` lua 111 | { name = 'vcard_addr'}, 112 | ``` 113 | and/or 114 | ``` lua 115 | {name = 'notmuch_addr'} 116 | ``` 117 | to you sources and update your formatting 118 | 119 | ## Pictures 120 | Saved searches 121 | 122 | 123 | 124 | Thread message browser 125 | 126 | 127 | 128 | Message view 129 | 130 | 131 | 132 | Telescope 133 | 134 | 135 | 136 | And with a couple of windows together 137 | 138 | 139 | 140 | ## Customize 141 | 142 | Global functions 143 | ---------------- 144 | Most of galore functionallity can be ran globaly, from anywhere but you want to make sure 145 | that libs are loaded and that we setup variables (they are lazy loaded). 146 | Here is an example of a function that connects (if not connected) and do a telescope search: 147 | 148 | ``` lua 149 | vim.keymap.set('n', 'ms', function() 150 | require("galore").withconnect(function () 151 | require("galore.telescope").notmuch_search() end) 152 | end, {desc='Search email'}) 153 | ``` 154 | Inside of galore you can call require("galore.telescope").notmuch_search() directly. 155 | 156 | 157 | Some jobs doesn't require notmuch to be started: 158 | ``` lua 159 | require("galore.jobs").new() 160 | ``` 161 | 162 | config values 163 | ------------- 164 | A lot of values are loaded from the notmuch config but 165 | can be overridden. I reccomend setting values in the notmuch config 166 | and let galore fetch them from there. That way you will not need to 167 | write values to multiple places. 168 | 169 | For settings to change, look in lua/galore/config.lua 170 | for values to change with setup. 171 | They will be documented here in the future. 172 | 173 | For cmp support you need to install cmp completers (see install above) 174 | 175 | For cmp support you need to add the following: 176 | { name = 'vcard_addr'}, 177 | { name = 'notmuch_addr'}, 178 | to your cmp sources 179 | 180 | views 181 | ----- 182 | If you want to customize colors, just change the Galore* highlights. 183 | 184 | If you wanna customize how galore is rendered, you change the print 185 | functions and also the syntax files. You need to create your own 186 | syntax/galore-browser.vim that matches your syntax. 187 | 188 | If you only wanna customize the colors, you can just 189 | 190 | Buffers 191 | ------- 192 | 193 | Galore comes with a couple of different buffers to navigate your email 194 | 195 | Saved is a buffer for saved searches, selecting a saved search will run that search 196 | in a selected browsers. Saved accepts a list of generators that produce output. 197 | gen_tags: Take all tags the db uses and display them one by one. 198 | gen_internal: All searches we have saved internally and 199 | don't want to export into the ecosystem (saved in a file) 200 | gen_excluded: tags excluded from our searches. For example if we exclude archive, 201 | then archive will gets it's own entry 202 | 203 | Browsers: 204 | A browser lets you browse your messages, with a preview of date, sender(s) and subject. 205 | Galore comes with 3 browsers depending on your need/preference. 206 | Message browser: Display one message at the time, sorted by date. 207 | Thread browser: Display a thread as one entry, sorted by when the thread resieved it's last message. 208 | Thread message browser: Display messages but group them as threads, displaying a treelike structure. 209 | 210 | View: 211 | Views emails and comes in 2 flavours: thread viewer and message viewer. 212 | Thread viewer: View all messages in a thread. Action taken depends on the cursor location. 213 | Message view: View a single message. 214 | 215 | Compose: 216 | Write an email, send it, store as a draft or discard it. Compared to mutt, attachments are added 217 | while editing the email (or when generating the response) and displayed as virtual lines at the bottom 218 | of the text. Compose also allows hidden headers (see compose_headers in config), 219 | to unclutter your mailing experiance. 220 | 221 | Tips and trix 222 | ------------- 223 | You want a small gpg-ui to complement the email client? 224 | That is easy, with a plugin like toggleterm and gpg-tui, we can make 225 | a small popup window to manage your keys from 226 | ``` lua 227 | local terms = require("toggleterm.terminal") 228 | local gpgtui = terms.Terminal:new({ 229 | cmd = "gpg-tui", 230 | direction = "float", 231 | float_opts = { 232 | border = "single", 233 | }, 234 | }) 235 | 236 | local function gpgtui_toggle() 237 | gpgtui:toggle() 238 | end 239 | 240 | vim.keymap.set('n', 'mg', gpgtui_toggle, {noremap = true, silent = true}) 241 | ``` 242 | 243 | Look in examples for more ways extend galore for your needs. 244 | -------------------------------------------------------------------------------- /autoload/health/galore.vim: -------------------------------------------------------------------------------- 1 | function! health#galore#check() 2 | lua require 'galore.health'.check() 3 | endfunction 4 | -------------------------------------------------------------------------------- /doc/galore.txt: -------------------------------------------------------------------------------- 1 | *nvim-galore* *galore* 2 | 3 | A notmuch email client for neovim 4 | 5 | ============================================================================== 6 | *galore-contents* 7 | 8 | Abstract |galore-abstract| 9 | Usage |galore-usage| 10 | Configure |galore-configure| 11 | Functions |galore-functions| 12 | 13 | ============================================================================== 14 | Abstract *galore-abstract* 15 | An email client with the power of neovim! 16 | 17 | ============================================================================== 18 | Usage *galore-usage* 19 | 20 | To use, you need to have notmuch installed and configured: 21 | 22 | To use all features of galore, install the following: 23 | use {' 24 | dagle/galore', run = 'make', 25 | requires = { 26 | 'nvim-telescope/telescope.nvim', 27 | 'nvim-lua/popup.nvim', 28 | 'nvim-lua/plenary.nvim', 29 | 'nvim-telescope/telescope-file-browser.nvim', 30 | 'hrsh7th/nvim-cmp', 31 | } 32 | } 33 | 34 | It also has optional support for the address book mates, so in stall that for 35 | mates support 36 | 37 | Then to setup galore: 38 | local galore = require('galore') 39 | galore.setup() 40 | 41 | To start galore: 42 | galore.open() 43 | or 44 | :Galore 45 | 46 | ============================================================================== 47 | Configure *galore-config* 48 | For settings to change, look in lua/galore/config.lua 49 | for values to change with setup. 50 | They will be documented here in the future. 51 | 52 | For cmp support you need to add the following: 53 | { name = 'vcard_addr'}, 54 | { name = 'notmuch_addr'}, 55 | to your cmp sources 56 | 57 | ============================================================================== 58 | Global functions *Global functions* 59 | 60 | Most of galore functionallity can be ran globaly, from anywhere but you want to make sure 61 | that libs are loaded (they are lazy loaded by default). 62 | 63 | Here are 2 examples: 64 | vim.keymap.set('n', 'mf', function() 65 | require("galore").withconnect(function () 66 | require("galore.telescope").load_draft() end) 67 | end, {desc='Load draft'}) 68 | 69 | vim.keymap.set('n', 'mf', function() 70 | require("galore").withconnect(function () 71 | require("galore.thread_message_browser"):create("tag:work", {}) end) 72 | end, {desc='Open work inbox'}) 73 | 74 | ============================================================================== 75 | Buffers *galore-buffers* 76 | 77 | Galore comes with a couple of different buffers to navigate your email 78 | 79 | Saved is a buffer for saved searches, selecting a saved search will run that search 80 | in a selected browsers. Saved accepts a list of generators that produce output. 81 | gen_tags: Take all tags the db uses and display them one by one. 82 | gen_internal: All searches we have saved internally and 83 | don't want to export into the ecosystem (saved in a file) 84 | gen_excluded: tags excluded from our searches. For example if we exclude archive, 85 | then archive will gets it's own entry 86 | 87 | Browsers: 88 | A browser lets you browse your messages, with a preview of date, sender(s) and subject. 89 | Galore comes with 3 browsers depending on your need/preference. 90 | Message browser: Display one message at the time, sorted by date. 91 | Thread browser: Display a thread as one entry, sorted by when the thread resieved it's last message. 92 | Thread message browser: Display messages but group them as threads, displaying a treelike structure. 93 | 94 | View: 95 | Views emails and comes in 2 flavours: thread viewer and message viewer. 96 | Thread viewer: View all messages in a thread. Action taken depends on the cursor location. 97 | Message view: View a single message. 98 | 99 | Compose: 100 | Write an email, send it, store as a draft or discard it. Compared to mutt, attachments are added 101 | while editing the email (or when generating the response) and displayed as virtual lines at the bottom 102 | of the text. Compose also allows hidden headers (see compose_headers in config), 103 | to unclutter your mailing experiance. 104 | -------------------------------------------------------------------------------- /doc/tags: -------------------------------------------------------------------------------- 1 | galore galore.txt /*galore* 2 | galore-abstract galore.txt /*galore-abstract* 3 | galore-buffers galore.txt /*galore-buffers* 4 | galore-config galore.txt /*galore-config* 5 | galore-contents galore.txt /*galore-contents* 6 | galore-usage galore.txt /*galore-usage* 7 | nvim-galore galore.txt /*nvim-galore* 8 | -------------------------------------------------------------------------------- /examples/gpg-tui.lua: -------------------------------------------------------------------------------- 1 | local terms = require("toggleterm.terminal") 2 | local gpgtui = terms.Terminal:new({ 3 | cmd = "gpg-tui", 4 | direction = "float", 5 | float_opts = { 6 | border = "single", 7 | }, 8 | }) 9 | 10 | local function gpgtui_toggle() 11 | gpgtui:toggle() 12 | end 13 | 14 | vim.keymap.set('n', 'mg', gpgtui_toggle, {noremap = true, silent = true}) 15 | -------------------------------------------------------------------------------- /examples/keybindings.lua: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/examples/keybindings.lua -------------------------------------------------------------------------------- /examples/khard.lua: -------------------------------------------------------------------------------- 1 | local terms = require("toggleterm.terminal") 2 | local gpgtui = terms.Terminal:new({ 3 | cmd = "khard add", 4 | direction = "float", 5 | float_opts = { 6 | border = "single", 7 | }, 8 | }) 9 | 10 | local function gpgtui_toggle() 11 | gpgtui:toggle() 12 | end 13 | 14 | vim.keymap.set('n', 'mk', gpgtui_toggle, {noremap = true, silent = true}) 15 | -------------------------------------------------------------------------------- /examples/snippets.lua: -------------------------------------------------------------------------------- 1 | --- A small example on how to use luasnip 2 | --- for email. The filetype filetype that compose uses is mail 3 | local ls = require("luasnip") 4 | local s = ls.snippet 5 | local t = ls.text_node 6 | 7 | local function replace_text(replace) 8 | if type(replace) == "string" then 9 | return replace 10 | end 11 | if type(replace) == "table" then 12 | if replace.group then 13 | local ret = replace.group .. ": " 14 | ret = ret .. table.concat(replace, " ") .. ";" 15 | return ret 16 | end 17 | return table.concat(replace, " ") 18 | end 19 | end 20 | 21 | local alias = {{"Work-emails", "example@example.org, test@test.org"}} 22 | 23 | for _, v in ipairs(alias) do 24 | local text = replace_text(v[2]) 25 | ls.add_snippets("mail", { 26 | s(v[1], { 27 | t({text}) 28 | }) 29 | }) 30 | end 31 | -------------------------------------------------------------------------------- /galore.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Galore Mail 3 | Comment=A neovim email client using notmuch 4 | Exec=nvim -c ":GaloreCompose %u" 5 | Icon=galore 6 | Terminal=true 7 | Type=Application 8 | Keywords=Email;E-mail; 9 | Categories=Network;Email; 10 | MimeType=x-scheme-handler/mailto; 11 | -------------------------------------------------------------------------------- /install_local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # If we want to install it globally, you don't want to run this file 3 | 4 | NAME=$(dirname "$0") 5 | BASEDIR=$(realpath "${NAME}") 6 | 7 | if [ -d "${BASEDIR}/build" ]; then 8 | rm -r "${BASEDIR}"/build 9 | fi 10 | meson "${BASEDIR}"/build 11 | meson install -C "${BASEDIR}"/build 12 | sed -i "s|libgalore.so|${BASEDIR}/build/src/libgalore.so|" "${BASEDIR}"/build/src/Galore-0.1.gir 13 | g-ir-compiler "${BASEDIR}"/build/src/Galore-0.1.gir --output "${BASEDIR}"/build/src/Galore-0.1.typelib 14 | -------------------------------------------------------------------------------- /lua/galore/address-util.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | local gu = require('galore.gmime-util') 3 | local lgi = require('lgi') 4 | local gmime = lgi.require('GMime', '3.0') 5 | 6 | local M = {} 7 | 8 | local function unwild(ia) 9 | -- TODO 10 | -- remove + and - from the local part of the email 11 | -- so xyz+spam@domain.com => xyz@domain.com 12 | return ia 13 | end 14 | 15 | -- TODO expose 16 | local function normalize(ia1) 17 | if gmime.InternetAddressMailbox:is_type_of(ia1) then 18 | return ia1:get_idn_addr() 19 | end 20 | return ia1:to_string() 21 | end 22 | 23 | -- TODO expose 24 | local function removetags(emailstr) 25 | return emailstr.gsub('[+-].-@', '@') 26 | end 27 | 28 | function M.address_equal(ia1, ia2) 29 | -- we don't support groups for now 30 | local e1 = normalize(ia1) 31 | local e2 = normalize(ia2) 32 | return unwild(e1) == unwild(e2) 33 | end 34 | 35 | --- TODO move all compare functions to it's own file 36 | function M.ialist_contains(ia2, ialist) 37 | for ia1 in gu.internet_address_list_iter(ialist) do 38 | if M.address_equal(ia1, ia2) then 39 | return true 40 | end 41 | end 42 | return false 43 | end 44 | 45 | local function search_for(received, ourlist) 46 | local match, num = string.gsub(received, '.*by ]*).*', '%1') 47 | if num > 0 then 48 | for _, mail in gu.internet_address_list_iter(ourlist) do 49 | local e = normalize(mail) 50 | -- TODO match might not be idn normalized 51 | if e:find(match) then 52 | return e 53 | end 54 | end 55 | end 56 | end 57 | 58 | local function search_by(received, ourlist) 59 | local match, num = string.gsub(received, '.*for ]*).*', '%1') 60 | if num > 0 then 61 | for _, mail in gu.internet_address_list_iter(ourlist) do 62 | local e = normalize(mail) 63 | -- TODO match might not be idn normalized 64 | if e:find(match) then 65 | return e 66 | end 67 | end 68 | end 69 | end 70 | 71 | --- TODO 72 | local function guess_from(message, ourlist) 73 | -- local received = M.header_get_values(object, "Received") 74 | -- for _, r in ipairs(received) do 75 | -- search_for(r) 76 | -- end 77 | -- for _, r in ipairs(received) do 78 | -- search_by(r) 79 | -- end 80 | return nil 81 | end 82 | 83 | --- get what email addr we used to recieve this email 84 | --- useful if you have multiple emails and want to respond 85 | --- from the correct email 86 | --- This looks horrible and is but it's kinda tho only way 87 | --- and how is most mailers do it 88 | function M.get_our_email(message) 89 | local at = gmime.AddressType 90 | local emails = {} 91 | table.insert(emails, config.values.primary_email) 92 | vim.list_extend(emails, config.values.other_email) 93 | local str = table.concat(emails, ', ') 94 | local ourlist = gmime.InternetAddressList.parse(nil, str) 95 | local normal = { 96 | at.TO, 97 | at.CC, 98 | at.BCC, 99 | } 100 | local extra = { 101 | 'Envelope-to', 102 | 'X-Original-To', 103 | 'Delivered-To', 104 | } 105 | for _, h in ipairs(normal) do 106 | local addr = message:get_addresses(h) 107 | if not addr then 108 | goto continue 109 | end 110 | for ia1 in gu.internet_address_list_iter(addr) do 111 | if M.ialist_contains(ia1, ourlist) then 112 | return ia1 113 | end 114 | end 115 | ::continue:: 116 | end 117 | for _, h in ipairs(extra) do 118 | local header = message:get_header(h) 119 | if not header then 120 | goto continue 121 | end 122 | 123 | for ia1 in gu.internet_address_list_iter_str(header, nil) do 124 | if M.ialist_contains(ia1, ourlist) then 125 | return ia1 126 | end 127 | end 128 | ::continue:: 129 | end 130 | 131 | if not config.values.guess_email then 132 | return config.values.primary_email 133 | end 134 | local guess = guess_from(message, ourlist) 135 | if guess then 136 | return guess 137 | end 138 | return config.values.primary_email 139 | end 140 | 141 | return M 142 | -------------------------------------------------------------------------------- /lua/galore/browser.lua: -------------------------------------------------------------------------------- 1 | local async_job = require('telescope._') 2 | local LinesPipe = async_job.LinesPipe 3 | local async = require('plenary.async') 4 | local Browser = {} 5 | 6 | --- Move to the next line in the browser 7 | function Browser.next(browser, line) 8 | line = math.min(line + 1, #browser.State) 9 | local line_info = browser.State[line] 10 | return line_info, line 11 | end 12 | 13 | --- Move to the next line in the browser 14 | function Browser.prev(browser, line) 15 | line = math.max(line - 1, 1) 16 | local line_info = browser.State[line] 17 | return line_info, line 18 | end 19 | 20 | --- Move to the prev line in the browser 21 | function Browser.select(browser) 22 | local line = vim.api.nvim_win_get_cursor(0)[1] 23 | return line, browser.State[line] 24 | end 25 | 26 | --- Commit the line to the browser 27 | --- Moving to the next line doesn't really move to the next line 28 | --- it asks for the next line relative to the line in the view 29 | --- This move the line for all windows that has the browser buffer 30 | function Browser.set_line(browser, line) 31 | local wins = vim.fn.win_findbuf(browser.handle) 32 | for _, win in ipairs(wins) do 33 | local location = vim.api.nvim_win_get_cursor(win) 34 | vim.api.nvim_win_set_cursor(win, { line, location[2] }) 35 | end 36 | end 37 | 38 | function Browser.update_lines_helper(self, mode, search, line_nr) 39 | local bufnr = self.handle 40 | local args = { 'nm-livesearch', '-d', self.opts.runtime.db_path, mode, search } 41 | if self.opts.show_message_description then 42 | args = { 43 | 'nm-livesearch', 44 | '-e', 45 | self.opts.show_message_description[1], 46 | '-r', 47 | self.opts.show_message_description[2], 48 | '-d', 49 | self.opts.runtime.db_path, 50 | mode, 51 | search, 52 | } 53 | end 54 | vim.fn.jobstart(args, { 55 | stdout_buffered = true, 56 | stderr_buffered = true, 57 | on_stderr = function(_, data, _) 58 | vim.api.nvim_err_write(string.format("Couldn't update line for %s and %d", search, line_nr)) 59 | end, 60 | on_stdout = function(_, data, _) 61 | local message = vim.fn.json_decode(data) 62 | if message then 63 | vim.api.nvim_buf_set_option(bufnr, 'readonly', false) 64 | vim.api.nvim_buf_set_option(bufnr, 'modifiable', true) 65 | vim.api.nvim_buf_set_lines(bufnr, line_nr - 1, line_nr, false, { message.entry }) 66 | if message.highlight then 67 | vim.api.nvim_buf_add_highlight(bufnr, self.dians, 'GaloreHighlight', line_nr, 0, -1) 68 | end 69 | vim.api.nvim_buf_set_option(bufnr, 'readonly', true) 70 | vim.api.nvim_buf_set_option(bufnr, 'modifiable', false) 71 | end 72 | end, 73 | }) 74 | end 75 | 76 | function Browser.get_entries(self, mode, buffer_runner) 77 | local stdout = LinesPipe() 78 | local writer = nil 79 | 80 | self.State = {} 81 | 82 | local job 83 | local iter 84 | local args = { '-d', self.opts.runtime.db_path, mode, self.search } 85 | if self.opts.show_message_description then 86 | args = vim.list_extend( 87 | { '-e', self.opts.show_message_description[1], '-r', self.opts.show_message_description[2] }, 88 | args 89 | ) 90 | end 91 | if self.opts.emph then 92 | local highlight = vim.json.encode(self.opts.emph) 93 | args = vim.list_extend({ '-h', highlight }, args) 94 | end 95 | 96 | job = async_job.spawn({ 97 | command = 'nm-livesearch', 98 | args = args, 99 | writer = writer, 100 | 101 | stdout = stdout, 102 | }) 103 | iter = stdout:iter(true) 104 | return setmetatable({ 105 | close = function() 106 | if job then 107 | job:close(true) 108 | end 109 | iter = nil -- ? 110 | self.runner = nil 111 | end, 112 | resume = function(numlocal) 113 | if job and iter then 114 | local i = 1 115 | 116 | for line in iter do 117 | local entry = vim.json.decode(line) 118 | i = i + buffer_runner(entry, i) 119 | if numlocal and i > numlocal then 120 | return 121 | end 122 | end 123 | -- idk if I need these first 2 124 | job:close(true) 125 | iter = nil 126 | self.runner = nil 127 | end 128 | end, 129 | }, { 130 | -- __call = callable, 131 | }) 132 | end 133 | 134 | -- Produce more entries when we scroll 135 | function Browser.scroll(self) 136 | vim.api.nvim_create_autocmd({ 'CursorMoved, WinScrolled' }, { 137 | buffer = self.handle, 138 | callback = function() 139 | local line = vim.api.nvim_win_get_cursor(0)[1] 140 | local nums = vim.api.nvim_buf_line_count(self.handle) 141 | local winsize = vim.api.nvim_win_get_height(0) 142 | local treshold = winsize * 4 143 | if (nums - line) < treshold then 144 | -- We should make sure that we are alone! 145 | if self.runner then 146 | if not self.updating then 147 | self.updating = true 148 | local func = async.void(function() 149 | self:unlock() 150 | self.runner.resume(math.max(treshold, self.opts.limit)) 151 | self:lock() 152 | self.updating = false 153 | end) 154 | func() 155 | end 156 | end 157 | end 158 | end, 159 | }) 160 | end 161 | 162 | local function find_highlight_helper(highlight, start, stop, pos) 163 | local mid = math.floor((start + stop) / 2) 164 | local item = highlight[mid] 165 | if pos == item then 166 | return mid 167 | elseif start == stop then 168 | if pos < item then 169 | return start - 1 170 | else 171 | return start 172 | end 173 | elseif pos > item then 174 | return find_highlight_helper(highlight, math.min(mid + 1, stop), stop, pos) 175 | else 176 | return find_highlight_helper(highlight, start, math.max(mid - 1, start), pos) 177 | end 178 | end 179 | 180 | local function insert_highlight(highlight, pos) 181 | if pos < highlight[1] then 182 | table.insert(highlight, pos) 183 | return 184 | elseif pos > highlight[#highlight] then 185 | table.insert(highlight, #highlight, pos) 186 | return 187 | end 188 | local i = find_highlight_helper(highlight, 1, #highlight, pos, false) 189 | table.insert(highlight, i, pos) 190 | end 191 | 192 | local function find_highlight(highlight, pos) 193 | if pos < highlight[1] or pos > highlight[#highlight] then 194 | return nil 195 | end 196 | 197 | local index = find_highlight_helper(highlight, 1, #highlight, pos) 198 | if highlight[index] ~= pos then 199 | return nil 200 | end 201 | return index 202 | end 203 | 204 | local function highlight_move(highlight, win_id, pos, forward) 205 | local vert_pos = pos[1] - 1 206 | if not highlight or vim.tbl_isempty(highlight) then 207 | return 208 | end 209 | 210 | -- if vert_pos < highlight[1] or vert_pos > highlight[#highlight] then 211 | -- return nil 212 | -- end 213 | 214 | local index = find_highlight_helper(highlight, 1, #highlight, vert_pos) 215 | 216 | if not forward and index > 1 then 217 | vim.api.nvim_win_set_cursor(win_id, { highlight[index - 1] + 1, pos[2] }) 218 | elseif forward and index < #highlight then 219 | vim.api.nvim_win_set_cursor(win_id, { highlight[index + 1] + 1, pos[2] }) 220 | end 221 | end 222 | 223 | function Browser.prev_highlight(browser) 224 | local win_id = vim.api.nvim_get_current_win() 225 | local pos = vim.api.nvim_win_get_cursor(win_id) 226 | 227 | highlight_move(browser.highlight, win_id, pos, false) 228 | end 229 | 230 | function Browser.next_highlight(browser) 231 | local win_id = vim.api.nvim_get_current_win() 232 | -- local bufnr = vim.api.nvim_win_get_buf(win_id) 233 | local pos = vim.api.nvim_win_get_cursor(win_id) 234 | 235 | highlight_move(browser.highlight, win_id, pos, true) 236 | end 237 | 238 | return Browser 239 | -------------------------------------------------------------------------------- /lua/galore/builder.lua: -------------------------------------------------------------------------------- 1 | local lgi = require('lgi') 2 | local gmime = lgi.require('GMime', '3.0') 3 | 4 | local gu = require('galore.gmime-util') 5 | local gcu = require('galore.crypt-utils') 6 | local u = require('galore.util') 7 | local runtime = require('galore.runtime') 8 | local log = require('galore.log') 9 | 10 | local M = {} 11 | 12 | local function make_attach(attach) 13 | local mime = u.collect(string.gmatch(attach.mime_type, '([^/]+)')) 14 | if #mime ~= nil then 15 | log.log_err('bad mime-type') 16 | end 17 | local attachment = gmime.Part.new_with_type(mime[1], mime[2]) 18 | attachment:set_filename(attach.filename) 19 | return attachment 20 | end 21 | 22 | local function create_path_attachment(attach) 23 | local attachment = make_attach(attach) 24 | 25 | local fd = assert(vim.loop.fs_open(attach.path, 'r', 0644)) 26 | local stream = gmime.StreamFs.new(fd) 27 | 28 | local content = gmime.DataWrapper.new_with_stream(stream, gmime.ContentEncoding.DEFAULT) 29 | attachment:set_content(content) 30 | return attachment 31 | end 32 | 33 | local function create_part_attachment(attach) 34 | local attachment = make_attach(attach) 35 | 36 | local content = attach.part:get_content() 37 | attachment:set_content(content) 38 | return attachment 39 | end 40 | 41 | local function create_data_attachment(attach) 42 | local attachment = make_attach(attach) 43 | 44 | local stream = gmime.StreamMem.new() 45 | 46 | local content = gmime.DataWrapper.new_with_stream(stream, gmime.ContentEncoding.DEFAULT) 47 | attachment:set_content(content) 48 | stream:write(stream, attach.data) 49 | stream:flush() 50 | return attachment 51 | end 52 | 53 | -- @param attach attachment 54 | -- @return A MimePart object containing an attachment 55 | -- Support encryption? 56 | local function create_attachment(attach) 57 | if attach.data then 58 | return create_data_attachment(attach) 59 | elseif attach.part then 60 | return create_part_attachment(attach) 61 | elseif attach.path then 62 | return create_path_attachment(attach) 63 | else 64 | return nil 65 | end 66 | end 67 | 68 | -- encrypt a part and return the multipart 69 | function M.secure(part, opts, recipients) 70 | local ctx = gmime.GpgContext.new() 71 | if opts.encrypt then 72 | local encrypt, err = gmime.MultipartEncrypted.encrypt( 73 | ctx, 74 | part, 75 | opts.sign, 76 | opts.gpg_id, 77 | opts.encrypt_flags, 78 | recipients 79 | ) 80 | if encrypt ~= nil then 81 | return encrypt 82 | end 83 | if opts.encrypt == 2 then 84 | local str = string.format("Couldn't encrypt message: %s", err) 85 | log.log_err(str) 86 | end 87 | end 88 | if opts.sign then 89 | local signed, err = gcu.sign(ctx, part) 90 | if signed ~= nil then 91 | return signed 92 | else 93 | local str = string.format('Could not sign message: %s', err) 94 | log.log_err(str) 95 | end 96 | end 97 | -- if we don't want to sign and failed to encrypt 98 | -- we just return the part as is 99 | return part 100 | end 101 | 102 | local function required_headers(message) 103 | local function l(list) 104 | return list:length(list) > 0 105 | end 106 | local from = message:get_from() 107 | local to = message:get_to() 108 | local sub = message:get_subject() 109 | return l(from) and l(to) and sub and sub ~= '' 110 | end 111 | 112 | function M.textbuilder(text) 113 | local body = gmime.TextPart.new_with_subtype('plain') 114 | body:set_text(table.concat(text, '\n')) 115 | return body 116 | end 117 | 118 | -- create a message from strings 119 | -- @param buf table parameters: 120 | -- headers: headers to set, address_headers are parsed and concated 121 | -- body: A body to send to bodybuilder 122 | -- @param opts table. Accept these values: 123 | -- mid: string if you don't want an autogenerated message-id. 124 | -- encrypt: bool 125 | -- sign: bool 126 | -- gpg_id: id of your gpg-key, required if we want to sign the email 127 | -- encrypt_flags: "none"|"session"|"noverify"|"keyserver"|"online" 128 | -- @param attachments list of table. See attachment format 129 | -- @param extra_headers list headers that we set before buf (can be overwritten) 130 | -- @param bodybuilder function(any) string 131 | -- @return a gmime message 132 | function M.create_message(buf, opts, attachments, extra_headers, builder) 133 | local at = gmime.AddressType 134 | extra_headers = extra_headers or {} 135 | opts = opts or {} 136 | attachments = attachments or {} 137 | local current -- our current position in the mime tree 138 | 139 | local message = gmime.Message.new(true) 140 | local address_headers = { at.FROM, at.TO, at.CC, at.BCC, at.REPLY_TO } -- etc 141 | 142 | for k, v in pairs(extra_headers) do 143 | -- is "" == null and we use default charset? 144 | message:set_header(k, v, '') 145 | end 146 | 147 | for k, v in pairs(buf.headers) do 148 | k = k:lower() 149 | if vim.tbl_contains(address_headers, k) then 150 | local list = gmime.InternetAddress.parse(nil, v) 151 | -- TODO change k to enum 152 | local address = message:get_addresses(k) 153 | if not list then 154 | local err = string.format('Failed to parse %s-address:\n%s', v, buf.headers[v]) 155 | log.error(err) 156 | return 157 | end 158 | address:append(list) 159 | else 160 | message:set_header(k, v, '') 161 | end 162 | end 163 | 164 | if opts.mid then 165 | message:set_message_id(message, opts.mid) 166 | else 167 | local id = gu.make_id(buf.headers.from) 168 | message:set_message_id(id) 169 | end 170 | 171 | gu.insert_current_date(message) 172 | 173 | message:set_subject(buf.headers.subject, '') 174 | 175 | if not required_headers(message) then 176 | log.error('Missing non-optinal headers') 177 | return 178 | end 179 | -- done with headers 180 | 181 | -- make a body 182 | current = builder(buf.body) 183 | 184 | if not vim.tbl_isempty(attachments) then 185 | local multipart = gmime.Multipart.new_with_subtype('mixed') 186 | multipart:add(current) 187 | current = multipart 188 | for _, attach in pairs(attachments) do 189 | local attachment = create_attachment(attach) 190 | if attachment then 191 | multipart:add(attachment) 192 | end 193 | end 194 | end 195 | 196 | if opts.encrypt or opts.sign then 197 | local function normalize(ia1) 198 | if gmime.InternetAddress:is_type_of(ia1) then 199 | return ia1:get_addr() 200 | end 201 | return ia1:get_name() 202 | end 203 | local recipients = {} 204 | local rec = message:get_all_recipients() 205 | for ia in gu.internet_address_list_iter(rec) do 206 | local norm = normalize(ia) 207 | table.insert(recipients, norm) 208 | end 209 | local secure = M.secure(current, opts, recipients) 210 | if secure then 211 | current = secure 212 | end 213 | end 214 | 215 | message:set_mime_part(current) 216 | 217 | return message 218 | end 219 | 220 | return M 221 | -------------------------------------------------------------------------------- /lua/galore/callback.lua: -------------------------------------------------------------------------------- 1 | --- TODO, this file includes to much globally 2 | local message_view = require('galore.message_view') 3 | local thread_view = require('galore.thread_view') 4 | local compose = require('galore.compose') 5 | local gu = require('galore.gmime-util') 6 | local nu = require('galore.notmuch-util') 7 | local runtime = require('galore.runtime') 8 | local config = require('galore.config') 9 | local br = require('galore.browser') 10 | local tm = require('galore.templates') 11 | -- local nm = require("galore.notmuch") 12 | local nm = require('notmuch') 13 | 14 | local M = {} 15 | 16 | --- Select a saved search and open it in specified browser and mode 17 | --- @param saved any 18 | --- @param browser any 19 | --- @param mode any 20 | function M.select_search(saved, browser, mode) 21 | local search = saved:select()[4] 22 | browser:create(search, { kind = mode, parent = saved }) 23 | end 24 | 25 | function M.select_search_default(saved, mode) 26 | local default_browser = saved.opts.default_browser or 'tmb' 27 | local browser 28 | if default_browser == 'tmb' then 29 | browser = require('galore.thread_message_browser') 30 | elseif default_browser == 'message' then 31 | browser = require('galore.message_browser') 32 | elseif default_browser == 'thread' then 33 | browser = require('galore.thread_browser') 34 | end 35 | M.select_search(saved, browser, mode) 36 | end 37 | 38 | --- Open the selected mail in the browser for viewing 39 | --- @param browser any 40 | --- @param mode any 41 | function M.select_message(browser, mode) 42 | local vline, line_info = br.select(browser) 43 | message_view:create(line_info, { kind = mode, parent = browser, vline = vline }) 44 | end 45 | 46 | --- Open the selected thread in the browser for viewing 47 | --- @param browser any 48 | --- @param mode any 49 | function M.select_thread(browser, mode) 50 | local vline, line_info = browser:select_thread() 51 | thread_view:create(line_info, { kind = mode, parent = browser, vline = vline }) 52 | end 53 | 54 | --- Yank the current line using the selector 55 | --- @param browser any 56 | function M.yank_browser(browser) 57 | local _, id = br.select(browser) 58 | vim.fn.setreg('', id) 59 | end 60 | 61 | --- Yank the current message using the selector 62 | --- @param mv any 63 | --- @param select any 64 | --- TODO change, we only save mid! 65 | function M.yank_message(mv, select) 66 | vim.fn.setreg('', mv.line[select]) 67 | end 68 | 69 | function M.new_message(kind, opts) 70 | opts = opts or {} 71 | tm.compose_new(opts) 72 | compose:create(kind, opts) 73 | end 74 | 75 | --- load a message as it is 76 | function M.load_draft(kind, message, opts) 77 | opts = opts or {} 78 | tm.load_body(message, opts) 79 | tm.load_headers(message, opts) 80 | opts.mid = message:get_message_id(message) 81 | compose:create(kind, opts) 82 | end 83 | 84 | --- Create a reply compose from a message view 85 | --- @param message gmime.Message 86 | --- @param opts any 87 | function M.message_reply(kind, message, mode, opts) 88 | opts = opts or {} 89 | opts.reply = true 90 | mode = mode or 'reply' 91 | tm.load_body(message, opts) 92 | opts.Attach = nil 93 | tm.response_message(message, opts, mode) 94 | -- tm.response_message(message, opts, mode) 95 | gu.make_ref(message, opts) 96 | compose:create(kind, opts) 97 | end 98 | 99 | function M.send_template(opts) 100 | local buf = { headers = opts.headers, body = opts.Body } 101 | local send_opts = {} 102 | local message = builder.create_message(buf, send_opts, opts.Attach, {}, builder.textbuilder) 103 | -- something like this 104 | -- job.send_mail(message, function () 105 | -- end 106 | end 107 | 108 | function M.mid_reply(kind, mid, mode, opts) 109 | local line 110 | opts = opts or {} 111 | runtime.with_db(function(db) 112 | local nm_message = nm.db_find_message(db, mid) 113 | line = nu.get_message(nm_message) 114 | end) 115 | local draft = vim.tbl_contains(line.tags, 'draft') 116 | local message = gu.parse_message(line.filenames[1]) 117 | if message == nil then 118 | error("Couldn't parse message") 119 | end 120 | if draft then 121 | M.load_draft(kind, message, opts) 122 | else 123 | M.message_reply(kind, message, mode, opts) 124 | end 125 | end 126 | 127 | --- Change the tag for currently selected message and update the browser 128 | --- @param browser any 129 | --- @param tag string +tag to add tag, -tag to remove tag 130 | function M.change_tag(browser, tag) 131 | local vline, id = br.select(browser) 132 | if tag then 133 | runtime.with_db_writer(function(db) 134 | nu.change_tag(db, id, tag) 135 | nu.tag_if_nil(db, id, config.values.empty_tag) 136 | end) 137 | browser:update(vline) 138 | end 139 | end 140 | 141 | --- Ask for a change the tag for currently selected message and update the browser 142 | --- @param browser any 143 | function M.change_tag_ask(browser) 144 | vim.ui.input({ prompt = 'Tags change: ' }, function(tag) 145 | if tag then 146 | M.change_tag(browser, tag) 147 | else 148 | error('No tag') 149 | end 150 | end) 151 | end 152 | 153 | function M.change_tags_threads(tb, tag) 154 | local vline, thread = br.select(tb) 155 | runtime.with_db_writer(function(db) 156 | local q = nm.create_query(db, 'thread:' .. thread) 157 | for message in nm.thread_get_messages(thread) do 158 | local id = nm.message_get_id(message) 159 | nu.change_tag(db, id, tag) 160 | nu.tag_if_nil(db, id, config.value.empty_tag) 161 | end 162 | tb:update(vline) 163 | end) 164 | end 165 | 166 | --- Ask for a change the tag for currently selected message and update the browser 167 | --- @param tb any 168 | function M.change_tag_threads_ask(tb) 169 | vim.ui.input({ prompt = 'Tags change: ' }, function(tag) 170 | if tag then 171 | M.change_tags_threads(tb, tag) 172 | else 173 | error('No tag') 174 | end 175 | end) 176 | end 177 | 178 | return M 179 | -------------------------------------------------------------------------------- /lua/galore/compose.lua: -------------------------------------------------------------------------------- 1 | local u = require('galore.util') 2 | local gu = require('galore.gmime-util') 3 | local ui = require('galore.ui') 4 | local Buffer = require('galore.lib.buffer') 5 | local job = require('galore.jobs') 6 | local builder = require('galore.builder') 7 | local nu = require('galore.notmuch-util') 8 | local runtime = require('galore.runtime') 9 | local Path = require('plenary.path') 10 | local debug = require('galore.debug') 11 | local o = require('galore.opts') 12 | local log = require('galore.log') 13 | -- local hd = require("galore.header-diagnostics") 14 | 15 | local Compose = Buffer:new() 16 | 17 | function Compose:add_attachment_path(file_path) 18 | local path = Path:new(file_path) 19 | if path:exists() and not path:is_file() then 20 | local filename = path:normalize() 21 | local mime_type = job.get_type(file_path) 22 | table.insert(self.attachments, { filename = filename, path = file_path, mime_type = mime_type }) 23 | else 24 | log.log('Failed to add file', vim.log.levels.ERROR) 25 | end 26 | end 27 | 28 | function Compose:remove_attachment() 29 | vim.ui.select(self.attachments, { prompt = 'delete attachment' }, function(_, idx) 30 | if idx then 31 | table.remove(self.attachments, idx) 32 | end 33 | end) 34 | self:update_attachments() 35 | end 36 | 37 | function Compose:set_header_option(key, value) 38 | self.extra_headers[key] = value 39 | end 40 | 41 | function Compose:unset_option() 42 | local list = vim.tbl_keys(self.extra_headers) 43 | vim.ui.select(list, { 44 | prompt = 'Option to unset', 45 | format_item = function(item) 46 | return string.format('%s = %s', item, self.extra_headers[item]) 47 | end, 48 | }, function(item, _) 49 | if item then 50 | self.extra_headers[item] = nil 51 | end 52 | end) 53 | end 54 | 55 | local function empty(str) 56 | return u.trim(str) == '' 57 | end 58 | 59 | --- TODO Test multiline! 60 | local function get_headers(self) 61 | local headers = {} 62 | local body_line = vim.api.nvim_buf_get_extmark_by_id(self.handle, self.compose, self.marks, {})[1] 63 | local lines = vim.api.nvim_buf_get_lines(self.handle, 0, body_line, true) 64 | local last_key 65 | for i, line in ipairs(lines) do 66 | if empty(line) then 67 | goto continue 68 | end 69 | local start, _, key, value = string.find(line, '^%s*(.-)%s*:(.+)') 70 | if start ~= nil and key ~= '' and value ~= '' then 71 | key = string.lower(key) 72 | last_key = key 73 | value = vim.fn.trim(value) 74 | headers[key] = value 75 | else 76 | if not last_key then 77 | -- this should be a log function 78 | local str = "Bad formated headers, trying to add to value header above that doesn't exist" 79 | log.log(str, vim.log.levels.ERROR) 80 | error(str) 81 | end 82 | -- local value, prev = unpack(headers[last_key]) 83 | local key = headers[last_key] 84 | local extra = vim.fn.trim(line) 85 | -- headers[key] = {value .. " " .. extra, prev} 86 | headers[key] = value .. ' ' .. extra 87 | end 88 | ::continue:: 89 | end 90 | return headers, body_line 91 | end 92 | 93 | function Compose:parse_buffer() 94 | local buf = {} 95 | local headers, body_line = get_headers(self) 96 | -- local lines = vim.api.nvim_buf_line_count(self.handle) 97 | local body = vim.api.nvim_buf_get_lines(self.handle, body_line + 1, -1, false) 98 | if headers.subject == nil then 99 | headers.subject = self.opts.empty_topic 100 | end 101 | 102 | -- for i = body_line + 1, lines do 103 | -- table.insert(body, lines[i]) 104 | -- end 105 | buf.body = body 106 | buf.headers = headers 107 | return buf 108 | end 109 | 110 | -- TODO add opts 111 | function Compose:preview(kind) 112 | kind = kind or 'floating' 113 | local buf = self:parse_buffer() 114 | 115 | local message = 116 | builder.create_message(buf, self.opts, self.attachments, self.header_opts, builder.textbuilder) 117 | debug.view_raw_message(message, kind) 118 | self:set_option('modified', false) 119 | end 120 | 121 | -- Tries to send what is in the current buffer 122 | function Compose:send() 123 | -- local opts = {} 124 | local buf = self:parse_buffer() 125 | --- should be do encryt here or not? 126 | 127 | --- from here we want to be async 128 | local message = builder.create_message( 129 | buf, 130 | self.opts, 131 | self.attachments, 132 | self.extra_headers, 133 | builder.textbuilder 134 | ) 135 | if not message then 136 | log.log("Couldn't create message for sending", vim.log.levels.ERROR) 137 | return 138 | end 139 | if self.opts.pre_sent_hooks then 140 | self.opts.pre_sent_hooks(message) 141 | end 142 | 143 | job.send_mail(message, function() 144 | log.log('Email sent', vim.log.levels.INFO) 145 | local reply = message:get_header('References') 146 | if reply then 147 | local mid = gu.unbracket(reply) 148 | runtime.with_db_writer(function(db) 149 | nu.change_tag(db, mid, '+replied') 150 | end) 151 | end 152 | --- add an option for this or move it to post_ehooks? 153 | job.insert_mail(message, self.opts.sent_dir, '+sent') 154 | if self.opts.post_sent_hooks then 155 | self.opts.post_sent_hooks(message) 156 | end 157 | end) 158 | self:set_option('modified', false) 159 | end 160 | 161 | function Compose:save_draft(build_opts) 162 | build_opts = build_opts or {} 163 | local buf = self:parse_buffer() 164 | if self.opts.draft_encrypt and self.opts.gpg_id then 165 | build_opts.encrypt = true 166 | build_opts.recipients = self.opts.gpg_id 167 | end 168 | local message = builder.create_message( 169 | buf, 170 | build_opts, 171 | self.attachments, 172 | self.extra_headers, 173 | builder.textbuilder 174 | ) 175 | if not message then 176 | log.log("Couldn't create message for sending", vim.log.levels.ERROR) 177 | return 178 | end 179 | --- TODO from here we want to be async 180 | job.insert_mail(message, self.opts.draft_dir, self.opts.draft_tag) 181 | end 182 | 183 | function Compose:update_attachments() 184 | vim.api.nvim_buf_clear_namespace(self.handle, self.ns, 0, -1) 185 | local line = vim.fn.line('$') - 1 186 | ui.render_attachments(self.attachments, line, self.handle, self.ns) 187 | end 188 | 189 | function Compose:delete_tmp() 190 | -- vim.fn.delete() 191 | end 192 | 193 | local function addfiles(self, files) 194 | self.attachments = {} 195 | if not files then 196 | return 197 | end 198 | if type(files) == 'string' then 199 | self:add_attachment_path(files) 200 | return 201 | else 202 | self.attachments = files 203 | end 204 | end 205 | 206 | local function make_seperator(buffer, lines) 207 | local line_num = lines 208 | local col_num = 0 209 | 210 | local opts = { 211 | virt_lines = { 212 | { { 'Emailbody', 'GaloreSeperator' } }, 213 | }, 214 | } 215 | buffer.marks = buffer:set_extmark(buffer.compose, line_num, col_num, opts) 216 | end 217 | 218 | function Compose:commands() 219 | vim.api.nvim_buf_create_user_command(self.handle, 'GaloreAddAttachment', function(args) 220 | if args.fargs then 221 | for _, value in ipairs(args.fargs) do 222 | self:add_attachment(value) 223 | end 224 | self:update_attachments() 225 | end 226 | end, { 227 | nargs = '*', 228 | complete = 'file', 229 | }) 230 | vim.api.nvim_buf_create_user_command(self.handle, 'GalorePreview', function() 231 | self:preview() 232 | end, { 233 | nargs = 0, 234 | }) 235 | vim.api.nvim_buf_create_user_command(self.handle, 'GaloreSend', function() 236 | self:send() 237 | end, { 238 | nargs = 0, 239 | }) 240 | end 241 | 242 | local function consume_headers(buffer) 243 | local lines = {} 244 | local extra_headers = {} 245 | local lookback = {} 246 | for _, v in ipairs(buffer.opts.compose_headers) do 247 | lookback[v[1]] = v[2] 248 | end 249 | 250 | for _, v in ipairs(buffer.opts.compose_headers) do 251 | if v[2] and not buffer.headers[v[1]] then 252 | local header = string.format('%s: ', v[1]) 253 | table.insert(lines, header) 254 | elseif buffer.headers[v[1]] then 255 | local header = string.format('%s: %s', v[1], buffer.headers[v[1]]) 256 | local multiline = vim.fn.split(header, '\n') 257 | vim.list_extend(lines, multiline) 258 | end 259 | end 260 | 261 | for k, v in pairs(buffer.headers) do 262 | if lookback[k] == nil then 263 | extra_headers[k] = v 264 | end 265 | end 266 | buffer.extra_headers = extra_headers 267 | buffer:set_lines(0, 0, true, lines) 268 | end 269 | 270 | local function render_body(buffer) 271 | if buffer.body then 272 | buffer:set_lines(-1, -1, true, buffer.body) 273 | end 274 | end 275 | 276 | function Compose:update() 277 | self:clear() 278 | consume_headers(self) 279 | local after = vim.fn.line('$') - 1 280 | render_body(self) 281 | 282 | make_seperator(self, after) 283 | end 284 | 285 | local function checkheaders(self) 286 | vim.api.nvim_create_autocmd('InsertLeave', { 287 | callback = function() 288 | local headers = get_headers(self) 289 | hd.checkheaders(self.dians, self.handle, headers) 290 | end, 291 | buffer = self.handle, 292 | }) 293 | end 294 | 295 | -- change message to file 296 | function Compose:create(kind, opts) 297 | o.compose_options(opts) 298 | Buffer.create({ 299 | name = opts.bufname(), 300 | ft = 'mail', 301 | kind = kind, 302 | cursor = 'top', 303 | parent = opts.parent, 304 | buftype = '', -- fix this 305 | modifiable = true, 306 | mappings = opts.key_bindings, 307 | init = function(buffer) 308 | buffer.opts = opts 309 | buffer.headers = opts.headers 310 | buffer.body = opts.Body 311 | addfiles(buffer, opts.Attach) 312 | buffer.ns = vim.api.nvim_create_namespace('galore-attachments') 313 | buffer.compose = vim.api.nvim_create_namespace('galore-compose') 314 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 315 | buffer:update() 316 | -- checkheaders(buffer) 317 | 318 | buffer:set_option('modified', false) 319 | 320 | buffer:update_attachments() 321 | opts.init(buffer) 322 | buffer:commands() 323 | end, 324 | }, Compose) 325 | end 326 | 327 | return Compose 328 | -------------------------------------------------------------------------------- /lua/galore/crypt-utils.lua: -------------------------------------------------------------------------------- 1 | local config = require("galore.config") 2 | local lgi = require 'lgi' 3 | local log = require("galore.log") 4 | local gmime = lgi.require("GMime", "3.0") 5 | 6 | local M = {} 7 | 8 | local function verify_list(siglist) 9 | if siglist == nil then 10 | return false 11 | end 12 | 13 | local sigs = siglist:length(siglist) > 0 14 | -- P(siglist:length()) 15 | for i = 0, siglist:length() do 16 | local sig = siglist:get_signature(i) 17 | if sig ~= nil then 18 | local test = config.values.validate_key(sig:get_status()) 19 | sigs = sigs and test 20 | end 21 | end 22 | return sigs 23 | end 24 | 25 | function M.verify_signed(object) 26 | local signatures, error = object:verify(gmime.VerifyFlags(config.values.verify_flags)) 27 | if not signatures and error then 28 | return false 29 | else 30 | return verify_list(signatures) 31 | end 32 | end 33 | 34 | function M.decrypt_and_verify(obj, flags, key) 35 | -- we don't get a result or do we get either a result or an error? 36 | local decrypted, result, err = obj:decrypt( 37 | flags, 38 | key 39 | ) 40 | 41 | if not decrypted or err ~= nil then 42 | local str = string.format("Failed to decrypt message: %s", err) 43 | log.log(str, vim.log.levels.ERROR) 44 | return 45 | end 46 | 47 | local sign 48 | if result then 49 | sign = verify_list(result:get_signatures()) 50 | end 51 | 52 | local new_key = result:get_session_key() 53 | 54 | return decrypted, sign, new_key 55 | end 56 | 57 | -- maybe not do these 58 | function M.sign(ctx, obj) 59 | local ret, err = gmime.MultipartSigned.sign(ctx, obj, config.gpg_id) 60 | return ret, err 61 | end 62 | 63 | -- maybe not do these 64 | function M.encrypt(ctx, obj, opts, recipients) 65 | local multi, err = gmime.MultipartEncrypted.encrypt(ctx, obj, opts.sign, opts.gpg_id, opts.encrypt_flags, recipients) 66 | return multi, err 67 | end 68 | 69 | return M 70 | -------------------------------------------------------------------------------- /lua/galore/debug.lua: -------------------------------------------------------------------------------- 1 | local nm = require('notmuch') 2 | local runtime = require('galore.runtime') 3 | local Buffer = require('galore.lib.buffer') 4 | local gu = require('galore.gmime-util') 5 | local uv = vim.loop 6 | 7 | local lgi = require('lgi') 8 | local gmime = lgi.require('GMime', '3.0') 9 | 10 | local debug = {} 11 | 12 | --- Functions to aid in debugging. 13 | --- Atm it's just functions to view raw messages 14 | function debug.view_raw_file(filename, kind) 15 | Buffer.create({ 16 | ft = 'mail', 17 | kind = kind or 'floating', 18 | cursor = 'top', 19 | init = function(buffer) 20 | local fd = assert(uv.fs_open(filename, 'r', 438)) 21 | local stat = assert(uv.fs_fstat(fd)) 22 | local data = assert(uv.fs_read(fd, stat.size, 0)) 23 | assert(uv.fs_close(fd)) 24 | data = vim.split(data, '\n') 25 | buffer:set_lines(0, 0, true, data) 26 | end, 27 | }) 28 | end 29 | 30 | --- requires that the message is in the notmuch db 31 | function debug.view_raw_mid(mid, kind) 32 | local filename 33 | runtime.with_db(function(db) 34 | local message = nm.db_find_message(db, mid) 35 | filename = nm.message_get_filename(message) 36 | end) 37 | debug.view_raw_file(filename, kind) 38 | end 39 | 40 | --- maybe not do this for really big files? Maybe keep a cap? 41 | function debug.view_raw_attachment(attachment, kind) 42 | Buffer.create({ 43 | ft = attachment.mime_type, 44 | kind = kind or 'floating', 45 | cursor = 'top', 46 | init = function(buffer) 47 | local buf 48 | if attachment.part then 49 | buf = gu.part_to_string(attachment.part) 50 | elseif attachment.data then 51 | buf = attachment.data 52 | elseif attachment.filename then 53 | local fd = assert(uv.fs_open(attachment.filename, 'r', 438)) 54 | local stat = assert(uv.fs_fstat(fd)) 55 | buf = assert(uv.fs_read(fd, stat.size, 0)) 56 | assert(uv.fs_close(fd)) 57 | end 58 | local fixed = vim.split(buf, '\n', false) 59 | buffer:set_lines(0, 0, true, fixed) 60 | end, 61 | }) 62 | end 63 | 64 | function debug.view_raw_message(message, kind) 65 | if not message then 66 | return 67 | end 68 | local mem = gmime.StreamMem.new() 69 | message:write_to_stream(nil, mem) 70 | mem:flush() 71 | local str = mem:get_byte_array() 72 | local tbl = vim.split(str, '\n') 73 | Buffer.create({ 74 | ft = 'mail', 75 | kind = kind or 'floating', 76 | cursor = 'top', 77 | init = function(buffer) 78 | buffer:set_lines(0, 0, true, tbl) 79 | end, 80 | }) 81 | end 82 | 83 | return debug 84 | -------------------------------------------------------------------------------- /lua/galore/default.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | local grouped = config.values.browser_grouped 3 | local M = {} 4 | 5 | function M.init(opts, searches) 6 | local saved = require('galore.saved') 7 | local group = vim.api.nvim_create_augroup('galore-windowstyle', { clear = true }) 8 | vim.api.nvim_create_autocmd({ 'BufEnter', 'Filetype' }, { 9 | pattern = { 'galore-threads*', 'galore-messages' }, 10 | group = group, 11 | callback = function() 12 | vim.api.nvim_win_set_option(0, 'foldlevel', 1) 13 | vim.api.nvim_win_set_option(0, 'foldmethod', 'manual') 14 | vim.api.nvim_win_set_option(0, 'foldcolumn', '1') 15 | end, 16 | }) 17 | vim.api.nvim_create_autocmd({ 'BufEnter', 'Filetype' }, { 18 | pattern = { 'mail' }, 19 | group = group, 20 | callback = function() 21 | vim.api.nvim_win_set_option(0, 'foldlevel', 99) 22 | vim.api.nvim_win_set_option(0, 'foldmethod', 'syntax') 23 | vim.api.nvim_win_set_option(0, 'foldcolumn', '1') 24 | end, 25 | }) 26 | return saved:create(opts, searches) 27 | end 28 | 29 | return M 30 | -------------------------------------------------------------------------------- /lua/galore/gmime-util.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | local lgi = require('lgi') 3 | local gmime = lgi.require('GMime', '3.0') 4 | local glib = lgi.require('GLib', '2.0') 5 | 6 | local M = {} 7 | 8 | function M.get_domainname(mail_str) 9 | local fqdn = mail_str:gsub('.*@(%w*)%.(%w*).*', '%1.%2') 10 | return fqdn 11 | end 12 | 13 | function M.insert_current_date(message) 14 | local time = os.time() 15 | local gtime = glib.DateTime.new_from_unix_local(time) 16 | message:set_date(gtime) 17 | end 18 | 19 | function M.make_id(from) 20 | local fdqn = M.get_domainname(from) 21 | return gmime.utils_generate_message_id(fdqn) 22 | end 23 | 24 | function M.mime_type(object) 25 | local ct = object:get_content_type() 26 | if ct then 27 | local type = ct:get_mime_type() 28 | return type 29 | end 30 | end 31 | 32 | function M.parse_message(filename) 33 | local stream = gmime.StreamFile.open(filename, 'r') 34 | local parser = gmime.Parser.new_with_stream(stream) 35 | local opts = gmime.ParserOptions.new() 36 | local message = parser:construct_message(opts) 37 | return message 38 | end 39 | 40 | --- tries to recorstruct a partial message, 41 | --- if the message isn't partial then just parse the 42 | --- message 43 | -- idx needs to be in bound 44 | function M.reconstruct(filenames, idx) 45 | if #filenames == 1 then 46 | return M.parse_message(filenames[idx]) 47 | end 48 | local parts = {} 49 | 50 | local main_message = M.parse_message(filenames[idx]) 51 | if main_message then 52 | local is_partial = false 53 | main_message:foreach(function(_, part) 54 | if gmime.MessagePartial:is_type_of(part) then 55 | is_partial = true 56 | local id = part:get_id(part) 57 | parts[id] = {} 58 | end 59 | end) 60 | if not is_partial then 61 | return main_message 62 | end 63 | 64 | for filename in ipairs(filenames) do 65 | local message = M.parse_message(filename) 66 | message:foreach(function(_, part) 67 | if gmime.MessagePartial:is_type_of(part) then 68 | local id = part:get_id(part) 69 | table.insert(parts[id], part) 70 | end 71 | end) 72 | end 73 | --- TODO 74 | --- we only return the first message of the partials 75 | for _, partial in pairs(parts) do 76 | if #partial == partial[1]:get_total() then 77 | local message = gmime.MessagePartial.reconstruct_message(partial) 78 | return message 79 | end 80 | end 81 | -- fall back to original message 82 | return main_message 83 | end 84 | end 85 | 86 | function M.make_ref(message, opts) 87 | local ref_str = message:get_header('References') 88 | local ref 89 | if ref_str then 90 | ref = gmime.References.parse(nil, ref_str) 91 | else 92 | ref = gmime.References.new() 93 | end 94 | local mid = message:get_message_id() 95 | local reply = gmime.References.parse(nil, mid) 96 | ref:append(mid) 97 | opts.headers = opts.headers or {} 98 | opts.headers.References = M.references_format(ref) 99 | opts.headers['In-Reply-To'] = M.references_format(reply) 100 | end 101 | 102 | function M.references_format(refs) 103 | if refs == nil then 104 | return nil 105 | end 106 | local box = {} 107 | for ref in M.reference_iter(refs) do 108 | table.insert(box, '<' .. ref .. '>') 109 | end 110 | return table.concat(box, '\n\t') 111 | end 112 | 113 | function M.is_multipart_alt(object) 114 | local type = M.mime_type(object) 115 | if type == 'multipart/alternative' then 116 | return true 117 | end 118 | return false 119 | end 120 | 121 | function M.is_multipart_multilingual(object) 122 | local type = M.mime_type(object) 123 | if type == 'multipart/alternative' then 124 | return true 125 | end 126 | return false 127 | end 128 | 129 | function M.is_multipart_related(object) 130 | local type = M.mime_type(object) 131 | if type == 'multipart/alternative' then 132 | return true 133 | end 134 | return false 135 | end 136 | 137 | function M.multipart_foreach_level(part, parent, fun, level) 138 | if parent ~= part then 139 | fun(parent, part, level) 140 | end 141 | if gmime.Multipart:is_type_of(part) then 142 | local i = 0 143 | local j = part:get_count() 144 | level = level + 1 145 | while i < j do 146 | local child = part:get_part(i) 147 | M.multipart_foreach_level(child, part, fun, level) 148 | i = i + 1 149 | end 150 | end 151 | end 152 | 153 | function M.message_foreach_level(message, fun) 154 | local level = 1 155 | if not message or not fun then 156 | return 157 | end 158 | local part = message:get_mime_part() 159 | fun(part, part, level) 160 | 161 | if gmime.Multipart:is_type_of(part) then 162 | M.multipart_foreach_level(part, part, fun, level) 163 | end 164 | end 165 | 166 | --- @param str string 167 | --- @param opts gmime.ParserOptions|nil 168 | --- @return function 169 | function M.reference_iter_str(str, opts) 170 | local refs = gmime.Reference.parse(opts, str) 171 | if refs == nil then 172 | return function() 173 | return nil 174 | end 175 | end 176 | return M.reference_iter(refs) 177 | end 178 | 179 | --- @return function 180 | function M.reference_iter(refs) 181 | local i = 0 182 | return function() 183 | if i < refs:length() then 184 | local ref = refs:get_message_id(i) 185 | i = i + 1 186 | return ref 187 | end 188 | end 189 | end 190 | 191 | function M.header_iter(object) 192 | local ls = object:get_header_list() 193 | if ls == nil then 194 | return function() 195 | return nil 196 | end 197 | end 198 | local j = ls:get_count() 199 | local i = 0 200 | return function() 201 | if i < j then 202 | local header = ls:get_header_at(i) 203 | if header == nil then 204 | return nil, nil 205 | end 206 | local key = header:get_name() 207 | local value = header:get_value() 208 | i = i + 1 209 | return key, value 210 | end 211 | end 212 | end 213 | 214 | function M.internet_address_list_iter_str(str, opt) 215 | local list = gmime.InternetAddressList.parse(opt, str) 216 | if list == nil then 217 | return function() 218 | return nil 219 | end 220 | end 221 | return M.internet_address_list_iter(list) 222 | end 223 | 224 | function M.internet_address_list_iter(list) 225 | local i = 0 226 | return function() 227 | if i < list:length() then 228 | local addr = list:get_address(i) 229 | i = i + 1 230 | return addr 231 | end 232 | end 233 | end 234 | 235 | return M 236 | -------------------------------------------------------------------------------- /lua/galore/health.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local health = vim.health 3 | 4 | M.check = function() 5 | health.report_start('Galore health check') 6 | if vim.fn.executable('notmuch') == 1 then 7 | health.report_ok('Found notmuch') 8 | else 9 | health.report_error("Could not find notmuch, galore won't work") 10 | end 11 | if vim.fn.executable('nm-livesearch') == 1 then 12 | health.report_ok('found the executable nm-livesearch') 13 | else 14 | health.report_error("Could not find nm-livesearch, galore won't work") 15 | end 16 | if vim.fn.executable('browser-pipe') == 1 then 17 | health.report_ok('found the executable browser-pipe') 18 | else 19 | health.report_error( 20 | "Could not find browser-pipe, won't be able to view html in an external browser" 21 | ) 22 | end 23 | if vim.fn.executable('file') == 1 then 24 | health.report_ok('found the find executable') 25 | else 26 | health.report_error("Could not find file, galore won't work") 27 | end 28 | if pcall(require, 'cmp') then 29 | health.report_ok('Found cmp') 30 | else 31 | health.report_info('Missing cmp for email completion') 32 | end 33 | if pcall(require, 'telescope') then 34 | health.report_ok('Found telescope') 35 | else 36 | health.report_info('Missing telescope for email search') 37 | end 38 | if vim.fn.executable('mates') == 1 then 39 | health.report_ok('Found mates') 40 | else 41 | health.report_info('Missing optional mates for vcard support') 42 | end 43 | if vim.fn.executable('w3m') == 1 then 44 | health.report_ok('found w3m') 45 | else 46 | health.report_info('Missing w3m, the default html render') 47 | end 48 | end 49 | 50 | return M 51 | -------------------------------------------------------------------------------- /lua/galore/hooks.lua: -------------------------------------------------------------------------------- 1 | --- A hook takes a message and returns a boolean if success 2 | local config = require('galore.config') 3 | local job = require('galore.jobs') 4 | 5 | local lgi = require('lgi') 6 | local gmime = lgi.require('GMime', '3.0') 7 | 8 | local M = {} 9 | 10 | -- A pre hook is always return a boolean. If a pre hook return false 11 | 12 | --- @param message any 13 | --- @return boolean 14 | local function has_attachment(message) 15 | local state = {} 16 | local find_attachment = function(_, part, _) 17 | if gmime.Part:is_type_of(part) and part:is_attchment(part) then 18 | state.attachment = true 19 | end 20 | end 21 | message:foreach(find_attachment) 22 | return state.attachment 23 | end 24 | 25 | --- Pre sending, check for attachments 26 | --- @return boolean 27 | function M.missed_attachment(message) 28 | local sub = message:get_subject() 29 | local start, _ = string.find(sub, '[a,A]ttachment') 30 | local re, _ = string.find(sub, '^%s*Re:') 31 | if start and not re and not has_attachment(message) then 32 | return false 33 | end 34 | return true 35 | end 36 | 37 | --- Pre sending, ask if you want to send the email 38 | --- Should be in general be inserted before IO functions (like FCC) 39 | --- @return boolean 40 | function M.confirm() 41 | local ret = false 42 | vim.ui.input({ 43 | prompt = 'Wanna send email? Y[es]', 44 | }, function(input) 45 | if input then 46 | input = input:lower() 47 | if input == 'yes' or 'y' then 48 | ret = true 49 | end 50 | end 51 | end) 52 | return ret 53 | end 54 | 55 | --- @return boolean 56 | function M.fcc_nm_insert(message) 57 | local fcc_str = message:get_header('FCC') 58 | message:remove_header('FCC') 59 | 60 | -- Should we try to guard users? 61 | -- this shouldn't be an abs path 62 | local path = config.values.fccdir .. '/' .. fcc_str 63 | 64 | job.insert_mail(message, path, '') 65 | end 66 | 67 | --- write file to a maildir 68 | --- this should do a lot more checks 69 | --- create dirs etc if it doesn't exist 70 | 71 | --- Pre send, insert the mail into fcc dir and then remove FCC header 72 | --- @param message 73 | --- @return boolean 74 | function M.fcc_fs(message) 75 | local fcc_str = message:get_header('FCC') 76 | message:remove_header('FCC') 77 | end 78 | 79 | function M.preview() end 80 | 81 | --- 82 | 83 | function M.delete_draft() end 84 | 85 | return M 86 | -------------------------------------------------------------------------------- /lua/galore/hooks/post.lua: -------------------------------------------------------------------------------- 1 | --- After a mail have been sent 2 | local go = require('galore.gmime.object') 3 | -- Should config really be in here? 4 | local config = require('galore.config') 5 | local job = require('galore.jobs') 6 | local ffi = require('ffi') 7 | 8 | local M = {} 9 | 10 | -- XXX do error handling. 11 | function M.fcc_nm_insert(message) 12 | local obj = ffi.cast('GMimeObject *', message) 13 | local fcc_str = go.object_get_header(obj, 'Fcc') 14 | 15 | -- Should we try to guard users? 16 | -- this shouldn't be an abs path 17 | local path = config.values.fccdir .. '/' .. fcc_str 18 | 19 | go.object_set_header(obj, 'Fcc', nil) 20 | job.insert_mail(message, path, '') 21 | end 22 | 23 | --- write file to a maildir 24 | --- this should do a lot more checks 25 | --- create dirs etc if it doesn't exist 26 | function M.fcc_fs(message) 27 | local obj = ffi.cast('GMimeObject *', message) 28 | local fcc_str = go.object_get_header(obj, 'Fcc') 29 | 30 | go.object_set_header(obj, 'Fcc', nil) 31 | end 32 | 33 | function M.delete_draft(message) end 34 | 35 | return M 36 | -------------------------------------------------------------------------------- /lua/galore/hooks/send.lua: -------------------------------------------------------------------------------- 1 | local gp = require('galore.gmime.parts') 2 | local M = {} 3 | 4 | --- hook design: 5 | --- They should log when they run 6 | --- A hook should be able to block and not. 7 | 8 | function M.preview_ask(message) 9 | -- create preview 10 | vim.ui.input({ 11 | prompt = 'Do you want to send email? [Y]es/[N]o', 12 | }, function(input) 13 | if input then 14 | input = input:lower() 15 | if input == 'yes' or input == 'y' then 16 | return true 17 | end 18 | end 19 | return false 20 | end) 21 | end 22 | 23 | --- @param message any 24 | --- @return boolean 25 | function M.has_attachment(message) 26 | local state = {} 27 | local find_attachment = function(_, part) 28 | if gp.is_part(part) and gp.part_is_attachment(part) then 29 | state.attachment = true 30 | end 31 | end 32 | gp.message_foreach(message, find_attachment) 33 | return state.attachment 34 | end 35 | 36 | --- Doesn't handle re: 37 | function M.missed_attachment(message) 38 | local sub = gp.message_get_subject(message) 39 | if sub:match('[a,A]ttachment') and not M.has_attachment(message) then 40 | return false 41 | end 42 | return true 43 | end 44 | 45 | -- Delay sending for X seconds 46 | -- Returns a cancel channel and a delay-hook 47 | -- XXX api for async hooks? 48 | -- You don't use this directly but to make hooks 49 | function M.make_delay_hook(seconds) end 50 | 51 | return M 52 | -------------------------------------------------------------------------------- /lua/galore/init.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | 3 | local galore_build_root = (function() 4 | local dirname = string.sub(debug.getinfo(1).source, 2, #'/init.lua' * -1) 5 | return dirname .. '../../build/src/' 6 | end)() 7 | 8 | local galore = {} 9 | 10 | galore.connected = false 11 | 12 | function galore.open(opts) 13 | opts = opts or {} 14 | galore.withconnect(function() 15 | config.values.init(opts) 16 | end) 17 | end 18 | 19 | local function env(var) 20 | local value = vim.fn.getenv(var) 21 | if value ~= vim.NIL then 22 | value = galore_build_root .. ':' .. value 23 | else 24 | value = galore_build_root 25 | end 26 | vim.fn.setenv(var, value) 27 | end 28 | 29 | function galore.connect() 30 | if not galore.connected then 31 | env('GI_TYPELIB_PATH') 32 | local lgi = require('lgi') 33 | local gmime = lgi.require('GMime', '3.0') 34 | gmime.init() 35 | local galorelib = lgi.require('Galore', '0.1') 36 | galorelib.init() 37 | local runtime = require('galore.runtime') 38 | if galore.user_config ~= nil then 39 | config.values = vim.tbl_deep_extend('force', config.values, galore.user_config) 40 | end 41 | runtime.init() 42 | end 43 | galore.connected = true 44 | end 45 | 46 | function galore.setup(opts) 47 | galore.user_config = opts 48 | end 49 | 50 | function galore.withconnect(func) 51 | if not galore.connected then 52 | galore.connect() 53 | end 54 | func() 55 | end 56 | 57 | function galore.compose(kind) 58 | galore.withconnect(function() 59 | local cb = require('galore.callback') 60 | cb.new_message(kind) 61 | end) 62 | end 63 | 64 | function galore.mailto(kind, str) 65 | local url = galore('galore.url') 66 | str = str:gsub('<%s*(.*)%s*>', '%1') 67 | local opts = url.parse_url(str) 68 | 69 | -- remove things we don't trust in a mailto 70 | opts = url.normalize(opts) 71 | galore.withconnect(function() 72 | local cb = require('galore.callback') 73 | cb.new_message(kind, opts) 74 | end) 75 | end 76 | 77 | function galore.xdg_install() 78 | local dirname = string.sub(debug.getinfo(1).source, 2, #'/init.lua' * -1) 79 | local str = dirname .. '../../' .. 'xdg_install.sh' 80 | vim.fn.system(str) 81 | end 82 | 83 | return galore 84 | -------------------------------------------------------------------------------- /lua/galore/jobs.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | local runtime = require('galore.runtime') 3 | local Job = require('plenary.job') 4 | local log = require('galore.log') 5 | 6 | local lgi = require('lgi') 7 | local gmime = lgi.require('GMime', '3.0') 8 | 9 | local uv = vim.loop 10 | 11 | local M = {} 12 | 13 | function M.new() 14 | Job:new({ 15 | command = 'notmuch', 16 | args = { 'new' }, 17 | on_exit = function(_, ret_val) 18 | if ret_val == 0 then 19 | vim.notify('Notmuch updated successfully') 20 | else 21 | vim.notify('Notmuch update failed', vim.log.levels.ERROR) 22 | end 23 | end, 24 | }):start() 25 | end 26 | 27 | function M.get_type(file) 28 | local ret 29 | Job:new({ 30 | command = 'file', 31 | args = { '-b', '--mime-type', file }, 32 | on_exit = function(j, _) 33 | ret = j:result() 34 | end, 35 | }):sync() 36 | return ret 37 | end 38 | 39 | function M.html(text) 40 | local ret 41 | Job:new({ 42 | command = 'html2text', 43 | args = {}, 44 | writer = text, 45 | on_exit = function(j, _) 46 | ret = j:result() 47 | end, 48 | }):sync() 49 | return ret 50 | end 51 | 52 | -- add cols? 53 | function M.w3m(text) 54 | local ret 55 | Job:new({ 56 | command = 'w3m', 57 | args = { '-dump', '-T', 'text/html' }, 58 | writer = text, 59 | on_exit = function(j, code, signal) 60 | ret = j:result() 61 | end, 62 | }):sync() 63 | return ret 64 | end 65 | 66 | function M.pipe_str(cmd, text) 67 | local args = { unpack(cmd, 2) } 68 | Job:new({ 69 | command = cmd[1], 70 | args = args, 71 | writer = text, 72 | on_exit = function(j, code, signal) end, 73 | }):sync() 74 | end 75 | 76 | --- Add a callback to this? 77 | --- TODO set env for testing etc 78 | local function raw_pipe(object, cmd, args, cb) 79 | local stdout = uv.new_pipe() 80 | local stderr = uv.new_pipe() 81 | local stdin = uv.new_pipe() 82 | 83 | local fds = uv.pipe({ nonblock = true }, { nonblock = true }) 84 | -- local stream = gs.stream_pipe_new(fds.write) 85 | local stream = gmime.StreamPipe.new(fds.write) 86 | stdin:open(fds.read) 87 | local handle 88 | local pid 89 | 90 | local opts = { 91 | args = args, 92 | -- stdio = { fds.read, stdout, stderr} 93 | stdio = { stdin, stdout, stderr }, 94 | } 95 | 96 | handle, pid = uv.spawn(cmd, opts, function(code, signal) 97 | stdin:close() 98 | stdout:close() 99 | stderr:close() 100 | vim.schedule(function() 101 | if code ~= 0 then 102 | log.log(cmd .. ' exited with: ' .. tostring(code), vim.log.levels.ERROR) 103 | else 104 | if cb then 105 | cb() 106 | end 107 | end 108 | -- add a cb here? 109 | end) 110 | end) 111 | 112 | --- Maybe something like this 113 | --- If you wanna pipe the buffer, don't use this 114 | --- this isn't async, but maybe it shouldn't be beacuse 115 | --- the function that calls raw_pipe should be async. 116 | --- since creating a message could be a pita too 117 | --- 118 | --- TODO 119 | --- should I just vim.schedule() this? 120 | --- Nope! You should install a worker! 121 | -- if gp.is_part(object) then 122 | if gmime.Part:is_type_of(object) then 123 | local part = object 124 | if part:is_attachment() then 125 | local dw = part:get_content() 126 | dw:write_to_stream(stream) 127 | else 128 | local r = require('galore.render') 129 | r.part_to_stream(part, {}, stream) 130 | end 131 | else 132 | object:write_to_stream(runtime.format_opts, stream) 133 | end 134 | 135 | stream:flush() 136 | stream:close() 137 | 138 | uv.read_start(stdout, function(err, data) 139 | assert(not err, err) 140 | -- we shouldn't really do anything with the data, maybe log it 141 | if data then 142 | print(data) 143 | end 144 | end) 145 | 146 | uv.read_start(stderr, function(err, data) 147 | assert(not err, err) 148 | if data then 149 | print('stderr: ', data) 150 | end 151 | end) 152 | 153 | uv.shutdown(stdin, function() 154 | print('stdin shutdown', stdin) 155 | uv.close(handle, function() 156 | print('process closed', handle, pid) 157 | end) 158 | end) 159 | end 160 | 161 | function M.insert_mail(message, folder, tags) 162 | local parent_dir = config.values.select_dir(message) 163 | local folderflag = string.format('--folder=%s%s', parent_dir, folder) 164 | local args = vim.tbl_flatten({ 'insert', '--create-folder', folderflag, tags }) 165 | raw_pipe(message, 'notmuch', args) 166 | end 167 | 168 | --- being able to spawn in a terminal 169 | function M.send_mail(message, cb) 170 | local cmd, args = config.values.send_cmd(message) 171 | raw_pipe(message, cmd, args, cb) 172 | end 173 | 174 | function M.pipe_input(object) 175 | vim.ui.input({ 176 | prompt = 'command: ', 177 | }, function(ret) 178 | if ret ~= nil then 179 | local cmd = vim.split(ret, ' ') 180 | raw_pipe(cmd, object) 181 | end 182 | end) 183 | end 184 | 185 | --- @param cmd string[] 186 | --- @param obj gmime.MimeObject 187 | function M.pipe(cmd, obj) 188 | local args = { unpack(cmd, 2) } 189 | raw_pipe(obj, cmd[1], args) 190 | end 191 | 192 | return M 193 | -------------------------------------------------------------------------------- /lua/galore/lib/buffer.lua: -------------------------------------------------------------------------------- 1 | local Buffer = {} 2 | 3 | function Buffer:new(this) 4 | this = this or {} 5 | self.__index = self 6 | setmetatable(this, self) 7 | 8 | return this 9 | end 10 | 11 | function Buffer:focus() 12 | local windows = vim.fn.win_findbuf(self.handle) 13 | 14 | if #windows == 0 then 15 | vim.api.nvim_win_set_buf(0, self.handle) 16 | return 17 | end 18 | 19 | vim.fn.win_gotoid(windows[1]) 20 | end 21 | 22 | function Buffer:lock() 23 | self:set_option('readonly', true) 24 | self:set_option('modifiable', false) 25 | end 26 | 27 | function Buffer:unlock() 28 | self:set_option('readonly', false) 29 | self:set_option('modifiable', true) 30 | end 31 | 32 | function Buffer:is_open() 33 | return #vim.fn.win_findbuf(self.handle) ~= 0 34 | end 35 | 36 | function Buffer:clear() 37 | vim.api.nvim_buf_set_lines(self.handle, 0, -1, false, {}) 38 | end 39 | 40 | function Buffer:set_parent(parent) 41 | self.parent = parent 42 | end 43 | 44 | function Buffer:add_timer(timer) 45 | if self.timers == nil then 46 | self.timers = {} 47 | end 48 | table.insert(self.timers, timer) 49 | end 50 | 51 | function Buffer:stop_timers() 52 | for _, timer in ipairs(self.timers) do 53 | timer:stop() 54 | end 55 | -- free timers so we don't have backpointers etc 56 | self.timers = {} 57 | end 58 | 59 | local function cleanup(self) 60 | if self.cleanup then 61 | self:cleanup() 62 | end 63 | self.timers = {} 64 | self.parent = nil 65 | end 66 | 67 | -- split up this and add a callback handler for when the buffer is deleted 68 | function Buffer:close(delete) 69 | local bufwins = #vim.fn.win_findbuf(self.handle) 70 | 71 | local floating = vim.api.nvim_win_get_config(0).zindex 72 | 73 | if floating then 74 | vim.api.nvim_win_close(0, true) 75 | if self.parent then 76 | self.parent:focus() 77 | end 78 | elseif self.kind == 'replace' and self.parent then 79 | if self.parent.handle and vim.fn.bufexists(self.parent.handle) ~= 0 then 80 | vim.api.nvim_win_set_buf(0, self.parent.handle) 81 | end 82 | -- elseif self.kind == "floating" then 83 | -- vim.api.nvim_win_close(0, true) 84 | -- if self.parent then 85 | -- self.parent:focus() 86 | -- end 87 | else 88 | if self.parent then 89 | self.parent:focus() 90 | end 91 | end 92 | if delete and bufwins <= 1 then 93 | if self.runner then 94 | self.runner.close() 95 | end 96 | if self.timers then 97 | self:stop_timers() 98 | end 99 | cleanup(self) 100 | vim.api.nvim_buf_delete(self.handle, {}) 101 | end 102 | end 103 | 104 | function Buffer:get_lines(first, last, strict) 105 | return vim.api.nvim_buf_get_lines(self.handle, first, last, strict) 106 | end 107 | 108 | function Buffer:get_line(line) 109 | return vim.fn.getbufline(self.handle, line) 110 | end 111 | 112 | function Buffer:get_current_line() 113 | return self:get_line(vim.fn.getpos('.')[2]) 114 | end 115 | 116 | function Buffer:set_lines(first, last, strict, lines) 117 | vim.api.nvim_buf_set_lines(self.handle, first, last, strict, lines) 118 | end 119 | 120 | function Buffer:set_text(first_line, last_line, first_col, last_col, lines) 121 | vim.api.nvim_buf_set_text(self.handle, first_line, first_col, last_line, last_col, lines) 122 | end 123 | 124 | function Buffer:move_cursor(line) 125 | if line < 0 then 126 | self:focus() 127 | vim.cmd('norm G') 128 | else 129 | self:focus() 130 | vim.cmd('norm ' .. line .. 'G') 131 | end 132 | end 133 | 134 | function Buffer:is_valid() 135 | return vim.api.nvim_buf_is_valid(self.handle) 136 | end 137 | 138 | function Buffer:put(lines, after, follow) 139 | self:focus() 140 | vim.api.nvim_put(lines, 'l', after, follow) 141 | end 142 | 143 | function Buffer:create_fold(first, last) 144 | vim.cmd(string.format('%d,%dfold', first, last)) 145 | vim.cmd(string.format('%d,%dfoldopen', first, last)) 146 | end 147 | 148 | function Buffer:get_option(name) 149 | vim.api.nvim_buf_get_option(self.handle, name) 150 | end 151 | 152 | function Buffer:set_option(name, value) 153 | vim.api.nvim_buf_set_option(self.handle, name, value) 154 | end 155 | 156 | function Buffer:set_name(name) 157 | vim.api.nvim_buf_set_name(self.handle, name) 158 | end 159 | 160 | function Buffer:set_foldlevel(level) 161 | local windows = vim.fn.win_findbuf(self.handle) 162 | vim.api.nvim_win_set_option(0, 'foldlevel', level) 163 | end 164 | 165 | function Buffer:replace_content_with(lines) 166 | self:set_lines(0, -1, false, lines) 167 | end 168 | 169 | function Buffer:open_fold(line, reset_pos) 170 | local pos 171 | if reset_pos == true then 172 | pos = vim.fn.getpos() 173 | end 174 | 175 | vim.fn.setpos('.', { self.handle, line, 0, 0 }) 176 | vim.cmd('normal zo') 177 | 178 | if reset_pos == true then 179 | vim.fn.setpos('.', pos) 180 | end 181 | end 182 | 183 | -- remove? 184 | function Buffer:set_ns(name) 185 | self.ns = vim.api.nvim_create_namespace(name) 186 | end 187 | 188 | function Buffer:add_highlight(line, col_start, col_end, name) 189 | local ns_id = self.ns or 0 190 | vim.api.nvim_buf_add_highlight(self.handle, ns_id, name, line, col_start, col_end) 191 | end 192 | 193 | function Buffer:unplace_sign(id) 194 | vim.cmd('sign unplace ' .. id) 195 | end 196 | 197 | function Buffer:place_sign(line, name, group, id) 198 | -- Sign IDs should be unique within a group, however there's no downside as 199 | -- long as we don't want to uniquely identify the placed sign later. Thus, 200 | -- we leave the choice to the caller 201 | local sign_id = id or 1 202 | 203 | -- There's an equivalent function sign_place() which can automatically use 204 | -- a free ID, but is considerable slower, so we use the command for now 205 | local cmd = 'sign place ' .. sign_id .. ' line=' .. line .. ' name=' .. name 206 | if group ~= nil then 207 | cmd = cmd .. ' group=' .. group 208 | end 209 | cmd = cmd .. ' buffer=' .. self.handle 210 | 211 | vim.cmd(cmd) 212 | return sign_id 213 | end 214 | 215 | function Buffer:get_sign_at_line(line, group) 216 | group = group or '*' 217 | return vim.fn.sign_getplaced(self.handle, { 218 | group = group, 219 | lnum = line, 220 | })[1] 221 | end 222 | 223 | function Buffer:clear_sign_group(group) 224 | vim.cmd('sign unplace * group=' .. group .. ' buffer=' .. self.handle) 225 | end 226 | 227 | function Buffer:set_filetype(ft) 228 | self:set_option('filetype', ft) 229 | end 230 | 231 | function Buffer:call(f) 232 | vim.api.nvim_buf_call(self.handle, f) 233 | end 234 | 235 | function Buffer.exists(name) 236 | return vim.fn.bufnr(name) ~= -1 237 | end 238 | 239 | function Buffer:set_extmark(...) 240 | return vim.api.nvim_buf_set_extmark(self.handle, ...) 241 | end 242 | 243 | function Buffer:get_extmark(ns, id) 244 | return vim.api.nvim_buf_get_extmark_by_id(self.handle, ns, id, { details = true }) 245 | end 246 | 247 | function Buffer:del_extmark(ns, id) 248 | return vim.api.nvim_buf_del_extmark(self.handle, ns, id) 249 | end 250 | 251 | local function make_new(name) 252 | if vim.fn.bufexists(name) ~= 0 then 253 | local buf = vim.fn.bufnr(name) 254 | vim.api.nvim_win_set_buf(0, buf) 255 | return true 256 | end 257 | end 258 | 259 | function Buffer.create(config, class) 260 | config = config or {} 261 | 262 | local kind = config.kind or 'replace' 263 | local buffer = nil 264 | class = class or Buffer 265 | 266 | if kind == 'replace' then 267 | if make_new(config.name) then 268 | return 269 | end 270 | vim.cmd('enew') 271 | buffer = class:new({ handle = vim.api.nvim_get_current_buf() }) 272 | elseif kind == 'tab' then 273 | vim.cmd('tabnew') 274 | if make_new(config.name) then 275 | return 276 | end 277 | buffer = class:new({ handle = vim.api.nvim_get_current_buf() }) 278 | elseif kind == 'split' then 279 | vim.cmd('below new') 280 | if make_new(config.name) then 281 | return 282 | end 283 | buffer = class:new({ handle = vim.api.nvim_get_current_buf() }) 284 | elseif kind == 'split_above' then 285 | vim.cmd('top new') 286 | if make_new(config.name) then 287 | return 288 | end 289 | buffer = class:new({ handle = vim.api.nvim_get_current_buf() }) 290 | elseif kind == 'vsplit' then 291 | vim.cmd('bot vnew') 292 | if make_new(config.name) then 293 | return 294 | end 295 | buffer = class:new({ handle = vim.api.nvim_get_current_buf() }) 296 | elseif kind == 'floating' then 297 | -- Creates the border window 298 | -- TODO maybe do something fancy, like checking if we 299 | -- there are more floating windows shift the windows 300 | local vim_height = vim.api.nvim_eval([[&lines]]) 301 | local vim_width = vim.api.nvim_eval([[&columns]]) 302 | local width = math.floor(vim_width * 0.8) + 5 303 | local height = math.floor(vim_height * 0.7) + 2 304 | local col = vim_width * 0.1 - 2 305 | local row = vim_height * 0.15 - 1 306 | 307 | local content_buffer 308 | local inited = false 309 | if vim.fn.bufexists(config.name) ~= 0 then 310 | content_buffer = vim.fn.bufnr(config.name) 311 | inited = true 312 | else 313 | content_buffer = vim.api.nvim_create_buf(true, true) 314 | end 315 | 316 | local content_window = vim.api.nvim_open_win(content_buffer, true, { 317 | relative = 'editor', 318 | width = width, 319 | height = height, 320 | col = col, 321 | row = row, 322 | style = 'minimal', 323 | focusable = false, 324 | border = 'single', 325 | }) 326 | 327 | buffer = class:new({ handle = content_buffer }) 328 | if inited then 329 | return 330 | end 331 | vim.wo.winhl = 'Normal:Normal' 332 | vim.api.nvim_win_set_cursor(content_window, { 1, 0 }) 333 | else 334 | assert('Wrong buffer mode!') 335 | end 336 | 337 | buffer.kind = kind 338 | buffer.parent = config.parent 339 | buffer.cleanup = config.cleanup 340 | buffer.update = config.update 341 | 342 | vim.wo.nu = false 343 | vim.wo.rnu = false 344 | 345 | if config.name then 346 | buffer:set_name(config.name) 347 | end 348 | 349 | buffer:set_option('buflisted', config.buflisted or false) 350 | buffer:set_option('bufhidden', config.bufhidden or 'hide') 351 | buffer:set_option('buftype', config.buftype or 'nofile') 352 | buffer:set_option('swapfile', false) 353 | buffer:set_option('fileencoding', 'utf-8') 354 | buffer:set_option('fileformat', 'unix') 355 | 356 | -- don't want to do it like this 357 | vim.api.nvim_win_set_option(0, 'wrap', false) 358 | 359 | if config.ft then 360 | buffer:set_filetype(config.ft) 361 | end 362 | 363 | local mapopts = { noremap = true, silent = true, buffer = buffer.handle } 364 | if config.mappings then 365 | for mode, val in pairs(config.mappings) do 366 | for key, cb in pairs(val) do 367 | local opts = mapopts 368 | if type(cb) == 'table' then 369 | opts = vim.tbl_extend('keep', cb, mapopts) 370 | opts.rhs = nil 371 | cb = cb.rhs 372 | end 373 | local cbfunc = function() 374 | cb(buffer) 375 | end 376 | vim.keymap.set(mode, key, cbfunc, opts) 377 | end 378 | end 379 | else 380 | local conf = require('galore.config') 381 | for mode, val in pairs(conf.values.key_bindings.default) do 382 | for key, cb in pairs(val) do 383 | local cbfunc = function() 384 | cb(buffer) 385 | end 386 | vim.keymap.set(mode, key, cbfunc, mapopts) 387 | end 388 | end 389 | end 390 | 391 | if config.init then 392 | config.init(buffer) 393 | end 394 | 395 | if config.cursor == 'top' then 396 | vim.api.nvim_win_set_cursor(0, { 1, 0 }) 397 | end 398 | 399 | -- if config.autocmds then 400 | -- -- vim.api.nvim_create_augroup("") -- unique id? 401 | -- for event, cb in pairs(config.autocmds) do 402 | -- local cbfunc = function () 403 | -- cb(buffer) 404 | -- end 405 | -- vim.api.nvim_create_autocmd(event, {callback = cbfunc, buffer = buffer.handle}) 406 | -- end 407 | -- end 408 | vim.api.nvim_create_autocmd('BufDelete', { 409 | callback = function() 410 | -- buffer_delete(buffer.handle) 411 | end, 412 | buffer = buffer.handle, 413 | }) 414 | 415 | -- if not config.modifiable then 416 | -- buffer:set_option("modifiable", false) 417 | -- end 418 | 419 | if config.readonly ~= nil and config.readonly then 420 | buffer:set_option('readonly', true) 421 | end 422 | vim.b.galorebuf = function() 423 | return buffer 424 | end 425 | 426 | return buffer 427 | end 428 | 429 | return Buffer 430 | -------------------------------------------------------------------------------- /lua/galore/lib/ordered.lua: -------------------------------------------------------------------------------- 1 | local Ordered = {} 2 | 3 | function Ordered.insert(t, k, v) 4 | if v == nil then 5 | t.remove(k) 6 | return 7 | end 8 | if not rawget(t._values, k) then 9 | table.insert(t._list, k) 10 | t._values[k] = v 11 | end 12 | end 13 | 14 | local function find(t, v) 15 | for i, v2 in ipairs(t) do 16 | if v == v2 then 17 | return i 18 | end 19 | end 20 | end 21 | 22 | function Ordered.remove(t, k) 23 | local v = t._values[k] 24 | if v then 25 | table.remove(t._list, find(t._list, k)) 26 | t._values[k] = nil 27 | end 28 | end 29 | 30 | function Ordered.index(t, k) 31 | return rawget(t._values, k) 32 | end 33 | 34 | function Ordered.pairs(t) 35 | local i = 0 36 | return function() 37 | i = i + 1 38 | local key = t._list[i] 39 | if key ~= nil then 40 | return key, t._values[key] 41 | end 42 | end 43 | end 44 | 45 | function Ordered.new() 46 | local tbl = { _values = {}, _list = {} } 47 | return setmetatable(tbl, { 48 | __newindex = Ordered.insert, 49 | __len = function(t) 50 | return #t._list 51 | end, 52 | __pairs = Ordered.pairs, -- doesn't work in luajit 53 | __index = tbl._values, 54 | }) 55 | end 56 | 57 | return Ordered 58 | -------------------------------------------------------------------------------- /lua/galore/lib/timers.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | local ok, notify = pcall(require, 'notify') 3 | 4 | local M = {} 5 | 6 | if not ok then 7 | --- log this 8 | vim.api.nvim_err_writeln("Can't find nvim-notify, will try to fallback") 9 | notify = vim.notify 10 | end 11 | 12 | local function default_opts(opts) 13 | opts.title = opts.title or 'Timer running' 14 | opts.stop_title = opts.stop_title or 'Timer stopped' 15 | opts.done_msg = opts.done_msg or 'Timer hit 0!' 16 | opts.tick = 1000 17 | opts.time_to_level = opts.time_to_level or function() 18 | return vim.log.levels.INFO 19 | end 20 | end 21 | 22 | local function time_to_str(time) 23 | local seconds = math.floor(time / 1000) 24 | local min = math.floor(seconds / 60) 25 | local hours = math.floor(min / 60) 26 | min = min - hours * 60 27 | seconds = seconds - min * 60 28 | if hours ~= 0 then 29 | return string.format('%d:%d:%d', hours, min, seconds) 30 | end 31 | if min ~= 0 then 32 | return string.format('%d:%d', min, seconds) 33 | end 34 | return string.format('%d', seconds) 35 | end 36 | 37 | --- Creates a popup_timer 38 | --- @param time integer how long we should run the timer 39 | --- @param rep integer not used atm 40 | --- @param callback function() 41 | --- @param opts table timer costumization 42 | function M.popup_timer(time, rep, callback, opts) 43 | opts = opts or {} 44 | default_opts(opts) 45 | local id = notify(time_to_str(time), vim.log.levels.INFO, { 46 | title = opts.title, 47 | render = opts.render, 48 | timeout = opts.tick + 1000, 49 | }) 50 | local timer = uv.new_timer() 51 | timer:start( 52 | opts.tick, 53 | opts.tick, 54 | vim.schedule_wrap(function() 55 | time = time - opts.tick 56 | if time <= 0 then 57 | timer:stop() 58 | notify(opts.done_msg, vim.log.levels.INFO, { 59 | title = opts.stop_title, 60 | replace = id, 61 | timeout = opts.timeout, 62 | }) 63 | callback() 64 | -- if rep > 0 then 65 | -- M.popup_timer(rep, rep, callback, opts) 66 | -- end 67 | return 68 | end 69 | id = notify(time_to_str(time), opts.time_to_level(time), { 70 | title = opts.title, 71 | replace = id, 72 | timeout = opts.tick + 1000, 73 | }) 74 | end) 75 | ) 76 | return timer 77 | end 78 | 79 | return M 80 | -------------------------------------------------------------------------------- /lua/galore/log.lua: -------------------------------------------------------------------------------- 1 | local function show_level(level) 2 | if level == vim.log.levels.TRACE then 3 | return 'TRACE' 4 | elseif level == vim.log.levels.DEBUG then 5 | return 'DEBUG' 6 | elseif level == vim.log.levels.INFO then 7 | return 'INFO' 8 | elseif level == vim.log.levels.WARN then 9 | return 'WARN' 10 | elseif level == vim.log.levels.ERROR then 11 | return 'ERROR' 12 | -- elseif level == vim.log.levels.OFF then 13 | end 14 | end 15 | 16 | local p_debug = vim.fn.getenv('DEBUG_GALORE') 17 | if p_debug == vim.NIL then 18 | p_debug = false 19 | end 20 | 21 | -- User configuration section 22 | local default_config = { 23 | -- Name of the plugin. Prepended to log messages 24 | plugin = 'Galore', 25 | 26 | notify = true, 27 | 28 | -- Should write to a file 29 | use_file = true, 30 | -- default_level 31 | 32 | -- Should write to the quickfix list 33 | -- use notify to quickfix instead 34 | -- use_quickfix = false, 35 | 36 | -- Any messages above this level will be logged. 37 | level = p_debug and vim.log.levels.DEBUG or vim.log.levels.INFO, 38 | } 39 | 40 | local log = {} 41 | 42 | log.new = function(config) 43 | config = vim.tbl_deep_extend('force', default_config, config) 44 | 45 | local outfile = 46 | string.format('%s/%s.log', vim.api.nvim_call_function('stdpath', { 'cache' }), config.plugin) 47 | 48 | local obj = {} 49 | local logfun = function(str, level) 50 | if not level then 51 | level = vim.log.levels.INFO 52 | end 53 | if level < config.level then 54 | return 55 | end 56 | 57 | local info = debug.getinfo(2, 'Sl') 58 | local lineinfo = info.short_src .. ':' .. info.currentline 59 | 60 | -- Send a notify if we have that configured 61 | if config.notify then 62 | local message = string.format('[%s] %s', config.plugin, str) 63 | vim.notify(message, level) 64 | end 65 | 66 | local levelstr = show_level(level) 67 | -- Output to log file 68 | if config.use_file then 69 | local fp = assert(io.open(outfile, 'a')) 70 | local message = string.format('[%-6s%s] %s: %s\n', levelstr, os.date(), lineinfo, str) 71 | fp:write(message) 72 | fp:close() 73 | end 74 | 75 | -- maybe 76 | -- Output to quickfix 77 | if config.use_quickfix then 78 | local formatted_msg = string.format('[%s] %s', levelstr, str) 79 | local qf_entry = { 80 | -- remove the @ getinfo adds to the file path 81 | filename = info.source:sub(2), 82 | lnum = info.currentline, 83 | col = 1, 84 | text = formatted_msg, 85 | } 86 | vim.fn.setqflist({ qf_entry }, 'a') 87 | end 88 | end 89 | local log_err = function(str) 90 | logfun(str, vim.log.levels.ERROR) 91 | error(str) 92 | end 93 | obj.log = logfun 94 | obj.log_err = log_err 95 | return obj 96 | end 97 | 98 | -- return log 99 | -- pass config-value to log.new()? 100 | return log.new({}) 101 | -------------------------------------------------------------------------------- /lua/galore/message_browser.lua: -------------------------------------------------------------------------------- 1 | local Buffer = require('galore.lib.buffer') 2 | local o = require('galore.opts') 3 | local async = require('plenary.async') 4 | local browser = require('galore.browser') 5 | 6 | local Mb = Buffer:new() 7 | 8 | local function mb_get(self) 9 | local first = true 10 | self.highlight = {} 11 | return browser.get_entries(self, 'show-message', function(message, n) 12 | if message then 13 | table.insert(self.State, message.id) 14 | if first then 15 | vim.api.nvim_buf_set_lines(self.handle, 0, -1, false, { message.entry }) 16 | first = false 17 | else 18 | vim.api.nvim_buf_set_lines(self.handle, -1, -1, false, { message.entry }) 19 | end 20 | if message.highlight then 21 | table.insert(self.highlight, n) 22 | vim.api.nvim_buf_add_highlight(self.handle, self.dians, 'GaloreHighlight', n, 0, -1) 23 | end 24 | return 1 25 | end 26 | end) 27 | end 28 | 29 | function Mb:async_runner() 30 | self.updating = true 31 | local func = async.void(function() 32 | self.runner = mb_get(self) 33 | pcall(function() 34 | self.runner.resume() 35 | self:lock() 36 | self.updating = false 37 | end) 38 | end) 39 | func() 40 | end 41 | 42 | function Mb:refresh() 43 | if self.runner then 44 | self.runner.close() 45 | self.runner = nil 46 | end 47 | self:unlock() 48 | self:clear() 49 | self:async_runner() 50 | end 51 | 52 | function Mb:update(line_nr) 53 | local id = self.State[line_nr] 54 | browser.update_lines_helper(self, 'show-message', 'id:' .. id, line_nr) 55 | end 56 | 57 | function Mb:commands() 58 | vim.api.nvim_buf_create_user_command(self.handle, 'GaloreChangetag', function(args) 59 | if args.args then 60 | local callback = require('galore.callback') 61 | callback.change_tag(self, args) 62 | end 63 | end, { 64 | nargs = '*', 65 | }) 66 | end 67 | 68 | -- create a browser class 69 | function Mb:create(search, opts) 70 | o.mb_options(opts) 71 | Buffer.create({ 72 | name = opts.bufname(search), 73 | ft = 'galore-browser', 74 | kind = opts.kind, 75 | cursor = 'top', 76 | parent = opts.parent, 77 | mappings = opts.key_bindings, 78 | init = function(buffer) 79 | buffer.opts = opts 80 | buffer.search = search 81 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 82 | buffer:refresh() 83 | buffer:commands() 84 | if opts.limit then 85 | browser.scroll(buffer) 86 | end 87 | opts.init(buffer) 88 | end, 89 | }, Mb) 90 | end 91 | 92 | return Mb 93 | -------------------------------------------------------------------------------- /lua/galore/message_view.lua: -------------------------------------------------------------------------------- 1 | local r = require('galore.render') 2 | local u = require('galore.util') 3 | local Buffer = require('galore.lib.buffer') 4 | local views = require('galore.views') 5 | local ui = require('galore.ui') 6 | local nu = require('galore.notmuch-util') 7 | local nm = require('notmuch') 8 | local runtime = require('galore.runtime') 9 | local browser = require('galore.browser') 10 | local o = require('galore.opts') 11 | local gu = require('galore.gmime-util') 12 | 13 | local Message = Buffer:new() 14 | 15 | --- add opts 16 | function Message:select_attachment(cb) 17 | local files = {} 18 | for _, v in ipairs(self.state.attachments) do 19 | table.insert(files, v.filename) 20 | end 21 | vim.ui.select(files, { 22 | prompt = 'Select attachment: ', 23 | }, function(item, idx) 24 | if item then 25 | cb(self.state.attachments[idx]) 26 | else 27 | error('No file selected') 28 | end 29 | end) 30 | end 31 | 32 | function Message:update() 33 | self:unlock() 34 | self:clear() 35 | 36 | local message = gu.parse_message(self.line.filenames[self.index]) 37 | -- au.process_au(message, line) 38 | if message then 39 | vim.api.nvim_buf_clear_namespace(self.handle, self.ns, 0, -1) 40 | self.message = message 41 | local buffer = {} 42 | 43 | r.show_headers(message, self.handle, { ns = self.ns }, self.line) 44 | local offset = vim.fn.line('$') - 1 45 | self.state = r.render_message(r.default_render, message, buffer, { 46 | offset = offset, 47 | keys = self.line.keys, 48 | }) 49 | u.purge_empty(buffer) 50 | self:set_lines(-1, -1, true, buffer) 51 | local ns_line = vim.fn.line('$') - 1 52 | if not vim.tbl_isempty(self.state.attachments) then 53 | ui.render_attachments(self.state.attachments, ns_line, self.handle, self.ns) 54 | end 55 | vim.schedule(function() 56 | for i, cb in ipairs(self.state.callbacks) do 57 | cb(self.handle, self.ns) 58 | self.state.callbacks[i] = nil 59 | end 60 | end) 61 | end 62 | self:lock() 63 | end 64 | 65 | function Message:redraw() 66 | self:focus() 67 | self:update() 68 | end 69 | 70 | local function mark_read(self, pb, line, vline) 71 | runtime.with_db_writer(function(db) 72 | self.opts.tag_unread(db, line.id) 73 | nu.tag_if_nil(db, line.id, self.opts.empty_tag) 74 | nu.update_line(db, line) 75 | end) 76 | if vline and pb then 77 | pb:update(vline) 78 | end 79 | end 80 | 81 | function Message:next() 82 | if self.vline and self.parent then 83 | local mid, vline = browser.next(self.parent, self.vline) 84 | Message:create(mid, { kind = 'replace', parent = self.parent, vline = vline }) 85 | end 86 | end 87 | -- 88 | function Message:prev() 89 | if self.vline and self.parent then 90 | local mid, vline = browser.prev(self.parent, self.vline) 91 | Message:create(mid, { kind = 'replace', parent = self.parent, vline = vline }) 92 | end 93 | end 94 | 95 | function Message:version_next() 96 | self.index = math.max(#self.line.filenames, self.index + 1) 97 | self:redraw() 98 | end 99 | 100 | function Message:version_prev() 101 | self.index = math.min(1, self.index - 1) 102 | self:redraw() 103 | end 104 | 105 | function Message:commands() 106 | vim.api.nvim_buf_create_user_command(self.handle, 'GaloreSaveAttachment', function(args) 107 | if args.fargs then 108 | local save_path = '.' 109 | if #args.fargs > 2 then 110 | save_path = args.fargs[2] 111 | end 112 | views.save_attachment(self.state.attachments, args.fargs[1], save_path) 113 | end 114 | end, { 115 | nargs = '*', 116 | complete = function() 117 | local files = {} 118 | for _, v in ipairs(self.state.attachments) do 119 | table.insert(files, v.filename) 120 | end 121 | return files 122 | end, 123 | }) 124 | end 125 | 126 | function Message:thread_view() 127 | local tw = require('galore.thread_view') 128 | local opts = o.bufcopy(self.opts) 129 | opts.index = self.line.index 130 | tw:create(self.line.tid, opts) 131 | end 132 | 133 | function Message:create(mid, opts) 134 | o.view_options(opts) 135 | local line 136 | runtime.with_db(function(db) 137 | local message = nm.db_find_message(db, mid) 138 | line = nu.get_message(message) 139 | nu.line_populate(db, line) 140 | end) 141 | Buffer.create({ 142 | name = opts.bufname(line.filenames[1]), 143 | ft = 'mail', 144 | kind = opts.kind, 145 | parent = opts.parent, 146 | cursor = 'top', 147 | mappings = opts.key_bindings, 148 | init = function(buffer) 149 | buffer.line = line 150 | buffer.opts = opts 151 | buffer.index = opts.index or 1 152 | buffer.vline = opts.vline 153 | buffer.ns = vim.api.nvim_create_namespace('galore-message-view') 154 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 155 | mark_read(buffer, opts.parent, line, opts.vline) 156 | buffer:update() 157 | buffer:commands() 158 | opts.init(buffer) 159 | end, 160 | }, Message) 161 | end 162 | 163 | function Message.open_attach() end 164 | 165 | return Message 166 | -------------------------------------------------------------------------------- /lua/galore/notmuch-util.lua: -------------------------------------------------------------------------------- 1 | -- local nm = require("galore.notmuch") 2 | local nm = require('notmuch') 3 | local u = require('galore.util') 4 | 5 | local M = {} 6 | 7 | --- The thread will be freed on return, don't return the thread 8 | --- @param message any a message 9 | --- @param f function a function that takes a thread 10 | --- @return any returns the value after running f 11 | function M.message_with_thread(message, f) 12 | local id = nm.message_get_thread_id(message) 13 | local db = nm.message_get_db(message) 14 | local query = nm.create_query(db, 'thread:' .. id) 15 | for thread in nm.query_get_threads(query) do 16 | return f(thread) 17 | end 18 | end 19 | 20 | --- Get a single message and convert it into a line 21 | function M.get_message(message) 22 | local id = nm.message_get_id(message) 23 | local tid = nm.message_get_thread_id(message) 24 | local filenames = u.collect(nm.message_get_filenames(message)) 25 | local sub = nm.message_get_header(message, 'Subject') 26 | local tags = u.collect(nm.message_get_tags(message)) 27 | local from = nm.message_get_header(message, 'From') 28 | local date = nm.message_get_date(message) 29 | local key = u.collect(nm.message_get_properties(message, 'session-key', true)) 30 | return { 31 | id = id, 32 | tid = tid, 33 | filenames = filenames, 34 | level = 1, 35 | pre = '', 36 | index = 1, 37 | total = 1, 38 | date = date, 39 | from = from, 40 | sub = sub, 41 | tags = tags, 42 | key = key, 43 | } 44 | end 45 | 46 | local function pop_helper(line, iter, i) 47 | for message in iter do 48 | if line.id == nm.message_get_id(message) then 49 | line.index = i 50 | break 51 | end 52 | local new_iter = nm.message_get_replies(message) 53 | i = pop_helper(line, new_iter, i + 1) 54 | end 55 | return i 56 | end 57 | 58 | function M.line_populate(db, line) 59 | local id = line.id 60 | local query = nm.create_query(db, 'mid:' .. id) 61 | for thread in nm.query_get_threads(query) do 62 | local i = 1 63 | line.total = nm.thread_get_total_messages(thread) 64 | local iter = nm.thread_get_toplevel_messages(thread) 65 | pop_helper(line, iter, i) 66 | end 67 | end 68 | 69 | local function update_tags(message, changes) 70 | nm.message_freeze(message) 71 | for _, change in ipairs(changes) do 72 | local status 73 | local op = string.sub(change, 1, 1) 74 | local tag = string.sub(change, 2) 75 | if op == '-' then 76 | status = nm.message_remove_tag(message, tag) 77 | elseif op == '+' then 78 | status = nm.message_add_tag(message, tag) 79 | end 80 | if status ~= nil then 81 | -- print error 82 | end 83 | end 84 | nm.message_thaw(message) 85 | nm.message_tags_to_maildir_flags(message) 86 | end 87 | 88 | function M.change_tag(db, id, str) 89 | local changes = vim.split(str, ' ') 90 | local message = nm.db_find_message(db, id) 91 | if message == nil then 92 | vim.notify("Can't change tag, message not found", vim.log.levels.ERROR) 93 | return 94 | end 95 | nm.db_atomic_begin(db) 96 | update_tags(message, changes) 97 | nm.db_atomic_end(db) 98 | end 99 | 100 | function M.tag_if_nil(db, id, tag) 101 | local message = nm.db_find_message(db, id) 102 | local tags = u.collect(nm.message_get_tags(message)) 103 | if vim.tbl_isempty(tags) and tag then 104 | M.change_tag(db, id, tag) 105 | end 106 | end 107 | 108 | function M.update_line(db, line_info) 109 | local message = nm.db_find_message(db, line_info.id) 110 | local new_info = M.get_message(message) 111 | line_info.id = new_info.id 112 | line_info.filenames = new_info.filenames 113 | line_info.tags = new_info.tags 114 | end 115 | 116 | return M 117 | -------------------------------------------------------------------------------- /lua/galore/opts.lua: -------------------------------------------------------------------------------- 1 | -- Populate default options from the config file 2 | local config = require('galore.config') 3 | local builder = require('galore.builder') 4 | 5 | local M = {} 6 | 7 | --- TODO how should keybindings work, should we extend the table or overwrite it? 8 | 9 | local function parse_bufname(opt, def) 10 | if type(opt) == 'string' then 11 | return function() 12 | return opt 13 | end 14 | elseif type(opt) == 'function' then 15 | return opt 16 | elseif type(opt) == 'nil' then 17 | return def 18 | else 19 | error('Bad argument to bufname, only function(string) or string is allowed') 20 | end 21 | end 22 | 23 | local function fallbacks(...) 24 | for i = 1, select('#', ...) do 25 | local value = select(i, ...) 26 | if value ~= nil then 27 | return value 28 | end 29 | end 30 | end 31 | 32 | local function keybindings(opt, def) 33 | return fallbacks(opt, def, config.values.key_bindings.default) 34 | end 35 | 36 | --- values that should be copied when cloning opts from one buffer to another. 37 | --- atm we copy nothing and rely on the default opts, maybe this isn't what we 38 | --- want. We let future tell. 39 | function M.bufcopy(opts) 40 | -- local new = vim.deepcopy(opts) 41 | return {} 42 | end 43 | 44 | local function runtime(opts) 45 | opts.runtime = vim.F.if_nil(opts.runtime, {}) 46 | opts.runtime.config = vim.F.if_nil(opts.runtime.config, config.values.nm_config) 47 | opts.runtime.db_path = vim.F.if_nil(opts.runtime.db_path, config.values.db_path) 48 | opts.runtime.profile = vim.F.if_nil(opts.runtime.profile, config.values.nm_profile) 49 | opts.runtime.mail_root = vim.F.if_nil(opts.runtime.config, config.values.mail_root) 50 | opts.runtime.exclude_tags = 51 | vim.F.if_nil(opts.runtime.exclude_tags, config.values.synchronize_flags) 52 | opts.runtime.synchronize_flags = 53 | vim.F.if_nil(opts.runtime.synchronize_flags, config.values.synchronize_flags) 54 | end 55 | 56 | function M.saved_options(opts) 57 | opts.bufname = vim.F.if_nil(opts.bufname, 'galore-saved') 58 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.saved) 59 | opts.exclude_tags = vim.F.if_nil(opts.exclude_tags, config.values.exclude_tags) 60 | opts.default_browser = vim.F.if_nil(opts.default_browser, config.values.default_browser) 61 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.saved) 62 | end 63 | 64 | local function default_view(def_view, context_value) 65 | if def_view then 66 | if def_view == 'context' then 67 | return context_value 68 | end 69 | end 70 | if config.values.default_view then 71 | if config.values.default_view == 'context' then 72 | return context_value 73 | end 74 | end 75 | return vim.F.if_nil(def_view, config.values.default_view) 76 | end 77 | 78 | local function browser_opts(opts) 79 | opts.exclude_tags = vim.F.if_nil(opts.exclude_tags, config.values.exclude_tags) 80 | opts.sort = vim.F.if_nil(opts.sort, config.values.sort) 81 | opts.limit = vim.F.if_nil(opts.limit, config.values.browser_limit) 82 | -- change this to format 83 | opts.show_message_description = 84 | vim.F.if_nil(opts.show_message_description, config.values.show_message_description) 85 | opts.emph = vim.F.if_nil(opts.emph, config.values.default_emph) 86 | runtime(opts) 87 | end 88 | 89 | function M.tmb_options(opts) 90 | opts.bufname = parse_bufname(opts.bufname, function(search) 91 | return 'galore-tmb: ' .. search 92 | end) 93 | opts.thread_reverse = vim.F.if_nil(opts.thread_reverse, config.values.thread_reverse) 94 | opts.thread_expand = vim.F.if_nil(opts.thread_expand, config.values.thread_expand) 95 | browser_opts(opts) 96 | 97 | opts.key_bindings = 98 | keybindings(opts.key_bindings, config.values.key_bindings.thread_message_browser) 99 | opts.default_view = default_view(default_view, 'message_view') 100 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.thread_message_browser) 101 | end 102 | 103 | function M.mb_options(opts) 104 | opts.bufname = parse_bufname(opts.bufname, function(search) 105 | return 'galore-messages: ' .. search 106 | end) 107 | browser_opts(opts) 108 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.message_browser) 109 | opts.default_view = default_view(default_view, 'message_view') 110 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.message_browser) 111 | end 112 | 113 | function M.threads_options(opts) 114 | opts.bufname = parse_bufname(opts.bufname, function(search) 115 | return 'galore-threads: ' .. search 116 | end) 117 | browser_opts(opts) 118 | opts.default_view = default_view(default_view, 'threads_view') 119 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.thread_browser) 120 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.thread_browser) 121 | end 122 | 123 | function M.view_options(opts) 124 | opts.bufname = parse_bufname(opts.bufname, function(filename) 125 | return filename 126 | end) 127 | opts.tag_unread = vim.F.if_nil(opts.tag_unread, config.values.tag_unread) 128 | opts.empty_tag = vim.F.if_nil(opts.empty_tag, config.values.empty_tag) 129 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.message_view) 130 | opts.key_writeback = vim.F.if_nil(opts.key_writeback, config.values.key_writeback) 131 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.message_view) 132 | end 133 | 134 | function M.thread_view_options(opts) 135 | opts.bufname = parse_bufname(opts.bufname, function(tid) 136 | return 'galore-tid:' .. tid 137 | end) 138 | opts.tag_unread = vim.F.if_nil(opts.tag_unread, config.values.tag_unread) 139 | opts.empty_tag = vim.F.if_nil(opts.empty_tag, config.values.empty_tag) 140 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.thread_view) 141 | opts.key_writeback = vim.F.if_nil(opts.key_writeback, config.values.key_writeback) 142 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.thread_view) 143 | opts.sort = vim.F.if_nil(opts.sort, config.values.sort) 144 | opts.index = vim.F.if_nil(opts.index, 1) 145 | -- add render opts here! 146 | -- can we make a way for the user to select a render? 147 | -- 148 | -- extra_keys ? 149 | -- purge_empty ? 150 | end 151 | 152 | function M.render_options(opts) 153 | opts.mulilingual = vim.F.if_nil(opts.mulilingual, config.values.multilingual) 154 | opts.lang_order = vim.F.if_nil(opts.lang_order, config.values.lang_order) 155 | opts.alt_mode = vim.F.if_nil(opts.alt_mode, config.values.alt_mode) 156 | opts.alt_order = vim.F.if_nil(opts.alt_order, config.values.alt_order) 157 | opts.qoute_header = vim.F.if_nil(opts.qoute_header, config.values.qoute_header) 158 | opts.show_html = vim.F.if_nil(opts.show_html, config.values.show_html) 159 | opts.annotate_signature = vim.F.if_nil(opts.annotate_signature, config.values.annotate_signature) 160 | opts.decrypt_flags = vim.F.if_nil(opts.decrypt_flags, config.values.decrypt_flags) 161 | end 162 | 163 | function M.compose_options(opts) 164 | opts.bufname = parse_bufname(opts.bufname, function() 165 | return vim.fn.tempname() 166 | end) 167 | opts.empty_topic = fallbacks(opts.empty_topic, config.values.empty_topic, 'Empty subject') 168 | opts.key_bindings = keybindings(opts.key_bindings, config.values.key_bindings.compose) 169 | opts.pre_sent_hooks = vim.F.if_nil(opts.pre_sent_hooks, config.values.pre_sent_hooks) 170 | opts.post_sent_hooks = vim.F.if_nil(opts.post_sent_hooks, config.values.post_sent_hooks) 171 | opts.compose_headers = vim.F.if_nil(opts.compose_headers, config.values.compose_headers) 172 | opts.draft_dir = vim.F.if_nil(opts.draft_dir, config.values.draft_dir) 173 | opts.draft_tag = vim.F.if_nil(opts.draft_tag, config.values.draft_tag) 174 | opts.sent_dir = vim.F.if_nil(opts.sent_dir, config.values.sent_dir) 175 | 176 | opts.extra_headers = vim.F.if_nil(opts.extra_headers, config.values.extra_headers) 177 | --- these are builder options? 178 | opts.gpg_id = vim.F.if_nil(opts.gpg_id, config.values.gpg_id) 179 | opts.draft_encrypt = vim.F.if_nil(opts.draft_encrypt, config.values.draft_encrypt) 180 | 181 | opts.bodybuilder = vim.F.if_nil(opts.bodybuilder, builder.textbuilder) 182 | --- 183 | opts.init = vim.F.if_nil(opts.init, config.values.bufinit.compose) 184 | end 185 | 186 | return M 187 | -------------------------------------------------------------------------------- /lua/galore/runtime.lua: -------------------------------------------------------------------------------- 1 | local config = require('galore.config') 2 | local u = require('galore.util') 3 | local nm = require('notmuch') 4 | local log = require('galore.log') 5 | 6 | local lgi = require('lgi') 7 | local gmime = lgi.require('GMime', '3.0') 8 | 9 | local runtime_dir = vim.fn.stdpath('data') .. '/galore' 10 | if os.getenv('GALOREPATH') ~= nil then 11 | runtime_dir = os.getenv('GALOREPATH') 12 | end 13 | 14 | local save_file = runtime_dir .. '/nm_saved.txt' 15 | 16 | local runtime = {} 17 | 18 | local function get(db, name) 19 | return table.concat(u.collect(nm.config_get_values_string(db, name))) 20 | end 21 | 22 | local function gets(db, name) 23 | return u.collect(nm.config_get_values_string(db, name)) 24 | end 25 | 26 | function runtime.add_saved(str) 27 | local fp = io.open(save_file, 'a') 28 | if not fp then 29 | return 30 | end 31 | fp:write(str .. '\n') 32 | fp:close() 33 | end 34 | 35 | function runtime.edit_saved() 36 | if vim.fn.filereadable(save_file) ~= 0 then 37 | vim.cmd(':e ' .. save_file) 38 | end 39 | end 40 | 41 | function runtime.iterate_saved() 42 | if vim.fn.filereadable(save_file) == 0 then 43 | return function() end 44 | end 45 | return io.lines(save_file) 46 | end 47 | 48 | local function notmuch_init(path, conf, profile) 49 | local mode = 0 50 | local db = nm.db_open_with_config_raw(path, mode, conf, profile) 51 | local name = get(db, 'user.name') 52 | local primary_email = get(db, 'user.primary_email') 53 | local other_email = gets(db, 'user.other_email') 54 | local exclude_tags = gets(db, 'search.exclude_tags') 55 | local sync_flags = get(db, 'maildir.synchronize_flags') 56 | local mail_root = get(db, 'database.mail_root') 57 | local db_path = get(db, 'database.path') 58 | config.values.name = config.values.name or name 59 | config.values.synchronize_flags = vim.F.if_nil(config.values.synchronize_flags, sync_flags) 60 | config.values.primary_email = vim.F.if_nil(config.values.primary_email, primary_email) 61 | config.values.other_email = vim.F.if_nil(config.values.other_email, other_email) 62 | config.values.exclude_tags = vim.F.if_nil(config.values.exclude_tags, exclude_tags) 63 | config.values.mail_root = vim.F.if_nil(config.values.mail_root, mail_root) 64 | config.values.db_path = vim.F.if_nil(config.values.db_path, db_path) 65 | nm.db_close(db) 66 | end 67 | 68 | function runtime.with_db(func) 69 | local db = nm.db_open_with_config_raw( 70 | config.values.db_path, 71 | 0, 72 | config.values.nm_config, 73 | config.values.nm_profile 74 | ) 75 | func(db) 76 | nm.db_close(db) 77 | end 78 | 79 | --- for use in async/callback code 80 | function runtime.with_db_raw(func) 81 | local db = nm.db_open_with_config_raw( 82 | config.values.db_path, 83 | 0, 84 | config.values.nm_config, 85 | config.values.nm_profile 86 | ) 87 | func(db) 88 | end 89 | 90 | function runtime.with_db_writer(func) 91 | local db = nm.db_open_with_config_raw( 92 | config.values.db_path, 93 | 1, 94 | config.values.nm_config, 95 | config.values.nm_profile 96 | ) 97 | local ok, err = pcall(func, db) 98 | if not ok then 99 | log.log(err, vim.log.levels.ERROR) 100 | end 101 | nm.db_close(db) 102 | end 103 | 104 | --- @param offset number 105 | --- @param error gmime.ParserWarning 106 | --- @param item string 107 | local function parser_warning(offset, error, item, _) 108 | local off = tonumber(offset) 109 | local str = safe.safestring(item) or '' 110 | local error_str = convert.show_parser_warning(error) 111 | local level = convert.parser_warning_level(error) 112 | local notification = string.format('Parsing error, %s: %s at: %d ', error_str, str, off) 113 | log.debug(notification) 114 | end 115 | 116 | local function make_gmime_parser_options() 117 | local parser_opt = gmime.ParserOptions.new() 118 | runtime.parser_opts = parser_opt 119 | end 120 | 121 | local function make_gmime_format_options() 122 | local format = gmime.FormatOptions.new() 123 | 124 | runtime.format_opts = format 125 | end 126 | 127 | function runtime.runtime_dir() 128 | return runtime_dir 129 | end 130 | 131 | function runtime.init() 132 | if vim.fn.isdirectory(runtime_dir) == 0 then 133 | if vim.fn.empty(vim.fn.glob(runtime_dir)) == 0 then 134 | error("runtime_dir exist but isn't a directory") 135 | end 136 | vim.fn.mkdir(runtime_dir, 'p', '0o700') 137 | end 138 | notmuch_init(config.values.db_path, config.values.nm_config, config.values.nm_profile) 139 | -- runtime.header_function = {} 140 | make_gmime_parser_options() 141 | make_gmime_format_options() 142 | end 143 | 144 | return runtime 145 | -------------------------------------------------------------------------------- /lua/galore/saved.lua: -------------------------------------------------------------------------------- 1 | local nm = require('notmuch') 2 | local Buffer = require('galore.lib.buffer') 3 | local runtime = require('galore.runtime') 4 | local ordered = require('galore.lib.ordered') 5 | local o = require('galore.opts') 6 | 7 | local Saved = Buffer:new() 8 | 9 | local function make_entry(self, db, box, search, name, exclude) 10 | local q = nm.create_query(db, search) 11 | if exclude then 12 | for _, ex in ipairs(self.opts.exclude_tags) do 13 | -- we don't really care if the tags are removed or not, we want to do best effort 14 | pcall(function() 15 | nm.query_add_tag_exclude(q, ex) 16 | end) 17 | end 18 | end 19 | local unread_q = nm.create_query(db, search .. ' and tag:unread') 20 | local i = nm.query_count_messages(q) 21 | local unread_i = nm.query_count_messages(unread_q) 22 | table.insert(box, { i, unread_i, name, search }) 23 | end 24 | 25 | function Saved.manual(manual) 26 | return function(self, searches) 27 | for search in ipairs(manual) do 28 | ordered.insert(searches, search.search, { search.search, search.name, false }) 29 | end 30 | end 31 | end 32 | 33 | function Saved:gen_tags(searches) 34 | runtime.with_db(function(db) 35 | for tag in nm.db_get_all_tags(db) do 36 | local search = 'tag:' .. tag 37 | ordered.insert(searches, search, { search, tag, true }) 38 | end 39 | end) 40 | end 41 | 42 | function Saved:gen_internal(searches) 43 | for search in runtime.iterate_saved() do 44 | ordered.insert(searches, search, { search, search, true }) 45 | end 46 | end 47 | 48 | function Saved:gen_excluded(searches) 49 | for _, tag in ipairs(self.opts.exclude_tags) do 50 | local search = 'tag:' .. tag 51 | ordered.insert(searches, search, { search, tag, false }) 52 | end 53 | end 54 | 55 | function Saved:get_searches() 56 | local searches = ordered.new() 57 | for _, gen in ipairs(self.searches) do 58 | gen(self, searches) 59 | end 60 | return searches 61 | end 62 | 63 | local function ppsearch(tag) 64 | local num, unread, name, search = unpack(tag) 65 | local left = string.format('%d(%d) %s', num, unread, name) 66 | return string.format('%-35s (%s)', left, search) 67 | end 68 | 69 | --- Redraw all the saved searches and update the count 70 | function Saved:refresh() 71 | self:unlock() 72 | self:clear() 73 | local box = {} 74 | local searches = self:get_searches() 75 | runtime.with_db(function(db) 76 | for _, value in ordered.pairs(searches) do 77 | make_entry(self, db, box, value[1], value[2], value[3]) 78 | end 79 | end) 80 | local formated = vim.tbl_map(ppsearch, box) 81 | self:set_lines(0, 0, true, formated) 82 | self:set_lines(-2, -1, true, {}) 83 | self.State = box 84 | vim.api.nvim_win_set_cursor(0, { 1, 0 }) 85 | self:lock() 86 | end 87 | 88 | --- Return the currently selected line 89 | function Saved:select() 90 | local line = vim.fn.line('.') 91 | return self.State[line] 92 | end 93 | 94 | --- Create a new window for saved searches 95 | --- @param opts table {kind} 96 | --- @return any 97 | function Saved:create(opts, searches) 98 | o.saved_options(opts) 99 | return Buffer.create({ 100 | name = opts.bufname, 101 | ft = 'galore-saved', 102 | kind = opts.kind, 103 | cursor = 'top', 104 | mappings = opts.key_bindings, 105 | init = function(buffer) 106 | buffer.searches = searches 107 | buffer.opts = opts 108 | buffer:refresh() 109 | opts.init(buffer) 110 | end, 111 | }, Saved) 112 | end 113 | 114 | return Saved 115 | -------------------------------------------------------------------------------- /lua/galore/templates.lua: -------------------------------------------------------------------------------- 1 | local gu = require('galore.gmime-util') 2 | local au = require('galore.address-util') 3 | 4 | local r = require('galore.render') 5 | local config = require('galore.config') 6 | local runtime = require('galore.runtime') 7 | local u = require('galore.util') 8 | local lgi = require('lgi') 9 | local gmime = lgi.require('GMime', '3.0') 10 | local log = require('galore.log') 11 | 12 | local M = {} 13 | 14 | local function addrlist_parse(str) 15 | local ialist = gmime.InternetAddressList.parse(runtime.parser_opts, str) 16 | return ialist 17 | end 18 | 19 | -- TODO, make things more composable 20 | -- Atm we overwrite all the headers instead of merging them 21 | -- Maybe we should reparse the message before we pass, 22 | -- that way we don't have to worry about destorying it 23 | 24 | -- TODO move helper functions that could be useful for outside of this file 25 | 26 | -- Get the first none-nil value in a list of fields 27 | --- Can use non-standard fields 28 | local function get_backup(message, list) 29 | for _, v in ipairs(list) do 30 | local addr = message:get_addresses(v) 31 | if addr ~= nil and addr:length(addr) > 0 then 32 | return addr 33 | end 34 | end 35 | return nil 36 | end 37 | 38 | local function remove(list, addr) 39 | local i = 0 40 | for demail in gu.internet_address_list_iter(list) do 41 | if au.address_equal(demail, addr) then 42 | list:remove_at(i) 43 | return true 44 | end 45 | i = i + 1 46 | end 47 | return false 48 | end 49 | 50 | local function append_no_dup(addr, dst) 51 | local matched = au.ialist_contains(addr, dst) 52 | if not matched then 53 | dst:add(addr) 54 | end 55 | end 56 | 57 | local function PP(list) 58 | return list:to_string(nil, false) 59 | end 60 | 61 | local function pp(ia) 62 | return ia:to_string(nil, false) 63 | end 64 | 65 | local function safelist(...) 66 | local list = {} 67 | for i = 1, select('#', ...) do 68 | local value = select(i, ...) 69 | if value then 70 | table.insert(list, value) 71 | end 72 | end 73 | return list 74 | end 75 | 76 | local function issubscribed(addresses) 77 | local str = table.concat(config.values.mailinglist_subscribed, ', ') 78 | local list = gmime.InternetAddressList.parse(runtime.parser_opts, str) 79 | for v in gu.internet_address_list_iter(list) do 80 | if au.ialist_contains(v, addresses) then 81 | return true 82 | end 83 | end 84 | end 85 | 86 | local function get_key(gpg_id) 87 | local ctx = gmime.GpgContext.new() 88 | local mem = gmime.StreamMem.new() 89 | ctx:export_keys({ gpg_id }, mem) 90 | return mem:get_byte_array() 91 | end 92 | 93 | function M.compose_new(opts) 94 | local headers = opts.headers or {} 95 | local our = gmime.InternetAddressMailbox.new(config.values.name, config.values.primary_email) 96 | headers.From = pp(our) 97 | 98 | opts.headers = headers 99 | end 100 | 101 | --- this is wrong because we don't want to add a key as an attachments 102 | --- TODO make some default builders 103 | function M.mailkey(opts, gpg_id) 104 | local attachments = opts.attachments or {} 105 | gpg_id = gpg_id or config.values.gpg_id 106 | local key = get_key(gpg_id) 107 | table.insert( 108 | attachments, 109 | { filename = 'opengpg_pubkey.asc', data = key, mime_type = 'application/pgp-keys' } 110 | ) 111 | opts.attach = attachments 112 | end 113 | 114 | function M.load_body(message, opts) 115 | local bufrender = r.new({ 116 | verify = false, 117 | }, r.default_render) 118 | local buffer = {} 119 | local state = r.render_message(bufrender, message, buffer, opts) 120 | opts.Body = buffer 121 | opts.Attach = state.attachments 122 | end 123 | 124 | function M.load_headers(message, opts) 125 | opts = opts or {} 126 | local headers = {} 127 | for k, v in gu.header_iter(message) do 128 | headers[k] = v 129 | end 130 | opts.headers = headers 131 | end 132 | 133 | function M.subscribed(old_message) 134 | local to = old_message:get_to(old_message) 135 | local cc = old_message:get_cc(old_message) 136 | if issubscribed(to) or issubscribed(cc) then 137 | return true 138 | end 139 | end 140 | 141 | --- TODO clean up mft stuff 142 | function M.mft_response(old_message, opts, type) 143 | local headers = opts.headers or {} 144 | if not type or type == 'author' then 145 | local from = get_backup_header(old_message, { 'Mail-Reply-To', 'reply_to', 'from', 'sender' }) 146 | headers.To = from 147 | elseif type == 'reply_all' then 148 | local mft = old_message:get_header('Mail-Followup-To') 149 | if mft ~= nil then 150 | local ialist = gmime.InternetAddressList.parse(runtime.parser_opts, mft) 151 | headers.To = PP(ialist) 152 | else 153 | M.response_message(old_message, opts, type) 154 | end 155 | end 156 | opts.headers = headers 157 | end 158 | 159 | function M.mft_insert(opts) 160 | local headers = opts.headers 161 | headers['Mail-Reply-To'] = opts.headers['Reply-To'] 162 | local to = addrlist_parse(headers.To) 163 | local cc = addrlist_parse(headers.Cc) 164 | if issubscribed(to) or issubscribed(cc) then 165 | --- should we remove look and remove dups? 166 | --- because an address could be in both to and cc 167 | headers['Mail-Followup-To'] = table.concat(safelist(headers.To, headers.Cc), ',') 168 | end 169 | opts.headers = headers 170 | end 171 | 172 | function M.mft_insert_notsubbed(old_message, opts) 173 | local headers = opts.headers 174 | headers['Mail-Reply-To'] = opts.headers['Reply-To'] 175 | local to = addrlist_parse(headers.To) 176 | local cc = addrlist_parse(headers.Cc) 177 | local ml = old_message:get_header('List-Post') 178 | if ml ~= nil and not (issubscribed(to) or issubscribed(cc)) then 179 | ml = PP(ml) 180 | headers['Mail-Followup-To'] = table.concat(safelist(headers.From, ml), ',') 181 | end 182 | opts.headers = headers 183 | end 184 | 185 | function M.smart_response(old_message, opts, backup_type) 186 | local ml = old_message:get_header('List-Post') 187 | if ml then 188 | M.response_message(old_message, opts, 'mailinglist') 189 | return 190 | end 191 | M.response_message(old_message, opts, backup_type) 192 | end 193 | 194 | function M.response_message(old_message, opts, type) 195 | local at = gmime.AddressType 196 | local headers = opts.headers or {} 197 | 198 | local addr = au.get_our_email(old_message) 199 | local our = gmime.InternetAddressMailbox.new(config.values.name, addr) 200 | local our_str = pp(our) 201 | 202 | local sub = old_message:get_subject() 203 | headers.Subject = u.add_prefix(sub, 'Re:') 204 | 205 | headers.From = our_str 206 | 207 | local from = get_backup(old_message, { at.REPLY_TO, at.FROM, at.SENDER }):get_address(0) 208 | if not type or type == 'reply' then 209 | headers.To = pp(from) 210 | elseif type == 'reply_all' then 211 | --- these are destructive 212 | local to = old_message:get_addresses(at.TO) 213 | append_no_dup(from, to) 214 | remove(to, our) 215 | headers.To = PP(to) 216 | 217 | local cc = old_message:get_addresses(at.CC) 218 | remove(to, our) 219 | headers.Cc = PP(cc) 220 | 221 | local bcc = old_message:get_addresses(at.BCC) 222 | remove(to, our) 223 | headers.Bcc = PP(bcc) 224 | elseif type == 'mailinglist' then 225 | local ml = old_message:get_header('List-Post') 226 | headers.To = u.unmailto(ml) 227 | end 228 | opts.headers = headers 229 | end 230 | 231 | function M.forward_resent(old_message, to_str, opts) 232 | local at = gmime.AddressType 233 | local headers = opts.headers or {} 234 | 235 | local addr = au.get_our_email(old_message) 236 | local our = gmime.InternetAddressMailbox.new(config.values.name, addr) 237 | local our_str = pp(our) 238 | headers.From = our_str 239 | 240 | local sub = old_message:get_subject() 241 | sub = u.add_prefix(sub, 'FWD:') 242 | headers.Subject = sub 243 | 244 | headers.To = to_str 245 | 246 | opts.headers = headers 247 | 248 | headers['Resent-To'] = PP(old_message:get_address(at.TO)) 249 | headers['Resent-From'] = PP(old_message:get_address(at.FROM)) 250 | headers['Resent-Cc'] = PP(old_message:get_address(at.CC)) 251 | headers['Resent-Bcc'] = PP(old_message:get_address(at.BCC)) 252 | headers['Recent-Date'] = old_message:get_date() 253 | headers['Recent-Id'] = old_message:get_message_id() 254 | -- insert before the body 255 | table.insert(opts.Body, 1, { '--- Forwarded message ---' }) 256 | opts.headers = headers 257 | end 258 | 259 | function M.bounce(old_message, opts) 260 | local at = gmime.AddressType 261 | local from = get_backup(old_message, { at.REPLY_TO, at.FROM, at.SENDER }):get_address(0) 262 | M.forward_resent(old_message, from, opts) 263 | 264 | local sub = old_message:get_subject() 265 | sub = u.add_prefix(sub, 'Return:') 266 | opts.headers.Subject = sub 267 | table.remove(opts.Body, 1) 268 | table.insert(opts.Body, 1, { "--- This email isn't for me ---" }) 269 | opts.attachments = {} -- do not bounce the attachments 270 | end 271 | 272 | function M.Resent(old_message, opts) 273 | local at = gmime.AddressType 274 | local headers = opts.headers or {} 275 | 276 | headers.To = PP(old_message:get_address(at.TO)) 277 | headers.From = PP(old_message:get_address(at.FROM)) 278 | headers.Cc = PP(old_message:get_address(at.CC)) 279 | headers.Bcc = PP(old_message:get_address(at.BCC)) 280 | headers.Subject = old_message:get_subject() 281 | 282 | opts.headers = headers 283 | end 284 | 285 | function M.subscribe(old_message, opts) 286 | local unsub = old_message:get_header('List-Subscribe') 287 | if unsub == nil then 288 | log.log_err('Subscribe header not found') 289 | return 290 | end 291 | local addr = au.get_our_email(old_message) 292 | local headers = opts.headers or {} 293 | headers.From = { config.values.name, addr } 294 | headers.To = u.unmailto(unsub) 295 | headers.Subject = 'Subscribe' 296 | opts.headers = headers 297 | end 298 | 299 | function M.unsubscribe(old_message, opts) 300 | local unsub = old_message:get_header('List-Unsubscribe') 301 | if unsub == nil then 302 | log.log_err('Subscribe header not found') 303 | return 304 | end 305 | local addr = au.get_our_email(old_message) 306 | local headers = opts.headers or {} 307 | headers.From = { config.values.name, addr } 308 | headers.To = u.unmailto(unsub) 309 | headers.Subject = 'Unsubscribe' 310 | opts.headers = headers 311 | end 312 | 313 | return M 314 | -------------------------------------------------------------------------------- /lua/galore/test_utils.lua: -------------------------------------------------------------------------------- 1 | -- move this to tests 2 | local Job = require('plenary.job') 3 | local galore = require('galore') 4 | 5 | local M = {} 6 | -- lets do it like this for now 7 | -- local test_path = (function() 8 | -- local dirname = string.sub(debug.getinfo(1).source, 2, #"/test_utils.lua" * -1) 9 | -- return dirname .. "/../../" 10 | -- end)() 11 | 12 | local test_path = os.getenv('GALORETESTDATA') 13 | 14 | if not test_path then 15 | error('env GALORETESTDATA not set') 16 | end 17 | 18 | local script = test_path .. '/nm_init.sh' 19 | 20 | function M.setup(testname) 21 | local config_path = test_path .. testname .. '/notmuch/notmuch-config' 22 | Job:new({ 23 | command = script, 24 | args = { testname }, 25 | }):sync() 26 | galore.setup({ 27 | nm_config = config_path, 28 | }) 29 | galore.connect() 30 | end 31 | 32 | function M.cleanup(testname) 33 | local test = test_path .. testname 34 | vim.fn.delete(test, 'rf') 35 | end 36 | 37 | function M.notmuch_random_message() end 38 | 39 | function M.gmime_random_message() end 40 | 41 | return M 42 | -------------------------------------------------------------------------------- /lua/galore/thread_browser.lua: -------------------------------------------------------------------------------- 1 | local o = require('galore.opts') 2 | local async = require('plenary.async') 3 | local Buffer = require('galore.lib.buffer') 4 | local browser = require('galore.browser') 5 | 6 | local Threads = Buffer:new() 7 | 8 | local function threads_get(self) 9 | local first = true 10 | self.highlight = {} 11 | return browser.get_entries(self, 'show-thread', function(thread, n) 12 | if thread then 13 | table.insert(self.State, thread.id) 14 | if first then 15 | vim.api.nvim_buf_set_lines(self.handle, 0, -1, false, { thread.entry }) 16 | first = false 17 | else 18 | vim.api.nvim_buf_set_lines(self.handle, -1, -1, false, { thread.entry }) 19 | end 20 | if thread.highlight then 21 | table.insert(self.highlight, n) 22 | vim.api.nvim_buf_add_highlight(self.handle, self.dians, 'GaloreHighlight', n, 0, -1) 23 | end 24 | return 1 25 | end 26 | end) 27 | end 28 | 29 | function Threads:async_runner() 30 | self.updating = true 31 | local func = async.void(function() 32 | self.runner = threads_get(self) 33 | pcall(function() 34 | self.runner.resume(self.opts.limit) 35 | self:lock() 36 | self.updating = false 37 | end) 38 | end) 39 | func() 40 | end 41 | 42 | function Threads:refresh() 43 | if self.runner then 44 | self.runner.close() 45 | self.runner = nil 46 | end 47 | self:unlock() 48 | self:clear() 49 | self:async_runner() 50 | end 51 | 52 | function Threads:update(line_nr) 53 | local id = self.State[line_nr] 54 | browser.update_lines_helper(self, 'show-thread', 'thread:' .. id, line_nr) 55 | end 56 | 57 | function Threads:commands() end 58 | 59 | function Threads:select_thread() 60 | local line = vim.api.nvim_win_get_cursor(0)[1] 61 | return line, self.State[line] 62 | end 63 | 64 | function Threads:create(search, opts) 65 | o.threads_options(opts) 66 | Buffer.create({ 67 | name = opts.bufname(search), 68 | ft = 'galore-browser', 69 | kind = opts.kind, 70 | cursor = 'top', 71 | parent = opts.parent, 72 | mappings = opts.key_bindings, 73 | init = function(buffer) 74 | buffer.opts = opts 75 | buffer.search = search 76 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 77 | buffer:refresh() 78 | buffer:commands() 79 | if opts.limit then 80 | browser.scroll(buffer) 81 | end 82 | opts.init(buffer) 83 | end, 84 | }, Threads) 85 | end 86 | 87 | return Threads 88 | -------------------------------------------------------------------------------- /lua/galore/thread_message_browser.lua: -------------------------------------------------------------------------------- 1 | local Buffer = require('galore.lib.buffer') 2 | local o = require('galore.opts') 3 | local async = require('plenary.async') 4 | local browser = require('galore.browser') 5 | 6 | local Tmb = Buffer:new() 7 | 8 | local function tmb_get(self) 9 | local first = true 10 | self.highlight = {} 11 | return browser.get_entries(self, 'show-tree', function(thread, n) 12 | local i = 0 13 | for _, message in ipairs(thread) do 14 | table.insert(self.State, message.id) 15 | if first then 16 | vim.api.nvim_buf_set_lines(self.handle, 0, -1, false, { message.entry }) 17 | first = false 18 | else 19 | vim.api.nvim_buf_set_lines(self.handle, -1, -1, false, { message.entry }) 20 | end 21 | if message.highlight then 22 | local idx = i + n - 1 23 | table.insert(self.highlight, idx) 24 | vim.api.nvim_buf_add_highlight(self.handle, self.dians, 'GaloreHighlight', idx, 0, -1) 25 | end 26 | i = i + 1 27 | end 28 | -- end 29 | -- We need to api for folds etc and 30 | -- we don't want dump all off them like this 31 | -- but otherwise this works 32 | -- if #thread > 1 then 33 | -- local threadinfo = { 34 | -- stop = i-1, 35 | -- start = linenr, 36 | -- } 37 | -- table.insert(self.threads, threadinfo) 38 | -- end 39 | return i 40 | end) 41 | end 42 | 43 | function Tmb:async_runner() 44 | self.updating = true 45 | self.dias = {} 46 | self.threads = {} 47 | local func = async.void(function() 48 | self.runner = tmb_get(self) 49 | pcall(function() 50 | self.runner.resume(self.opts.limit) 51 | self:lock() 52 | self.updating = false 53 | end) 54 | end) 55 | func() 56 | end 57 | 58 | --- Redraw the whole window 59 | function Tmb:refresh() 60 | if self.runner then 61 | self.runner.close() 62 | self.runner = nil 63 | end 64 | self:unlock() 65 | self:clear() 66 | self:async_runner() 67 | end 68 | 69 | -- have an autocmd for refresh? 70 | function Tmb:trigger_refresh() 71 | -- trigger an refresh in autocmd 72 | end 73 | 74 | function Tmb:update(line_nr) 75 | local id = self.State[line_nr] 76 | browser.update_lines_helper(self, 'show-single-tree', 'id:' .. id, line_nr) 77 | end 78 | 79 | function Tmb:commands() 80 | vim.api.nvim_buf_create_user_command(self.handle, 'GaloreChangetag', function(args) 81 | if args.args then 82 | local callback = require('galore.callback') 83 | callback.change_tag(self, args) 84 | end 85 | end, { 86 | nargs = '*', 87 | }) 88 | end 89 | 90 | -- function Tmb:autocmd() 91 | 92 | --- Create a browser grouped by threads 93 | --- @param search string a notmuch search string 94 | --- @param opts table 95 | --- @return any 96 | function Tmb:create(search, opts) 97 | o.tmb_options(opts) 98 | return Buffer.create({ 99 | name = opts.bufname(search), 100 | ft = 'galore-browser', 101 | kind = opts.kind, 102 | cursor = 'top', 103 | parent = opts.parent, 104 | mappings = opts.key_bindings, 105 | init = function(buffer) 106 | buffer.search = search 107 | buffer.opts = opts 108 | buffer.diagnostics = {} 109 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 110 | buffer:refresh(search) 111 | buffer:commands() 112 | if opts.limit then 113 | browser.scroll(buffer) 114 | end 115 | opts.init(buffer) 116 | end, 117 | }, Tmb) 118 | end 119 | 120 | return Tmb 121 | -------------------------------------------------------------------------------- /lua/galore/thread_view.lua: -------------------------------------------------------------------------------- 1 | local r = require('galore.render') 2 | local u = require('galore.util') 3 | local Buffer = require('galore.lib.buffer') 4 | local nu = require('galore.notmuch-util') 5 | -- local nm = require("galore.notmuch") 6 | local nm = require('notmuch') 7 | local ui = require('galore.ui') 8 | local runtime = require('galore.runtime') 9 | local o = require('galore.opts') 10 | local gu = require('galore.gmime-util') 11 | 12 | local Thread = Buffer:new() 13 | 14 | function Thread:update(tid) 15 | self:unlock() 16 | self:clear() 17 | self.thread_parts = {} 18 | self.states = {} 19 | self.lines = {} 20 | 21 | runtime.with_db(function(db) 22 | local query = nm.create_query(db, 'thread:' .. tid) 23 | nm.query_set_sort(query, self.opts.sort) 24 | local i = 1 25 | local tot = nm.query_count_messages(query) 26 | for nm_message in nm.query_get_messages(query) do 27 | local message_start = vim.fn.line('$') 28 | 29 | local line = nu.get_message(nm_message) 30 | line.total = tot 31 | line.index = i 32 | 33 | local message = gu.parse_message(line.filenames[1]) 34 | 35 | local buffer = {} 36 | r.show_headers(message, self.handle, { ns = self.ns }, line, message_start) 37 | local body = vim.fn.line('$') 38 | local state = r.render_message(r.default_render, message, buffer, { 39 | offset = body - 1, 40 | keys = line.keys, 41 | }) 42 | table.insert(self.states, state) 43 | table.insert(self.lines, line) 44 | u.purge_empty(buffer) 45 | self:set_lines(-1, -1, true, buffer) 46 | local message_stop = vim.fn.line('$') 47 | if not vim.tbl_isempty(state.attachments) then 48 | ui.render_attachments(state.attachments, message_stop - 1, self.handle, self.ns) 49 | end 50 | table.insert( 51 | self.thread_parts, 52 | { start = message_start, stop = message_stop, body = body, mid = line.id } 53 | ) 54 | i = i + 1 55 | end 56 | self:set_lines(0, 1, true, {}) 57 | -- vim.schedule(function () 58 | -- for idx, state in ipairs(self.states) do 59 | -- for _, cb in ipairs(state.callbacks) do 60 | -- cb(self.handle, self.ns) 61 | -- end 62 | -- self.states[idx] = nil 63 | -- end 64 | -- end) 65 | end) 66 | 67 | self:lock() 68 | end 69 | 70 | function Thread:with_all_attachments(func, ...) 71 | local attachments = {} 72 | for _, state in ipairs(self.states) do 73 | vim.list_extend(attachments, state.attachments) 74 | end 75 | func(attachments, ...) 76 | end 77 | 78 | function Thread:with_selected_attachments(func, ...) 79 | local _, i = self:get_selected() 80 | local attachments = self.states[i].attachments 81 | func(attachments, ...) 82 | end 83 | 84 | function Thread:get_selected() 85 | local line = unpack(vim.api.nvim_win_get_cursor(0)) 86 | line = line + 1 87 | for i, m in ipairs(self.thread_parts) do 88 | if m.start <= line and m.stop >= line then 89 | return m.mid, i 90 | end 91 | end 92 | end 93 | 94 | function Thread:message_view() 95 | local mid = self:get_selected() 96 | local mw = require('galore.message_view') 97 | local opts = o.bufcopy(self.opts) 98 | mw:create(mid, opts) 99 | end 100 | 101 | function Thread:redraw(line) 102 | self:focus() 103 | self:update(line) 104 | end 105 | 106 | function Thread:set(i) 107 | local _, col = unpack(vim.api.nvim_win_get_cursor(0)) 108 | for j, m in ipairs(self.thread_parts) do 109 | if i == j then 110 | vim.api.nvim_win_set_cursor(0, { m.start, col }) 111 | end 112 | end 113 | end 114 | 115 | function Thread:next() 116 | local found = false 117 | local line, col = unpack(vim.api.nvim_win_get_cursor(0)) 118 | for _, m in ipairs(self.thread_parts) do 119 | if m.start <= line and m.stop >= line then 120 | vim.api.nvim_win_set_cursor(0, { m.start, col }) 121 | found = true 122 | elseif found then 123 | vim.api.nvim_win_set_cursor(0, { m.start, col }) 124 | return 125 | end 126 | end 127 | end 128 | 129 | function Thread:prev() 130 | local line, col = unpack(vim.api.nvim_win_get_cursor(0)) 131 | for i, m in ipairs(self.thread_parts) do 132 | if m and m.start <= line and m.stop >= line then 133 | vim.api.nvim_win_set_cursor(0, { m.start, col }) 134 | return 135 | end 136 | end 137 | end 138 | 139 | -- local function verify_signatures(self) 140 | -- local state = {} 141 | -- local function verify(_, part, _) 142 | -- if gp.is_multipart_signed(part) then 143 | -- local verified = gcu.verify_signed(part) 144 | -- if state.verified == nil then 145 | -- state.verified = verified 146 | -- end 147 | -- state.verified = state.verified and verified 148 | -- end 149 | -- end 150 | -- if not self.message then 151 | -- return 152 | -- end 153 | -- gp.message_foreach_dfs(self.message, verify) 154 | -- return state.verified or state.verified == nil 155 | -- end 156 | 157 | function Thread:commands() 158 | -- vim.api.nvim_buf_create_user_command(self.handle, "GaloreSaveAttachment", function (args) 159 | -- if args.fargs then 160 | -- local save_path = "." 161 | -- if #args.fargs > 2 then 162 | -- save_path = args.fargs[2] 163 | -- end 164 | -- save_attachment(self.state.attachments, args.fargs[1], save_path) 165 | -- end 166 | -- end, { 167 | -- nargs = "*", 168 | -- complete = function () 169 | -- local files = {} 170 | -- for _, v in ipairs(self.state.attachments) do 171 | -- table.insert(files, v.filename) 172 | -- end 173 | -- return files 174 | -- end, 175 | -- }) 176 | -- vim.api.nvim_buf_create_user_command(self.handle, "GaloreVerify", function () 177 | -- print(verify_signatures(self)) 178 | -- end, { 179 | -- }) 180 | end 181 | 182 | function Thread:create(tid, opts) 183 | o.thread_view_options(opts) 184 | Buffer.create({ 185 | name = opts.bufname(tid), 186 | ft = 'mail', 187 | kind = opts.kind, 188 | parent = opts.parent, 189 | mappings = opts.key_bindings, 190 | init = function(buffer) 191 | buffer.opts = opts 192 | buffer.vline = opts.vline 193 | buffer.ns = vim.api.nvim_create_namespace('galore-thread-view') 194 | buffer.dians = vim.api.nvim_create_namespace('galore-dia') 195 | -- mark_read(buffer, opts.parent, line, opts.vline) 196 | buffer:update(tid) 197 | buffer:commands() 198 | buffer:set(opts.index) 199 | opts.init(buffer) 200 | end, 201 | }, Thread) 202 | end 203 | 204 | function Thread.open_attach() end 205 | 206 | return Thread 207 | -------------------------------------------------------------------------------- /lua/galore/ui.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | --- Adds the attachments in the bottom of the buffer 4 | function M.render_attachments(attachments, line, buffer, ns) 5 | if #attachments == 0 then 6 | return 7 | end 8 | local marks = {} 9 | for _, v in ipairs(attachments) do 10 | local str = string.format('- [%s]', v.filename) 11 | table.insert(marks, { str, 'GaloreAttachment' }) 12 | end 13 | local opts = { 14 | virt_lines = { 15 | marks, 16 | }, 17 | } 18 | vim.api.nvim_buf_set_extmark(buffer, ns, line, 0, opts) 19 | end 20 | 21 | function M.extmark(buf, ns, style, text, line) 22 | -- for now 23 | if not ns then 24 | return 25 | end 26 | local opts = { 27 | virt_lines = { 28 | { { text, style } }, 29 | }, 30 | } 31 | vim.api.nvim_buf_set_extmark(buf, ns, line, 0, opts) 32 | end 33 | 34 | return M 35 | -------------------------------------------------------------------------------- /lua/galore/url.lua: -------------------------------------------------------------------------------- 1 | local function firstToUpper(str) 2 | return (str:gsub('^%l', string.upper)) 3 | end 4 | 5 | local function decode(str) 6 | local function hex_to_char(hex) 7 | return string.char(tonumber(hex, 16)) 8 | end 9 | return str:gsub('%%([a-fA-F0-9][a-fA-F0-9])', hex_to_char) 10 | end 11 | 12 | local function parse_mailto(str) 13 | if type(str) ~= 'string' then 14 | vim.api.nvim_err_write('Argument should be a string') 15 | return 16 | end 17 | local values = {} 18 | 19 | str = str:gsub('mailto:', '') 20 | if string.gmatch(str, '?') then 21 | local lp = str:gsub('.*?', '') 22 | for k, v in string.gmatch(lp, '([^&=?]+)=([^&=?]+)') do 23 | k = firstToUpper(k) 24 | values[k] = decode(v) 25 | end 26 | end 27 | local to = decode(str:gsub('?.*', '')) 28 | --- dunno if we should concat these 29 | if to and to ~= '' then 30 | if values['To'] then 31 | values['To'] = to .. ', ' .. values['To'] 32 | else 33 | values['To'] = to 34 | end 35 | end 36 | return values 37 | end 38 | 39 | local function normalize(tbl) 40 | return { To = tbl.To, Cc = tbl.Cc, Subject = tbl.Subject, Attach = tbl.Attach, Body = tbl.Body } 41 | end 42 | 43 | return { 44 | parse_mailto, 45 | normalize, 46 | } 47 | -------------------------------------------------------------------------------- /lua/galore/util.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.trim(s) 4 | return s:gsub('^%s*(.-)%s*$', '%1') 5 | end 6 | 7 | function M.id(arg) 8 | return arg 9 | end 10 | 11 | function M.make_keys(iter) 12 | local box = {} 13 | for v in iter do 14 | box[v] = true 15 | end 16 | return box 17 | end 18 | 19 | --- @param t table 20 | --- @param sep string 21 | --- @param i? number 22 | --- @param j? number 23 | function M.keys_concat(t, sep, i, j) 24 | return table.concat(vim.tbl_keys(t), sep, i, j) 25 | end 26 | 27 | function M.contains(list, item) 28 | for _, l in ipairs(list) do 29 | if l == item then 30 | return true 31 | end 32 | end 33 | return false 34 | end 35 | 36 | function M.string_setlength(str, len) 37 | local trimmed = vim.fn.strcharpart(str, 0, len) 38 | local tlen = vim.fn.strchars(trimmed) 39 | return trimmed .. string.rep(' ', len - tlen) 40 | end 41 | 42 | function M.reverse(list) 43 | local box = {} 44 | for i = #list, 1, -1 do 45 | box[#box + 1] = list[i] 46 | end 47 | return box 48 | end 49 | 50 | function M.upairs(list) 51 | local i = 1 52 | return function() 53 | if i < #list then 54 | local element = list[i] 55 | i = i + 1 56 | return element 57 | end 58 | end 59 | end 60 | 61 | -- Go over any iterator and just put all the values in an array 62 | -- This can be slow but great for toying around 63 | --- @param it function iterator to loop over 64 | --- @param t? table 65 | --- @param i? number index 66 | --- @return array 67 | function M.collect(it, t, i) 68 | local box = {} 69 | if t == nil then 70 | for v in it do 71 | table.insert(box, v) 72 | end 73 | return box 74 | else 75 | for _, v in it, t, i do 76 | table.insert(box, v) 77 | end 78 | return box 79 | end 80 | end 81 | 82 | function M.add_prefix(str, prefix) 83 | if not vim.startswith(str, prefix) then 84 | str = prefix .. ' ' .. str 85 | end 86 | return str 87 | end 88 | 89 | function M.basename(path) 90 | return string.gsub(path, '.*/(.*)', '%1') 91 | end 92 | 93 | function M.is_absolute(path) 94 | return path:sub(1, 1) == '/' 95 | end 96 | 97 | function M.save_path(path, default_path) 98 | path = vim.fn.expand(path) 99 | default_path = default_path or '' 100 | if not M.is_absolute(path) then 101 | path = default_path .. path 102 | end 103 | return path 104 | end 105 | 106 | function M.gen_name(name, num) 107 | if num == 1 then 108 | return name 109 | end 110 | return string.format('%s-%d', name, num) 111 | end 112 | 113 | function M.format(part, qoute) 114 | local box = {} 115 | for line in string.gmatch(part, '[^\n]+') do 116 | table.insert(box, line) 117 | end 118 | return box 119 | end 120 | 121 | function M.purge_empty(list) 122 | for i, v in ipairs(list) do 123 | if v == '' then 124 | table.remove(list, i) 125 | else 126 | break 127 | end 128 | end 129 | 130 | --- remove any empty line at the end of the list 131 | local stop = #list 132 | while stop > 0 do 133 | if list[stop] == '' then 134 | table.remove(list, stop) 135 | stop = stop - 1 136 | else 137 | break 138 | end 139 | end 140 | end 141 | 142 | function M.unmailto(addr) 143 | return addr:gsub('', '%1') 144 | end 145 | 146 | return M 147 | -------------------------------------------------------------------------------- /lua/galore/views.lua: -------------------------------------------------------------------------------- 1 | local Path = require('plenary.path') 2 | local gu = require('galore.gmime-util') 3 | local runtime = require('galore.runtime') 4 | local M = {} 5 | 6 | --- TODO 7 | 8 | function M.save_attachment(attachments, idx, save_path, overwrite) 9 | if attachments[idx] then 10 | local filename = attachments[idx].filename 11 | local path = Path:new(save_path) 12 | if path:is_dir() then 13 | path = path:joinpath(filename) 14 | end 15 | if path:exists() and not overwrite then 16 | error('file exists') 17 | return 18 | end 19 | gu.save_part(attachments[idx].part, path:expand()) 20 | return 21 | end 22 | vim.api.nvim_err_writeln('No attachment with that name') 23 | end 24 | 25 | function M.select_attachment(attachments, cb) 26 | local files = {} 27 | for _, v in ipairs(attachments) do 28 | table.insert(files, v.filename) 29 | end 30 | vim.ui.select(files, { 31 | prompt = 'Select attachment: ', 32 | }, function(item, idx) 33 | if item then 34 | cb(attachments[idx]) 35 | else 36 | vim.api.nvim_err_writeln('No file selected') 37 | end 38 | end) 39 | end 40 | 41 | local function mark_read(self, pb, line, vline) 42 | runtime.with_db_writer(function(db) 43 | self.opts.tag_unread(db, line.id) 44 | nu.tag_if_nil(db, line.id, self.opts.empty_tag) 45 | --- this doesn't work because we can't redraw just a message but the whole thread? 46 | --- Maybe redraw the whole thread? 47 | nu.update_line(db, pb, line, vline) 48 | end) 49 | end 50 | 51 | return M 52 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('galore', 'c', 2 | version : '0.1', 3 | default_options : ['warning_level=3']) 4 | subdir('src') 5 | -------------------------------------------------------------------------------- /plugin/galore.lua: -------------------------------------------------------------------------------- 1 | if 1 ~= vim.fn.has "nvim-0.7.0" then 2 | vim.api.nvim_err_writeln "Galore requires at least nvim-0.7.0." 3 | return 4 | end 5 | 6 | if vim.g.loaded_galore == 1 then 7 | return 8 | end 9 | vim.g.loaded_galore = 1 10 | 11 | vim.api.nvim_set_hl(0, "GaloreVerifyGreen", {fg="Green"}) 12 | vim.api.nvim_set_hl(0, "GaloreVerifyRed", {fg="Red"}) 13 | vim.api.nvim_set_hl(0, "GaloreSeperator", {fg="Red"}) 14 | vim.api.nvim_set_hl(0, "GaloreAttachment", {fg="Red"}) 15 | vim.api.nvim_set_hl(0, "GaloreHeader", {link="Comment"}) 16 | vim.api.nvim_set_hl(0, "GaloreVerifyGreen", {fg="Green"}) 17 | vim.api.nvim_set_hl(0, "GaloreVerifyRed", {fg="Red"}) 18 | 19 | vim.api.nvim_create_user_command("Galore", function (args) 20 | local opts = {} 21 | if args.args and args.args ~= "" then 22 | opts.search = args.args 23 | end 24 | require('galore').open(opts) 25 | end, { 26 | nargs = "*", 27 | }) 28 | 29 | vim.api.nvim_create_user_command("GaloreCompose", function (args) 30 | require('galore').compose("replace", args.args) 31 | end, { 32 | nargs = "?", 33 | }) 34 | 35 | vim.api.nvim_create_user_command("GaloreMailto", function (args) 36 | require('galore').mailto("replace", args.args) 37 | end, { 38 | nargs = "?", 39 | }) 40 | 41 | vim.api.nvim_create_user_command("GaloreNew", function () 42 | require('galore.jobs').new() 43 | end, { 44 | nargs = 0, 45 | }) 46 | -------------------------------------------------------------------------------- /res/galore.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 46 | neovim-mark@2x 48 | Created with Sketch (http://www.bohemiancoding.com/sketch) 49 | 51 | 59 | 64 | 69 | 70 | 78 | 82 | 86 | 87 | 95 | 100 | 105 | 106 | 114 | 119 | 124 | 125 | 133 | 138 | 143 | 144 | 145 | 153 | 157 | 163 | 169 | 176 | 183 | 189 | 195 | 196 | 197 | 199 | 200 | 202 | neovim-mark@2x 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /res/message_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/res/message_view.png -------------------------------------------------------------------------------- /res/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/res/overview.png -------------------------------------------------------------------------------- /res/saved.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/res/saved.png -------------------------------------------------------------------------------- /res/telescope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/res/telescope.png -------------------------------------------------------------------------------- /res/thread_message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagle/galore/0fe4c74186d18e6449b1be951564c5392cf970d2/res/thread_message.png -------------------------------------------------------------------------------- /src/galore-autocrypt.c: -------------------------------------------------------------------------------- 1 | /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- */ 2 | /* GMime 3 | * Copyright (C) 2000-2020 Jeffrey Stedfast 4 | * 5 | * This library is free software; you can redistribute it and/or 6 | * modify it under the terms of the GNU Lesser General Public License 7 | * as published by the Free Software Foundation; either version 2.1 8 | * of the License, or (at your option) any later version. 9 | * 10 | * This library is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | * Lesser General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Lesser General Public 16 | * License along with this library; if not, write to the Free 17 | * Software Foundation, 51 Franklin Street, Fifth Floor, Boston, MA 18 | * 02110-1301, USA. 19 | */ 20 | 21 | 22 | #ifdef HAVE_CONFIG_H 23 | #include 24 | #endif 25 | 26 | #include 27 | #include 28 | #include 29 | 30 | #include "galore-autocrypt.h" 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | #include "galore-gpgme-utils.h" 39 | 40 | #ifdef ENABLE_DEBUG 41 | #define d(x) x 42 | #else 43 | #define d(x) 44 | #endif 45 | 46 | #define _(x) x 47 | 48 | 49 | /** 50 | * SECTION: gmime-gpg-context 51 | * @title: GaloreAutoCryptContext 52 | * @short_description: GnuPG crypto contexts 53 | * @see_also: #GMimeCryptoContext 54 | * 55 | * A #GaloreAutoCryptContext is a #GMimeCryptoContext that uses GnuPG to do 56 | * all of the encryption and digital signatures. 57 | **/ 58 | 59 | 60 | /** 61 | * GaloreAutoCryptContext: 62 | * 63 | * A GnuPG crypto context. 64 | **/ 65 | struct _GaloreAutoCryptContext { 66 | GMimeCryptoContext parent_object; 67 | 68 | gpgme_ctx_t ctx; 69 | }; 70 | 71 | struct _GaloreAutoCryptContextClass { 72 | GMimeCryptoContextClass parent_class; 73 | 74 | }; 75 | 76 | 77 | static void galore_au_context_class_init (GaloreAutoCryptContextClass *klass); 78 | static void galore_au_context_init (GaloreAutoCryptContext *ctx, GaloreAutoCryptContextClass *klass); 79 | static void galore_au_context_finalize (GObject *object); 80 | 81 | static GMimeDigestAlgo au_digest_id (GMimeCryptoContext *ctx, const char *name); 82 | static const char *au_digest_name (GMimeCryptoContext *ctx, GMimeDigestAlgo digest); 83 | 84 | static int au_sign (GMimeCryptoContext *ctx, gboolean detach, const char *userid, 85 | GMimeStream *istream, GMimeStream *ostream, GError **err); 86 | 87 | static const char *au_get_signature_protocol (GMimeCryptoContext *ctx); 88 | static const char *au_get_encryption_protocol (GMimeCryptoContext *ctx); 89 | static const char *au_get_key_exchange_protocol (GMimeCryptoContext *ctx); 90 | 91 | static GMimeSignatureList *au_verify (GMimeCryptoContext *ctx, GMimeVerifyFlags flags, 92 | GMimeStream *istream, GMimeStream *sigstream, 93 | GMimeStream *ostream, GError **err); 94 | 95 | static int au_encrypt (GMimeCryptoContext *ctx, gboolean sign, const char *userid, GMimeEncryptFlags flags, 96 | GPtrArray *recipients, GMimeStream *istream, GMimeStream *ostream, GError **err); 97 | 98 | static GMimeDecryptResult *au_decrypt (GMimeCryptoContext *ctx, GMimeDecryptFlags flags, const char *session_key, 99 | GMimeStream *istream, GMimeStream *ostream, GError **err); 100 | 101 | static int au_import_keys (GMimeCryptoContext *ctx, GMimeStream *istream, GError **err); 102 | 103 | static int au_export_keys (GMimeCryptoContext *ctx, const char *keys[], 104 | GMimeStream *ostream, GError **err); 105 | 106 | 107 | static GMimeCryptoContextClass *parent_class = NULL; 108 | static char *path = NULL; 109 | 110 | 111 | GType 112 | galore_au_context_get_type () 113 | { 114 | static GType type = 0; 115 | 116 | if (!type) { 117 | static const GTypeInfo info = { 118 | sizeof (GaloreAutoCryptContextClass), 119 | NULL, /* base_class_init */ 120 | NULL, /* base_class_finalize */ 121 | (GClassInitFunc) galore_au_context_class_init, 122 | NULL, /* class_finalize */ 123 | NULL, /* class_data */ 124 | sizeof (GaloreAutoCryptContext), 125 | 0, /* n_preallocs */ 126 | (GInstanceInitFunc) galore_au_context_init, 127 | }; 128 | 129 | type = g_type_register_static (GMIME_TYPE_CRYPTO_CONTEXT, "GMimeAutoCryptContext", &info, 0); 130 | } 131 | 132 | return type; 133 | } 134 | 135 | 136 | static void 137 | galore_au_context_class_init (GaloreAutoCryptContextClass *klass) 138 | { 139 | GObjectClass *object_class = G_OBJECT_CLASS (klass); 140 | GMimeCryptoContextClass *crypto_class = GMIME_CRYPTO_CONTEXT_CLASS (klass); 141 | 142 | parent_class = g_type_class_ref (G_TYPE_OBJECT); 143 | 144 | object_class->finalize = galore_au_context_finalize; 145 | 146 | crypto_class->digest_id = au_digest_id; 147 | crypto_class->digest_name = au_digest_name; 148 | crypto_class->sign = au_sign; 149 | crypto_class->verify = au_verify; 150 | crypto_class->encrypt = au_encrypt; 151 | crypto_class->decrypt = au_decrypt; 152 | crypto_class->import_keys = au_import_keys; 153 | crypto_class->export_keys = au_export_keys; 154 | crypto_class->get_signature_protocol = au_get_signature_protocol; 155 | crypto_class->get_encryption_protocol = au_get_encryption_protocol; 156 | crypto_class->get_key_exchange_protocol = au_get_key_exchange_protocol; 157 | } 158 | 159 | static void 160 | galore_au_context_init (GaloreAutoCryptContext *gpg, GaloreAutoCryptContextClass *klass) 161 | { 162 | gpg->ctx = NULL; 163 | } 164 | 165 | static void 166 | galore_au_context_finalize (GObject *object) 167 | { 168 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) object; 169 | 170 | if (gpg->ctx) 171 | gpgme_release (gpg->ctx); 172 | 173 | G_OBJECT_CLASS (parent_class)->finalize (object); 174 | } 175 | 176 | static GMimeDigestAlgo 177 | au_digest_id (GMimeCryptoContext *ctx, const char *name) 178 | { 179 | if (name == NULL) 180 | return GMIME_DIGEST_ALGO_DEFAULT; 181 | 182 | if (!g_ascii_strncasecmp (name, "pgp-", 4)) 183 | name += 4; 184 | 185 | if (!g_ascii_strcasecmp (name, "md2")) 186 | return GMIME_DIGEST_ALGO_MD2; 187 | else if (!g_ascii_strcasecmp (name, "md4")) 188 | return GMIME_DIGEST_ALGO_MD4; 189 | else if (!g_ascii_strcasecmp (name, "md5")) 190 | return GMIME_DIGEST_ALGO_MD5; 191 | else if (!g_ascii_strcasecmp (name, "sha1")) 192 | return GMIME_DIGEST_ALGO_SHA1; 193 | else if (!g_ascii_strcasecmp (name, "sha224")) 194 | return GMIME_DIGEST_ALGO_SHA224; 195 | else if (!g_ascii_strcasecmp (name, "sha256")) 196 | return GMIME_DIGEST_ALGO_SHA256; 197 | else if (!g_ascii_strcasecmp (name, "sha384")) 198 | return GMIME_DIGEST_ALGO_SHA384; 199 | else if (!g_ascii_strcasecmp (name, "sha512")) 200 | return GMIME_DIGEST_ALGO_SHA512; 201 | else if (!g_ascii_strcasecmp (name, "ripemd160")) 202 | return GMIME_DIGEST_ALGO_RIPEMD160; 203 | else if (!g_ascii_strcasecmp (name, "tiger192")) 204 | return GMIME_DIGEST_ALGO_TIGER192; 205 | else if (!g_ascii_strcasecmp (name, "haval-5-160")) 206 | return GMIME_DIGEST_ALGO_HAVAL5160; 207 | 208 | return GMIME_DIGEST_ALGO_DEFAULT; 209 | } 210 | 211 | static const char * 212 | au_digest_name (GMimeCryptoContext *ctx, GMimeDigestAlgo digest) 213 | { 214 | switch (digest) { 215 | case GMIME_DIGEST_ALGO_MD2: 216 | return "pgp-md2"; 217 | case GMIME_DIGEST_ALGO_MD4: 218 | return "pgp-md4"; 219 | case GMIME_DIGEST_ALGO_MD5: 220 | return "pgp-md5"; 221 | case GMIME_DIGEST_ALGO_SHA1: 222 | return "pgp-sha1"; 223 | case GMIME_DIGEST_ALGO_SHA224: 224 | return "pgp-sha224"; 225 | case GMIME_DIGEST_ALGO_SHA256: 226 | return "pgp-sha256"; 227 | case GMIME_DIGEST_ALGO_SHA384: 228 | return "pgp-sha384"; 229 | case GMIME_DIGEST_ALGO_SHA512: 230 | return "pgp-sha512"; 231 | case GMIME_DIGEST_ALGO_RIPEMD160: 232 | return "pgp-ripemd160"; 233 | case GMIME_DIGEST_ALGO_TIGER192: 234 | return "pgp-tiger192"; 235 | case GMIME_DIGEST_ALGO_HAVAL5160: 236 | return "pgp-haval-5-160"; 237 | default: 238 | return "pgp-sha1"; 239 | } 240 | } 241 | 242 | static const char * 243 | au_get_signature_protocol (GMimeCryptoContext *ctx) 244 | { 245 | return "application/pgp-signature"; 246 | } 247 | 248 | static const char * 249 | au_get_encryption_protocol (GMimeCryptoContext *ctx) 250 | { 251 | return "application/pgp-encrypted"; 252 | } 253 | 254 | static const char * 255 | au_get_key_exchange_protocol (GMimeCryptoContext *ctx) 256 | { 257 | return "application/pgp-keys"; 258 | } 259 | 260 | static void 261 | set_passphrase_callback (GMimeCryptoContext *context) 262 | { 263 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 264 | 265 | if (context->request_passwd) 266 | gpgme_set_passphrase_cb (gpg->ctx, g_mime_gpgme_passphrase_callback, gpg); 267 | else 268 | gpgme_set_passphrase_cb (gpg->ctx, NULL, NULL); 269 | } 270 | 271 | static int 272 | au_sign (GMimeCryptoContext *context, gboolean detach, const char *userid, 273 | GMimeStream *istream, GMimeStream *ostream, GError **err) 274 | { 275 | gpgme_sig_mode_t mode = detach ? GPGME_SIG_MODE_DETACH : GPGME_SIG_MODE_CLEAR; 276 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 277 | 278 | set_passphrase_callback (context); 279 | 280 | gpgme_set_textmode (gpg->ctx, !detach); 281 | 282 | return g_mime_gpgme_sign (gpg->ctx, mode, userid, istream, ostream, err); 283 | } 284 | 285 | static GMimeSignatureList * 286 | au_verify (GMimeCryptoContext *context, GMimeVerifyFlags flags, GMimeStream *istream, GMimeStream *sigstream, 287 | GMimeStream *ostream, GError **err) 288 | { 289 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 290 | 291 | return g_mime_gpgme_verify (gpg->ctx, flags, istream, sigstream, ostream, err); 292 | } 293 | 294 | static int 295 | au_encrypt (GMimeCryptoContext *context, gboolean sign, const char *userid, GMimeEncryptFlags flags, 296 | GPtrArray *recipients, GMimeStream *istream, GMimeStream *ostream, GError **err) 297 | { 298 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 299 | 300 | if (sign) 301 | set_passphrase_callback (context); 302 | 303 | return g_mime_gpgme_encrypt (gpg->ctx, sign, userid, flags, recipients, istream, ostream, err); 304 | } 305 | 306 | static GMimeDecryptResult * 307 | au_decrypt (GMimeCryptoContext *context, GMimeDecryptFlags flags, const char *session_key, 308 | GMimeStream *istream, GMimeStream *ostream, GError **err) 309 | { 310 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 311 | 312 | set_passphrase_callback (context); 313 | 314 | return g_mime_gpgme_decrypt (gpg->ctx, flags, session_key, istream, ostream, err); 315 | } 316 | 317 | static int 318 | au_import_keys (GMimeCryptoContext *context, GMimeStream *istream, GError **err) 319 | { 320 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 321 | 322 | set_passphrase_callback (context); 323 | 324 | return g_mime_gpgme_import (gpg->ctx, istream, err); 325 | } 326 | 327 | static int 328 | au_export_keys (GMimeCryptoContext *context, const char *keys[], GMimeStream *ostream, GError **err) 329 | { 330 | GaloreAutoCryptContext *gpg = (GaloreAutoCryptContext *) context; 331 | 332 | set_passphrase_callback (context); 333 | 334 | // return g_mime_au_export (gpg->ctx, keys, ostream, err); 335 | return g_mime_gpgme_import (gpg->ctx, ostream, err); 336 | } 337 | 338 | 339 | /** 340 | * g_mime_gpg_context_new: 341 | * 342 | * Creates a new gpg crypto context object. 343 | * 344 | * Returns: (transfer full): a new gpg crypto context object. 345 | **/ 346 | GMimeCryptoContext * 347 | galore_au_context_new (void) 348 | { 349 | GaloreAutoCryptContext *gpg; 350 | gpgme_ctx_t ctx; 351 | 352 | /* make sure GpgMe supports the OpenPGP protocols */ 353 | if (gpgme_engine_check_version (GPGME_PROTOCOL_OpenPGP) != GPG_ERR_NO_ERROR) 354 | return NULL; 355 | 356 | /* create the GpgMe context */ 357 | if (gpgme_new (&ctx) != GPG_ERR_NO_ERROR) 358 | return NULL; 359 | if (gpgme_ctx_set_engine_info(ctx, GPGME_PROTOCOL_OpenPGP, NULL, path) != GPG_ERR_NO_ERROR) 360 | return NULL; 361 | 362 | gpg = g_object_new (GALORE_TYPE_AU_CONTEXT, NULL); 363 | gpgme_set_protocol (ctx, GPGME_PROTOCOL_OpenPGP); 364 | gpgme_set_armor (ctx, TRUE); 365 | gpg->ctx = ctx; 366 | 367 | return (GMimeCryptoContext *) gpg; 368 | } 369 | -------------------------------------------------------------------------------- /src/galore-autocrypt.h: -------------------------------------------------------------------------------- 1 | /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- */ 2 | /* GMime 3 | * Copyright (C) 2000-2020 Jeffrey Stedfast 4 | * 5 | * This library is free software; you can redistribute it and/or 6 | * modify it under the terms of the GNU Lesser General Public License 7 | * as published by the Free Software Foundation; either version 2.1 8 | * of the License, or (at your option) any later version. 9 | * 10 | * This library is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | * Lesser General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Lesser General Public 16 | * License along with this library; if not, write to the Free 17 | * Software Foundation, 51 Franklin Street, Fifth Floor, Boston, MA 18 | * 02110-1301, USA. 19 | */ 20 | 21 | 22 | #ifndef __GALORE_AU_CONTEXT_H__ 23 | #define __GALORE_AU_CONTEXT_H__ 24 | 25 | #include 26 | 27 | G_BEGIN_DECLS 28 | 29 | #define GALORE_TYPE_AU_CONTEXT (galore_au_context_get_type ()) 30 | #define GALORE_AU_CONTEXT(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), GALORE_TYPE_AU_CONTEXT, GMimeGpgContext)) 31 | #define GALORE_AU_CONTEXT_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), GALORE_TYPE_AU_CONTEXT, GMimeGpgContextClass)) 32 | #define GALORE_IS_AU_CONTEXT(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), GALORE_TYPE_AU_CONTEXT)) 33 | #define GALORE_IS_AU_CONTEXT_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), GALORE_TYPE_AU_CONTEXT)) 34 | #define GALORE_AU_CONTEXT_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), GALORE_TYPE_AU_CONTEXT, GMimeGpgContextClass)) 35 | 36 | typedef struct _GaloreAutoCryptContext GaloreAutoCryptContext; 37 | typedef struct _GaloreAutoCryptContextClass GaloreAutoCryptContextClass; 38 | 39 | GType galore_au_context_get_type (void); 40 | 41 | GMimeCryptoContext *galore_au_context_new (void); 42 | 43 | G_END_DECLS 44 | 45 | #endif /* __GALORE_AU_CONTEXT_H__ */ 46 | -------------------------------------------------------------------------------- /src/galore-filter-reply.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2009 Keith Packard 3 | * 4 | * This program is free software; you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation, either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, but 10 | * WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | * General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License along 15 | * with this program; if not, write to the Free Software Foundation, Inc., 16 | * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. 17 | */ 18 | 19 | #include 20 | 21 | #include "galore-filter-reply.h" 22 | // #include "notmuch-client.h" 23 | 24 | /** 25 | * SECTION: gmime-filter-reply 26 | * @title: GaloreFilterReply 27 | * @short_description: Add/remove reply markers 28 | * 29 | * A #GMimeFilter for adding or removing reply markers 30 | **/ 31 | #define unused(x) x ## _unused __attribute__ ((unused)) 32 | 33 | static void galore_filter_reply_class_init (GaloreFilterReplyClass *klass, void *class_data); 34 | static void galore_filter_reply_init (GaloreFilterReply *filter, GaloreFilterReplyClass *klass); 35 | static void galore_filter_reply_finalize (GObject *object); 36 | 37 | static GMimeFilter *filter_copy (GMimeFilter *filter); 38 | static void filter_filter (GMimeFilter *filter, char *in, size_t len, size_t prespace, 39 | char **out, size_t *outlen, size_t *outprespace); 40 | static void filter_complete (GMimeFilter *filter, char *in, size_t len, size_t prespace, 41 | char **out, size_t *outlen, size_t *outprespace); 42 | static void filter_reset (GMimeFilter *filter); 43 | 44 | 45 | static GMimeFilterClass *parent_class = NULL; 46 | 47 | GType 48 | galore_filter_reply_get_type (void) 49 | { 50 | static GType type = 0; 51 | if (!type) { 52 | static const GTypeInfo info = { 53 | .class_size = sizeof (GaloreFilterReplyClass), 54 | .base_init = NULL, 55 | .base_finalize = NULL, 56 | .class_init = (GClassInitFunc) galore_filter_reply_class_init, 57 | .class_finalize = NULL, 58 | .class_data = NULL, 59 | .instance_size = sizeof (GaloreFilterReply), 60 | .n_preallocs = 0, 61 | .instance_init = (GInstanceInitFunc) galore_filter_reply_init, 62 | .value_table = NULL, 63 | }; 64 | type = g_type_register_static (GMIME_TYPE_FILTER, "GaloreFilterReply", &info, 0); 65 | } 66 | return type; 67 | } 68 | 69 | static void 70 | galore_filter_reply_class_init (GaloreFilterReplyClass *klass, unused (void *class_data)) 71 | { 72 | GObjectClass *object_class = G_OBJECT_CLASS (klass); 73 | GMimeFilterClass *filter_class = GMIME_FILTER_CLASS (klass); 74 | 75 | parent_class = g_type_class_ref (GMIME_TYPE_FILTER); 76 | object_class->finalize = galore_filter_reply_finalize; 77 | 78 | filter_class->copy = filter_copy; 79 | filter_class->filter = filter_filter; 80 | filter_class->complete = filter_complete; 81 | filter_class->reset = filter_reset; 82 | } 83 | 84 | static void 85 | galore_filter_reply_init (GaloreFilterReply *filter, GaloreFilterReplyClass *klass) 86 | { 87 | (void) klass; 88 | filter->saw_nl = true; 89 | filter->saw_angle = false; 90 | } 91 | 92 | static void 93 | galore_filter_reply_finalize (GObject *object) 94 | { 95 | G_OBJECT_CLASS (parent_class)->finalize (object); 96 | } 97 | 98 | 99 | static GMimeFilter * 100 | filter_copy (GMimeFilter *filter) 101 | { 102 | GaloreFilterReply *reply = (GaloreFilterReply *) filter; 103 | 104 | return galore_filter_reply_new (reply->encode); 105 | } 106 | 107 | static void 108 | filter_filter (GMimeFilter *filter, char *inbuf, size_t inlen, size_t prespace, 109 | char **outbuf, size_t *outlen, size_t *outprespace) 110 | { 111 | GaloreFilterReply *reply = (GaloreFilterReply *) filter; 112 | const char *inptr = inbuf; 113 | const char *inend = inbuf + inlen; 114 | char *outptr; 115 | 116 | (void) prespace; 117 | if (reply->encode) { 118 | g_mime_filter_set_size (filter, 3 * inlen, false); 119 | 120 | outptr = filter->outbuf; 121 | while (inptr < inend) { 122 | if (reply->saw_nl) { 123 | *outptr++ = '>'; 124 | // only add the space if there isn't a qoute 125 | if (*inptr != '>') { 126 | *outptr++ = ' '; 127 | } 128 | reply->saw_nl = false; 129 | } 130 | if (*inptr == '\n') 131 | reply->saw_nl = true; 132 | else 133 | reply->saw_nl = false; 134 | if (*inptr != '\r') 135 | *outptr++ = *inptr; 136 | inptr++; 137 | } 138 | } else { 139 | g_mime_filter_set_size (filter, inlen + 1, false); 140 | 141 | outptr = filter->outbuf; 142 | while (inptr < inend) { 143 | if (reply->saw_nl) { 144 | if (*inptr == '>') 145 | reply->saw_angle = true; 146 | else 147 | *outptr++ = *inptr; 148 | reply->saw_nl = false; 149 | } else if (reply->saw_angle) { 150 | if (*inptr == ' ') 151 | ; 152 | else 153 | *outptr++ = *inptr; 154 | reply->saw_angle = false; 155 | } else if (*inptr != '\r') { 156 | if (*inptr == '\n') 157 | reply->saw_nl = true; 158 | *outptr++ = *inptr; 159 | } 160 | 161 | inptr++; 162 | } 163 | } 164 | 165 | *outlen = outptr - filter->outbuf; 166 | *outprespace = filter->outpre; 167 | *outbuf = filter->outbuf; 168 | } 169 | 170 | static void 171 | filter_complete (GMimeFilter *filter, char *inbuf, size_t inlen, size_t prespace, 172 | char **outbuf, size_t *outlen, size_t *outprespace) 173 | { 174 | if (inbuf && inlen) 175 | filter_filter (filter, inbuf, inlen, prespace, outbuf, outlen, outprespace); 176 | } 177 | 178 | static void 179 | filter_reset (GMimeFilter *filter) 180 | { 181 | GaloreFilterReply *reply = (GaloreFilterReply *) filter; 182 | 183 | reply->saw_nl = true; 184 | reply->saw_angle = false; 185 | } 186 | 187 | 188 | /** 189 | * galore_filter_reply_new: 190 | * @encode: %true if the filter should encode or %false otherwise 191 | * 192 | * If @encode is %true, then all lines will be prefixed by "> ", 193 | * otherwise any lines starting with "> " will have that removed 194 | * 195 | * Returns: a new reply filter with @encode. 196 | **/ 197 | GMimeFilter * 198 | galore_filter_reply_new (gboolean encode) 199 | { 200 | GaloreFilterReply *new_reply; 201 | 202 | new_reply = (GaloreFilterReply *) g_object_new (GALORE_TYPE_FILTER_REPLY, NULL); 203 | new_reply->encode = encode; 204 | 205 | return (GMimeFilter *) new_reply; 206 | } 207 | -------------------------------------------------------------------------------- /src/galore-filter-reply.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2009 Keith Packard 3 | * 4 | * This program is free software; you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation, either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, but 10 | * WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | * General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License along 15 | * with this program; if not, write to the Free Software Foundation, Inc., 16 | * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. 17 | */ 18 | 19 | #ifndef _GALORE_FILTER_REPLY_H_ 20 | #define _GALORE_FILTER_REPLY_H_ 21 | 22 | #include 23 | 24 | void galore_filter_reply_module_init (void); 25 | 26 | G_BEGIN_DECLS 27 | 28 | #define GALORE_TYPE_FILTER_REPLY (galore_filter_reply_get_type ()) 29 | #define GALORE_FILTER_REPLY(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), \ 30 | GALORE_TYPE_FILTER_REPLY, \ 31 | GaloreFilterReply)) 32 | #define GALORE_FILTER_REPLY_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), GALORE_TYPE_FILTER_REPLY, \ 33 | GaloreFilterReplyClass)) 34 | #define GALORE_IS_FILTER_REPLY(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), \ 35 | GALORE_TYPE_FILTER_REPLY)) 36 | #define GALORE_IS_FILTER_REPLY_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), \ 37 | GALORE_TYPE_FILTER_REPLY)) 38 | #define GALORE_FILTER_REPLY_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), GALORE_TYPE_FILTER_REPLY, \ 39 | GaloreFilterReplyClass)) 40 | 41 | typedef struct _GaloreFilterReply GaloreFilterReply; 42 | typedef struct _GaloreFilterReplyClass GaloreFilterReplyClass; 43 | 44 | /** 45 | * GaloreFilterReply: 46 | * @parent_object: parent #GMimeFilter 47 | * @encode: encoding vs decoding reply markers 48 | * @saw_nl: previous char was a \n 49 | * @saw_angle: previous char was a > 50 | * 51 | * A filter to insert/remove reply markers (lines beginning with >) 52 | **/ 53 | struct _GaloreFilterReply { 54 | GMimeFilter parent_object; 55 | 56 | gboolean encode; 57 | gboolean saw_nl; 58 | gboolean saw_angle; 59 | }; 60 | 61 | struct _GaloreFilterReplyClass { 62 | GMimeFilterClass parent_class; 63 | 64 | }; 65 | 66 | 67 | GType galore_filter_reply_get_type (void); 68 | 69 | GMimeFilter *galore_filter_reply_new (gboolean encode); 70 | 71 | G_END_DECLS 72 | 73 | 74 | #endif /* _GALORE_FILTER_REPLY_H_ */ 75 | -------------------------------------------------------------------------------- /src/galore-gpgme-utils.h: -------------------------------------------------------------------------------- 1 | /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- */ 2 | /* GMime 3 | * Copyright (C) 2000-2022 Jeffrey Stedfast 4 | * 5 | * This library is free software; you can redistribute it and/or 6 | * modify it under the terms of the GNU Lesser General Public License 7 | * as published by the Free Software Foundation; either version 2.1 8 | * of the License, or (at your option) any later version. 9 | * 10 | * This library is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | * Lesser General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Lesser General Public 16 | * License along with this library; if not, write to the Free 17 | * Software Foundation, 51 Franklin Street, Fifth Floor, Boston, MA 18 | * 02110-1301, USA. 19 | */ 20 | 21 | 22 | #ifndef __GMIME_GPGME_UTILS_H__ 23 | #define __GMIME_GPGME_UTILS_H__ 24 | 25 | #ifdef ENABLE_CRYPTO 26 | 27 | #include 28 | #include 29 | #include 30 | 31 | G_BEGIN_DECLS 32 | 33 | G_GNUC_INTERNAL gpgme_error_t g_mime_gpgme_passphrase_callback (void *hook, const char *uid_hint, 34 | const char *passphrase_info, 35 | int prev_was_bad, int fd); 36 | 37 | G_GNUC_INTERNAL int g_mime_gpgme_sign (gpgme_ctx_t ctx, gpgme_sig_mode_t mode, const char *userid, 38 | GMimeStream *istream, GMimeStream *ostream, GError **err); 39 | 40 | G_GNUC_INTERNAL GMimeSignatureList *g_mime_gpgme_verify (gpgme_ctx_t ctx, GMimeVerifyFlags flags, GMimeStream *istream, 41 | GMimeStream *sigstream, GMimeStream *ostream, GError **err); 42 | 43 | G_GNUC_INTERNAL int g_mime_gpgme_encrypt (gpgme_ctx_t ctx, gboolean sign, const char *userid, 44 | GMimeEncryptFlags flags, GPtrArray *recipients, 45 | GMimeStream *istream, GMimeStream *ostream, 46 | GError **err); 47 | 48 | G_GNUC_INTERNAL GMimeDecryptResult *g_mime_gpgme_decrypt (gpgme_ctx_t ctx, GMimeDecryptFlags flags, const char *session_key, 49 | GMimeStream *istream, GMimeStream *ostream, GError **err); 50 | 51 | G_GNUC_INTERNAL int g_mime_gpgme_import (gpgme_ctx_t ctx, GMimeStream *istream, GError **err); 52 | 53 | G_GNUC_INTERNAL int g_mime_gpgme_export (gpgme_ctx_t ctx, const char *keys[], GMimeStream *ostream, GError **err); 54 | 55 | G_END_DECLS 56 | 57 | #endif /* ENABLE_CRYPTO */ 58 | 59 | #endif /* __GMIME_GPGME_UTILS_H__ */ 60 | -------------------------------------------------------------------------------- /src/galore.c: -------------------------------------------------------------------------------- 1 | #include "galore.h" 2 | 3 | // helper C functions for gmime 4 | void galore_init() { 5 | galore_filter_reply_get_type(); 6 | } 7 | -------------------------------------------------------------------------------- /src/galore.h: -------------------------------------------------------------------------------- 1 | #ifndef __GALORE_H__ 2 | #define __GALORE_H__ 3 | 4 | #include "galore-filter-reply.h" 5 | 6 | void galore_init(); 7 | 8 | #endif /* __GALORE_H__ */ 9 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | source_c = [ 2 | 'galore.c', 3 | 'galore-filter-reply.c', 4 | ] 5 | 6 | source_h = [ 7 | 'galore.h', 8 | 'galore-filter-reply.h', 9 | ] 10 | 11 | deps = [ 12 | dependency('gmime-3.0'), 13 | dependency('glib-2.0'), 14 | dependency('gobject-2.0'), 15 | dependency('gio-2.0'), 16 | ] 17 | filter_lib = library('galore', source_c, dependencies : deps) 18 | gnome = import('gnome') 19 | 20 | if true 21 | gir_args = [ 22 | '--quiet', 23 | '--warn-all', 24 | ] 25 | 26 | filter_glib_gir = gnome.generate_gir( 27 | filter_lib, 28 | sources: source_c + source_h, 29 | namespace: 'Galore', 30 | nsversion: '0.1', 31 | symbol_prefix: ['galore'], 32 | includes: [ 'GObject-2.0', 'Gio-2.0', 'GMime-3.0'], 33 | dependencies: deps, 34 | extra_args: gir_args, 35 | fatal_warnings: true, 36 | ) 37 | else 38 | # Just warn and quit 39 | json_glib_gir = [] 40 | endif 41 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 100 2 | indent_type = "Spaces" 3 | indent_width = 2 4 | quote_style = "AutoPreferSingle" 5 | -------------------------------------------------------------------------------- /syntax/galore-browser.vim: -------------------------------------------------------------------------------- 1 | setlocal conceallevel=3 2 | setlocal concealcursor=nciv 3 | 4 | " init should set this value 5 | " let GaloreFromLength = 10 6 | " let GaloreFromStr = "syntax match GaloreFrom '\s\+[^│]\{0,5}' contained nextgroup=GaloreFromConc" 7 | 8 | " Use this for now, I kinda want it to be order indpendent 9 | " Could I make the order generated and loaded on init? 10 | " conceal 11 | syntax region GaloreThreads start=/^/ end=/$/ oneline contains=GaloreDate 12 | " syntax match GaloreDate "\d\{4}-\d\{2}-\d\{2}" contained nextgroup=GaloreThreadCount 13 | syntax match GaloreDate "[0-9A-Za-z.\-]\+\(\s[a-z0-9:.]\+\)\?\(\sago\)\?" contained nextgroup=GaloreThreadCount 14 | syntax match GaloreThreadCount "\s\+\[[0-9]\+\/[0-9()]\+\]" contained nextgroup=GaloreFrom 15 | " seems to not work well with unicode, doesn't seem to be a unicode but a 16 | " space problem 17 | syntax match GaloreFrom '\s\+[^│]\{0,25}' contained nextgroup=GaloreFromConc 18 | " execute GaloreFromStr 19 | syntax match GaloreFromConc "[^│]*" contained nextgroup=GaloreFromEnd conceal 20 | syntax match GaloreFromEnd "│" contained nextgroup=GaloreSubject 21 | syntax match GaloreSubject /.\{0,}\(([^()]\+)$\)\@=/ contained nextgroup=GaloreTags 22 | syntax match GaloreTags "(.*)$" contained 23 | 24 | highlight GaloreFrom ctermfg=224 guifg=Orange 25 | highlight GaloreFromConc ctermfg=224 guifg=Green 26 | highlight GaloreFromEnd ctermfg=224 guifg=Red 27 | highlight link GaloreDate String 28 | highlight link GaloreThreadCount Comment 29 | highlight link GaloreSubject Statement 30 | highlight link GaloreTags Comment 31 | -------------------------------------------------------------------------------- /syntax/galore-saved.vim: -------------------------------------------------------------------------------- 1 | syntax region GaloreSaved start=/^/ end=/$/ oneline contains=GaloreSavedCount 2 | 3 | syntax match GaloreSavedCount "\d*" contained nextgroup=GaloreSavedUnread 4 | syntax match GaloreSavedUnread "(\d*)" contained nextgroup=GaloreSavedName 5 | syntax match GaloreSavedName "\s\+.*\s" contained nextgroup=GaloreSavedSearch 6 | syntax match GaloreSavedSearch "([^()]\+)" 7 | 8 | highlight link GaloreSavedCount Statement 9 | highlight GaloreSavedUnread ctermfg=224 guifg=#9c453e 10 | highlight link GaloreSavedName Type 11 | highlight link GaloreSavedSearch String 12 | 13 | " highlight CursorLine term=reverse cterm=reverse gui=reverse 14 | 15 | -------------------------------------------------------------------------------- /syntax/galore-threads.vim: -------------------------------------------------------------------------------- 1 | setlocal conceallevel=3 2 | setlocal concealcursor=nciv 3 | 4 | " init should set this value 5 | let GaloreFromLength = 10 6 | " let GaloreFromStr = "syntax match GaloreFrom '\s\+[^│]\{0,5}' contained nextgroup=GaloreFromConc" 7 | 8 | " Use this for now, I kinda want it to be order indpendent 9 | " Could I make the order generated and loaded on init? 10 | " conceal 11 | syntax region GaloreThreads start=/^/ end=/$/ oneline contains=GaloreDate 12 | syntax match GaloreDate "[0-9A-Za-z.\-]\+\(\s[a-z0-9:.]\+\)\?\(\sago\)\?" contained nextgroup=GaloreThreadCount 13 | syntax match GaloreThreadCount "\s\+\[[0-9]\+\/[0-9()]\+\]" contained nextgroup=GaloreFrom 14 | syntax match GaloreFrom '\s\+[^│]\{0,25}' contained nextgroup=GaloreFromConc 15 | " execute GaloreFromStr 16 | syntax match GaloreFromConc "[^│]*" contained nextgroup=GaloreFromEnd conceal 17 | syntax match GaloreFromEnd "│" contained nextgroup=GaloreSubject 18 | syntax match GaloreSubject /.\{0,}\(([^()]\+)$\)\@=/ contained nextgroup=GaloreTags 19 | syntax match GaloreTags "(.*)$" contained 20 | 21 | highlight GaloreFrom ctermfg=224 guifg=Orange 22 | highlight GaloreFromConc ctermfg=224 guifg=Green 23 | highlight GaloreFromEnd ctermfg=224 guifg=Red 24 | highlight link GaloreDate String 25 | highlight link GaloreThreadCount Comment 26 | highlight link GaloreSubject Statement 27 | highlight link GaloreTags Comment 28 | -------------------------------------------------------------------------------- /test/ci_init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # TEST_ROOT=$(dirname "$0") 4 | # git clone https://github.com/dagle/galore-test testdata 5 | 6 | MAIL_DIR="./testdata/testmail" 7 | mkdir -p ${HOME}/.config/notmuch/default 8 | 9 | cat <${HOME}/.config/notmuch/default/config 10 | [database] 11 | path=${MAIL_DIR} 12 | 13 | [user] 14 | name=Testi McTest 15 | primary_email=testi@testmail.org 16 | other_email=test_suite_other@testmailtwo.org;test_suite@otherdomain.org 17 | EOF 18 | 19 | # init 20 | notmuch new 21 | 22 | # run tests 23 | 24 | # fi 25 | -------------------------------------------------------------------------------- /test/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## setup test data 4 | TEST_ROOT=$(dirname "$0") 5 | TEST_ROOT=$(realpath ${TEST_ROOT}) 6 | TMP_DIRECTORY="${TEST_ROOT}/testdir" 7 | 8 | 9 | if [[ ! -d "$TMP_DIRECTORY" ]]; then 10 | mkdir ${TMP_DIRECTORY} 11 | MAIL_DIR="${TMP_DIRECTORY}/testdata" 12 | 13 | if [[ ! -d "$MAIL_DIR" ]]; then 14 | git clone https://github.com/dagle/galore-test $MAIL_DIR 15 | fi 16 | 17 | # setup notmuch 18 | NOTMUCHDIR="${TMP_DIRECTORY}/notmuch/" 19 | mkdir ${NOTMUCHDIR} 20 | export NOTMUCH_CONFIG="${NOTMUCHDIR}/notmuch-config" 21 | 22 | cat <"${NOTMUCH_CONFIG}" 23 | [database] 24 | path=${MAIL_DIR}/testmail 25 | hook_dir=${NOTMUCHDIR} 26 | 27 | [user] 28 | name=Testi McTest 29 | primary_email=testi@testmail.org 30 | other_email=test_suite_other@testmailtwo.org;test_suite@otherdomain.org 31 | EOF 32 | 33 | # init 34 | export GNUPGHOME="${TMP_DIRECTORY}/gnupg" 35 | add_gnupg_home () { 36 | _gnupg_exit () { gpgconf --kill all 2>/dev/null || true; } 37 | at_exit_function _gnupg_exit 38 | mkdir -p -m 0700 "$GNUPGHOME" 39 | gpg --no-tty --import <$MAIL_DIR/testkey.asc >"$GNUPGHOME"/import.log 2>&1 40 | 41 | if (gpg --quick-random --version >/dev/null 2>&1) ; then 42 | echo quick-random >> "$GNUPGHOME"/gpg.conf 43 | elif (gpg --debug-quick-random --version >/dev/null 2>&1) ; then 44 | echo debug-quick-random >> "$GNUPGHOME"/gpg.conf 45 | fi 46 | echo no-emit-version >> "$GNUPGHOME"/gpg.conf 47 | 48 | FINGERPRINT="F998009F4AD9084F82096B36140FC9CB9DB2A71B" 49 | SELF_EMAIL="testi@testmail.org" 50 | printf '%s:6:\n' "$FINGERPRINT" | gpg --quiet --batch --no-tty --import-ownertrust 51 | gpgconf --kill all 2>/dev/null || true; 52 | } 53 | 54 | add_gnupg_home 55 | 56 | notmuch new 57 | 58 | fi 59 | -------------------------------------------------------------------------------- /test/minimal.vim: -------------------------------------------------------------------------------- 1 | " Grabbing refactoring code 2 | set rtp+=. 3 | 4 | " Using local versions of plenary and nvim-treesitter if possible 5 | " This is required for CI 6 | set rtp+=../plenary.nvim 7 | set rtp+=../nvim-treesitter 8 | set rtp+=../telescope 9 | set rtp+=../filebrowser 10 | set rtp+=../notmuch-lua 11 | 12 | " If you use vim-plug if you got it locally 13 | set rtp+=~/.vim/plugged/plenary.nvim 14 | set rtp+=~/.vim/plugged/nvim-treesitter 15 | set rtp+=~/.vim/plugged/telescope.nvim 16 | set rtp+=~/.vim/plugged/telescope-file-browser.nvim 17 | set rtp+=~/.vim/plugged/notmuch-lua 18 | 19 | " If you are using packer 20 | set rtp+=~/.local/share/nvim/site/pack/packer/start/plenary.nvim 21 | set rtp+=~/.local/share/nvim/site/pack/packer/start/nvim-treesitter 22 | set rtp+=~/.local/share/nvim/site/pack/packer/start/telescope.nvim 23 | set rtp+=~/.local/share/nvim/site/pack/packer/start/telescope-file-browser.nvim 24 | set rtp+=~/.local/share/nvim/site/pack/packer/start/notmuch-lua 25 | 26 | runtime! plugin/plenary.vim 27 | runtime! plugin/nvim-treesitter.lua 28 | runtime! plugin/telescope.lua 29 | 30 | lua <